From 2ae11e8d1e2d67a11171f228af70bd4580bf7f30 Mon Sep 17 00:00:00 2001 From: Tom Flux Date: Fri, 14 Aug 2026 22:40:51 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU --- .gitignore | 11 ++ Gemfile | 19 ++ README.md => README.nullboard.md | 0 app.rb | 290 +++++++++++++++++++++++++++++++ bin/validboard-passwd | 53 ++++++ config.ru | 5 + store.rb | 205 ++++++++++++++++++++++ views/login.erb | 123 +++++++++++++ 8 files changed, 706 insertions(+) create mode 100644 .gitignore create mode 100644 Gemfile rename README.md => README.nullboard.md (100%) create mode 100644 app.rb create mode 100755 bin/validboard-passwd create mode 100644 config.ru create mode 100644 store.rb create mode 100644 views/login.erb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52fac5f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..2c036bd --- /dev/null +++ b/Gemfile @@ -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' diff --git a/README.md b/README.nullboard.md similarity index 100% rename from README.md rename to README.nullboard.md diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..904ae48 --- /dev/null +++ b/app.rb @@ -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 " — or "