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