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:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.idea
|
||||
docs
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
Reference in New Issue
Block a user