From 6eb77c8733ee3c68b7df31c6b58c0b9afbc4a5a5 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 21:33:45 +0200 Subject: [PATCH] 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". --- .dockerignore | 5 ++++ .github/workflows/ci.yml | 36 ++++++++++++++++++++++ .github/workflows/pages.yml | 46 +++++++++++++++++++++++++++++ Dockerfile | 16 ++++++++++ README.md | 59 +++++++++++++++++++++++++++++++++++-- package.json | 4 ++- server.ts | 47 +++++++++++++++++++++++++++++ 7 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pages.yml create mode 100644 Dockerfile create mode 100644 server.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2bd3cbc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.idea +docs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9261a06 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..9acb05f --- /dev/null +++ b/.github/workflows/pages.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc8a24e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index a8ea802..93d2f19 100644 --- a/README.md +++ b/README.md @@ -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 +`//` 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` diff --git a/package.json b/package.json index 1b956aa..144b718 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..3e8c0b2 --- /dev/null +++ b/server.ts @@ -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}`);