feat(deploy): add Docker image, static server, and CI workflows

One build artifact serves both a self-hosted subpath deploy and GitHub
Pages. CI runs the tests, typechecks, builds, and smoke-tests the image.

server.ts's traversal guard uses a separator-aware check
(resolved === root || resolved.startsWith(root + path.sep)) instead of
a bare startsWith(root), which would let a request resolving to
"/app/dist-evil/x" pass a check against root "/app/dist".
This commit is contained in:
Lucas Winther
2026-09-01 21:33:45 +02:00
parent 4478c46a43
commit 6eb77c8733
7 changed files with 210 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.idea
docs
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun test
- run: bunx tsc --noEmit
- run: bun run build
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t budget2 .
- name: Serve the image and check it responds
run: |
docker run -d --rm -p 3000:3000 --name budget2 budget2
for i in $(seq 1 30); do
if curl -fsS http://localhost:3000/ > /dev/null; then break; fi
sleep 1
done
curl -fsS http://localhost:3000/ | grep -q "Budget"
curl -fsS http://localhost:3000/sw.js > /dev/null
curl -fsS http://localhost:3000/manifest.webmanifest > /dev/null
docker stop budget2
+46
View File
@@ -0,0 +1,46 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun test
- name: Build for the Pages subpath
env:
BUN_PUBLIC_BASE_PATH: /${{ github.event.repository.name }}/
BUILD_VERSION: ${{ github.sha }}
run: bun run build
- name: Stop Pages from running the output through Jekyll
run: touch dist/.nojekyll
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+16
View File
@@ -0,0 +1,16 @@
FROM oven/bun:1.3-alpine AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
ARG BUN_PUBLIC_BASE_PATH=/
ENV BUN_PUBLIC_BASE_PATH=$BUN_PUBLIC_BASE_PATH
RUN bun run build
FROM oven/bun:1.3-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY server.ts ./
ENV PORT=3000
EXPOSE 3000
CMD ["bun", "server.ts"]
+57 -2
View File
@@ -1,2 +1,57 @@
# budget2.0
New version of budget page, vibecoded.
# Budget 2.0
A personal budgeting PWA. Envelope-style monthly budgets with rollover, split
purchases, finance plans, multi-currency, and JSON/ODS export. All data lives in
your browser — there is no server and no account.
## Development
```bash
bun install
bun --hot src/index.ts # dev server with hot reload
bun test # run the test suite
bun run build # production build into dist/
```
## Self-hosting with Docker
```bash
docker build -t budget2 .
docker run --rm -p 3000:3000 budget2
```
To serve from a subpath, build and run with `BUN_PUBLIC_BASE_PATH`:
```bash
docker build -t budget2 --build-arg BUN_PUBLIC_BASE_PATH=/budget/ .
docker run --rm -p 3000:3000 -e BUN_PUBLIC_BASE_PATH=/budget/ budget2
```
## GitHub Pages
Pushing to `main` builds and deploys to Pages automatically, using
`/<repository-name>/` as the base path. Enable Pages with "GitHub Actions" as
the source in the repository settings.
## Your data
Everything is stored in this browser's IndexedDB. **Export regularly** from
Settings → Your data: the JSON export is both your backup and how you move your
budget to another device. Import replaces everything and downloads a backup of
your current data first.
## Known limitations
- **Data is per-browser.** There is no account and no sync. If you clear this
browser's site data, or move to a different browser or device, your budget is
gone unless you exported it first. The JSON export is not just a nice-to-have
backup — it is the only way to move a budget between devices.
- **The install prompt is incomplete.** `manifest.webmanifest` currently only
ships an SVG icon. Chrome requires PNG icons at 192×192 and 512×512 to offer
the "Install app" prompt; until those are added, the app works fine as a
regular tab but won't be installable on Chrome/Chromium.
## Documentation
- Design: `docs/superpowers/specs/2026-09-01-budget-app-design.md`
- Implementation plan: `docs/superpowers/plans/2026-09-01-budget-app.md`
+3 -1
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "NODE_ENV=production bun src/index.ts",
"build": "bun run build.ts"
"build": "bun run build.ts",
"serve": "bun server.ts",
"test": "bun test"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
+47
View File
@@ -0,0 +1,47 @@
import { serve } from "bun";
import path from "node:path";
const root = path.join(import.meta.dir, "dist");
const port = Number(process.env.PORT ?? 3000);
const basePath = (process.env.BUN_PUBLIC_BASE_PATH ?? "/").replace(/\/*$/, "/");
serve({
port,
async fetch(request) {
const url = new URL(request.url);
let pathname = decodeURIComponent(url.pathname);
if (basePath !== "/" && pathname.startsWith(basePath)) {
pathname = `/${pathname.slice(basePath.length)}`;
}
// Reject traversal before touching the filesystem. `startsWith(root)`
// alone is insufficient: with root = "/app/dist", a request resolving to
// "/app/dist-evil/x" would pass that check while escaping the intended
// directory. Require an exact match or a match followed by the path
// separator.
const resolved = path.join(root, pathname);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
return new Response("Forbidden", { status: 403 });
}
const file = Bun.file(resolved);
if (await file.exists()) {
const headers =
// sw.js must never be served stale, or updates can never land.
pathname.endsWith("/sw.js")
? { "Cache-Control": "no-cache" }
: /-[A-Za-z0-9]{8,}\.(js|css)$/.test(pathname)
? { "Cache-Control": "public, max-age=31536000, immutable" }
: { "Cache-Control": "no-cache" };
return new Response(file, { headers });
}
// Client-side routing: unknown paths get the shell.
return new Response(Bun.file(path.join(root, "index.html")), {
headers: { "Cache-Control": "no-cache", "Content-Type": "text/html" },
});
},
});
console.log(`Serving dist on http://localhost:${port}${basePath}`);