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