Compare commits

..
13 Commits
Author SHA1 Message Date
Tom FluxandClaude Opus 5 8338374a8d Follow the OS dark mode on the login page
The board's dark theme is a saved preference, which there is no way to
know before you're signed in, so the login page follows
prefers-color-scheme instead. Palette taken from nullboard's .theme-dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU
2026-08-14 22:44:41 +01:00
Tom FluxandClaude Opus 5 00d3cb0e61 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
2026-08-14 22:41:11 +01:00
Tom FluxandClaude Opus 5 d94984cd97 Add tests for the store and the API
Rack::MockRequest with a cookie jar rather than rack-test, so the suite
needs nothing beyond the Debian packages the app already uses.

Covers key validation and batch atomicity, password hashing, the schema
version guard, auth and lockout, the storage API, and that a note
containing a closing script tag can't break out of the bootstrap block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU
2026-08-14 22:41:11 +01:00
Tom FluxandClaude Opus 5 7e2f280acc Store boards on the server rather than in localStorage
Adds Storage_Server next to upstream's Storage_Local and swaps which one
is instantiated; the rest of nullboard.html is untouched so upstream
changes still merge.

The Storage contract is synchronous — setItem has to return true or false
there and then — which no round trip can satisfy. So the whole keyspace
is held in memory and the server is somewhere to push it to:

  - The initial copy is embedded in the page by the server, already
    parsed and in hand before the app boots. In the page rather than a
    .js file so another site can't <script src> it and read the boards.
  - Writes are collected by key, so a note edited repeatedly is one
    write, and flushed shortly after as a single transaction. A failed
    batch goes back on the queue and retries with a backoff rather than
    being dropped.
  - The logo shows saving/not saved, and closing the tab with writes
    still queued asks first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU
2026-08-14 22:41:01 +01:00
Tom FluxandClaude Opus 5 2ae11e8d1e Add a Sinatra server that keeps boards in SQLite
Nullboard's storage layer bottoms out in getItem/setItem/delItem over
string keys, with boards, revisions and undo history all modelled on top
of them client-side. So the server needs to be nothing more than a
key/value store, and it doesn't parse a board anywhere.

One password, bcrypt-hashed, in the same database as the boards. The
session secret lives there too, so restarting the service doesn't sign
you out and the secret never has to exist in the unit file or in git.

