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
206 lines
6.4 KiB
Ruby
206 lines
6.4 KiB
Ruby
# 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
|