Compare commits
13
Commits
b4ea5a316e
...
8338374a8d
| 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.
|
So the server is just a key/value store, and knows nothing about boards:
|
||||||
* Can be used completely offline. In fact, it's written exactly with this use in mind.
|
|
||||||
|
|
||||||
## 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 fork adds one class, `Storage_Server`, alongside upstream's
|
||||||
* The data can be exported to- or imported from a plain text file in a simple JSON format.
|
`Storage_Local`, and swaps which one gets instantiated. Everything else in
|
||||||
* The data can also be automatically backed up to a local disk with the help of:
|
`nullboard.html` is untouched, so upstream changes still merge.
|
||||||
* [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
|
|
||||||
|
|
||||||
## 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 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
|
||||||
The color theme can be inverted:
|
be instantiated — so conflicts should be rare and small.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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
|
## License
|
||||||
|
|
||||||
The [2-clause BSD license](https://opensource.org/licenses/BSD-2-Clause/) with the [Commons Clause](https://commonsclause.com/).
|
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
|
||||||
That is, you can use, change and re-distribute it for as long as you don't try and sell it.
|
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.
|
||||||
## Updates
|
|
||||||
|
|
||||||
Primary feed is through [@nullboard](https://twitter.com/nullboard) on Twitter.
|
|
||||||
|
|
||||||
The changelog is here => https://nullboard.io/changes
|
|
||||||
|
|||||||
@@ -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>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<!--
|
<!--
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
|
|
||||||
-->
|
-->
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Nullboard</title>
|
<title>Nullboard</title>
|
||||||
<link rel="icon" href="extras/favicon-16.png"/>
|
<link rel="icon" href="extras/favicon-16.png"/>
|
||||||
<style>
|
<style>
|
||||||
@@ -220,7 +221,7 @@
|
|||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
/***/
|
/***/
|
||||||
.clearfix:after,
|
.clearfix:after,
|
||||||
.board:after,
|
.board:after,
|
||||||
@@ -750,6 +751,31 @@
|
|||||||
color: #d20;
|
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 {
|
.config {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1546,17 +1572,28 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<a class=site href=https://nullboard.io>Nullboard</a>
|
<a class=site href=/>ValidBoard</a>
|
||||||
<i class=alert></i>
|
<i class=alert></i>
|
||||||
|
<i class=sync></i>
|
||||||
<div class=bulk>
|
<div class=bulk>
|
||||||
<a href=# class=view-about>About</a>
|
<a href=# class=view-about>About</a>
|
||||||
<a href=# class=view-license>License</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/changes target=_blank class=view-changes>Changes</a>
|
||||||
<a href=https://nullboard.io/github target=_blank>Github</a>
|
<a href=https://nullboard.io target=_blank>Nullboard</a>
|
||||||
<a href=https://nullboard.io/twitter target=_blank>Twitter</a>
|
<a href=# class=sign-out>Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
</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'>
|
<div class='config no-user-select'>
|
||||||
<a href=# class=teaser><i>≡</i><u>✔</u></a>
|
<a href=# class=teaser><i>≡</i><u>✔</u></a>
|
||||||
<div class=bulk>
|
<div class=bulk>
|
||||||
@@ -1748,6 +1785,14 @@
|
|||||||
this.backupStatus = { }; // agentId => [ 'conf' ]
|
this.backupStatus = { }; // agentId => [ 'conf' ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const default_backup_agents =
|
||||||
|
[
|
||||||
|
{ base: 'http://127.0.0.1:10001', auth: '' }, // local agent
|
||||||
|
{ base: '', auth: '' } // remote agent
|
||||||
|
];
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
function BoardMeta()
|
function BoardMeta()
|
||||||
{
|
{
|
||||||
this.title = '';
|
this.title = '';
|
||||||
@@ -1985,7 +2030,7 @@
|
|||||||
var meta = this.boardIndex.get(board_id);
|
var meta = this.boardIndex.get(board_id);
|
||||||
|
|
||||||
if (! meta)
|
if (! meta)
|
||||||
throw `Invalid board_id in nukeBoard(${board.id})`;
|
throw `Invalid board_id in nukeBoard(${board_id})`;
|
||||||
|
|
||||||
var title = meta.title + '';
|
var title = meta.title + '';
|
||||||
|
|
||||||
@@ -2038,7 +2083,7 @@
|
|||||||
var meta = this.boardIndex.get(board_id);
|
var meta = this.boardIndex.get(board_id);
|
||||||
|
|
||||||
if (! meta)
|
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;
|
meta.ui_spot = ui_spot;
|
||||||
|
|
||||||
@@ -2101,22 +2146,26 @@
|
|||||||
agents[0].type != simp || agents[0].conf.base != 'http://127.0.0.1:10001' ||
|
agents[0].type != simp || agents[0].conf.base != 'http://127.0.0.1:10001' ||
|
||||||
agents[1].type != simp)
|
agents[1].type != simp)
|
||||||
{
|
{
|
||||||
|
const def = default_backup_agents;
|
||||||
|
|
||||||
console.log('Unexpected backup config, will re-initialize.', agents);
|
console.log('Unexpected backup config, will re-initialize.', agents);
|
||||||
|
|
||||||
conf.backups.agents = [];
|
conf.backups.agents = [];
|
||||||
|
|
||||||
conf.backups.agents.push({
|
conf.backups.agents.push({
|
||||||
type: simp,
|
// localhost
|
||||||
id: simp + '-' + (conf.backups.nextId++),
|
type : simp,
|
||||||
enabled: false,
|
id : simp + '-' + (conf.backups.nextId++),
|
||||||
conf: { base: 'http://127.0.0.1:10001', auth: '' }
|
enabled : def[0].base && def[0].auth,
|
||||||
|
conf : def[0],
|
||||||
})
|
})
|
||||||
|
|
||||||
conf.backups.agents.push({
|
conf.backups.agents.push({
|
||||||
type: simp,
|
// remote
|
||||||
id: simp + '-' + (conf.backups.nextId++),
|
type : simp,
|
||||||
enabled: false,
|
id : simp + '-' + (conf.backups.nextId++),
|
||||||
conf: { base: '', auth: '' }
|
enabled : def[1].base && def[1].auth,
|
||||||
|
conf : def[1],
|
||||||
})
|
})
|
||||||
|
|
||||||
this.saveConfig();
|
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 =
|
var NB =
|
||||||
{
|
{
|
||||||
codeVersion: 20221112,
|
codeVersion: 20231105,
|
||||||
blobVersion: 20190412, // board blob format in Storage
|
blobVersion: 20190412, // board blob format in Storage
|
||||||
board: null,
|
board: null,
|
||||||
storage: null,
|
storage: null,
|
||||||
@@ -5099,7 +5510,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
NB.storage = new Storage_Local();
|
NB.storage = new Storage_Server();
|
||||||
|
|
||||||
if (! NB.storage.open())
|
if (! NB.storage.open())
|
||||||
{
|
{
|
||||||
@@ -5107,6 +5518,28 @@
|
|||||||
throw new Error();
|
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();
|
var boards = NB.storage.getBoardIndex();
|
||||||
|
|
||||||
boards.forEach( function(meta, board_id) {
|
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