fuzzyworld-tools / commit
Add fw deploy, vite plugin and apps.fuzzyworld.net index
README.md | 50 ++++++++- bin/fw | 12 ++- lib/deploy.mjs | 242 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 22 ++++ server/fw | 17 +++- server/gen.py | 53 +++++++++- server/install.sh | 4 + server/site-apps/extra.json | 8 ++ server/site-apps/htaccess | 34 +++++++ vite/index.js | 39 +++++++ 10 files changed, 468 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7c3fc55..3fc975e 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,56 @@ Tooling for Pavel's self-hosted code and apps on DreamHost: - **code.fuzzyworld.net**: static browse pages and read-only `git clone` for public repos -- **apps.fuzzyworld.net**: deployed web apps (`fw deploy`, Phase 4 of the migration; not built yet) +- **apps.fuzzyworld.net**: deployed web apps, one per path (`/bubbles/`, `/slide/`, ...), published with `fw deploy` There is no app server. Pages are generated when you push; Apache only serves files. +## Making a repo deploy as an app + +Add to the app repo's `package.json`: + +```jsonc +"scripts": { "deploy": "fw deploy" }, +"fuzzyworld": { + "app": "my-game", // URL: apps.fuzzyworld.net/my-game/ (required) + "title": "My Game", // apps index + "description": "One line.", // apps index + "repo": "my-game", // DreamHost repo name, if different from app + "prebuild": ["pnpm gen-assets"], // commands before checks and build + "checks": ["typecheck", "test"], // scripts that must pass (default: whichever of typecheck/lint/test exist) + "build": "make public", // default: `<pm> run build` + "out": "dist" // default: dist, else public, else build +}, +"devDependencies": { "fuzzyworld-tools": "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#v1" } +``` + +For vite, add the plugin; it sets `base` to `/<app>/` for builds (dev server unchanged): + +```ts +import fuzzyworld from 'fuzzyworld-tools/vite'; +export default defineConfig({ plugins: [fuzzyworld()] }); +``` + +Then `pnpm run deploy` / `npm run deploy`. **`fw deploy`**: + +1. clones branch `main` of `dh:git/<repo>.git` into a temp dir. It builds what is pushed, never your working copy (`--ref <branch>` for another branch, `--here` to build the current directory as is) +2. installs with the lockfile's package manager (`pnpm install --frozen-lockfile`, `npm ci`, `yarn install --frozen-lockfile`) +3. runs `prebuild`, then the `checks`, then the build. Any failure stops the deploy. +4. checks the output: every same-site URL in `index.html`, the web manifest's `id`/`scope`/`start_url`, and every `serviceWorker.register(...)` script and scope must stay inside `/<app>/`. That keeps apps on the shared origin from capturing each other, and makes each one install separately on Android. Any problem stops the deploy. +5. `rsync --delete` to `apps.fuzzyworld.net/<app>/`, writes `.fuzzyworld.json` (commit, time; not web-readable), and refreshes the apps index and the repo's code page +6. prints the URL + +`--dry-run` does everything except upload. + +Apps hosted elsewhere are listed on the index from `server/site-apps/extra.json` (engye: Cloudflare Pages; `apps.fuzzyworld.net/engye/...` redirects there). + ## Commands -On the laptop, `bin/fw` forwards repo commands to DreamHost (`ssh dh ~/bin/fw ...`): +On the laptop, `bin/fw` runs `deploy` locally and forwards repo commands to DreamHost (`ssh dh ~/bin/fw ...`): | Command | What it does | |---|---| +| `fw deploy [--ref B] [--here] [--dry-run]` | build and publish this repo's app (above) | | `fw list` | every repo, its visibility and last commit | | `fw new <name> [--public] [--description TEXT]` | create `~/git/<name>.git` on DreamHost; **private unless `--public`** | | `fw public <name>` | publish the source on code.fuzzyworld.net right away | @@ -38,6 +78,7 @@ Other settings in the repo config: `fuzzyworld.description`, plus `fuzzyworld.gi | `~/git/<name>.git` | canonical bare repos (push here; not web-visible) | | `~/code.fuzzyworld.net/<name>/` | generated pages for public repos | | `~/code.fuzzyworld.net/<name>.git` | public clone copy (dumb HTTP), refreshed on push | +| `~/apps.fuzzyworld.net/<app>/` | deployed apps (`fw deploy`); `index.html` at the root is generated | | `~/fw/tools/server/` | this repo's `server/` directory, installed by `server/install.sh` | | `~/bin/fw` | symlink to `~/fw/tools/server/fw` | | `~/fw/state/` | per-repo page manifests and locks | @@ -70,6 +111,7 @@ The site never executes anything from a repo: - raw copies are written only for image extensions; `.htaccess` also denies `.php`, `.shtml`, `.cgi`, `.pl`, `.py` and `.sh` and disables SSI/CGI (DreamHost runs `.php` files and SSI otherwise; tested 2026-10-26) - `Content-Security-Policy: script-src 'none'` on every response (this also covers SVG images) - inside public clone copies, `config`, `description`, `hooks/` and `logs/` return 404 +- apps.fuzzyworld.net gets the same no-execution rules. HTML, service workers and manifests are sent with `no-cache` (a deploy is seen immediately); `/<app>/assets/` (hashed by vite) is cached for a year. ## Install / update @@ -78,4 +120,6 @@ server/install.sh # copy server/ to dh, install site files and hooks server/install.sh --publish # ...and republish every repo ``` -Requirements on DreamHost: git, Python 3.12, `flock`, `setsid`. +Requirements on DreamHost: git, Python 3.12, `flock`, `setsid`. On the laptop: Node ≥ 18, git, rsync, ssh with the `dh` alias. + +Using `fw` outside an app repo (e.g. on a second laptop): `npm install -g git+https://code.fuzzyworld.net/fuzzyworld-tools.git#v1`. diff --git a/bin/fw b/bin/fw index ff837b2..6ec609d 100755 --- a/bin/fw +++ b/bin/fw @@ -1,17 +1,19 @@ #!/bin/bash -# fw (laptop side): runs repo commands on DreamHost over ssh. +# fw (laptop side). +# fw deploy [--ref <branch>] [--here] [--dry-run] build and publish this repo's app (see lib/deploy.mjs) # fw list | new <name> [--public] [--description TEXT] | public <name> | private <name> -# fw describe <name> TEXT | publish <name> | publish-all -# `fw deploy` and app scaffolding in `fw new` arrive in Phase 4. +# fw describe <name> TEXT | publish <name> | publish-all (run on DreamHost over ssh) set -euo pipefail HOST=${FW_HOST:-dh} +# Follow the symlink npm creates in node_modules/.bin to find this package. +HERE=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) case "${1:-help}" in + deploy) + shift; exec node "$HERE/lib/deploy.mjs" "$@" ;; list|new|public|private|describe|publish|publish-all|help|-h|--help) # printf %q keeps arguments (e.g. a description with spaces) intact through ssh. exec ssh "$HOST" "~/bin/fw $(printf '%q ' "$@")" ;; - deploy) - echo "fw deploy: not available yet (Phase 4)" >&2; exit 2 ;; *) echo "fw: unknown command '$1' (try: fw help)" >&2; exit 2 ;; esac diff --git a/lib/deploy.mjs b/lib/deploy.mjs new file mode 100644 index 0000000..677740e --- /dev/null +++ b/lib/deploy.mjs @@ -0,0 +1,242 @@ +// fw deploy: build an app from its canonical repo and publish it to apps.fuzzyworld.net/<app>/. +// +// fw deploy build branch `main` of dh:git/<repo>.git (a fresh clone) +// fw deploy --ref <branch> build another branch or tag +// fw deploy --here build the current directory as it is (uncommitted work included) +// fw deploy --dry-run build and check, but don't upload +// +// Configuration comes from package.json of the *built* source: +// "fuzzyworld": { +// "app": "my-game", // URL path: apps.fuzzyworld.net/my-game/ (required) +// "title": "My Game", // shown on the apps index +// "description": "...", // shown on the apps index +// "repo": "my-game", // repo name on DreamHost, if different from app +// "prebuild": ["pnpm gen-assets"], // shell commands before checks and build +// "checks": ["typecheck", "test"], // package scripts that must pass (default: those of typecheck/lint/test that exist) +// "build": "make public", // build command (default: `<pm> run build`) +// "out": "dist" // output directory (default: dist, else public, else build) +// } +import { spawnSync, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const HOST = process.env.FW_HOST || 'dh'; +const APPS_URL = 'https://apps.fuzzyworld.net'; +const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +class DeployError extends Error {} + +// Throws instead of exiting, so the temp clone is always removed (see `finally` in deploy()). +function die(msg) { + throw new DeployError(msg); +} + +function step(msg) { + console.log(`\n▶ ${msg}`); +} + +function run(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts }); + if (r.status !== 0) die(`failed: ${cmd} ${args.join(' ')}`); +} + +function shell(command, opts) { + run('/bin/sh', ['-c', command], opts); +} + +function git(args, cwd) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); +} + +function parseArgs(argv) { + const o = { ref: 'main', here: false, dryRun: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--here') o.here = true; + else if (a === '--dry-run') o.dryRun = true; + else if (a === '--ref') o.ref = argv[++i] || die('--ref needs a value'); + else die(`unknown option ${a}`); + } + return o; +} + +function readConfig(dir) { + const file = path.join(dir, 'package.json'); + if (!fs.existsSync(file)) die(`no package.json in ${dir}`); + const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); + const cfg = pkg.fuzzyworld; + if (!cfg) die(`no "fuzzyworld" section in ${file}`); + if (!cfg.app || !NAME_RE.test(cfg.app)) die(`invalid or missing fuzzyworld.app in ${file}`); + if (cfg.repo && !NAME_RE.test(cfg.repo)) die(`invalid fuzzyworld.repo in ${file}`); + return { pkg, cfg }; +} + +function packageManager(dir) { + if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return { pm: 'pnpm', install: ['install', '--frozen-lockfile'] }; + if (fs.existsSync(path.join(dir, 'yarn.lock'))) return { pm: 'yarn', install: ['install', '--frozen-lockfile'] }; + if (fs.existsSync(path.join(dir, 'package-lock.json'))) return { pm: 'npm', install: ['ci'] }; + return { pm: 'npm', install: ['install'] }; +} + +// ---------------------------------------------------------------- output checks + +function walk(dir, out = []) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, out); + else out.push(p); + } + return out; +} + +// Resolve a URL as the browser would from the app's index page. +function resolveUrl(ref, app, from = `/${app}/`) { + return new URL(ref, `${APPS_URL}${from}`); +} + +function checkOutput(out, app) { + const problems = []; + const warnings = []; + const base = `/${app}/`; + const index = path.join(out, 'index.html'); + if (!fs.existsSync(index)) die(`build output has no index.html: ${out}`); + const html = fs.readFileSync(index, 'utf8'); + + // Every same-site URL in index.html must stay inside /<app>/. + for (const m of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) { + const u = resolveUrl(m[1], app); + if (u.origin === APPS_URL && !u.pathname.startsWith(base)) { + problems.push(`index.html references ${m[1]} (resolves outside ${base})`); + } + } + + // Web manifest: id, scope and start_url must be under /<app>/, so each app installs separately. + const mf = html.match(/<link[^>]+rel=["']manifest["'][^>]*>/i); + if (mf) { + const href = mf[0].match(/href=["']([^"']+)["']/)?.[1]; + const mUrl = href && resolveUrl(href, app); + const file = mUrl && path.join(out, decodeURIComponent(mUrl.pathname.slice(base.length))); + if (!file || !fs.existsSync(file)) { + problems.push(`manifest ${href} not found in build output`); + } else { + const m = JSON.parse(fs.readFileSync(file, 'utf8')); + const at = mUrl.pathname; + for (const key of ['scope', 'start_url', 'id']) { + if (m[key] === undefined) continue; + const p = resolveUrl(m[key], app, at).pathname; + if (!p.startsWith(base)) problems.push(`manifest ${key} "${m[key]}" resolves to ${p}, outside ${base}`); + } + if (m.id === undefined) warnings.push(`manifest has no "id"; browsers fall back to start_url. Set "id": "${base}" to be explicit.`); + } + } + + // Service workers: a registration scope (or script) outside /<app>/ would capture other apps. + for (const f of walk(out).filter((f) => /\.(m?js|html)$/.test(f))) { + const text = fs.readFileSync(f, 'utf8'); + for (const m of text.matchAll(/serviceWorker\.register\(\s*(["'`])([^"'`]+)\1([^)]*)\)/g)) { + const script = resolveUrl(m[2], app).pathname; + if (!script.startsWith(base)) problems.push(`${path.relative(out, f)} registers service worker ${m[2]} outside ${base}`); + const scope = m[3].match(/scope\s*:\s*(["'`])([^"'`]+)\1/)?.[2]; + if (scope && !resolveUrl(scope, app).pathname.startsWith(base)) { + problems.push(`${path.relative(out, f)} registers a service worker with scope "${scope}" outside ${base}`); + } + } + if (text.includes('gitlab.io')) warnings.push(`${path.relative(out, f)} mentions gitlab.io`); + } + return { problems, warnings }; +} + +// ---------------------------------------------------------------- main + +export function deploy(argv) { + const o = parseArgs(argv); + const cwd = process.cwd(); + let top; + try { + top = git(['rev-parse', '--show-toplevel'], cwd); + } catch { + top = cwd; + } + let { cfg } = readConfig(top); + const repo = cfg.repo || cfg.app; + + let src = top; + let tmp = null; + if (o.here) { + const dirty = spawnSync('git', ['status', '--porcelain'], { cwd: top, encoding: 'utf8' }).stdout.trim(); + if (dirty) console.warn('fw deploy: --here with uncommitted changes; the deployed app will not match any commit'); + } else { + // Build what is pushed, never the working copy: a fresh shallow clone of the canonical repo. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), `fw-deploy-${cfg.app}-`)); + src = path.join(tmp, 'src'); + step(`cloning ${HOST}:git/${repo}.git (${o.ref})`); + run('git', ['clone', '--quiet', '--depth', '1', '--branch', o.ref, `${HOST}:git/${repo}.git`, src]); + ({ cfg } = readConfig(src)); // the committed config is what gets deployed + } + const app = cfg.app; + const commit = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: src, encoding: 'utf8' }).stdout.trim() || 'unknown'; + const { pkg } = readConfig(src); + const env = { ...process.env, FW_APP: app, FW_BASE: `/${app}/` }; + + try { + const { pm, install } = packageManager(src); + step(`${pm} ${install.join(' ')}`); + run(pm, install, { cwd: src, env }); + + for (const c of cfg.prebuild || []) { + step(c); + shell(c, { cwd: src, env }); + } + + const scripts = pkg.scripts || {}; + const checks = cfg.checks ?? ['typecheck', 'lint', 'test'].filter((s) => scripts[s] && !/no test specified/.test(scripts[s])); + for (const c of checks) { + if (!scripts[c]) die(`check "${c}" is not a script in package.json`); + step(`check: ${pm} run ${c}`); + run(pm, ['run', c], { cwd: src, env }); + } + + step(`build: ${cfg.build || `${pm} run build`}`); + if (cfg.build) shell(cfg.build, { cwd: src, env }); + else run(pm, ['run', 'build'], { cwd: src, env }); + + const outName = cfg.out || ['dist', 'public', 'build'].find((d) => fs.existsSync(path.join(src, d, 'index.html'))); + if (!outName) die('could not find the build output (set fuzzyworld.out)'); + const out = path.join(src, outName); + + step(`checking ${outName}/`); + const { problems, warnings } = checkOutput(out, app); + for (const w of warnings) console.warn(` warning: ${w}`); + if (problems.length) { + for (const p of problems) console.error(` ✗ ${p}`); + die(`${problems.length} problem(s) in the build output; nothing was uploaded`); + } + console.log(' ✓ all URLs, manifest and service worker stay inside', `/${app}/`); + + fs.writeFileSync(path.join(out, '.fuzzyworld.json'), JSON.stringify({ + app, repo, title: cfg.title || app, description: cfg.description || '', + commit, ref: o.here ? '(working copy)' : o.ref, deployed: new Date().toISOString(), + }, null, 2)); + + if (o.dryRun) { + console.log(`\n(dry run) would upload ${out}/ to ${HOST}:apps.fuzzyworld.net/${app}/`); + return; + } + step(`uploading to apps.fuzzyworld.net/${app}/`); + run('ssh', [HOST, `mkdir -p apps.fuzzyworld.net/${app}`]); + run('rsync', ['-rlptz', '--delete', `${out}/`, `${HOST}:apps.fuzzyworld.net/${app}/`]); + run('ssh', [HOST, `~/bin/fw app-deployed ${app} ${repo}`]); + console.log(`\n✅ ${APPS_URL}/${app}/ (commit ${commit.slice(0, 12)})`); + } finally { + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +try { + deploy(process.argv.slice(2)); +} catch (e) { + if (!(e instanceof DeployError)) throw e; + console.error(`\nfw deploy: ${e.message}`); + process.exit(1); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..31beb98 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "fuzzyworld-tools", + "version": "1.0.0", + "description": "Tooling for code.fuzzyworld.net and apps.fuzzyworld.net: fw command, deploy, vite plugin.", + "type": "module", + "bin": { + "fw": "bin/fw" + }, + "exports": { + "./vite": "./vite/index.js", + "./package.json": "./package.json" + }, + "files": [ + "bin/", + "lib/", + "vite/" + ], + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/server/fw b/server/fw index 1ed6227..c2ca242 100755 --- a/server/fw +++ b/server/fw @@ -10,6 +10,8 @@ # fw publish <name> refresh public copy + pages (the push hook runs this) # fw publish-all publish every public repo, then the index # fw install-hooks point every repo at the shared hooks +# fw apps-index rebuild the apps.fuzzyworld.net index +# fw app-deployed <app> <repo> after `fw deploy`: apps index, plus the repo's code page # # Visibility is `git config fuzzyworld.visibility` inside each repo. Anything other # than exactly "public" is treated as private. @@ -150,8 +152,21 @@ cmd_install_hooks() { echo "hooks installed on $(ls -d "$GIT_ROOT"/*.git | wc -l) repos" } +cmd_app_deployed() { + local app=${1:-} name=${2:-} + valid_name "$app" + "${GEN[@]}" apps + # The repo's summary page links to its app; refresh it if the source is public. + if [ -n "$name" ] && [ -d "$(repo_dir "$name")" ] && [ "$(visibility "$name")" = public ]; then + with_lock "$name" publish_one "$name" >/dev/null + "${GEN[@]}" index >/dev/null + fi +} + mkdir -p "$STATE" "$LOGS" case "${1:-}" in + apps-index) "${GEN[@]}" apps ;; + app-deployed) cmd_app_deployed "${2:-}" "${3:-}" ;; list) cmd_list ;; new) shift; cmd_new "$@" ;; public|private) cmd_set_visibility "$1" "${2:-}" ;; @@ -159,6 +174,6 @@ case "${1:-}" in publish) cmd_publish "${2:-}" ;; publish-all) cmd_publish_all ;; install-hooks) cmd_install_hooks ;; - ""|-h|--help|help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' ;; + ""|-h|--help|help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' ;; *) die "unknown command: $1 (try: fw help)" ;; esac diff --git a/server/gen.py b/server/gen.py index 7e06d66..69027bd 100755 --- a/server/gen.py +++ b/server/gen.py @@ -3,6 +3,7 @@ gen.py repo <name> build or update the pages of one public repo gen.py index rebuild the site index (public repos only) + gen.py apps rebuild the apps.fuzzyworld.net index from deployed apps Only the git CLI and the standard library are required. Python-Markdown is used for README/.md rendering when importable (from ~/fw/pylib); otherwise those files @@ -107,7 +108,13 @@ def url(*parts): return "/" + "/".join(urllib.parse.quote(s) for s in path.split("/")) -def page(title, body, crumbs=None, repo=None): +def page(title, body, crumbs=None, repo=None, site="code"): + if site == "apps": + host, footer = "apps.fuzzyworld.net", (f'<a href="/">All apps</a> · <a href="{BASE_URL}/">Code</a> · ' + f'<a href="{BASE_URL}/why/">Why this site exists</a>') + else: + host, footer = "code.fuzzyworld.net", (f'<a href="/">All repositories</a> · <a href="{APPS_URL}/">Apps</a> · ' + f'<a href="/why/">Why this site exists</a>') nav = "" if repo: nav = (f'<nav class="repo-nav"><a href="{url(repo)}/">Summary</a>' @@ -122,17 +129,17 @@ def page(title, body, crumbs=None, repo=None): <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>{esc(title)} · code.fuzzyworld.net</title> +<title>{esc(title)} · {host}</title> <link rel="stylesheet" href="/style.css"> </head> <body> -<header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header> +<header class="site"><a class="home" href="/">{host}</a></header> <main> {nav} {crumb_html} {body} </main> -<footer class="site"><a href="/">All repositories</a> · <a href="{APPS_URL}/">Apps</a> · <a href="/why/">Why this site exists</a></footer> +<footer class="site">{footer}</footer> </body> </html> """ @@ -504,11 +511,49 @@ def build_index(): print(f"index: {len(rows)} public repos") +def build_apps_index(): + """apps.fuzzyworld.net/index.html from each deployed app's .fuzzyworld.json.""" + apps = [] + for entry in sorted(os.listdir(APPS)): + meta_path = os.path.join(APPS, entry, ".fuzzyworld.json") + if not os.path.isfile(meta_path): + continue + try: + with open(meta_path) as f: + meta = json.load(f) + except ValueError: + continue + apps.append({"href": f"/{entry}/", "title": meta.get("title") or entry, + "description": meta.get("description", ""), "repo": meta.get("repo", entry)}) + # Apps hosted elsewhere (e.g. engye on Cloudflare Pages), listed in server/site-apps/extra.json. + extra = os.path.join(os.path.dirname(os.path.abspath(__file__)), "site-apps", "extra.json") + if os.path.isfile(extra): + with open(extra) as f: + apps += json.load(f) + apps.sort(key=lambda a: a["title"].lower()) + items = [] + for a in apps: + repo = os.path.join(GIT_ROOT, a.get("repo", "") + ".git") + src = (f' · <a href="{BASE_URL}/{urllib.parse.quote(a["repo"])}/">source</a>' + if a.get("repo") and os.path.isdir(repo) and is_public(repo) else "") + desc = f'<p class="desc">{esc(a["description"])}</p>' if a.get("description") else "" + items.append(f'<li><a class="name" href="{esc(a["href"])}">{esc(a["title"])}</a>' + f'<span class="meta">{src}</span>{desc}</li>') + body = (f"<h1>Pavel's apps</h1>" + f'<p>Small web apps and games. No accounts, no sign-in. Source code is at ' + f'<a href="{BASE_URL}/">code.fuzzyworld.net</a>.</p>' + f'<ul class="repos">{"".join(items)}</ul>') + write(os.path.join(APPS, "index.html"), page("Apps", body, site="apps")) + print(f"apps index: {len(apps)} apps") + + def main(argv): if len(argv) == 3 and argv[1] == "repo": build_repo(argv[2]) elif len(argv) == 2 and argv[1] == "index": build_index() + elif len(argv) == 2 and argv[1] == "apps": + build_apps_index() else: raise SystemExit(__doc__) diff --git a/server/install.sh b/server/install.sh index 833cbdc..5956cbf 100755 --- a/server/install.sh +++ b/server/install.sh @@ -17,7 +17,11 @@ site=~/code.fuzzyworld.net cp ~/fw/tools/server/site/htaccess "$site/.htaccess" cp ~/fw/tools/server/site/style.css ~/fw/tools/server/site/robots.txt ~/fw/tools/server/site/404.html "$site/" mkdir -p "$site/why" && cp ~/fw/tools/server/site/why/index.html "$site/why/" +apps=~/apps.fuzzyworld.net +cp ~/fw/tools/server/site-apps/htaccess "$apps/.htaccess" +cp ~/fw/tools/server/site/style.css "$apps/" ~/bin/fw install-hooks +~/bin/fw apps-index EOF if [ "${1:-}" = --publish ]; then ssh "$HOST" '~/bin/fw publish-all'; fi echo "installed on $HOST" diff --git a/server/site-apps/extra.json b/server/site-apps/extra.json new file mode 100644 index 0000000..5047f39 --- /dev/null +++ b/server/site-apps/extra.json @@ -0,0 +1,8 @@ +[ + { + "href": "https://engye.fuzzyworld.net/", + "title": "engye", + "description": "Share files by QR code, directly between devices. No accounts, no cloud.", + "repo": "engye" + } +] diff --git a/server/site-apps/htaccess b/server/site-apps/htaccess new file mode 100644 index 0000000..9b84e43 --- /dev/null +++ b/server/site-apps/htaccess @@ -0,0 +1,34 @@ +# apps.fuzzyworld.net. Installed as .htaccess by fuzzyworld-tools (server/install.sh). +# Every app lives in /<app>/ and is a static build uploaded by `fw deploy`. +Options -Indexes -Includes -ExecCGI +DirectoryIndex index.html + +RewriteEngine On +# www.<site> -> <site>, keeping path and query. +RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] +RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L] + +# engye is hosted on Cloudflare Pages (it needs a Pages Function); keep old paths working. +RewriteRule ^engye(?:/(.*))?$ https://engye.fuzzyworld.net/$1 [R=301,L] + +# Apps are static; nothing here is executed on the server. +RemoveHandler .php .php3 .php4 .php5 .php7 .php8 .phtml .phar .shtml .cgi .pl .py +RemoveType .php .php3 .php4 .php5 .php7 .php8 .phtml .phar +<FilesMatch "\.(php\d?|phtml|phar|shtml|cgi|pl|py|sh)$"> + Require all denied +</FilesMatch> +<Files ".fuzzyworld.json"> + Require all denied +</Files> + +<IfModule mod_headers.c> + Header always set X-Content-Type-Options nosniff + Header always set Referrer-Policy strict-origin-when-cross-origin + # Entry points must revalidate so a deploy is picked up; hashed build assets never change. + <FilesMatch "(\.html|sw\.js|service-worker\.js|\.webmanifest|manifest\.json)$"> + Header set Cache-Control "no-cache" + </FilesMatch> + <If "%{REQUEST_URI} =~ m#^/[^/]+/assets/#"> + Header set Cache-Control "public, max-age=31536000, immutable" + </If> +</IfModule> diff --git a/vite/index.js b/vite/index.js new file mode 100644 index 0000000..15e57cc --- /dev/null +++ b/vite/index.js @@ -0,0 +1,39 @@ +// Vite plugin for apps on apps.fuzzyworld.net. +// +// import fuzzyworld from 'fuzzyworld-tools/vite'; +// export default defineConfig({ plugins: [fuzzyworld()] }); +// +// Sets `base` to `/<app>/`, where <app> is `fuzzyworld.app` in package.json, so every +// asset URL stays inside the app's own path on the shared origin. `fw deploy` checks +// the built output (manifest, service worker scope) before anything is uploaded. +import fs from 'node:fs'; +import path from 'node:path'; + +export function readAppConfig(root = process.cwd()) { + const pkgPath = path.join(root, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const cfg = pkg.fuzzyworld; + if (!cfg || !cfg.app) { + throw new Error(`fuzzyworld: add "fuzzyworld": { "app": "<name>" } to ${pkgPath}`); + } + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(cfg.app)) { + throw new Error(`fuzzyworld: invalid app name "${cfg.app}"`); + } + return cfg; +} + +export default function fuzzyworld() { + return { + name: 'fuzzyworld', + config(config, { command }) { + const root = config.root ? path.resolve(config.root) : process.cwd(); + const { app } = readAppConfig(root); + const base = `/${app}/`; + // `vite dev` keeps the developer's own base; builds always use the app path. + if (command === 'build' && config.base && config.base !== base && config.base !== './') { + console.warn(`fuzzyworld: overriding base "${config.base}" with "${base}"`); + } + return command === 'build' ? { base } : {}; + }, + }; +}