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 "