Until a password is set every route returns 503 pointing at
bin/validboard-passwd. A first-run setup page would be friendlier, but it
would also hand the board to whoever found the URL first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU
2026-08-14 22:40:51 +01:00
Alex Pankratov db65363530 Extract default backup agent config into a variable of its own 2023-11-05 18:35:09 +01:00
Alexander PankratovandGitHub d6384d0522 Merge pull request #66 from rpavlik/eslint
Eslint fixes
2023-11-05 16:10:17 +01:00
Alexander PankratovandGitHub 91411d9b67 Merge pull request #87 from RageGamerBoi/master
Make site scale correctly on Mobile
2023-11-05 16:06:28 +01:00
B2server e241fb5046 Make site scale correctly on Mobile 2023-11-01 22:37:05 -05:00
Alexander PankratovandGitHub b67c2a5451 + nbagent by luismedel 2023-09-23 13:42:29 +02:00
Ryan Pavlik a3cc607a8d Remove trailing whitespace 2022-11-14 09:29:55 -06:00
Ryan Pavlik 478d6af8f3 Fix typos found by eslint 2022-11-14 09:29:55 -06:00
Ryan Pavlik 486747d28e Remove apparent stray brace 2022-11-14 09:29:55 -06:00
16 changed files with 2104 additions and 113 deletions
+11
View File
@@ -0,0 +1,11 @@
# The SQLite database (plus its WAL sidecars) — boards, password hash and
# session secret all live here. Never commit it.
/data/
*.db
*.db-wal
*.db-shm
# bundler, if you use it
/.bundle/
/vendor/bundle/
Gemfile.lock
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
#
# On Debian/Ubuntu the dependencies are all packaged, and that is how the
# systemd unit runs — no bundler involved:
#
# apt install ruby-full ruby-sqlite3 ruby-sinatra ruby-bcrypt \
# ruby-rack ruby-rack-protection ruby-json puma
#
# This Gemfile is here for anyone who'd rather use bundler, and to pin the
# versions the code was written against. Sinatra 4 moved sessions around, hence
# the ~> 3.0.
source 'https://rubygems.org'
gem 'bcrypt', '~> 3.1'
gem 'puma', '~> 6.0'
gem 'rack', '~> 2.2'
gem 'sinatra', '~> 3.0'
gem 'sqlite3', '~> 1.4'
+103 -94
View File
@@ -1,131 +1,140 @@
# Nullboard
# ValidBoard
Nullboard is a minimalist take on a kanban board / a task list manager, designed to be compact, readable and quick in use.
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.
https://nullboard.io/preview
Same board, same keyboard shortcuts, same everything — it just follows you
between browsers and machines now, and survives clearing your site data.
![Nullboard](images/nullboard-example-alt.png)
Upstream's own README is kept as [README.nullboard.md](README.nullboard.md).
The name also happens to abbreviate to [NB](https://en.wikipedia.org/wiki/Nota_bene), which I think is a nice touch.
## How it works
## Dead simple
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.
* Single-page web app - just one HTML file, an ancient jQuery package and a webfont pack.
* Can be used completely offline. In fact, it's written exactly with this use in mind.
So the server is just a key/value store, and knows nothing about boards:
## Locally stored
| | |
|---|---|
| `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) |
* All data is stored locally, for now using [localStorage](https://developer.mozilla.org/en/docs/Web/API/Window/localStorage).
* The data can be exported to- or imported from a plain text file in a simple JSON format.
* The data can also be automatically backed up to a local disk with the help of:
* [Nullboard Agent](https://nullboard.io/backups) - a native Windows app
* [Nullboard Agent Express Port](https://github.com/justinpchang/nullboard-agent-express) - an express.js-based portable app
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.
## Beta
Two details worth knowing:
Still very much in beta. Caveat emptor and all that.
**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.
## UI & UX
**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.
The whole thing is largely about making it convenient to use.
## Running it
Everything is editable in place, all changes are saved automatically and last 50 revisions are kept for undo/redo:
Dependencies are all packaged on Debian/Ubuntu; no bundler needed:
![In-place editing](images/nullboard-inplace-editing.gif)
```
sudo apt install ruby-full ruby-sqlite3 ruby-sinatra ruby-bcrypt \
ruby-rack ruby-rack-protection ruby-json puma
```
New notes can be quickly added directly where they are needed, e.g. before or after existing notes:
Set the password, then start it:
![Ctrl-add note](images/nullboard-ctrl-add-note.gif)
```
bin/validboard-passwd # or: pass show validboard | bin/validboard-passwd
rake server # http://127.0.0.1:8047
```
Notes can also be dragged around, including to and from other lists:
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.
![Drag-n-drop](images/nullboard-drag-n-drop.gif)
### Tests
Nearly all controls are hidden by default to reduce visual clutter to its minimum:
```
rake test
```
![Hidden controls](images/nullboard-hidden-controls.gif)
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).
Longer notes can be collapsed to show just the first line, for even more compact view of the board:
### Configuration
![Collapsed notes](images/nullboard-collapsed-notes.gif)
| 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 |
The default font is [Barlow](https://tribby.com/fonts/barlow/) - it's both narrow *and* still very legible. Absolutely fantastic design!
## Deploying
![Barlow speciment](images/barlow-specimen.png)
```
sudo cp deploy/validboard.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now validboard
Notes can also be set to look a bit different. This is useful for partitioning lists into sections:
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
```
![Raw notes](images/nullboard-raw-notes.gif)
The nginx block is plain http on purpose — certbot adds the TLS server block
and the redirect itself.
Links starting with https:// and http:// are recognized. They will "pulse" on mouse hover and can be opened via the right-click menu.
**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.
![Links on hover](images/nullboard-links-on-hover.gif)
### Backups
Pressing CapsLock will highlight all links and make them left-clickable.
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.
![Links reveal](images/nullboard-links-reveal.gif)
## Security
Lists can be moved around as well, though not as flashy as notes:
- 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.
![List swapping](images/nullboard-list-swap.gif)
## Keeping up with upstream
The font can be changed; its size and line height can be adjusted:
```
git fetch upstream
git merge upstream/master
```
![Theme and zoom](images/nullboard-ui-preferences.gif)
The color theme can be inverted:
![Dark theme](images/nullboard-dark-theme.gif)
Also:
* Support for multiple boards with near-instant switching
* Undo/redo for 50 revisions per board (configurable in the code)
* Keyboard shortcuts, including Tab'ing through notes
## Caveats
* Written for desktop and keyboard/mouse use
* Essentially untested on mobile devices and against tap/touch input
* Works in Firefox, tested in Chrome, should work in Safari and may work in Edge (or what it's called now)
* Uses localStorage for storing boards/lists/notes, so be careful around [clearing your cache](https://stackoverflow.com/questions/9948284/how-persistent-is-localstorage)
You spot a bug, file an issue.
## Dockerized version
See [this fork](https://github.com/rsoper/nullboard).
## Background
Nullboard is something that handles ToDo lists in the way that works really well. For *me* that is.
Tried a lot of options, some were almost *it*, but none was 100%.
**Trello** wasn't bad, but never was comfortable with the idea of storing my data in cloud without any actual need.
**Wekan** looked promising, but ultimately too heavy and had no offline usage support or a local storage option.
**Things** was beautiful, but not the right tool for the job.
**Inkscape** - I kid you not - with a laundry list of text items was actually OK, but didn't scale well.
Ditto for the plain **text files**.
Pieces of **paper** were almost there, but rearranging items can be quite a hassle.
So finally got annoyed enough to sit down and write exactly what I wanted.
And, voilà, Nullboard came out => https://nullboard.io/preview
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
The [2-clause BSD license](https://opensource.org/licenses/BSD-2-Clause/) with the [Commons Clause](https://commonsclause.com/).
That is, you can use, change and re-distribute it for as long as you don't try and sell it.
## Updates
Primary feed is through [@nullboard](https://twitter.com/nullboard) on Twitter.
The changelog is here => https://nullboard.io/changes
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.
+132
View File
@@ -0,0 +1,132 @@
# Nullboard
Nullboard is a minimalist take on a kanban board / a task list manager, designed to be compact, readable and quick in use.
https://nullboard.io/preview
![Nullboard](images/nullboard-example-alt.png)
The name also happens to abbreviate to [NB](https://en.wikipedia.org/wiki/Nota_bene), which I think is a nice touch.
## Dead simple
* Single-page web app - just one HTML file, an ancient jQuery package and a webfont pack.
* Can be used completely offline. In fact, it's written exactly with this use in mind.
## Locally stored
* All data is stored locally, for now using [localStorage](https://developer.mozilla.org/en/docs/Web/API/Window/localStorage).
* The data can be exported to- or imported from a plain text file in a simple JSON format.
* The data can also be automatically backed up to a local disk with the help of:
* [Nullboard Agent](https://nullboard.io/backups) - a native Windows app
* [Nullboard Agent Express Port](https://github.com/justinpchang/nullboard-agent-express) - an express.js-based portable app
* [nbagent](https://github.com/luismedel/nbagent) - a version for Unix systems, in Python
## Beta
Still very much in beta. Caveat emptor and all that.
## UI & UX
The whole thing is largely about making it convenient to use.
Everything is editable in place, all changes are saved automatically and last 50 revisions are kept for undo/redo:
![In-place editing](images/nullboard-inplace-editing.gif)
New notes can be quickly added directly where they are needed, e.g. before or after existing notes:
![Ctrl-add note](images/nullboard-ctrl-add-note.gif)
Notes can also be dragged around, including to and from other lists:
![Drag-n-drop](images/nullboard-drag-n-drop.gif)
Nearly all controls are hidden by default to reduce visual clutter to its minimum:
![Hidden controls](images/nullboard-hidden-controls.gif)
Longer notes can be collapsed to show just the first line, for even more compact view of the board:
![Collapsed notes](images/nullboard-collapsed-notes.gif)
The default font is [Barlow](https://tribby.com/fonts/barlow/) - it's both narrow *and* still very legible. Absolutely fantastic design!
![Barlow speciment](images/barlow-specimen.png)
Notes can also be set to look a bit different. This is useful for partitioning lists into sections:
![Raw notes](images/nullboard-raw-notes.gif)
Links starting with https:// and http:// are recognized. They will "pulse" on mouse hover and can be opened via the right-click menu.
![Links on hover](images/nullboard-links-on-hover.gif)
Pressing CapsLock will highlight all links and make them left-clickable.
![Links reveal](images/nullboard-links-reveal.gif)
Lists can be moved around as well, though not as flashy as notes:
![List swapping](images/nullboard-list-swap.gif)
The font can be changed; its size and line height can be adjusted:
![Theme and zoom](images/nullboard-ui-preferences.gif)
The color theme can be inverted:
![Dark theme](images/nullboard-dark-theme.gif)
Also:
* Support for multiple boards with near-instant switching
* Undo/redo for 50 revisions per board (configurable in the code)
* Keyboard shortcuts, including Tab'ing through notes
## Caveats
* Written for desktop and keyboard/mouse use
* Essentially untested on mobile devices and against tap/touch input
* Works in Firefox, tested in Chrome, should work in Safari and may work in Edge (or what it's called now)
* Uses localStorage for storing boards/lists/notes, so be careful around [clearing your cache](https://stackoverflow.com/questions/9948284/how-persistent-is-localstorage)
You spot a bug, file an issue.
## Dockerized version
See [this fork](https://github.com/rsoper/nullboard).
## Background
Nullboard is something that handles ToDo lists in the way that works really well. For *me* that is.
Tried a lot of options, some were almost *it*, but none was 100%.
**Trello** wasn't bad, but never was comfortable with the idea of storing my data in cloud without any actual need.
**Wekan** looked promising, but ultimately too heavy and had no offline usage support or a local storage option.
**Things** was beautiful, but not the right tool for the job.
**Inkscape** - I kid you not - with a laundry list of text items was actually OK, but didn't scale well.
Ditto for the plain **text files**.
Pieces of **paper** were almost there, but rearranging items can be quite a hassle.
So finally got annoyed enough to sit down and write exactly what I wanted.
And, voilà, Nullboard came out => https://nullboard.io/preview
## License
The [2-clause BSD license](https://opensource.org/licenses/BSD-2-Clause/) with the [Commons Clause](https://commonsclause.com/).
That is, you can use, change and re-distribute it for as long as you don't try and sell it.
## Updates
Primary feed is through [@nullboard](https://twitter.com/nullboard) on Twitter.
The changelog is here => https://nullboard.io/changes
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
require 'rake/testtask'
Rake::TestTask.new(:test) do |t|
t.libs << 'test'
t.test_files = FileList['test/*_test.rb']
t.warning = false
end
desc 'Run the app locally on http://127.0.0.1:8047'
task :server do
sh 'puma --bind tcp://127.0.0.1:8047 config.ru'
end
task default: :test
+290
View File
@@ -0,0 +1,290 @@
# frozen_string_literal: true
#
# ValidBoard — a fork of Nullboard (https://github.com/apankrat/nullboard) that
# keeps boards in SQLite on the server instead of the browser's localStorage.
#
# The API is deliberately tiny: hydrate the whole keyspace on load, then push
# batches of set/delete back. See store.rb for why that's all it needs to be.
require 'sinatra/base'
require 'json'
require_relative 'store'
module ValidBoard
STORE = Store.new(db_path)
# Set VALIDBOARD_SECURE_COOKIE=1 once you're behind https, so the session
# cookie is never sent in the clear. Off by default so plain-http local runs
# can still log in.
SECURE_COOKIE = ENV['VALIDBOARD_SECURE_COOKIE'] == '1'
# Failed logins are throttled per IP. bcrypt already makes guessing slow, but
# a lockout turns "slow" into "not worth trying". In-memory is fine: there is
# one process, and a restart clearing the counters is not a meaningful win for
# an attacker who still has to get through bcrypt.
class LoginThrottle
MAX_FAILURES = 10
WINDOW = 15 * 60 # seconds
def initialize
@mutex = Mutex.new
@failures = Hash.new { |h, k| h[k] = [] }
end
def locked?(ip)
@mutex.synchronize { recent(ip).length >= MAX_FAILURES }
end
def retry_after(ip)
@mutex.synchronize do
oldest = recent(ip).first
oldest ? (oldest + WINDOW - Time.now.to_i) : 0
end
end
def record_failure(ip)
@mutex.synchronize { @failures[ip] = recent(ip) << Time.now.to_i }
end
def clear(ip)
@mutex.synchronize { @failures.delete(ip) }
end
private
# Caller holds the mutex.
def recent(ip)
cutoff = Time.now.to_i - WINDOW
@failures[ip].select { |t| t > cutoff }
end
end
class App < Sinatra::Base
set :root, ROOT
set :views, File.join(ROOT, 'views')
set :static, false # every path is served by an explicit route below
set :show_exceptions, false
set :dump_errors, true
# Sinatra's default protection stack gives us HttpOrigin (which rejects
# cross-site writes) and friends. It is wired up ahead of the session
# middleware for us, which is why sessions are configured through Sinatra
# rather than a bare `use Rack::Session::Cookie`.
set :protection, true
set :sessions,
key: 'validboard.session',
secret: ENV.fetch('VALIDBOARD_SECRET') { STORE.session_secret },
httponly: true,
secure: SECURE_COOKIE,
same_site: :lax,
expire_after: 60 * 60 * 24 * 30
THROTTLE = LoginThrottle.new
#
# helpers
#
helpers do
def authenticated?
session[:authenticated] == true
end
def json(obj, status_code = 200)
content_type :json
status status_code
obj.to_json
end
def api_request?
request.path_info.start_with?('/api/')
end
def client_ip
request.ip
end
# Nullboard's storage layer is synchronous, so the board data has to be
# in the page before its script runs. Embedding it here rather than
# serving it as a .js file means another site can't <script src> it and
# read your boards.
def board_html
html = File.read(File.join(ROOT, 'nullboard.html'))
# Without this the app would quietly boot with an empty keyspace and
# look like a fresh install sitting on top of a full database.
unless html.include?(BOOTSTRAP_MARKER)
raise "nullboard.html no longer contains the #nb-bootstrap placeholder"
end
html.sub(BOOTSTRAP_MARKER) { bootstrap_tag }
end
def bootstrap_tag
# A note containing "</script>" — or "<!--" followed by "<script" —
# would otherwise end the block early and spill board text into the
# document. Escaping every '<' removes the whole class of problem: it
# is valid JSON and parses straight back to '<' in the browser.
payload = JSON.generate(STORE.all_items).gsub('<', JSON_ESCAPED_LT)
%(<script type="application/json" id="nb-bootstrap">#{payload}</script>)
end
end
#
# filters
#
# Until a password exists there is nothing to log in with, and letting the
# first visitor pick one would hand the board to whoever found the URL
# first. Refuse to serve anything instead, and say how to fix it.
before do
next if request.path_info == '/healthz'
next if STORE.password_set?
halt 503, { 'Content-Type' => 'text/plain' },
"ValidBoard has no password set.\n\n" \
"Run this on the server, then reload:\n" \
" #{File.join(ROOT, 'bin', 'validboard-passwd')}\n"
end
before do
next if %w[/login /healthz].include?(request.path_info)
next if request.path_info.start_with?('/extras/', '/images/')
next if authenticated?
# An expired session shouldn't look like a server fault to the board: the
# client watches for this 401 and sends the tab to the login page.
if api_request?
halt 401, { 'Content-Type' => 'application/json' }, { error: 'unauthenticated' }.to_json
end
redirect '/login'
end
#
# the board itself
#
# The placeholder that nullboard.html carries for us to fill in.
BOOTSTRAP_MARKER = '<script type="application/json" id="nb-bootstrap">{}</script>'
# The six characters backslash-u-0-0-3-c, spelled as a concatenation so no
# editor or escape-processing step can quietly turn the source back into a
# bare '<'. There is a test covering exactly that.
JSON_ESCAPED_LT = '\\' + 'u003c'
get '/' do
cache_control :no_cache
content_type :html
board_html
end
# Upstream's fonts and jquery. Left unauthenticated so the login page can
# use the same fonts; they're public static assets either way.
#
# The character class is the path traversal defence — no slashes and no
# dots-only names get through it — so nothing here can escape the two
# directories named. Sinatra anchors the pattern itself; adding \A and \z
# here is an error.
get %r{/(?<dir>extras|images)/(?<file>[A-Za-z0-9._-]+)} do
path = File.join(ROOT, params[:dir], params[:file])
halt 404 unless File.file?(path)
cache_control :public, max_age: 86_400
send_file path
end
get '/healthz' do
content_type :text
"ok\n"
end
#
# auth
#
get '/login' do
redirect '/' if authenticated?
erb :login, locals: { error: nil }
end
post '/login' do
if THROTTLE.locked?(client_ip)
minutes = [(THROTTLE.retry_after(client_ip) / 60.0).ceil, 1].max
status 429
halt erb(:login, locals: { error: "Too many failed attempts. Try again in #{minutes} minute#{'s' if minutes != 1}." })
end
if STORE.password_matches?(params[:password])
THROTTLE.clear(client_ip)
session.clear
session[:authenticated] = true
redirect '/'
else
THROTTLE.record_failure(client_ip)
status 401
erb :login, locals: { error: 'Wrong password.' }
end
end
post '/logout' do
session.clear
redirect '/login'
end
#
# storage API
#
# Keys and values are opaque strings — the server never parses a board.
#
# The client's whole keyspace, in one round trip, so that nullboard's
# synchronous getItem() can be answered from an in-memory copy.
get '/api/items' do
json STORE.all_items
end
# A batch of writes: {"ops":[{"op":"set","key":"...","value":"..."},
# {"op":"del","key":"..."}]}
# Applied in a single transaction so a board save can't half-land.
post '/api/items' do
request.body.rewind
body = JSON.parse(request.body.read)
ops = body['ops']
halt 400, json({ error: 'ops must be an array' }) unless ops.is_a?(Array)
halt 400, json({ error: 'too many ops in one batch' }) if ops.length > 500
json({ applied: STORE.apply(ops) })
rescue JSON::ParserError
json({ error: 'malformed JSON' }, 400)
rescue Store::InvalidKey => e
json({ error: e.message }, 400)
rescue Store::ValueTooBig => e
json({ error: e.message }, 413)
end
# Backs nullboard's "wipe all data". Deliberately leaves the password and
# session secret alone, so you end up logged in with an empty board list
# rather than locked out.
delete '/api/items' do
STORE.wipe_items!
json({ ok: true })
end
error do |e|
warn "ValidBoard error: #{e.class}: #{e.message}\n #{e.backtrace&.first(5)&.join("\n ")}"
if api_request?
json({ error: 'internal error' }, 500)
else
status 500
content_type :text
"Something went wrong.\n"
end
end
end
end
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Sets (or resets) the single ValidBoard password.
#
# bin/validboard-passwd # prompts, twice, without echo
# VALIDBOARD_DB=/var/lib/validboard/validboard.db bin/validboard-passwd
#
# Run it as the same user the service runs as, or the new database file ends up
# owned by the wrong account.
require 'io/console'
require_relative '../store'
MIN_LENGTH = 8
store = ValidBoard::Store.new(ValidBoard.db_path)
puts "ValidBoard database: #{store.path}"
puts store.password_set? ? 'A password is already set; this will replace it.' : 'No password set yet.'
puts
def prompt(label)
$stdout.print(label)
$stdout.flush
value = $stdin.noecho(&:gets)
puts
value&.chomp
end
# Piped in — `pass show validboard | bin/validboard-passwd` — so take the one
# line and skip the confirmation there is no one there to type.
password =
if $stdin.tty?
typed = prompt('New password: ')
abort 'Aborted.' if typed.nil?
abort 'Passwords did not match.' unless prompt('Repeat password: ') == typed
typed
else
$stdin.gets&.chomp
end
abort 'No password given.' if password.nil? || password.empty?
if password.length < MIN_LENGTH
abort "Password must be at least #{MIN_LENGTH} characters."
end
store.password = password
puts 'Password updated.'
puts 'Existing sessions stay valid — restart the service if you want to force a re-login.'
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
require_relative 'app'
run ValidBoard::App
+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
+450 -17
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html>
<head>
<!--
@@ -46,6 +46,7 @@
-->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nullboard</title>
<link rel="icon" href="extras/favicon-16.png"/>
<style>
@@ -220,7 +221,7 @@
-ms-user-select: none;
user-select: none;
}
}
/***/
.clearfix:after,
.board:after,
@@ -750,6 +751,31 @@
color: #d20;
}
/*** ValidBoard: save state, next to the (updated) alert */
.logo .sync {
display: none;
font-style: normal;
margin-left: 5px;
}
.logo.sync-busy .sync,
.logo.sync-err .sync {
display: inline-block;
}
.logo.sync-busy .sync {
color: #999;
}
/* An unsaved board is worth breaking the logo's usual fade-out for. */
.logo.sync-err {
opacity: 1;
}
.logo.sync-err .sync {
color: #d20;
}
/***/
.config {
position: absolute;
@@ -1546,17 +1572,28 @@
</head>
<body>
<div class="logo">
<a class=site href=https://nullboard.io>Nullboard</a>
<a class=site href=/>ValidBoard</a>
<i class=alert></i>
<i class=sync></i>
<div class=bulk>
<a href=# class=view-about>About</a>
<a href=# class=view-license>License</a>
<a href=https://nullboard.io/changes target=_blank class=view-changes>Changes</a>
<a href=https://nullboard.io/github target=_blank>Github</a>
<a href=https://nullboard.io/twitter target=_blank>Twitter</a>
<a href=https://nullboard.io target=_blank>Nullboard</a>
<a href=# class=sign-out>Sign out</a>
</div>
</div>
<!--
ValidBoard: the server drops the whole keyspace in here before the app
script runs, so Storage_Server can answer nullboard's synchronous
getItem() from memory without a blocking request. Embedded in the page
rather than served as a .js file so it can't be read cross-site.
-->
<script type="application/json" id="nb-bootstrap">{}</script>
<form class=sign-out-form method=post action=/logout hidden></form>
<div class='config no-user-select'>
<a href=# class=teaser><i>&equiv;</i><u>&#x2714;</u></a>
<div class=bulk>
@@ -1748,6 +1785,14 @@
this.backupStatus = { }; // agentId => [ 'conf' ]
}
const default_backup_agents =
[
{ base: 'http://127.0.0.1:10001', auth: '' }, // local agent
{ base: '', auth: '' } // remote agent
];
//
function BoardMeta()
{
this.title = '';
@@ -1985,7 +2030,7 @@
var meta = this.boardIndex.get(board_id);
if (! meta)
throw `Invalid board_id in nukeBoard(${board.id})`;
throw `Invalid board_id in nukeBoard(${board_id})`;
var title = meta.title + '';
@@ -2038,7 +2083,7 @@
var meta = this.boardIndex.get(board_id);
if (! meta)
throw `Invalid board_id in setBoardRevision(${board_id}, ${revision})`;
throw `Invalid board_id in setBoardUiSpot(${board_id}, ${ui_spot})`;
meta.ui_spot = ui_spot;
@@ -2101,22 +2146,26 @@
agents[0].type != simp || agents[0].conf.base != 'http://127.0.0.1:10001' ||
agents[1].type != simp)
{
const def = default_backup_agents;
console.log('Unexpected backup config, will re-initialize.', agents);
conf.backups.agents = [];
conf.backups.agents.push({
type: simp,
id: simp + '-' + (conf.backups.nextId++),
enabled: false,
conf: { base: 'http://127.0.0.1:10001', auth: '' }
// localhost
type : simp,
id : simp + '-' + (conf.backups.nextId++),
enabled : def[0].base && def[0].auth,
conf : def[0],
})
conf.backups.agents.push({
type: simp,
id: simp + '-' + (conf.backups.nextId++),
enabled: false,
conf: { base: '', auth: '' }
// remote
type : simp,
id : simp + '-' + (conf.backups.nextId++),
enabled : def[1].base && def[1].auth,
conf : def[1],
})
this.saveConfig();
@@ -2431,6 +2480,368 @@
}
}
/*
* ValidBoard: boards live in SQLite on the server.
*
* The Storage contract above is synchronous — setItem() has to say yes or
* no there and then — and a round trip can't answer that fast. So this
* keeps the whole keyspace in memory and treats the server as somewhere to
* push it: reads are memory reads, writes are optimistic and batched.
*
* The initial copy is embedded in the page by the server (see #nb-bootstrap
* in the body), so it is already here by the time openInner() runs and no
* blocking request is needed at load.
*
* Keys are the same names Storage_Local uses, minus its 'nullboard.'
* prefix — the server's table is ours alone and needs no namespacing.
*/
class Storage_Server extends Storage
{
constructor()
{
super();
this.type = 'Server';
this.items = new Map(); // key -> value; the copy the app reads
this.pending = new Map(); // key -> op, queued for the next batch
this.inflight = null; // the batch currently being POSTed
this.timer = null;
this.failures = 0;
this.status = 'ok'; // 'ok' | 'busy' | 'error'
this.warned = false; // "session expired" alert is once-only
}
/*
* Storage interface
*/
getItem(name)
{
var val = this.items.get(name);
return (val === undefined) ? null : val;
}
setItem(name, val)
{
this.items.set(name, val);
this.enqueue(name, { op: 'set', key: name, value: val });
return true;
}
delItem(name)
{
this.items.delete(name);
this.enqueue(name, { op: 'del', key: name });
return true;
}
openInner()
{
if (! this.hydrate())
return false;
// Decide this before saving a default config, or the write below
// would make every new install look like an existing one.
var newInstall = (this.items.size == 0);
var conf = this.getJson('config');
if (conf)
{
this.conf = Object.assign(new AppConfig(), conf);
}
else if (! this.setJson('config', this.conf))
{
this.conf = null;
return false;
}
this.boardIndex = new Map();
// Array.from() because rebuildMeta() may write while we iterate.
for (var k of Array.from(this.items.keys()))
{
var m = k.match(/^board\.(\d+)\.meta$/);
if (! m)
continue;
var board_id = parseInt(m[1]);
var meta = this.getJson('board.' + board_id + '.meta');
if (! meta || ! meta.hasOwnProperty('history'))
{
console.log( `Invalid meta for board ${board_id}` );
continue;
}
for (var rev of meta.history)
if (! this.getJson('board.' + board_id + '.' + rev))
{
console.log( `Invalid revision ${rev} in history of ${board_id}` );
meta = this.rebuildMeta(board_id);
break;
}
if (! meta)
continue;
delete meta.backingUp; // run-time var
delete meta.needsBackup; // ditto
this.boardIndex.set(board_id, Object.assign(new BoardMeta(), meta));
}
this.fixupConfig(newInstall);
this.type = 'Server';
return true;
}
wipeInner()
{
this.items.clear();
this.pending.clear();
this.inflight = null;
var self = this;
fetch('/api/items', { method: 'DELETE', credentials: 'same-origin' })
.then(function(rsp){ if (! rsp.ok) throw new Error('server said ' + rsp.status); })
.catch(function(err){ self.setStatus('error', 'wipe failed: ' + err.message); });
this.conf = new AppConfig();
this.boardIndex = new Map();
}
/*
* private
*/
hydrate()
{
var el = document.getElementById('nb-bootstrap');
if (! el)
{
alert("ValidBoard: this page is missing its board data.\n\nReload to try again.");
return false;
}
try
{
var data = JSON.parse(el.textContent);
for (var k in data)
this.items.set(k, data[k]);
}
catch (x)
{
alert("ValidBoard: couldn't read the boards sent by the server.\n\n" + x);
return false;
}
console.log( `Loaded ${this.items.size} key(s) from the server` );
// Nothing else needs it, and leaving a copy of every board sitting
// in the DOM only invites confusion when debugging.
el.textContent = '{}';
return true;
}
// Same recovery as Storage_Local: a board whose meta has gone stale is
// worth rebuilding from whatever revisions did survive.
rebuildMeta(board_id)
{
var meta = new BoardMeta();
console.log( `Rebuilding meta for ${board_id} ...` );
meta.current = this.getItem('board.' + board_id); // may be null
var re = new RegExp('^board\\.' + board_id + '\\.(\\d+)$');
var revs = new Array();
for (var k of this.items.keys())
{
var m = k.match(re);
if (m) revs.push( parseInt(m[1]) );
}
if (! revs.length)
{
console.log('* No revisions found');
this.delItem('board.' + board_id);
return false;
}
revs.sort(function(a,b){ return b-a; });
meta.history = revs;
if (! meta.history.includes(meta.current))
meta.current = meta.history[meta.history.length-1];
var board = this.getJson('board.' + board_id + '.' + meta.current)
meta.title = (board.title || '(untitled board)');
this.setJson('board.' + board_id + '.meta', meta);
return meta;
}
/*
* write-behind
*
* Changes are collected by key — a note edited five times in a second
* is one write — and flushed as a single transaction shortly after.
*/
enqueue(key, op)
{
this.pending.set(key, op);
this.scheduleFlush();
}
// No argument: batch up whatever lands in the next FLUSH_DELAY ms, but
// don't let a steady stream of edits push the flush back forever.
// With one: a retry, which overrides any sooner flush already queued.
scheduleFlush(delay)
{
if (delay === undefined)
{
if (this.timer !== null)
return;
delay = Storage_Server.FLUSH_DELAY;
}
else if (this.timer !== null)
{
clearTimeout(this.timer);
}
var self = this;
this.timer = setTimeout(function(){
self.timer = null;
self.flush();
}, delay);
}
flush()
{
if (this.inflight || ! this.pending.size)
return;
this.inflight = this.pending;
this.pending = new Map();
var self = this;
var ops = Array.from(this.inflight.values());
this.setStatus('busy');
fetch('/api/items', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials : 'same-origin',
body : JSON.stringify({ ops: ops })
})
.then(function(rsp){
if (rsp.status == 401)
{
self.onSessionLost();
throw new Error('not signed in');
}
if (! rsp.ok)
throw new Error('server said ' + rsp.status);
self.inflight = null;
self.failures = 0;
if (self.pending.size)
{
self.setStatus('busy');
self.scheduleFlush();
}
else
{
self.setStatus('ok');
}
})
.catch(function(err){
// The batch goes back on the queue, so nothing is lost while
// the server is unreachable — it just keeps trying.
self.requeue();
self.failures++;
self.setStatus('error', err.message);
var delay = Math.min(30000, 1000 * Math.pow(2, self.failures - 1));
console.log( `Save failed (${err.message}), retrying in ${delay}ms` );
self.scheduleFlush(delay);
});
}
requeue()
{
if (! this.inflight)
return;
var merged = this.inflight;
// Anything queued while the batch was in flight is newer, so it
// wins over the copy going back on the queue.
this.pending.forEach(function(op, key){ merged.set(key, op); });
this.pending = merged;
this.inflight = null;
}
// The cookie expired, or you signed out in another tab. Keep retrying
// rather than dropping the edits — signing back in anywhere makes the
// next attempt succeed.
onSessionLost()
{
if (this.warned)
return;
this.warned = true;
alert("ValidBoard: your session has expired, so recent changes aren't saved yet.\n\n" +
"Sign in again in another tab and they'll be saved automatically.");
}
hasUnsavedChanges()
{
return (this.pending.size > 0) || (this.inflight !== null);
}
setStatus(status, detail)
{
this.status = status;
var $logo = $('.logo').removeClass('sync-busy sync-err');
var $sync = $('.logo .sync').attr('title', detail || '');
if (status == 'busy')
{
$logo.addClass('sync-busy');
$sync.text('saving...');
}
else if (status == 'error')
{
$logo.addClass('sync-err');
$sync.text('not saved');
}
else
{
$sync.text('');
}
}
}
Storage_Server.FLUSH_DELAY = 300; // ms
/*
*
*/
@@ -5081,7 +5492,7 @@
*/
var NB =
{
codeVersion: 20221112,
codeVersion: 20231105,
blobVersion: 20190412, // board blob format in Storage
board: null,
storage: null,
@@ -5099,7 +5510,7 @@
}
};
NB.storage = new Storage_Local();
NB.storage = new Storage_Server();
if (! NB.storage.open())
{
@@ -5107,6 +5518,28 @@
throw new Error();
}
/*
* ValidBoard: writes are asynchronous now, so a tab can be closed while a
* batch is still queued. Say so rather than losing it.
*/
window.addEventListener('beforeunload', function(e){
if (! NB.storage.hasUnsavedChanges())
return;
e.preventDefault();
e.returnValue = '';
return '';
});
$('.logo .sign-out').click(function(){
if (NB.storage.hasUnsavedChanges() &&
! confirm("Some changes haven't reached the server yet.\n\nSign out anyway?"))
return false;
$('.sign-out-form').submit();
return false;
});
var boards = NB.storage.getBoardIndex();
boards.forEach( function(meta, board_id) {
+205
View File
@@ -0,0 +1,205 @@
# frozen_string_literal: true
#
# ValidBoard — SQLite storage.
#
# The whole server is a key/value store. Nullboard's own storage layer already
# bottoms out in getItem/setItem/delItem over string keys (see `class Storage`
# in nullboard.html), so the database mirrors exactly that and nothing more:
# boards, revisions and undo history stay modelled client-side, and the server
# never needs to understand any of it.
require 'sqlite3'
require 'bcrypt'
require 'securerandom'
require 'fileutils'
module ValidBoard
ROOT = __dir__
# Kept next to the checkout by default so the service runs with no setup at
# all; point VALIDBOARD_DB somewhere like /var/lib/validboard if you'd rather
# keep data off the repo disk.
def self.db_path
ENV.fetch('VALIDBOARD_DB', File.join(ROOT, 'data', 'validboard.db'))
end
# Thin wrapper over a single SQLite connection.
#
# Puma is threaded and the sqlite3 gem's Database object is not safe to share
# across threads, so every statement goes through one mutex. A board is a few
# kilobytes of text, so the contention is irrelevant and this is a great deal
# simpler than a connection pool.
class Store
SCHEMA_VERSION = 1
# Nullboard's key names look like "config", "board.1699.meta", "board.1699.7".
# Anything outside this shape is a bug or an attack, so reject it at the door
# rather than storing junk.
KEY_RE = /\A[A-Za-z0-9._-]{1,128}\z/
# Generous next to a text kanban board, small enough to bound a bad request.
MAX_VALUE_BYTES = 2 * 1024 * 1024
class InvalidKey < StandardError; end
class ValueTooBig < StandardError; end
attr_reader :path
def initialize(path)
@path = path
@mutex = Mutex.new
FileUtils.mkdir_p(File.dirname(path))
@db = SQLite3::Database.new(path)
@db.busy_timeout = 5_000
# WAL keeps a slow fsync from blocking reads; NORMAL is the usual
# companion to it and is the right trade for a personal board.
@db.execute('PRAGMA journal_mode = WAL')
@db.execute('PRAGMA synchronous = NORMAL')
migrate!
# The DB holds the password hash and the session secret, so keep it to the
# owning user even if the umask would have been laxer.
File.chmod(0o600, path) if File.exist?(path)
end
#
# items — the board data
#
# The client hydrates its whole keyspace in one request on load. That is what
# lets Storage_Server answer nullboard's synchronous getItem() from memory.
def all_items
rows = @mutex.synchronize { @db.execute('SELECT key, value FROM items') }
rows.to_h
end
# Applies a batch of {op:, key:, value:} in one transaction, so a board save
# (which writes the revision, the meta and sometimes the config together)
# either lands whole or not at all.
def apply(ops)
now = Time.now.to_i
ops.each do |op|
key = op['key']
raise InvalidKey, "bad key: #{key.inspect}" unless key.is_a?(String) && key.match?(KEY_RE)
next unless op['op'] == 'set'
value = op['value']
raise InvalidKey, "missing value for #{key}" unless value.is_a?(String)
raise ValueTooBig, "value for #{key} exceeds #{MAX_VALUE_BYTES} bytes" if value.bytesize > MAX_VALUE_BYTES
end
@mutex.synchronize do
@db.transaction do
ops.each do |op|
case op['op']
when 'set'
@db.execute(<<~SQL, [op['key'], op['value'], now])
INSERT INTO items (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
SQL
when 'del'
@db.execute('DELETE FROM items WHERE key = ?', [op['key']])
else
raise InvalidKey, "unknown op: #{op['op'].inspect}"
end
end
end
end
ops.length
end
# Backs nullboard's "wipe all data". Only touches items, so the password and
# session secret survive — you stay logged in with an empty board list.
def wipe_items!
@mutex.synchronize { @db.execute('DELETE FROM items') }
end
def item_count
@mutex.synchronize { @db.get_first_value('SELECT COUNT(*) FROM items') }
end
#
# meta — server-side settings
#
def password_set?
!meta_get('password_hash').nil?
end
def password=(plaintext)
raise ArgumentError, 'password must be at least 8 characters' if plaintext.to_s.length < 8
meta_set('password_hash', BCrypt::Password.create(plaintext).to_s)
end
def password_matches?(plaintext)
hash = meta_get('password_hash')
return false if hash.nil?
BCrypt::Password.new(hash) == plaintext.to_s
rescue BCrypt::Errors::InvalidHash
false
end
# Kept in the database rather than the unit file so that restarts don't log
# you out and the secret never has to exist in git or in the process listing.
# VALIDBOARD_SECRET overrides it when you'd rather manage it yourself.
def session_secret
existing = meta_get('session_secret')
return existing if existing
secret = SecureRandom.hex(64)
meta_set('session_secret', secret)
secret
end
def meta_get(key)
@mutex.synchronize { @db.get_first_value('SELECT value FROM meta WHERE key = ?', [key]) }
end
def meta_set(key, value)
@mutex.synchronize do
@db.execute(<<~SQL, [key, value.to_s])
INSERT INTO meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
SQL
end
end
private
def migrate!
@mutex.synchronize do
@db.execute_batch(<<~SQL)
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS items (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
SQL
current = @db.get_first_value("SELECT value FROM meta WHERE key = 'schema_version'")
if current.nil?
@db.execute("INSERT INTO meta (key, value) VALUES ('schema_version', ?)", [SCHEMA_VERSION.to_s])
elsif current.to_i > SCHEMA_VERSION
raise "database schema v#{current} is newer than this code (v#{SCHEMA_VERSION})"
end
# Future migrations go here, keyed off `current`.
end
end
end
end
+292
View File
@@ -0,0 +1,292 @@
# frozen_string_literal: true
require_relative 'test_helper'
require 'sqlite3'
class AppTest < Minitest::Test
include TestHelpers
def setup
reset_app_store!
end
#
# nothing is reachable without a session
#
def test_board_redirects_to_login_when_signed_out
response = Client.new.get('/')
assert_equal 302, response.status
assert_equal '/login', URI(response.headers['Location']).path
end
def test_api_returns_401_json_when_signed_out
response = Client.new.get('/api/items')
assert_equal 401, response.status
assert_equal 'unauthenticated', json_body(response)['error']
end
def test_api_writes_are_refused_when_signed_out
response = Client.new.post_json('/api/items', ops: [{ op: 'set', key: 'config', value: 'x' }])
assert_equal 401, response.status
assert_equal 0, ValidBoard::STORE.item_count
end
def test_login_page_is_reachable_when_signed_out
response = Client.new.get('/login')
assert_equal 200, response.status
assert_includes response.body, 'ValidBoard'
end
def test_health_check_needs_no_session
response = Client.new.get('/healthz')
assert_equal 200, response.status
assert_equal "ok\n", response.body
end
#
# signing in
#
def test_wrong_password_is_refused
response = Client.new(remote_addr: '10.0.0.1').login('not-the-password')
assert_equal 401, response.status
assert_includes response.body, 'Wrong password'
end
def test_correct_password_starts_a_session
client = Client.new
response = client.login
assert_equal 303, response.status
assert_equal '/', URI(response.headers['Location']).path
refute_nil client.session_cookie
end
def test_session_cookie_is_httponly_and_samesite
response = Client.new.login
cookie = Array(response.headers['Set-Cookie']).flat_map { |h| h.split("\n") }
.find { |h| h.start_with?('validboard.session=') }
assert_match(/HttpOnly/i, cookie)
assert_match(/SameSite=Lax/i, cookie)
end
def test_signing_out_ends_the_session
client = signed_in_client
assert_equal 200, client.get('/api/items').status
response = client.post('/logout')
assert_equal 303, response.status
assert_equal 401, client.get('/api/items').status
end
def test_repeated_failures_are_locked_out
client = Client.new(remote_addr: '10.0.0.99')
ValidBoard::LoginThrottle::MAX_FAILURES.times do
assert_equal 401, client.login('wrong').status
end
response = client.login('wrong')
assert_equal 429, response.status
assert_includes response.body, 'Too many failed attempts'
# Still locked out even with the right password — that's the point.
assert_equal 429, client.login.status
end
def test_lockout_is_per_client
blocked = Client.new(remote_addr: '10.0.0.98')
ValidBoard::LoginThrottle::MAX_FAILURES.times { blocked.login('wrong') }
assert_equal 429, blocked.login.status
assert_equal 303, Client.new(remote_addr: '10.0.0.97').login.status
end
#
# the board page
#
def test_board_page_is_served_once_signed_in
response = signed_in_client.get('/')
assert_equal 200, response.status
assert_includes response.body, 'id="nb-bootstrap"'
assert_includes response.body, 'class Storage_Server'
end
def test_board_page_carries_the_stored_items
ValidBoard::STORE.apply([{ 'op' => 'set', 'key' => 'config', 'value' => '{"theme":"dark"}' }])
assert_equal({ 'config' => '{"theme":"dark"}' }, bootstrap_from(signed_in_client.get('/')))
end
def test_bootstrap_escapes_markup_so_a_note_cannot_break_out
payload = JSON.generate({ 'title' => 'closing </script> tag' })
ValidBoard::STORE.apply([{ 'op' => 'set', 'key' => 'board.1.1', 'value' => payload }])
response = signed_in_client.get('/')
script = response.body[/<script type="application\/json" id="nb-bootstrap">.*?<\/script>/m]
# The only </script> in the block must be the one that closes it.
assert_equal 1, script.scan(%r{</script>}).length
assert_includes script, 'u003c'
# ...and it still parses back to exactly what was stored.
assert_equal payload, bootstrap_from(response)['board.1.1']
end
def test_board_page_is_not_cached
assert_match(/no-cache/, signed_in_client.get('/').headers['Cache-Control'].to_s)
end
#
# the storage API
#
def test_write_then_read_back
client = signed_in_client
response = client.post_json('/api/items', ops: [
{ op: 'set', key: 'config', value: '{"a":1}' },
{ op: 'set', key: 'board.7.meta', value: '{"b":2}' }
])
assert_equal 200, response.status
assert_equal 2, json_body(response)['applied']
assert_equal({ 'config' => '{"a":1}', 'board.7.meta' => '{"b":2}' },
json_body(client.get('/api/items')))
end
def test_delete_op_removes_an_item
client = signed_in_client
client.post_json('/api/items', ops: [{ op: 'set', key: 'board.7.1', value: 'x' }])
client.post_json('/api/items', ops: [{ op: 'del', key: 'board.7.1' }])
assert_equal({}, json_body(client.get('/api/items')))
end
def test_malformed_json_is_a_400
client = signed_in_client
response = client.post('/api/items', input: 'not json', 'CONTENT_TYPE' => 'application/json')
assert_equal 400, response.status
assert_equal 'malformed JSON', json_body(response)['error']
end
def test_missing_ops_array_is_a_400
assert_equal 400, signed_in_client.post_json('/api/items', {}).status
end
def test_oversized_batch_is_refused
ops = Array.new(501) { |i| { op: 'set', key: "board.1.#{i}", value: 'x' } }
assert_equal 400, signed_in_client.post_json('/api/items', ops: ops).status
assert_equal 0, ValidBoard::STORE.item_count
end
def test_bad_key_is_a_400_and_writes_nothing
client = signed_in_client
response = client.post_json('/api/items', ops: [{ op: 'set', key: 'nullboard.../x', value: 'x' }])
assert_equal 400, response.status
assert_equal 0, ValidBoard::STORE.item_count
end
def test_oversized_value_is_a_413
response = signed_in_client.post_json('/api/items',
ops: [{ op: 'set', key: 'big', value: 'x' * (2 * 1024 * 1024 + 1) }])
assert_equal 413, response.status
end
def test_wipe_clears_boards_but_leaves_you_signed_in
client = signed_in_client
client.post_json('/api/items', ops: [{ op: 'set', key: 'config', value: 'x' }])
assert_equal 200, client.delete('/api/items').status
assert_equal({}, json_body(client.get('/api/items')))
assert ValidBoard::STORE.password_set?, 'wiping must not clear the password'
end
#
# cross-site protection
#
# rack-protection's HttpOrigin catches this. Sinatra configures every
# protection with reaction :drop_session rather than :deny, so the request
# arrives at the auth filter with an empty session and comes back 401 instead
# of 403 — both are a refusal, and which one it is isn't the point worth
# pinning down here.
def test_write_from_another_origin_is_rejected
client = signed_in_client
response = client.post_json('/api/items',
{ ops: [{ op: 'set', key: 'config', value: 'x' }] },
'HTTP_ORIGIN' => 'https://evil.example')
assert_includes [401, 403], response.status
assert_equal 0, ValidBoard::STORE.item_count, 'a cross-site write must not reach the database'
end
def test_write_from_our_own_origin_is_allowed
client = signed_in_client
response = client.post_json('/api/items',
{ ops: [{ op: 'set', key: 'config', value: 'x' }] },
'HTTP_ORIGIN' => 'http://example.org', 'HTTP_HOST' => 'example.org')
assert_equal 200, response.status
end
#
# first run
#
def test_service_refuses_to_serve_until_a_password_is_set
without_password do
response = Client.new.get('/')
assert_equal 503, response.status
assert_includes response.body, 'validboard-passwd'
end
end
def test_health_check_still_answers_without_a_password
without_password do
assert_equal 200, Client.new.get('/healthz').status
end
end
private
def bootstrap_from(response)
json = response.body[/<script type="application\/json" id="nb-bootstrap">(.*?)<\/script>/m, 1]
refute_nil json, 'page had no bootstrap block'
JSON.parse(json)
end
# Reaches around the Store to unset the password, since nothing in the app is
# allowed to do that.
def without_password
hash = ValidBoard::STORE.meta_get('password_hash')
db = SQLite3::Database.new(ValidBoard::STORE.path)
db.execute("DELETE FROM meta WHERE key = 'password_hash'")
db.close
yield
ensure
ValidBoard::STORE.meta_set('password_hash', hash) if hash
end
end
+192
View File
@@ -0,0 +1,192 @@
# frozen_string_literal: true
require_relative 'test_helper'
class StoreTest < Minitest::Test
def setup
@path = File.join(TEST_DIR, "store-#{name}-#{object_id}.db")
@store = ValidBoard::Store.new(@path)
end
def teardown
FileUtils.rm_f(Dir.glob("#{@path}*"))
end
#
# items
#
def test_starts_empty
assert_equal({}, @store.all_items)
assert_equal 0, @store.item_count
end
def test_set_and_read_back
@store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => '{"theme":"dark"}' }])
assert_equal({ 'config' => '{"theme":"dark"}' }, @store.all_items)
end
def test_set_overwrites_existing_key
@store.apply([{ 'op' => 'set', 'key' => 'board.1.meta', 'value' => 'first' }])
@store.apply([{ 'op' => 'set', 'key' => 'board.1.meta', 'value' => 'second' }])
assert_equal 'second', @store.all_items['board.1.meta']
assert_equal 1, @store.item_count
end
def test_delete_removes_key
@store.apply([{ 'op' => 'set', 'key' => 'board.1.7', 'value' => 'x' }])
@store.apply([{ 'op' => 'del', 'key' => 'board.1.7' }])
assert_equal({}, @store.all_items)
end
def test_deleting_a_missing_key_is_not_an_error
assert_equal 1, @store.apply([{ 'op' => 'del', 'key' => 'never.existed' }])
end
def test_mixed_batch_applies_in_order
@store.apply([{ 'op' => 'set', 'key' => 'keep', 'value' => 'a' },
{ 'op' => 'set', 'key' => 'drop', 'value' => 'b' }])
applied = @store.apply([{ 'op' => 'set', 'key' => 'keep', 'value' => 'c' },
{ 'op' => 'del', 'key' => 'drop' },
{ 'op' => 'set', 'key' => 'fresh', 'value' => 'd' }])
assert_equal 3, applied
assert_equal({ 'keep' => 'c', 'fresh' => 'd' }, @store.all_items)
end
def test_values_survive_awkward_characters
value = %(unicode ✓ / quote " / backslash \\ / newline \n / tag </script>)
@store.apply([{ 'op' => 'set', 'key' => 'odd', 'value' => value }])
assert_equal value, @store.all_items['odd']
end
#
# validation — a whole batch is checked before any of it is written, so a
# bad op can't leave half a board save behind
#
def test_rejects_key_with_illegal_characters
assert_raises(ValidBoard::Store::InvalidKey) do
@store.apply([{ 'op' => 'set', 'key' => '../../etc/passwd', 'value' => 'x' }])
end
end
def test_rejects_empty_key
assert_raises(ValidBoard::Store::InvalidKey) do
@store.apply([{ 'op' => 'set', 'key' => '', 'value' => 'x' }])
end
end
def test_rejects_unknown_op
assert_raises(ValidBoard::Store::InvalidKey) do
@store.apply([{ 'op' => 'truncate', 'key' => 'config' }])
end
end
def test_rejects_oversized_value
assert_raises(ValidBoard::Store::ValueTooBig) do
@store.apply([{ 'op' => 'set', 'key' => 'huge', 'value' => 'x' * (2 * 1024 * 1024 + 1) }])
end
end
def test_bad_op_leaves_the_rest_of_the_batch_unwritten
@store.apply([{ 'op' => 'set', 'key' => 'before', 'value' => 'original' }])
assert_raises(ValidBoard::Store::InvalidKey) do
@store.apply([{ 'op' => 'set', 'key' => 'before', 'value' => 'changed' },
{ 'op' => 'set', 'key' => 'bad key!', 'value' => 'x' }])
end
assert_equal 'original', @store.all_items['before']
assert_equal 1, @store.item_count
end
#
# wipe
#
def test_wipe_clears_items_but_keeps_the_password
@store.password = 'a-long-enough-password'
@store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => 'x' }])
@store.wipe_items!
assert_equal({}, @store.all_items)
assert @store.password_set?, 'wiping boards must not lock you out'
end
#
# password
#
def test_no_password_until_one_is_set
refute @store.password_set?
refute @store.password_matches?('anything')
end
def test_password_round_trip
@store.password = 'a-long-enough-password'
assert @store.password_set?
assert @store.password_matches?('a-long-enough-password')
refute @store.password_matches?('a-long-enough-passwerd')
refute @store.password_matches?('')
end
def test_password_is_hashed_not_stored
@store.password = 'a-long-enough-password'
refute_includes @store.meta_get('password_hash'), 'a-long-enough-password'
assert_match(/\A\$2[aby]\$/, @store.meta_get('password_hash'))
end
def test_short_password_is_refused
assert_raises(ArgumentError) { @store.password = 'short' }
end
def test_password_can_be_changed
@store.password = 'the-first-password'
@store.password = 'the-second-password'
refute @store.password_matches?('the-first-password')
assert @store.password_matches?('the-second-password')
end
#
# session secret
#
def test_session_secret_is_generated_once_and_kept
first = @store.session_secret
assert_operator first.length, :>=, 64
assert_equal first, @store.session_secret
assert_equal first, ValidBoard::Store.new(@path).session_secret, 'secret must survive a restart'
end
#
# schema
#
def test_reopening_an_existing_database_keeps_the_data
@store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => 'kept' }])
assert_equal 'kept', ValidBoard::Store.new(@path).all_items['config']
end
def test_refuses_a_database_from_a_newer_version
@store.meta_set('schema_version', ValidBoard::Store::SCHEMA_VERSION + 1)
error = assert_raises(RuntimeError) { ValidBoard::Store.new(@path) }
assert_match(/newer than this code/, error.message)
end
def test_database_file_is_not_world_readable
assert_equal '600', format('%o', File.stat(@path).mode & 0o777)
end
end
+98
View File
@@ -0,0 +1,98 @@
# frozen_string_literal: true
require 'minitest/autorun'
require 'tmpdir'
require 'fileutils'
require 'json'
require 'rack'
require 'rack/mock'
# Point the app at a throwaway database *before* loading it — app.rb opens the
# store at require time.
TEST_DIR = Dir.mktmpdir('validboard-test')
ENV['VALIDBOARD_DB'] = File.join(TEST_DIR, 'test.db')
ENV.delete('VALIDBOARD_SECRET')
ENV.delete('VALIDBOARD_SECURE_COOKIE')
Minitest.after_run { FileUtils.remove_entry(TEST_DIR, true) }
require_relative '../app'
TEST_PASSWORD = 'correct-horse-battery'
# A cookie-carrying client. Rack::MockRequest doesn't keep a jar of its own, so
# without this every request would look like a fresh browser and nothing that
# depends on a session could be tested.
class Client
COOKIE_NAME = 'validboard.session'
def initialize(remote_addr: '127.0.0.1')
@mock = Rack::MockRequest.new(ValidBoard::App)
@cookies = {}
@remote_addr = remote_addr
end
def get(path, env = {}) = request('GET', path, env)
def post(path, env = {}) = request('POST', path, env)
def delete(path, env = {}) = request('DELETE', path, env)
def post_json(path, obj, env = {})
post(path, env.merge(input: JSON.generate(obj), 'CONTENT_TYPE' => 'application/json'))
end
def login(password = TEST_PASSWORD)
post('/login', params: { 'password' => password })
end
def session_cookie
@cookies[COOKIE_NAME]
end
private
def request(method, path, env)
# Rack::MockRequest leaves HTTP_VERSION unset, which reads as HTTP/1.0 —
# and Sinatra answers a POST redirect with 302 there but 303 over 1.1. Pin
# it so the tests see what a browser will.
env = { 'REMOTE_ADDR' => @remote_addr, 'HTTP_VERSION' => 'HTTP/1.1' }.merge(env)
env['HTTP_COOKIE'] = @cookies.map { |k, v| "#{k}=#{v}" }.join('; ') unless @cookies.empty?
response = @mock.request(method, path, env)
absorb_cookies(response)
response
end
def absorb_cookies(response)
raw = response.headers['Set-Cookie']
return if raw.nil?
Array(raw).flat_map { |header| header.split("\n") }.each do |line|
name, value = line.split(';', 2).first.to_s.split('=', 2)
next if name.nil?
@cookies[name] = value.to_s
end
end
end
module TestHelpers
# Puts the app's store back to a known state: one password, no boards.
def reset_app_store!
ValidBoard::STORE.wipe_items!
ValidBoard::STORE.password = TEST_PASSWORD
end
def signed_in_client(**kwargs)
client = Client.new(**kwargs)
response = client.login
raise "login failed: #{response.status}" unless response.status == 303
client
end
def json_body(response)
JSON.parse(response.body)
end
end
+147
View File
@@ -0,0 +1,147 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ValidBoard</title>
<link rel="icon" href="/extras/favicon-16.png" sizes="16x16" type="image/png">
<style>
/* Barlow at nullboard's own sizes, so the login doesn't feel bolted on. */
@font-face {
font-family: 'f-barlow';
font-weight: 400;
font-style: normal;
src: url('/extras/Barlow-Regular.woff') format('woff');
}
@font-face {
font-family: 'f-barlow';
font-weight: 500;
font-style: normal;
src: url('/extras/Barlow-Medium.woff') format('woff');
}
html, body, input {
font-family: f-barlow, sans-serif;
font-size: 13px;
line-height: 17px;
padding: 0;
margin: 0;
}
body {
background: #f8f9fb;
color: #333;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.box {
width: 260px;
margin-top: -60px; /* sit a little above centre */
padding: 0 20px 20px;
}
h1 {
font-size: 18px;
font-weight: 500;
line-height: 24px;
margin: 0 0 2px;
color: #222;
}
.sub {
color: #999;
margin-bottom: 18px;
}
label {
display: block;
color: #999;
margin-bottom: 4px;
}
input[type=password] {
width: 100%;
box-sizing: border-box;
padding: 7px 9px;
border: 1px solid #dcdfe4;
border-radius: 3px;
background: #fff;
color: #333;
outline: none;
}
input[type=password]:focus {
border-color: #9fb4c7;
box-shadow: 0 0 0 2px rgba(159, 180, 199, .25);
}
button {
width: 100%;
margin-top: 12px;
padding: 8px;
border: 0;
border-radius: 3px;
background: #5c7a99;
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
button:hover { background: #4e6b88; }
.error {
margin-bottom: 14px;
padding: 7px 9px;
border-radius: 3px;
background: #fdeaea;
color: #a33;
}
/* The board's own dark mode is a saved preference, which we can't know
before you're signed in — so follow the OS here instead. Colours are
nullboard's .theme-dark palette. */
@media (prefers-color-scheme: dark) {
body { background: #22272b; color: #b3b9c0; }
h1 { color: #d4d8dc; }
.sub, label { color: #6f7780; }
input[type=password] {
background: #2c3238;
border-color: #3b434b;
color: #d4d8dc;
}
input[type=password]:focus {
border-color: #5c7a99;
box-shadow: 0 0 0 2px rgba(92, 122, 153, .3);
}
.error { background: #3b2a2a; color: #e08585; }
}
</style>
</head>
<body>
<div class="box">
<h1>ValidBoard</h1>
<div class="sub">Sign in to your boards.</div>
<% if error %>
<div class="error"><%= Rack::Utils.escape_html(error) %></div>
<% end %>
<form method="post" action="/login">
<label for="password">Password</label>
<input type="password" id="password" name="password" autofocus autocomplete="current-password">
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>