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:
co-authored by
Claude Opus 5
parent
db65363530
commit
2ae11e8d1e
+11
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# Sets (or resets) the single ValidBoard password.
|
||||
#
|
||||
# bin/validboard-passwd # prompts, twice, without echo
|
||||
# VALIDBOARD_DB=/var/lib/validboard/validboard.db bin/validboard-passwd
|
||||
#
|
||||
# Run it as the same user the service runs as, or the new database file ends up
|
||||
# owned by the wrong account.
|
||||
|
||||
require 'io/console'
|
||||
require_relative '../store'
|
||||
|
||||
MIN_LENGTH = 8
|
||||
|
||||
store = ValidBoard::Store.new(ValidBoard.db_path)
|
||||
|
||||
puts "ValidBoard database: #{store.path}"
|
||||
puts store.password_set? ? 'A password is already set; this will replace it.' : 'No password set yet.'
|
||||
puts
|
||||
|
||||
def prompt(label)
|
||||
$stdout.print(label)
|
||||
$stdout.flush
|
||||
value = $stdin.noecho(&:gets)
|
||||
puts
|
||||
value&.chomp
|
||||
end
|
||||
|
||||
# Piped in — `pass show validboard | bin/validboard-passwd` — so take the one
|
||||
# line and skip the confirmation there is no one there to type.
|
||||
password =
|
||||
if $stdin.tty?
|
||||
typed = prompt('New password: ')
|
||||
abort 'Aborted.' if typed.nil?
|
||||
abort 'Passwords did not match.' unless prompt('Repeat password: ') == typed
|
||||
|
||||
typed
|
||||
else
|
||||
$stdin.gets&.chomp
|
||||
end
|
||||
|
||||
abort 'No password given.' if password.nil? || password.empty?
|
||||
|
||||
if password.length < MIN_LENGTH
|
||||
abort "Password must be at least #{MIN_LENGTH} characters."
|
||||
end
|
||||
|
||||
store.password = password
|
||||
|
||||
puts 'Password updated.'
|
||||
puts 'Existing sessions stay valid — restart the service if you want to force a re-login.'
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'app'
|
||||
|
||||
run ValidBoard::App
|
||||
@@ -0,0 +1,205 @@
|
||||
# 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
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ValidBoard</title>
|
||||
<link rel="icon" href="/extras/favicon-16.png" sizes="16x16" type="image/png">
|
||||
<style>
|
||||
/* Barlow at nullboard's own sizes, so the login doesn't feel bolted on. */
|
||||
@font-face {
|
||||
font-family: 'f-barlow';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
src: url('/extras/Barlow-Regular.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'f-barlow';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
src: url('/extras/Barlow-Medium.woff') format('woff');
|
||||
}
|
||||
|
||||
html, body, input {
|
||||
font-family: f-barlow, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 17px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f8f9fb;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.box {
|
||||
width: 260px;
|
||||
margin-top: -60px; /* sit a little above centre */
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
margin: 0 0 2px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: #999;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
input[type=password] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #dcdfe4;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type=password]:focus {
|
||||
border-color: #9fb4c7;
|
||||
box-shadow: 0 0 0 2px rgba(159, 180, 199, .25);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: #5c7a99;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { background: #4e6b88; }
|
||||
|
||||
.error {
|
||||
margin-bottom: 14px;
|
||||
padding: 7px 9px;
|
||||
border-radius: 3px;
|
||||
background: #fdeaea;
|
||||
color: #a33;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>ValidBoard</h1>
|
||||
<div class="sub">Sign in to your boards.</div>
|
||||
|
||||
<% if error %>
|
||||
<div class="error"><%= Rack::Utils.escape_html(error) %></div>
|
||||
<% end %>
|
||||
|
||||
<form method="post" action="/login">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autofocus autocomplete="current-password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user