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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU
This commit is contained in:
Tom Flux
2026-08-14 22:41:11 +01:00
co-authored by Claude Opus 5
parent d94984cd97
commit 00d3cb0e61
3 changed files with 229 additions and 0 deletions
+140
View File
@@ -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 `<script src>` it and read your
boards.
**Writes are batched and optimistic.** Changes are collected by key — a note
edited five times in a second is one write — and flushed ~300 ms later as a
single transaction. Failures go back on the queue and retry with a backoff, the
logo shows *saving…* / *not saved*, and closing the tab with writes still
pending asks for confirmation. Nothing is lost if the server is briefly away.
## Running it
Dependencies are all packaged on Debian/Ubuntu; no bundler needed:
```
sudo apt install ruby-full ruby-sqlite3 ruby-sinatra ruby-bcrypt \
ruby-rack ruby-rack-protection ruby-json puma
```
Set the password, then start it:
```
bin/validboard-passwd # or: pass show validboard | bin/validboard-passwd
rake server # http://127.0.0.1:8047
```
Until a password is set every route returns 503 telling you to run that
command — letting the first visitor choose one would hand your board to
whoever found the URL first.
### Tests
```
rake test
```
49 tests covering the store (validation, transactions, password hashing,
schema guard) and the app (auth, lockout, the API, cross-site writes, and that
a note containing `</script>` 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.
+39
View File
@@ -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;
}
}
+50
View File
@@ -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