From 00d3cb0e61a49d1d529fe541fdde78f4f64c26b5 Mon Sep 17 00:00:00 2001 From: Tom Flux Date: Fri, 14 Aug 2026 22:41:11 +0100 Subject: [PATCH] Add systemd unit, nginx config and a README The nginx block is plain http; certbot adds the TLS server block and the redirect itself. It sets X-Forwarded-Proto, which is load-bearing: the app compares the browser's Origin against the URL it believes it is serving, and without that header it thinks it is on http while the browser says https, decides every save is cross-site and drops the session. Upstream's README is kept as README.nullboard.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU --- README.md | 140 ++++++++++++++++++++++++++++++++++ deploy/board.jihakuz.xyz.conf | 39 ++++++++++ deploy/validboard.service | 50 ++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 README.md create mode 100644 deploy/board.jihakuz.xyz.conf create mode 100644 deploy/validboard.service diff --git a/README.md b/README.md new file mode 100644 index 0000000..30c73fd --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# ValidBoard + +A fork of [Nullboard](https://github.com/apankrat/nullboard) that stores boards +in SQLite on a server instead of the browser's `localStorage`, behind a login. + +Same board, same keyboard shortcuts, same everything — it just follows you +between browsers and machines now, and survives clearing your site data. + +Upstream's own README is kept as [README.nullboard.md](README.nullboard.md). + +## How it works + +Nullboard's storage layer already bottoms out in three methods over string +keys — `getItem`, `setItem`, `delItem` (see `class Storage` in +`nullboard.html`). Boards, revisions, undo history and preferences are all +built on top of those, client-side. + +So the server is just a key/value store, and knows nothing about boards: + +| | | +|---|---| +| `GET /api/items` | every key/value pair | +| `POST /api/items` | a batch of `set`/`del` ops, applied in one transaction | +| `DELETE /api/items` | wipe the boards (keeps your password) | + +The fork adds one class, `Storage_Server`, alongside upstream's +`Storage_Local`, and swaps which one gets instantiated. Everything else in +`nullboard.html` is untouched, so upstream changes still merge. + +Two details worth knowing: + +**Reads are synchronous, so the data ships with the page.** `setItem` has to +return true or false immediately, which no network round trip can do. The +server therefore embeds the whole keyspace into the page as a JSON block +(`#nb-bootstrap`), and `Storage_Server` hydrates an in-memory `Map` from it +before the app boots. Reads are memory reads. It's in the page rather than a +separate `.js` file so another site can't `` can't break out of the bootstrap block). + +### Configuration + +| Variable | Default | | +|---|---|---| +| `VALIDBOARD_DB` | `./data/validboard.db` | boards, password hash and session secret | +| `VALIDBOARD_SECURE_COOKIE` | off | set to `1` behind https | +| `VALIDBOARD_SECRET` | from the database | session signing key; generated and stored on first run, so restarts don't sign you out | + +## Deploying + +``` +sudo cp deploy/validboard.service /etc/systemd/system/ +sudo systemctl daemon-reload && sudo systemctl enable --now validboard + +sudo cp deploy/board.jihakuz.xyz.conf /etc/nginx/sites-available/board.jihakuz.xyz +sudo ln -s /etc/nginx/sites-available/board.jihakuz.xyz /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d board.jihakuz.xyz +``` + +The nginx block is plain http on purpose — certbot adds the TLS server block +and the redirect itself. + +**If saves start coming back 401 after enabling TLS**, it's the +`X-Forwarded-Proto` header. The app compares the browser's `Origin` against the +URL it thinks it's serving; without that header it believes it's on `http://` +while the browser says `https://`, decides the write is cross-site, and drops +the session. The supplied nginx config sets it. + +### Backups + +Everything is one file. `sqlite3 data/validboard.db ".backup /somewhere/vb.db"` +takes a consistent copy while the service is running — don't just `cp` it, the +WAL sidecar may hold recent writes. + +## Security + +- One password, bcrypt-hashed. No user table; adding a second user means a + schema migration. +- Session cookie is signed, `HttpOnly`, `SameSite=Lax`, and `Secure` when + `VALIDBOARD_SECURE_COOKIE=1`. +- Ten failed logins from an IP locks that IP out for 15 minutes. +- rack-protection is on, which includes session hijacking detection keyed on + the User-Agent — so a browser that changes its UA string signs you out. If + that ever gets irritating, `set :protection, except: [:session_hijacking]` + in `app.rb`. +- Keys are validated against `[A-Za-z0-9._-]{1,128}` and values capped at 2 MB. + +## Keeping up with upstream + +``` +git fetch upstream +git merge upstream/master +``` + +The changes to `nullboard.html` are additive — one class, one CSS block, some +markup in the header, and a single changed line where `Storage_Local` used to +be instantiated — so conflicts should be rare and small. + +## License + +Nullboard is by Alexander Pankratov, under the 2-clause BSD license with the +[Commons Clause](LICENSE) — free to use, change and redistribute, but not to +sell or run as a paid service. This fork keeps that license unchanged, and the +licence requires the full text to travel with any copy, so `LICENSE` stays put. diff --git a/deploy/board.jihakuz.xyz.conf b/deploy/board.jihakuz.xyz.conf new file mode 100644 index 0000000..c42e289 --- /dev/null +++ b/deploy/board.jihakuz.xyz.conf @@ -0,0 +1,39 @@ +# ValidBoard — nginx site +# +# sudo cp deploy/board.jihakuz.xyz.conf /etc/nginx/sites-available/board.jihakuz.xyz +# sudo ln -s /etc/nginx/sites-available/board.jihakuz.xyz /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx +# sudo certbot --nginx -d board.jihakuz.xyz +# +# Plain http only, on purpose — certbot adds the 443 server block, the +# certificate lines and the http->https redirect itself. + +server { + listen 80; + listen [::]:80; + + server_name board.jihakuz.xyz; + + # ValidBoard rejects cross-site writes by comparing the browser's Origin + # header against the URL it thinks it is serving. It builds that URL from + # the headers below, so without X-Forwarded-Proto it will believe it is on + # http:// while the browser says https:// — and every save comes back 403 + # the moment certbot switches the site to TLS. + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # The whole board set is inlined into the page at load, so it is worth + # compressing; it's all text. + gzip on; + gzip_types text/html application/json application/javascript text/css; + gzip_min_length 1024; + + # Matches the server's own 2 MB per-item cap, with room for the envelope. + client_max_body_size 4m; + + location / { + proxy_pass http://127.0.0.1:8047; + } +} diff --git a/deploy/validboard.service b/deploy/validboard.service new file mode 100644 index 0000000..59a47ba --- /dev/null +++ b/deploy/validboard.service @@ -0,0 +1,50 @@ +# ValidBoard — systemd unit +# +# sudo cp deploy/validboard.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now validboard +# systemctl status validboard +# +# Set the password before the first start, as the same user this runs as: +# bin/validboard-passwd + +[Unit] +Description=ValidBoard - kanban boards stored in SQLite +Documentation=https://git.tomflux.xyz/tom/ValidBoard +After=network.target + +[Service] +Type=simple +User=susan +Group=www-data +WorkingDirectory=/disks/git-repos/ValidBoard + +# The database holds the boards, the password hash and the session secret. +# Point it elsewhere (e.g. /var/lib/validboard/validboard.db) if you'd rather +# keep data off the repo disk — just create the directory and chown it first. +Environment=VALIDBOARD_DB=/disks/git-repos/ValidBoard/data/validboard.db + +# nginx terminates TLS, so the session cookie should never go out over plain +# http. Drop this line if you ever run the service without a certificate. +Environment=VALIDBOARD_SECURE_COOKIE=1 + +Environment=RACK_ENV=production +Environment=APP_ENV=production + +ExecStart=/usr/bin/puma --bind tcp://127.0.0.1:8047 --threads 0:8 --environment production config.ru + +Restart=on-failure +RestartSec=5s + +# Boards are small and the process is a single Ruby app; none of this is +# load-bearing, it just limits the blast radius. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=full +ProtectHome=read-only +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes + +[Install] +WantedBy=multi-user.target