Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8338374a8d | ||
|
|
00d3cb0e61 | ||
|
|
d94984cd97 | ||
|
|
7e2f280acc | ||
|
|
2ae11e8d1e | ||
|
|
db65363530 | ||
|
|
d6384d0522 | ||
|
|
91411d9b67 | ||
|
|
e241fb5046 | ||
|
|
b67c2a5451 | ||
|
|
a3cc607a8d | ||
|
|
478d6af8f3 | ||
|
|
486747d28e |
+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'
|
||||
@@ -1,131 +1,140 @@
|
||||
# Nullboard
|
||||
# ValidBoard
|
||||
|
||||
Nullboard is a minimalist take on a kanban board / a task list manager, designed to be compact, readable and quick in use.
|
||||
A fork of [Nullboard](https://github.com/apankrat/nullboard) that stores boards
|
||||
in SQLite on a server instead of the browser's `localStorage`, behind a login.
|
||||
|
||||
https://nullboard.io/preview
|
||||
Same board, same keyboard shortcuts, same everything — it just follows you
|
||||
between browsers and machines now, and survives clearing your site data.
|
||||
|
||||

|
||||
Upstream's own README is kept as [README.nullboard.md](README.nullboard.md).
|
||||
|
||||
The name also happens to abbreviate to [NB](https://en.wikipedia.org/wiki/Nota_bene), which I think is a nice touch.
|
||||
## How it works
|
||||
|
||||
## Dead simple
|
||||
Nullboard's storage layer already bottoms out in three methods over string
|
||||
keys — `getItem`, `setItem`, `delItem` (see `class Storage` in
|
||||
`nullboard.html`). Boards, revisions, undo history and preferences are all
|
||||
built on top of those, client-side.
|
||||
|
||||
* Single-page web app - just one HTML file, an ancient jQuery package and a webfont pack.
|
||||
* Can be used completely offline. In fact, it's written exactly with this use in mind.
|
||||
So the server is just a key/value store, and knows nothing about boards:
|
||||
|
||||
## Locally stored
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/items` | every key/value pair |
|
||||
| `POST /api/items` | a batch of `set`/`del` ops, applied in one transaction |
|
||||
| `DELETE /api/items` | wipe the boards (keeps your password) |
|
||||
|
||||
* All data is stored locally, for now using [localStorage](https://developer.mozilla.org/en/docs/Web/API/Window/localStorage).
|
||||
* The data can be exported to- or imported from a plain text file in a simple JSON format.
|
||||
* The data can also be automatically backed up to a local disk with the help of:
|
||||
* [Nullboard Agent](https://nullboard.io/backups) - a native Windows app
|
||||
* [Nullboard Agent Express Port](https://github.com/justinpchang/nullboard-agent-express) - an express.js-based portable app
|
||||
The fork adds one class, `Storage_Server`, alongside upstream's
|
||||
`Storage_Local`, and swaps which one gets instantiated. Everything else in
|
||||
`nullboard.html` is untouched, so upstream changes still merge.
|
||||
|
||||
## Beta
|
||||
Two details worth knowing:
|
||||
|
||||
Still very much in beta. Caveat emptor and all that.
|
||||
**Reads are synchronous, so the data ships with the page.** `setItem` has to
|
||||
return true or false immediately, which no network round trip can do. The
|
||||
server therefore embeds the whole keyspace into the page as a JSON block
|
||||
(`#nb-bootstrap`), and `Storage_Server` hydrates an in-memory `Map` from it
|
||||
before the app boots. Reads are memory reads. It's in the page rather than a
|
||||
separate `.js` file so another site can't `<script src>` it and read your
|
||||
boards.
|
||||
|
||||
## UI & UX
|
||||
**Writes are batched and optimistic.** Changes are collected by key — a note
|
||||
edited five times in a second is one write — and flushed ~300 ms later as a
|
||||
single transaction. Failures go back on the queue and retry with a backoff, the
|
||||
logo shows *saving…* / *not saved*, and closing the tab with writes still
|
||||
pending asks for confirmation. Nothing is lost if the server is briefly away.
|
||||
|
||||
The whole thing is largely about making it convenient to use.
|
||||
## Running it
|
||||
|
||||
Everything is editable in place, all changes are saved automatically and last 50 revisions are kept for undo/redo:
|
||||
Dependencies are all packaged on Debian/Ubuntu; no bundler needed:
|
||||
|
||||

|
||||
```
|
||||
sudo apt install ruby-full ruby-sqlite3 ruby-sinatra ruby-bcrypt \
|
||||
ruby-rack ruby-rack-protection ruby-json puma
|
||||
```
|
||||
|
||||
New notes can be quickly added directly where they are needed, e.g. before or after existing notes:
|
||||
Set the password, then start it:
|
||||
|
||||

|
||||
```
|
||||
bin/validboard-passwd # or: pass show validboard | bin/validboard-passwd
|
||||
rake server # http://127.0.0.1:8047
|
||||
```
|
||||
|
||||
Notes can also be dragged around, including to and from other lists:
|
||||
Until a password is set every route returns 503 telling you to run that
|
||||
command — letting the first visitor choose one would hand your board to
|
||||
whoever found the URL first.
|
||||
|
||||

|
||||
### Tests
|
||||
|
||||
Nearly all controls are hidden by default to reduce visual clutter to its minimum:
|
||||
```
|
||||
rake test
|
||||
```
|
||||
|
||||

|
||||
49 tests covering the store (validation, transactions, password hashing,
|
||||
schema guard) and the app (auth, lockout, the API, cross-site writes, and that
|
||||
a note containing `</script>` can't break out of the bootstrap block).
|
||||
|
||||
Longer notes can be collapsed to show just the first line, for even more compact view of the board:
|
||||
### Configuration
|
||||
|
||||

|
||||
| Variable | Default | |
|
||||
|---|---|---|
|
||||
| `VALIDBOARD_DB` | `./data/validboard.db` | boards, password hash and session secret |
|
||||
| `VALIDBOARD_SECURE_COOKIE` | off | set to `1` behind https |
|
||||
| `VALIDBOARD_SECRET` | from the database | session signing key; generated and stored on first run, so restarts don't sign you out |
|
||||
|
||||
The default font is [Barlow](https://tribby.com/fonts/barlow/) - it's both narrow *and* still very legible. Absolutely fantastic design!
|
||||
## Deploying
|
||||
|
||||

|
||||
```
|
||||
sudo cp deploy/validboard.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now validboard
|
||||
|
||||
Notes can also be set to look a bit different. This is useful for partitioning lists into sections:
|
||||
sudo cp deploy/board.jihakuz.xyz.conf /etc/nginx/sites-available/board.jihakuz.xyz
|
||||
sudo ln -s /etc/nginx/sites-available/board.jihakuz.xyz /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d board.jihakuz.xyz
|
||||
```
|
||||
|
||||

|
||||
The nginx block is plain http on purpose — certbot adds the TLS server block
|
||||
and the redirect itself.
|
||||
|
||||
Links starting with https:// and http:// are recognized. They will "pulse" on mouse hover and can be opened via the right-click menu.
|
||||
**If saves start coming back 401 after enabling TLS**, it's the
|
||||
`X-Forwarded-Proto` header. The app compares the browser's `Origin` against the
|
||||
URL it thinks it's serving; without that header it believes it's on `http://`
|
||||
while the browser says `https://`, decides the write is cross-site, and drops
|
||||
the session. The supplied nginx config sets it.
|
||||
|
||||

|
||||
### Backups
|
||||
|
||||
Pressing CapsLock will highlight all links and make them left-clickable.
|
||||
Everything is one file. `sqlite3 data/validboard.db ".backup /somewhere/vb.db"`
|
||||
takes a consistent copy while the service is running — don't just `cp` it, the
|
||||
WAL sidecar may hold recent writes.
|
||||
|
||||

|
||||
## Security
|
||||
|
||||
Lists can be moved around as well, though not as flashy as notes:
|
||||
- One password, bcrypt-hashed. No user table; adding a second user means a
|
||||
schema migration.
|
||||
- Session cookie is signed, `HttpOnly`, `SameSite=Lax`, and `Secure` when
|
||||
`VALIDBOARD_SECURE_COOKIE=1`.
|
||||
- Ten failed logins from an IP locks that IP out for 15 minutes.
|
||||
- rack-protection is on, which includes session hijacking detection keyed on
|
||||
the User-Agent — so a browser that changes its UA string signs you out. If
|
||||
that ever gets irritating, `set :protection, except: [:session_hijacking]`
|
||||
in `app.rb`.
|
||||
- Keys are validated against `[A-Za-z0-9._-]{1,128}` and values capped at 2 MB.
|
||||
|
||||

|
||||
## Keeping up with upstream
|
||||
|
||||
The font can be changed; its size and line height can be adjusted:
|
||||
```
|
||||
git fetch upstream
|
||||
git merge upstream/master
|
||||
```
|
||||
|
||||

|
||||
|
||||
The color theme can be inverted:
|
||||
|
||||

|
||||
|
||||
Also:
|
||||
|
||||
* Support for multiple boards with near-instant switching
|
||||
* Undo/redo for 50 revisions per board (configurable in the code)
|
||||
* Keyboard shortcuts, including Tab'ing through notes
|
||||
|
||||
## Caveats
|
||||
|
||||
* Written for desktop and keyboard/mouse use
|
||||
* Essentially untested on mobile devices and against tap/touch input
|
||||
* Works in Firefox, tested in Chrome, should work in Safari and may work in Edge (or what it's called now)
|
||||
* Uses localStorage for storing boards/lists/notes, so be careful around [clearing your cache](https://stackoverflow.com/questions/9948284/how-persistent-is-localstorage)
|
||||
|
||||
You spot a bug, file an issue.
|
||||
|
||||
## Dockerized version
|
||||
|
||||
See [this fork](https://github.com/rsoper/nullboard).
|
||||
|
||||
## Background
|
||||
|
||||
Nullboard is something that handles ToDo lists in the way that works really well. For *me* that is.
|
||||
|
||||
Tried a lot of options, some were almost *it*, but none was 100%.
|
||||
|
||||
**Trello** wasn't bad, but never was comfortable with the idea of storing my data in cloud without any actual need.
|
||||
|
||||
**Wekan** looked promising, but ultimately too heavy and had no offline usage support or a local storage option.
|
||||
|
||||
**Things** was beautiful, but not the right tool for the job.
|
||||
|
||||
**Inkscape** - I kid you not - with a laundry list of text items was actually OK, but didn't scale well.
|
||||
|
||||
Ditto for the plain **text files**.
|
||||
|
||||
Pieces of **paper** were almost there, but rearranging items can be quite a hassle.
|
||||
|
||||
So finally got annoyed enough to sit down and write exactly what I wanted.
|
||||
|
||||
And, voilà, Nullboard came out => https://nullboard.io/preview
|
||||
The changes to `nullboard.html` are additive — one class, one CSS block, some
|
||||
markup in the header, and a single changed line where `Storage_Local` used to
|
||||
be instantiated — so conflicts should be rare and small.
|
||||
|
||||
## License
|
||||
|
||||
The [2-clause BSD license](https://opensource.org/licenses/BSD-2-Clause/) with the [Commons Clause](https://commonsclause.com/).
|
||||
|
||||
That is, you can use, change and re-distribute it for as long as you don't try and sell it.
|
||||
|
||||
## Updates
|
||||
|
||||
Primary feed is through [@nullboard](https://twitter.com/nullboard) on Twitter.
|
||||
|
||||
The changelog is here => https://nullboard.io/changes
|
||||
Nullboard is by Alexander Pankratov, under the 2-clause BSD license with the
|
||||
[Commons Clause](LICENSE) — free to use, change and redistribute, but not to
|
||||
sell or run as a paid service. This fork keeps that license unchanged, and the
|
||||
licence requires the full text to travel with any copy, so `LICENSE` stays put.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Nullboard
|
||||
|
||||
Nullboard is a minimalist take on a kanban board / a task list manager, designed to be compact, readable and quick in use.
|
||||
|
||||
https://nullboard.io/preview
|
||||
|
||||

|
||||
|
||||
The name also happens to abbreviate to [NB](https://en.wikipedia.org/wiki/Nota_bene), which I think is a nice touch.
|
||||
|
||||
## Dead simple
|
||||
|
||||
* Single-page web app - just one HTML file, an ancient jQuery package and a webfont pack.
|
||||
* Can be used completely offline. In fact, it's written exactly with this use in mind.
|
||||
|
||||
## Locally stored
|
||||
|
||||
* All data is stored locally, for now using [localStorage](https://developer.mozilla.org/en/docs/Web/API/Window/localStorage).
|
||||
* The data can be exported to- or imported from a plain text file in a simple JSON format.
|
||||
* The data can also be automatically backed up to a local disk with the help of:
|
||||
* [Nullboard Agent](https://nullboard.io/backups) - a native Windows app
|
||||
* [Nullboard Agent Express Port](https://github.com/justinpchang/nullboard-agent-express) - an express.js-based portable app
|
||||
* [nbagent](https://github.com/luismedel/nbagent) - a version for Unix systems, in Python
|
||||
|
||||
## Beta
|
||||
|
||||
Still very much in beta. Caveat emptor and all that.
|
||||
|
||||
## UI & UX
|
||||
|
||||
The whole thing is largely about making it convenient to use.
|
||||
|
||||
Everything is editable in place, all changes are saved automatically and last 50 revisions are kept for undo/redo:
|
||||
|
||||

|
||||
|
||||
New notes can be quickly added directly where they are needed, e.g. before or after existing notes:
|
||||
|
||||

|
||||
|
||||
Notes can also be dragged around, including to and from other lists:
|
||||
|
||||

|
||||
|
||||
Nearly all controls are hidden by default to reduce visual clutter to its minimum:
|
||||
|
||||

|
||||
|
||||
Longer notes can be collapsed to show just the first line, for even more compact view of the board:
|
||||
|
||||

|
||||
|
||||
The default font is [Barlow](https://tribby.com/fonts/barlow/) - it's both narrow *and* still very legible. Absolutely fantastic design!
|
||||
|
||||

|
||||
|
||||
Notes can also be set to look a bit different. This is useful for partitioning lists into sections:
|
||||
|
||||

|
||||
|
||||
Links starting with https:// and http:// are recognized. They will "pulse" on mouse hover and can be opened via the right-click menu.
|
||||
|
||||

|
||||
|
||||
Pressing CapsLock will highlight all links and make them left-clickable.
|
||||
|
||||

|
||||
|
||||
Lists can be moved around as well, though not as flashy as notes:
|
||||
|
||||

|
||||
|
||||
The font can be changed; its size and line height can be adjusted:
|
||||
|
||||

|
||||
|
||||
The color theme can be inverted:
|
||||
|
||||

|
||||
|
||||
Also:
|
||||
|
||||
* Support for multiple boards with near-instant switching
|
||||
* Undo/redo for 50 revisions per board (configurable in the code)
|
||||
* Keyboard shortcuts, including Tab'ing through notes
|
||||
|
||||
## Caveats
|
||||
|
||||
* Written for desktop and keyboard/mouse use
|
||||
* Essentially untested on mobile devices and against tap/touch input
|
||||
* Works in Firefox, tested in Chrome, should work in Safari and may work in Edge (or what it's called now)
|
||||
* Uses localStorage for storing boards/lists/notes, so be careful around [clearing your cache](https://stackoverflow.com/questions/9948284/how-persistent-is-localstorage)
|
||||
|
||||
You spot a bug, file an issue.
|
||||
|
||||
## Dockerized version
|
||||
|
||||
See [this fork](https://github.com/rsoper/nullboard).
|
||||
|
||||
## Background
|
||||
|
||||
Nullboard is something that handles ToDo lists in the way that works really well. For *me* that is.
|
||||
|
||||
Tried a lot of options, some were almost *it*, but none was 100%.
|
||||
|
||||
**Trello** wasn't bad, but never was comfortable with the idea of storing my data in cloud without any actual need.
|
||||
|
||||
**Wekan** looked promising, but ultimately too heavy and had no offline usage support or a local storage option.
|
||||
|
||||
**Things** was beautiful, but not the right tool for the job.
|
||||
|
||||
**Inkscape** - I kid you not - with a laundry list of text items was actually OK, but didn't scale well.
|
||||
|
||||
Ditto for the plain **text files**.
|
||||
|
||||
Pieces of **paper** were almost there, but rearranging items can be quite a hassle.
|
||||
|
||||
So finally got annoyed enough to sit down and write exactly what I wanted.
|
||||
|
||||
And, voilà, Nullboard came out => https://nullboard.io/preview
|
||||
|
||||
## License
|
||||
|
||||
The [2-clause BSD license](https://opensource.org/licenses/BSD-2-Clause/) with the [Commons Clause](https://commonsclause.com/).
|
||||
|
||||
That is, you can use, change and re-distribute it for as long as you don't try and sell it.
|
||||
|
||||
## Updates
|
||||
|
||||
Primary feed is through [@nullboard](https://twitter.com/nullboard) on Twitter.
|
||||
|
||||
The changelog is here => https://nullboard.io/changes
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rake/testtask'
|
||||
|
||||
Rake::TestTask.new(:test) do |t|
|
||||
t.libs << 'test'
|
||||
t.test_files = FileList['test/*_test.rb']
|
||||
t.warning = false
|
||||
end
|
||||
|
||||
desc 'Run the app locally on http://127.0.0.1:8047'
|
||||
task :server do
|
||||
sh 'puma --bind tcp://127.0.0.1:8047 config.ru'
|
||||
end
|
||||
|
||||
task default: :test
|
||||
@@ -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,39 @@
|
||||
# ValidBoard — nginx site
|
||||
#
|
||||
# sudo cp deploy/board.jihakuz.xyz.conf /etc/nginx/sites-available/board.jihakuz.xyz
|
||||
# sudo ln -s /etc/nginx/sites-available/board.jihakuz.xyz /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
# sudo certbot --nginx -d board.jihakuz.xyz
|
||||
#
|
||||
# Plain http only, on purpose — certbot adds the 443 server block, the
|
||||
# certificate lines and the http->https redirect itself.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name board.jihakuz.xyz;
|
||||
|
||||
# ValidBoard rejects cross-site writes by comparing the browser's Origin
|
||||
# header against the URL it thinks it is serving. It builds that URL from
|
||||
# the headers below, so without X-Forwarded-Proto it will believe it is on
|
||||
# http:// while the browser says https:// — and every save comes back 403
|
||||
# the moment certbot switches the site to TLS.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# The whole board set is inlined into the page at load, so it is worth
|
||||
# compressing; it's all text.
|
||||
gzip on;
|
||||
gzip_types text/html application/json application/javascript text/css;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Matches the server's own 2 MB per-item cap, with room for the envelope.
|
||||
client_max_body_size 4m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8047;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# ValidBoard — systemd unit
|
||||
#
|
||||
# sudo cp deploy/validboard.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now validboard
|
||||
# systemctl status validboard
|
||||
#
|
||||
# Set the password before the first start, as the same user this runs as:
|
||||
# bin/validboard-passwd
|
||||
|
||||
[Unit]
|
||||
Description=ValidBoard - kanban boards stored in SQLite
|
||||
Documentation=https://git.tomflux.xyz/tom/ValidBoard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=susan
|
||||
Group=www-data
|
||||
WorkingDirectory=/disks/git-repos/ValidBoard
|
||||
|
||||
# The database holds the boards, the password hash and the session secret.
|
||||
# Point it elsewhere (e.g. /var/lib/validboard/validboard.db) if you'd rather
|
||||
# keep data off the repo disk — just create the directory and chown it first.
|
||||
Environment=VALIDBOARD_DB=/disks/git-repos/ValidBoard/data/validboard.db
|
||||
|
||||
# nginx terminates TLS, so the session cookie should never go out over plain
|
||||
# http. Drop this line if you ever run the service without a certificate.
|
||||
Environment=VALIDBOARD_SECURE_COOKIE=1
|
||||
|
||||
Environment=RACK_ENV=production
|
||||
Environment=APP_ENV=production
|
||||
|
||||
ExecStart=/usr/bin/puma --bind tcp://127.0.0.1:8047 --threads 0:8 --environment production config.ru
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Boards are small and the process is a single Ruby app; none of this is
|
||||
# load-bearing, it just limits the blast radius.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=full
|
||||
ProtectHome=read-only
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+450
-17
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nullboard</title>
|
||||
<link rel="icon" href="extras/favicon-16.png"/>
|
||||
<style>
|
||||
@@ -220,7 +221,7 @@
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
/***/
|
||||
.clearfix:after,
|
||||
.board:after,
|
||||
@@ -750,6 +751,31 @@
|
||||
color: #d20;
|
||||
}
|
||||
|
||||
/*** ValidBoard: save state, next to the (updated) alert */
|
||||
.logo .sync {
|
||||
display: none;
|
||||
font-style: normal;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.logo.sync-busy .sync,
|
||||
.logo.sync-err .sync {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.logo.sync-busy .sync {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* An unsaved board is worth breaking the logo's usual fade-out for. */
|
||||
.logo.sync-err {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.logo.sync-err .sync {
|
||||
color: #d20;
|
||||
}
|
||||
|
||||
/***/
|
||||
.config {
|
||||
position: absolute;
|
||||
@@ -1546,17 +1572,28 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo">
|
||||
<a class=site href=https://nullboard.io>Nullboard</a>
|
||||
<a class=site href=/>ValidBoard</a>
|
||||
<i class=alert></i>
|
||||
<i class=sync></i>
|
||||
<div class=bulk>
|
||||
<a href=# class=view-about>About</a>
|
||||
<a href=# class=view-license>License</a>
|
||||
<a href=https://nullboard.io/changes target=_blank class=view-changes>Changes</a>
|
||||
<a href=https://nullboard.io/github target=_blank>Github</a>
|
||||
<a href=https://nullboard.io/twitter target=_blank>Twitter</a>
|
||||
<a href=https://nullboard.io target=_blank>Nullboard</a>
|
||||
<a href=# class=sign-out>Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
ValidBoard: the server drops the whole keyspace in here before the app
|
||||
script runs, so Storage_Server can answer nullboard's synchronous
|
||||
getItem() from memory without a blocking request. Embedded in the page
|
||||
rather than served as a .js file so it can't be read cross-site.
|
||||
-->
|
||||
<script type="application/json" id="nb-bootstrap">{}</script>
|
||||
|
||||
<form class=sign-out-form method=post action=/logout hidden></form>
|
||||
|
||||
<div class='config no-user-select'>
|
||||
<a href=# class=teaser><i>≡</i><u>✔</u></a>
|
||||
<div class=bulk>
|
||||
@@ -1748,6 +1785,14 @@
|
||||
this.backupStatus = { }; // agentId => [ 'conf' ]
|
||||
}
|
||||
|
||||
const default_backup_agents =
|
||||
[
|
||||
{ base: 'http://127.0.0.1:10001', auth: '' }, // local agent
|
||||
{ base: '', auth: '' } // remote agent
|
||||
];
|
||||
|
||||
//
|
||||
|
||||
function BoardMeta()
|
||||
{
|
||||
this.title = '';
|
||||
@@ -1985,7 +2030,7 @@
|
||||
var meta = this.boardIndex.get(board_id);
|
||||
|
||||
if (! meta)
|
||||
throw `Invalid board_id in nukeBoard(${board.id})`;
|
||||
throw `Invalid board_id in nukeBoard(${board_id})`;
|
||||
|
||||
var title = meta.title + '';
|
||||
|
||||
@@ -2038,7 +2083,7 @@
|
||||
var meta = this.boardIndex.get(board_id);
|
||||
|
||||
if (! meta)
|
||||
throw `Invalid board_id in setBoardRevision(${board_id}, ${revision})`;
|
||||
throw `Invalid board_id in setBoardUiSpot(${board_id}, ${ui_spot})`;
|
||||
|
||||
meta.ui_spot = ui_spot;
|
||||
|
||||
@@ -2101,22 +2146,26 @@
|
||||
agents[0].type != simp || agents[0].conf.base != 'http://127.0.0.1:10001' ||
|
||||
agents[1].type != simp)
|
||||
{
|
||||
const def = default_backup_agents;
|
||||
|
||||
console.log('Unexpected backup config, will re-initialize.', agents);
|
||||
|
||||
conf.backups.agents = [];
|
||||
|
||||
conf.backups.agents.push({
|
||||
type: simp,
|
||||
id: simp + '-' + (conf.backups.nextId++),
|
||||
enabled: false,
|
||||
conf: { base: 'http://127.0.0.1:10001', auth: '' }
|
||||
// localhost
|
||||
type : simp,
|
||||
id : simp + '-' + (conf.backups.nextId++),
|
||||
enabled : def[0].base && def[0].auth,
|
||||
conf : def[0],
|
||||
})
|
||||
|
||||
conf.backups.agents.push({
|
||||
type: simp,
|
||||
id: simp + '-' + (conf.backups.nextId++),
|
||||
enabled: false,
|
||||
conf: { base: '', auth: '' }
|
||||
// remote
|
||||
type : simp,
|
||||
id : simp + '-' + (conf.backups.nextId++),
|
||||
enabled : def[1].base && def[1].auth,
|
||||
conf : def[1],
|
||||
})
|
||||
|
||||
this.saveConfig();
|
||||
@@ -2431,6 +2480,368 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ValidBoard: boards live in SQLite on the server.
|
||||
*
|
||||
* The Storage contract above is synchronous — setItem() has to say yes or
|
||||
* no there and then — and a round trip can't answer that fast. So this
|
||||
* keeps the whole keyspace in memory and treats the server as somewhere to
|
||||
* push it: reads are memory reads, writes are optimistic and batched.
|
||||
*
|
||||
* The initial copy is embedded in the page by the server (see #nb-bootstrap
|
||||
* in the body), so it is already here by the time openInner() runs and no
|
||||
* blocking request is needed at load.
|
||||
*
|
||||
* Keys are the same names Storage_Local uses, minus its 'nullboard.'
|
||||
* prefix — the server's table is ours alone and needs no namespacing.
|
||||
*/
|
||||
class Storage_Server extends Storage
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super();
|
||||
|
||||
this.type = 'Server';
|
||||
this.items = new Map(); // key -> value; the copy the app reads
|
||||
|
||||
this.pending = new Map(); // key -> op, queued for the next batch
|
||||
this.inflight = null; // the batch currently being POSTed
|
||||
this.timer = null;
|
||||
this.failures = 0;
|
||||
this.status = 'ok'; // 'ok' | 'busy' | 'error'
|
||||
this.warned = false; // "session expired" alert is once-only
|
||||
}
|
||||
|
||||
/*
|
||||
* Storage interface
|
||||
*/
|
||||
getItem(name)
|
||||
{
|
||||
var val = this.items.get(name);
|
||||
return (val === undefined) ? null : val;
|
||||
}
|
||||
|
||||
setItem(name, val)
|
||||
{
|
||||
this.items.set(name, val);
|
||||
this.enqueue(name, { op: 'set', key: name, value: val });
|
||||
return true;
|
||||
}
|
||||
|
||||
delItem(name)
|
||||
{
|
||||
this.items.delete(name);
|
||||
this.enqueue(name, { op: 'del', key: name });
|
||||
return true;
|
||||
}
|
||||
|
||||
openInner()
|
||||
{
|
||||
if (! this.hydrate())
|
||||
return false;
|
||||
|
||||
// Decide this before saving a default config, or the write below
|
||||
// would make every new install look like an existing one.
|
||||
var newInstall = (this.items.size == 0);
|
||||
var conf = this.getJson('config');
|
||||
|
||||
if (conf)
|
||||
{
|
||||
this.conf = Object.assign(new AppConfig(), conf);
|
||||
}
|
||||
else if (! this.setJson('config', this.conf))
|
||||
{
|
||||
this.conf = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.boardIndex = new Map();
|
||||
|
||||
// Array.from() because rebuildMeta() may write while we iterate.
|
||||
for (var k of Array.from(this.items.keys()))
|
||||
{
|
||||
var m = k.match(/^board\.(\d+)\.meta$/);
|
||||
|
||||
if (! m)
|
||||
continue;
|
||||
|
||||
var board_id = parseInt(m[1]);
|
||||
var meta = this.getJson('board.' + board_id + '.meta');
|
||||
|
||||
if (! meta || ! meta.hasOwnProperty('history'))
|
||||
{
|
||||
console.log( `Invalid meta for board ${board_id}` );
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var rev of meta.history)
|
||||
if (! this.getJson('board.' + board_id + '.' + rev))
|
||||
{
|
||||
console.log( `Invalid revision ${rev} in history of ${board_id}` );
|
||||
meta = this.rebuildMeta(board_id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (! meta)
|
||||
continue;
|
||||
|
||||
delete meta.backingUp; // run-time var
|
||||
delete meta.needsBackup; // ditto
|
||||
|
||||
this.boardIndex.set(board_id, Object.assign(new BoardMeta(), meta));
|
||||
}
|
||||
|
||||
this.fixupConfig(newInstall);
|
||||
|
||||
this.type = 'Server';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
wipeInner()
|
||||
{
|
||||
this.items.clear();
|
||||
this.pending.clear();
|
||||
this.inflight = null;
|
||||
|
||||
var self = this;
|
||||
|
||||
fetch('/api/items', { method: 'DELETE', credentials: 'same-origin' })
|
||||
.then(function(rsp){ if (! rsp.ok) throw new Error('server said ' + rsp.status); })
|
||||
.catch(function(err){ self.setStatus('error', 'wipe failed: ' + err.message); });
|
||||
|
||||
this.conf = new AppConfig();
|
||||
this.boardIndex = new Map();
|
||||
}
|
||||
|
||||
/*
|
||||
* private
|
||||
*/
|
||||
hydrate()
|
||||
{
|
||||
var el = document.getElementById('nb-bootstrap');
|
||||
|
||||
if (! el)
|
||||
{
|
||||
alert("ValidBoard: this page is missing its board data.\n\nReload to try again.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = JSON.parse(el.textContent);
|
||||
|
||||
for (var k in data)
|
||||
this.items.set(k, data[k]);
|
||||
}
|
||||
catch (x)
|
||||
{
|
||||
alert("ValidBoard: couldn't read the boards sent by the server.\n\n" + x);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log( `Loaded ${this.items.size} key(s) from the server` );
|
||||
|
||||
// Nothing else needs it, and leaving a copy of every board sitting
|
||||
// in the DOM only invites confusion when debugging.
|
||||
el.textContent = '{}';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Same recovery as Storage_Local: a board whose meta has gone stale is
|
||||
// worth rebuilding from whatever revisions did survive.
|
||||
rebuildMeta(board_id)
|
||||
{
|
||||
var meta = new BoardMeta();
|
||||
|
||||
console.log( `Rebuilding meta for ${board_id} ...` );
|
||||
|
||||
meta.current = this.getItem('board.' + board_id); // may be null
|
||||
|
||||
var re = new RegExp('^board\\.' + board_id + '\\.(\\d+)$');
|
||||
var revs = new Array();
|
||||
|
||||
for (var k of this.items.keys())
|
||||
{
|
||||
var m = k.match(re);
|
||||
if (m) revs.push( parseInt(m[1]) );
|
||||
}
|
||||
|
||||
if (! revs.length)
|
||||
{
|
||||
console.log('* No revisions found');
|
||||
this.delItem('board.' + board_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
revs.sort(function(a,b){ return b-a; });
|
||||
meta.history = revs;
|
||||
|
||||
if (! meta.history.includes(meta.current))
|
||||
meta.current = meta.history[meta.history.length-1];
|
||||
|
||||
var board = this.getJson('board.' + board_id + '.' + meta.current)
|
||||
meta.title = (board.title || '(untitled board)');
|
||||
|
||||
this.setJson('board.' + board_id + '.meta', meta);
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/*
|
||||
* write-behind
|
||||
*
|
||||
* Changes are collected by key — a note edited five times in a second
|
||||
* is one write — and flushed as a single transaction shortly after.
|
||||
*/
|
||||
enqueue(key, op)
|
||||
{
|
||||
this.pending.set(key, op);
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
// No argument: batch up whatever lands in the next FLUSH_DELAY ms, but
|
||||
// don't let a steady stream of edits push the flush back forever.
|
||||
// With one: a retry, which overrides any sooner flush already queued.
|
||||
scheduleFlush(delay)
|
||||
{
|
||||
if (delay === undefined)
|
||||
{
|
||||
if (this.timer !== null)
|
||||
return;
|
||||
|
||||
delay = Storage_Server.FLUSH_DELAY;
|
||||
}
|
||||
else if (this.timer !== null)
|
||||
{
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
this.timer = setTimeout(function(){
|
||||
self.timer = null;
|
||||
self.flush();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
flush()
|
||||
{
|
||||
if (this.inflight || ! this.pending.size)
|
||||
return;
|
||||
|
||||
this.inflight = this.pending;
|
||||
this.pending = new Map();
|
||||
|
||||
var self = this;
|
||||
var ops = Array.from(this.inflight.values());
|
||||
|
||||
this.setStatus('busy');
|
||||
|
||||
fetch('/api/items', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials : 'same-origin',
|
||||
body : JSON.stringify({ ops: ops })
|
||||
})
|
||||
.then(function(rsp){
|
||||
if (rsp.status == 401)
|
||||
{
|
||||
self.onSessionLost();
|
||||
throw new Error('not signed in');
|
||||
}
|
||||
|
||||
if (! rsp.ok)
|
||||
throw new Error('server said ' + rsp.status);
|
||||
|
||||
self.inflight = null;
|
||||
self.failures = 0;
|
||||
|
||||
if (self.pending.size)
|
||||
{
|
||||
self.setStatus('busy');
|
||||
self.scheduleFlush();
|
||||
}
|
||||
else
|
||||
{
|
||||
self.setStatus('ok');
|
||||
}
|
||||
})
|
||||
.catch(function(err){
|
||||
// The batch goes back on the queue, so nothing is lost while
|
||||
// the server is unreachable — it just keeps trying.
|
||||
self.requeue();
|
||||
self.failures++;
|
||||
self.setStatus('error', err.message);
|
||||
|
||||
var delay = Math.min(30000, 1000 * Math.pow(2, self.failures - 1));
|
||||
|
||||
console.log( `Save failed (${err.message}), retrying in ${delay}ms` );
|
||||
self.scheduleFlush(delay);
|
||||
});
|
||||
}
|
||||
|
||||
requeue()
|
||||
{
|
||||
if (! this.inflight)
|
||||
return;
|
||||
|
||||
var merged = this.inflight;
|
||||
|
||||
// Anything queued while the batch was in flight is newer, so it
|
||||
// wins over the copy going back on the queue.
|
||||
this.pending.forEach(function(op, key){ merged.set(key, op); });
|
||||
|
||||
this.pending = merged;
|
||||
this.inflight = null;
|
||||
}
|
||||
|
||||
// The cookie expired, or you signed out in another tab. Keep retrying
|
||||
// rather than dropping the edits — signing back in anywhere makes the
|
||||
// next attempt succeed.
|
||||
onSessionLost()
|
||||
{
|
||||
if (this.warned)
|
||||
return;
|
||||
|
||||
this.warned = true;
|
||||
|
||||
alert("ValidBoard: your session has expired, so recent changes aren't saved yet.\n\n" +
|
||||
"Sign in again in another tab and they'll be saved automatically.");
|
||||
}
|
||||
|
||||
hasUnsavedChanges()
|
||||
{
|
||||
return (this.pending.size > 0) || (this.inflight !== null);
|
||||
}
|
||||
|
||||
setStatus(status, detail)
|
||||
{
|
||||
this.status = status;
|
||||
|
||||
var $logo = $('.logo').removeClass('sync-busy sync-err');
|
||||
var $sync = $('.logo .sync').attr('title', detail || '');
|
||||
|
||||
if (status == 'busy')
|
||||
{
|
||||
$logo.addClass('sync-busy');
|
||||
$sync.text('saving...');
|
||||
}
|
||||
else if (status == 'error')
|
||||
{
|
||||
$logo.addClass('sync-err');
|
||||
$sync.text('not saved');
|
||||
}
|
||||
else
|
||||
{
|
||||
$sync.text('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Storage_Server.FLUSH_DELAY = 300; // ms
|
||||
|
||||
/*
|
||||
*
|
||||
*/
|
||||
@@ -5081,7 +5492,7 @@
|
||||
*/
|
||||
var NB =
|
||||
{
|
||||
codeVersion: 20221112,
|
||||
codeVersion: 20231105,
|
||||
blobVersion: 20190412, // board blob format in Storage
|
||||
board: null,
|
||||
storage: null,
|
||||
@@ -5099,7 +5510,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
NB.storage = new Storage_Local();
|
||||
NB.storage = new Storage_Server();
|
||||
|
||||
if (! NB.storage.open())
|
||||
{
|
||||
@@ -5107,6 +5518,28 @@
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
/*
|
||||
* ValidBoard: writes are asynchronous now, so a tab can be closed while a
|
||||
* batch is still queued. Say so rather than losing it.
|
||||
*/
|
||||
window.addEventListener('beforeunload', function(e){
|
||||
if (! NB.storage.hasUnsavedChanges())
|
||||
return;
|
||||
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
return '';
|
||||
});
|
||||
|
||||
$('.logo .sign-out').click(function(){
|
||||
if (NB.storage.hasUnsavedChanges() &&
|
||||
! confirm("Some changes haven't reached the server yet.\n\nSign out anyway?"))
|
||||
return false;
|
||||
|
||||
$('.sign-out-form').submit();
|
||||
return false;
|
||||
});
|
||||
|
||||
var boards = NB.storage.getBoardIndex();
|
||||
|
||||
boards.forEach( function(meta, board_id) {
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,292 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'test_helper'
|
||||
require 'sqlite3'
|
||||
|
||||
class AppTest < Minitest::Test
|
||||
include TestHelpers
|
||||
|
||||
def setup
|
||||
reset_app_store!
|
||||
end
|
||||
|
||||
#
|
||||
# nothing is reachable without a session
|
||||
#
|
||||
|
||||
def test_board_redirects_to_login_when_signed_out
|
||||
response = Client.new.get('/')
|
||||
|
||||
assert_equal 302, response.status
|
||||
assert_equal '/login', URI(response.headers['Location']).path
|
||||
end
|
||||
|
||||
def test_api_returns_401_json_when_signed_out
|
||||
response = Client.new.get('/api/items')
|
||||
|
||||
assert_equal 401, response.status
|
||||
assert_equal 'unauthenticated', json_body(response)['error']
|
||||
end
|
||||
|
||||
def test_api_writes_are_refused_when_signed_out
|
||||
response = Client.new.post_json('/api/items', ops: [{ op: 'set', key: 'config', value: 'x' }])
|
||||
|
||||
assert_equal 401, response.status
|
||||
assert_equal 0, ValidBoard::STORE.item_count
|
||||
end
|
||||
|
||||
def test_login_page_is_reachable_when_signed_out
|
||||
response = Client.new.get('/login')
|
||||
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.body, 'ValidBoard'
|
||||
end
|
||||
|
||||
def test_health_check_needs_no_session
|
||||
response = Client.new.get('/healthz')
|
||||
|
||||
assert_equal 200, response.status
|
||||
assert_equal "ok\n", response.body
|
||||
end
|
||||
|
||||
#
|
||||
# signing in
|
||||
#
|
||||
|
||||
def test_wrong_password_is_refused
|
||||
response = Client.new(remote_addr: '10.0.0.1').login('not-the-password')
|
||||
|
||||
assert_equal 401, response.status
|
||||
assert_includes response.body, 'Wrong password'
|
||||
end
|
||||
|
||||
def test_correct_password_starts_a_session
|
||||
client = Client.new
|
||||
response = client.login
|
||||
|
||||
assert_equal 303, response.status
|
||||
assert_equal '/', URI(response.headers['Location']).path
|
||||
refute_nil client.session_cookie
|
||||
end
|
||||
|
||||
def test_session_cookie_is_httponly_and_samesite
|
||||
response = Client.new.login
|
||||
cookie = Array(response.headers['Set-Cookie']).flat_map { |h| h.split("\n") }
|
||||
.find { |h| h.start_with?('validboard.session=') }
|
||||
|
||||
assert_match(/HttpOnly/i, cookie)
|
||||
assert_match(/SameSite=Lax/i, cookie)
|
||||
end
|
||||
|
||||
def test_signing_out_ends_the_session
|
||||
client = signed_in_client
|
||||
assert_equal 200, client.get('/api/items').status
|
||||
|
||||
response = client.post('/logout')
|
||||
assert_equal 303, response.status
|
||||
|
||||
assert_equal 401, client.get('/api/items').status
|
||||
end
|
||||
|
||||
def test_repeated_failures_are_locked_out
|
||||
client = Client.new(remote_addr: '10.0.0.99')
|
||||
|
||||
ValidBoard::LoginThrottle::MAX_FAILURES.times do
|
||||
assert_equal 401, client.login('wrong').status
|
||||
end
|
||||
|
||||
response = client.login('wrong')
|
||||
assert_equal 429, response.status
|
||||
assert_includes response.body, 'Too many failed attempts'
|
||||
|
||||
# Still locked out even with the right password — that's the point.
|
||||
assert_equal 429, client.login.status
|
||||
end
|
||||
|
||||
def test_lockout_is_per_client
|
||||
blocked = Client.new(remote_addr: '10.0.0.98')
|
||||
ValidBoard::LoginThrottle::MAX_FAILURES.times { blocked.login('wrong') }
|
||||
|
||||
assert_equal 429, blocked.login.status
|
||||
assert_equal 303, Client.new(remote_addr: '10.0.0.97').login.status
|
||||
end
|
||||
|
||||
#
|
||||
# the board page
|
||||
#
|
||||
|
||||
def test_board_page_is_served_once_signed_in
|
||||
response = signed_in_client.get('/')
|
||||
|
||||
assert_equal 200, response.status
|
||||
assert_includes response.body, 'id="nb-bootstrap"'
|
||||
assert_includes response.body, 'class Storage_Server'
|
||||
end
|
||||
|
||||
def test_board_page_carries_the_stored_items
|
||||
ValidBoard::STORE.apply([{ 'op' => 'set', 'key' => 'config', 'value' => '{"theme":"dark"}' }])
|
||||
|
||||
assert_equal({ 'config' => '{"theme":"dark"}' }, bootstrap_from(signed_in_client.get('/')))
|
||||
end
|
||||
|
||||
def test_bootstrap_escapes_markup_so_a_note_cannot_break_out
|
||||
payload = JSON.generate({ 'title' => 'closing </script> tag' })
|
||||
ValidBoard::STORE.apply([{ 'op' => 'set', 'key' => 'board.1.1', 'value' => payload }])
|
||||
|
||||
response = signed_in_client.get('/')
|
||||
script = response.body[/<script type="application\/json" id="nb-bootstrap">.*?<\/script>/m]
|
||||
|
||||
# The only </script> in the block must be the one that closes it.
|
||||
assert_equal 1, script.scan(%r{</script>}).length
|
||||
assert_includes script, 'u003c'
|
||||
|
||||
# ...and it still parses back to exactly what was stored.
|
||||
assert_equal payload, bootstrap_from(response)['board.1.1']
|
||||
end
|
||||
|
||||
def test_board_page_is_not_cached
|
||||
assert_match(/no-cache/, signed_in_client.get('/').headers['Cache-Control'].to_s)
|
||||
end
|
||||
|
||||
#
|
||||
# the storage API
|
||||
#
|
||||
|
||||
def test_write_then_read_back
|
||||
client = signed_in_client
|
||||
|
||||
response = client.post_json('/api/items', ops: [
|
||||
{ op: 'set', key: 'config', value: '{"a":1}' },
|
||||
{ op: 'set', key: 'board.7.meta', value: '{"b":2}' }
|
||||
])
|
||||
|
||||
assert_equal 200, response.status
|
||||
assert_equal 2, json_body(response)['applied']
|
||||
|
||||
assert_equal({ 'config' => '{"a":1}', 'board.7.meta' => '{"b":2}' },
|
||||
json_body(client.get('/api/items')))
|
||||
end
|
||||
|
||||
def test_delete_op_removes_an_item
|
||||
client = signed_in_client
|
||||
client.post_json('/api/items', ops: [{ op: 'set', key: 'board.7.1', value: 'x' }])
|
||||
client.post_json('/api/items', ops: [{ op: 'del', key: 'board.7.1' }])
|
||||
|
||||
assert_equal({}, json_body(client.get('/api/items')))
|
||||
end
|
||||
|
||||
def test_malformed_json_is_a_400
|
||||
client = signed_in_client
|
||||
response = client.post('/api/items', input: 'not json', 'CONTENT_TYPE' => 'application/json')
|
||||
|
||||
assert_equal 400, response.status
|
||||
assert_equal 'malformed JSON', json_body(response)['error']
|
||||
end
|
||||
|
||||
def test_missing_ops_array_is_a_400
|
||||
assert_equal 400, signed_in_client.post_json('/api/items', {}).status
|
||||
end
|
||||
|
||||
def test_oversized_batch_is_refused
|
||||
ops = Array.new(501) { |i| { op: 'set', key: "board.1.#{i}", value: 'x' } }
|
||||
|
||||
assert_equal 400, signed_in_client.post_json('/api/items', ops: ops).status
|
||||
assert_equal 0, ValidBoard::STORE.item_count
|
||||
end
|
||||
|
||||
def test_bad_key_is_a_400_and_writes_nothing
|
||||
client = signed_in_client
|
||||
response = client.post_json('/api/items', ops: [{ op: 'set', key: 'nullboard.../x', value: 'x' }])
|
||||
|
||||
assert_equal 400, response.status
|
||||
assert_equal 0, ValidBoard::STORE.item_count
|
||||
end
|
||||
|
||||
def test_oversized_value_is_a_413
|
||||
response = signed_in_client.post_json('/api/items',
|
||||
ops: [{ op: 'set', key: 'big', value: 'x' * (2 * 1024 * 1024 + 1) }])
|
||||
|
||||
assert_equal 413, response.status
|
||||
end
|
||||
|
||||
def test_wipe_clears_boards_but_leaves_you_signed_in
|
||||
client = signed_in_client
|
||||
client.post_json('/api/items', ops: [{ op: 'set', key: 'config', value: 'x' }])
|
||||
|
||||
assert_equal 200, client.delete('/api/items').status
|
||||
assert_equal({}, json_body(client.get('/api/items')))
|
||||
assert ValidBoard::STORE.password_set?, 'wiping must not clear the password'
|
||||
end
|
||||
|
||||
#
|
||||
# cross-site protection
|
||||
#
|
||||
|
||||
# rack-protection's HttpOrigin catches this. Sinatra configures every
|
||||
# protection with reaction :drop_session rather than :deny, so the request
|
||||
# arrives at the auth filter with an empty session and comes back 401 instead
|
||||
# of 403 — both are a refusal, and which one it is isn't the point worth
|
||||
# pinning down here.
|
||||
def test_write_from_another_origin_is_rejected
|
||||
client = signed_in_client
|
||||
|
||||
response = client.post_json('/api/items',
|
||||
{ ops: [{ op: 'set', key: 'config', value: 'x' }] },
|
||||
'HTTP_ORIGIN' => 'https://evil.example')
|
||||
|
||||
assert_includes [401, 403], response.status
|
||||
assert_equal 0, ValidBoard::STORE.item_count, 'a cross-site write must not reach the database'
|
||||
end
|
||||
|
||||
def test_write_from_our_own_origin_is_allowed
|
||||
client = signed_in_client
|
||||
|
||||
response = client.post_json('/api/items',
|
||||
{ ops: [{ op: 'set', key: 'config', value: 'x' }] },
|
||||
'HTTP_ORIGIN' => 'http://example.org', 'HTTP_HOST' => 'example.org')
|
||||
|
||||
assert_equal 200, response.status
|
||||
end
|
||||
|
||||
#
|
||||
# first run
|
||||
#
|
||||
|
||||
def test_service_refuses_to_serve_until_a_password_is_set
|
||||
without_password do
|
||||
response = Client.new.get('/')
|
||||
|
||||
assert_equal 503, response.status
|
||||
assert_includes response.body, 'validboard-passwd'
|
||||
end
|
||||
end
|
||||
|
||||
def test_health_check_still_answers_without_a_password
|
||||
without_password do
|
||||
assert_equal 200, Client.new.get('/healthz').status
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bootstrap_from(response)
|
||||
json = response.body[/<script type="application\/json" id="nb-bootstrap">(.*?)<\/script>/m, 1]
|
||||
refute_nil json, 'page had no bootstrap block'
|
||||
|
||||
JSON.parse(json)
|
||||
end
|
||||
|
||||
# Reaches around the Store to unset the password, since nothing in the app is
|
||||
# allowed to do that.
|
||||
def without_password
|
||||
hash = ValidBoard::STORE.meta_get('password_hash')
|
||||
|
||||
db = SQLite3::Database.new(ValidBoard::STORE.path)
|
||||
db.execute("DELETE FROM meta WHERE key = 'password_hash'")
|
||||
db.close
|
||||
|
||||
yield
|
||||
ensure
|
||||
ValidBoard::STORE.meta_set('password_hash', hash) if hash
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,192 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'test_helper'
|
||||
|
||||
class StoreTest < Minitest::Test
|
||||
def setup
|
||||
@path = File.join(TEST_DIR, "store-#{name}-#{object_id}.db")
|
||||
@store = ValidBoard::Store.new(@path)
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.rm_f(Dir.glob("#{@path}*"))
|
||||
end
|
||||
|
||||
#
|
||||
# items
|
||||
#
|
||||
|
||||
def test_starts_empty
|
||||
assert_equal({}, @store.all_items)
|
||||
assert_equal 0, @store.item_count
|
||||
end
|
||||
|
||||
def test_set_and_read_back
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => '{"theme":"dark"}' }])
|
||||
|
||||
assert_equal({ 'config' => '{"theme":"dark"}' }, @store.all_items)
|
||||
end
|
||||
|
||||
def test_set_overwrites_existing_key
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'board.1.meta', 'value' => 'first' }])
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'board.1.meta', 'value' => 'second' }])
|
||||
|
||||
assert_equal 'second', @store.all_items['board.1.meta']
|
||||
assert_equal 1, @store.item_count
|
||||
end
|
||||
|
||||
def test_delete_removes_key
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'board.1.7', 'value' => 'x' }])
|
||||
@store.apply([{ 'op' => 'del', 'key' => 'board.1.7' }])
|
||||
|
||||
assert_equal({}, @store.all_items)
|
||||
end
|
||||
|
||||
def test_deleting_a_missing_key_is_not_an_error
|
||||
assert_equal 1, @store.apply([{ 'op' => 'del', 'key' => 'never.existed' }])
|
||||
end
|
||||
|
||||
def test_mixed_batch_applies_in_order
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'keep', 'value' => 'a' },
|
||||
{ 'op' => 'set', 'key' => 'drop', 'value' => 'b' }])
|
||||
|
||||
applied = @store.apply([{ 'op' => 'set', 'key' => 'keep', 'value' => 'c' },
|
||||
{ 'op' => 'del', 'key' => 'drop' },
|
||||
{ 'op' => 'set', 'key' => 'fresh', 'value' => 'd' }])
|
||||
|
||||
assert_equal 3, applied
|
||||
assert_equal({ 'keep' => 'c', 'fresh' => 'd' }, @store.all_items)
|
||||
end
|
||||
|
||||
def test_values_survive_awkward_characters
|
||||
value = %(unicode ✓ / quote " / backslash \\ / newline \n / tag </script>)
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'odd', 'value' => value }])
|
||||
|
||||
assert_equal value, @store.all_items['odd']
|
||||
end
|
||||
|
||||
#
|
||||
# validation — a whole batch is checked before any of it is written, so a
|
||||
# bad op can't leave half a board save behind
|
||||
#
|
||||
|
||||
def test_rejects_key_with_illegal_characters
|
||||
assert_raises(ValidBoard::Store::InvalidKey) do
|
||||
@store.apply([{ 'op' => 'set', 'key' => '../../etc/passwd', 'value' => 'x' }])
|
||||
end
|
||||
end
|
||||
|
||||
def test_rejects_empty_key
|
||||
assert_raises(ValidBoard::Store::InvalidKey) do
|
||||
@store.apply([{ 'op' => 'set', 'key' => '', 'value' => 'x' }])
|
||||
end
|
||||
end
|
||||
|
||||
def test_rejects_unknown_op
|
||||
assert_raises(ValidBoard::Store::InvalidKey) do
|
||||
@store.apply([{ 'op' => 'truncate', 'key' => 'config' }])
|
||||
end
|
||||
end
|
||||
|
||||
def test_rejects_oversized_value
|
||||
assert_raises(ValidBoard::Store::ValueTooBig) do
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'huge', 'value' => 'x' * (2 * 1024 * 1024 + 1) }])
|
||||
end
|
||||
end
|
||||
|
||||
def test_bad_op_leaves_the_rest_of_the_batch_unwritten
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'before', 'value' => 'original' }])
|
||||
|
||||
assert_raises(ValidBoard::Store::InvalidKey) do
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'before', 'value' => 'changed' },
|
||||
{ 'op' => 'set', 'key' => 'bad key!', 'value' => 'x' }])
|
||||
end
|
||||
|
||||
assert_equal 'original', @store.all_items['before']
|
||||
assert_equal 1, @store.item_count
|
||||
end
|
||||
|
||||
#
|
||||
# wipe
|
||||
#
|
||||
|
||||
def test_wipe_clears_items_but_keeps_the_password
|
||||
@store.password = 'a-long-enough-password'
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => 'x' }])
|
||||
|
||||
@store.wipe_items!
|
||||
|
||||
assert_equal({}, @store.all_items)
|
||||
assert @store.password_set?, 'wiping boards must not lock you out'
|
||||
end
|
||||
|
||||
#
|
||||
# password
|
||||
#
|
||||
|
||||
def test_no_password_until_one_is_set
|
||||
refute @store.password_set?
|
||||
refute @store.password_matches?('anything')
|
||||
end
|
||||
|
||||
def test_password_round_trip
|
||||
@store.password = 'a-long-enough-password'
|
||||
|
||||
assert @store.password_set?
|
||||
assert @store.password_matches?('a-long-enough-password')
|
||||
refute @store.password_matches?('a-long-enough-passwerd')
|
||||
refute @store.password_matches?('')
|
||||
end
|
||||
|
||||
def test_password_is_hashed_not_stored
|
||||
@store.password = 'a-long-enough-password'
|
||||
|
||||
refute_includes @store.meta_get('password_hash'), 'a-long-enough-password'
|
||||
assert_match(/\A\$2[aby]\$/, @store.meta_get('password_hash'))
|
||||
end
|
||||
|
||||
def test_short_password_is_refused
|
||||
assert_raises(ArgumentError) { @store.password = 'short' }
|
||||
end
|
||||
|
||||
def test_password_can_be_changed
|
||||
@store.password = 'the-first-password'
|
||||
@store.password = 'the-second-password'
|
||||
|
||||
refute @store.password_matches?('the-first-password')
|
||||
assert @store.password_matches?('the-second-password')
|
||||
end
|
||||
|
||||
#
|
||||
# session secret
|
||||
#
|
||||
|
||||
def test_session_secret_is_generated_once_and_kept
|
||||
first = @store.session_secret
|
||||
|
||||
assert_operator first.length, :>=, 64
|
||||
assert_equal first, @store.session_secret
|
||||
assert_equal first, ValidBoard::Store.new(@path).session_secret, 'secret must survive a restart'
|
||||
end
|
||||
|
||||
#
|
||||
# schema
|
||||
#
|
||||
|
||||
def test_reopening_an_existing_database_keeps_the_data
|
||||
@store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => 'kept' }])
|
||||
|
||||
assert_equal 'kept', ValidBoard::Store.new(@path).all_items['config']
|
||||
end
|
||||
|
||||
def test_refuses_a_database_from_a_newer_version
|
||||
@store.meta_set('schema_version', ValidBoard::Store::SCHEMA_VERSION + 1)
|
||||
|
||||
error = assert_raises(RuntimeError) { ValidBoard::Store.new(@path) }
|
||||
assert_match(/newer than this code/, error.message)
|
||||
end
|
||||
|
||||
def test_database_file_is_not_world_readable
|
||||
assert_equal '600', format('%o', File.stat(@path).mode & 0o777)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'minitest/autorun'
|
||||
require 'tmpdir'
|
||||
require 'fileutils'
|
||||
require 'json'
|
||||
require 'rack'
|
||||
require 'rack/mock'
|
||||
|
||||
# Point the app at a throwaway database *before* loading it — app.rb opens the
|
||||
# store at require time.
|
||||
TEST_DIR = Dir.mktmpdir('validboard-test')
|
||||
|
||||
ENV['VALIDBOARD_DB'] = File.join(TEST_DIR, 'test.db')
|
||||
ENV.delete('VALIDBOARD_SECRET')
|
||||
ENV.delete('VALIDBOARD_SECURE_COOKIE')
|
||||
|
||||
Minitest.after_run { FileUtils.remove_entry(TEST_DIR, true) }
|
||||
|
||||
require_relative '../app'
|
||||
|
||||
TEST_PASSWORD = 'correct-horse-battery'
|
||||
|
||||
# A cookie-carrying client. Rack::MockRequest doesn't keep a jar of its own, so
|
||||
# without this every request would look like a fresh browser and nothing that
|
||||
# depends on a session could be tested.
|
||||
class Client
|
||||
COOKIE_NAME = 'validboard.session'
|
||||
|
||||
def initialize(remote_addr: '127.0.0.1')
|
||||
@mock = Rack::MockRequest.new(ValidBoard::App)
|
||||
@cookies = {}
|
||||
@remote_addr = remote_addr
|
||||
end
|
||||
|
||||
def get(path, env = {}) = request('GET', path, env)
|
||||
def post(path, env = {}) = request('POST', path, env)
|
||||
def delete(path, env = {}) = request('DELETE', path, env)
|
||||
|
||||
def post_json(path, obj, env = {})
|
||||
post(path, env.merge(input: JSON.generate(obj), 'CONTENT_TYPE' => 'application/json'))
|
||||
end
|
||||
|
||||
def login(password = TEST_PASSWORD)
|
||||
post('/login', params: { 'password' => password })
|
||||
end
|
||||
|
||||
def session_cookie
|
||||
@cookies[COOKIE_NAME]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request(method, path, env)
|
||||
# Rack::MockRequest leaves HTTP_VERSION unset, which reads as HTTP/1.0 —
|
||||
# and Sinatra answers a POST redirect with 302 there but 303 over 1.1. Pin
|
||||
# it so the tests see what a browser will.
|
||||
env = { 'REMOTE_ADDR' => @remote_addr, 'HTTP_VERSION' => 'HTTP/1.1' }.merge(env)
|
||||
env['HTTP_COOKIE'] = @cookies.map { |k, v| "#{k}=#{v}" }.join('; ') unless @cookies.empty?
|
||||
|
||||
response = @mock.request(method, path, env)
|
||||
absorb_cookies(response)
|
||||
response
|
||||
end
|
||||
|
||||
def absorb_cookies(response)
|
||||
raw = response.headers['Set-Cookie']
|
||||
return if raw.nil?
|
||||
|
||||
Array(raw).flat_map { |header| header.split("\n") }.each do |line|
|
||||
name, value = line.split(';', 2).first.to_s.split('=', 2)
|
||||
next if name.nil?
|
||||
|
||||
@cookies[name] = value.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module TestHelpers
|
||||
# Puts the app's store back to a known state: one password, no boards.
|
||||
def reset_app_store!
|
||||
ValidBoard::STORE.wipe_items!
|
||||
ValidBoard::STORE.password = TEST_PASSWORD
|
||||
end
|
||||
|
||||
def signed_in_client(**kwargs)
|
||||
client = Client.new(**kwargs)
|
||||
response = client.login
|
||||
|
||||
raise "login failed: #{response.status}" unless response.status == 303
|
||||
|
||||
client
|
||||
end
|
||||
|
||||
def json_body(response)
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
end
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
/* The board's own dark mode is a saved preference, which we can't know
|
||||
before you're signed in — so follow the OS here instead. Colours are
|
||||
nullboard's .theme-dark palette. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #22272b; color: #b3b9c0; }
|
||||
|
||||
h1 { color: #d4d8dc; }
|
||||
|
||||
.sub, label { color: #6f7780; }
|
||||
|
||||
input[type=password] {
|
||||
background: #2c3238;
|
||||
border-color: #3b434b;
|
||||
color: #d4d8dc;
|
||||
}
|
||||
|
||||
input[type=password]:focus {
|
||||
border-color: #5c7a99;
|
||||
box-shadow: 0 0 0 2px rgba(92, 122, 153, .3);
|
||||
}
|
||||
|
||||
.error { background: #3b2a2a; color: #e08585; }
|
||||
}
|
||||
</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