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
+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