# 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