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
This commit is contained in:
Tom Flux
2026-08-14 22:40:51 +01:00
co-authored by Claude Opus 5
parent db65363530
commit 2ae11e8d1e
8 changed files with 706 additions and 0 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'
View File
+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
+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
+123
View File
@@ -0,0 +1,123 @@
<!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;
}
</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>