A guestbook with its own store
Save and list entries in a store only this site can reach.
In this tutorial you build a guestbook that saves entries in an embedded store of its own, lists them newest first, keeps them across restarts and redeploys, and runs on a host as well as on your laptop.
You need the crisptastic executable from the Quickstart, and
the standard openssl and age tools for the keys. No database server and no
Rust are involved: the store is declared in crisptastic.toml and used from
pages. Every file below was served by the real engine while this tutorial was
written.
1. Create the folder
mkdir guestbook && cd guestbook
mkdir _partials
Everything below is a file inside guestbook/.
2. Declare the store
[data]
provider = "residiuum-embedded-development"
application_id = "guestbook"
records = ["entries", "counters"]
memory_budget = "128M"
[routes."/sign"]
methods = ["POST"]
body = "form"
max_body_bytes = 2048
capabilities = ["data"]
rate = [{ per = "client", limit = 20, window = "1m" }]
providerhas one possible value. Despite the name, it is what a single host uses too.application_idnames the store.recordslists the collections of records you want. Each name gets five operations on thedatacapability, such asdata.entries_listanddata.entries_create.memory_budgetis what this store may use. A server can inherit a limit from its service unit, but a laptop has none to inherit, so say it here.
The [routes."/sign"] table describes a page you will write in step 4. The
route must exist when the site loads, so write the pages before you start the
server. Keep a form honest goes through these settings
one at a time.
3. The list page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guestbook</title>
</head>
<body>
<main>
</main>
</body>
</html>
@call page = data.entries_list({ limit: 5, after: request.query.after })
@include("_partials/_layout-open.html.crisp")
<h1>Guestbook</h1>
<form method="post" action="/sign">
<input type="hidden" name="_csrf" value="{{ context.csrf.token }}">
<p><label>Name <input name="name" required></label></p>
<p><label>Message <textarea name="message" required></textarea></label></p>
<button>Sign</button>
</form>
@if (page.ok) {
@for (entry in page.value.items) {
<article><h2>{{ entry.document.name }}</h2><p>{{ entry.document.message }}</p></article>
}
@if (page.value.next) {
<p><a href="/?after={{ page.value.next }}">Older entries</a></p>
}
} @else {
@status page.status
<p>The guestbook cannot be read right now ({{ page.status }}).</p>
}
@include("_partials/_layout-close.html.crisp")
@call binds an envelope, the same idea as @fetch: a failure is a value, so
check .ok. A record comes back as { id, document, version, created, updated },
and what you stored is under .document. list answers
{ items, next }, where next is a cursor for the following page.
4. The page that writes
list returns records in id order, and a page cannot sort: there is no sort
function, SDA does not sort, and a page has no clock. So choose ids that sort the
way you want. For newest first, keep a count in the counters collection and
count down: 9000000 - n gives 8999999, 8999998 and so on, all the same
width.
@let name = trim(request.body.name)
@let message = trim(request.body.message)
@if (!empty(name) and !empty(message)) {
@call counter = data.counters_get({ id: "entries" })
@let n = num(counter.value.document.n) + 1
@if (counter.ok) {
@call bumped = data.counters_replace({ id: "entries", version: counter.value.version, document: { n: n } })
} @else {
@call bumped = data.counters_create({ id: "entries", document: { n: n } })
}
@if (bumped.ok) {
@call saved = data.entries_create({ id: str(9000000 - n), document: { name: name, message: message } })
@if (saved.ok) {
@redirect "/"
}
}
@status 409
@include("_partials/_layout-open.html.crisp")
<h1>Not signed</h1>
<p>Someone else was signing at the same moment. <a href="/">Try again</a></p>
@include("_partials/_layout-close.html.crisp")
} @else {
@status 400
@include("_partials/_layout-open.html.crisp")
<h1>Not signed</h1>
<p>A guestbook entry needs a name and a message. <a href="/">Try again</a></p>
@include("_partials/_layout-close.html.crisp")
}
Reading it from the top:
counter.value.document.nis a missing path until the counter exists. A missing path isnullandnum(null)is0, so the firstnis1.@callbinds its name for the rest of the page, even from inside a block, which is howbumpedcan be read after the@if.createtakes anidand adocumentand succeeds only if theidis free.replacesucceeds only at theversionthe page read. If another visitor moved the counter first, this one gets a failed result instead of overwriting them, and sees the message at the bottom.@redirectstops the page and sends a303, so a refresh does not sign twice.
5. Start it, once with a key
A store is sealed. It is created once, with a 32-byte unseal key, and after that
it opens without one. Try starting without it, in another terminal inside
guestbook/:
crisptastic dev --bind 127.0.0.1:8080
error: failed to open embedded Residiuum: an unseal key is required to create a new Residiuum store
Make a key, outside the site folder because everything in that folder gets deployed:
openssl rand -hex 32 > ../guestbook.key
dev does not take the key as a value. You name a file descriptor, and the
shell opens the file on it:
CRISPTASTIC_RESIDIUUM_UNSEAL_KEY_FD=3 crisptastic dev --bind 127.0.0.1:8080 3< ../guestbook.key
The store now exists under .crisptastic/residiuum/guestbook/.
6. Sign it
In the first terminal, sign the book twice and read it back. Use the browser at http://127.0.0.1:8080/ if you prefer.
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -d 'name=Ada&message=First!' http://127.0.0.1:8080/sign
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -d 'name=Grace&message=<b>Second</b>' http://127.0.0.1:8080/sign
curl -s http://127.0.0.1:8080/ | grep article
303 http://127.0.0.1:8080/
303 http://127.0.0.1:8080/
<article><h2>Grace</h2><p><b>Second</b></p></article>
<article><h2>Ada</h2><p>First!</p></article>
The newest is on top, and the message is escaped on the way out, so nobody can put
markup into your page. Now check the other answers. An empty form, and a GET,
which the [routes."/sign"] table refuses before the page runs:
curl -s -o /dev/null -w '%{http_code}\n' -d 'name=&message=' http://127.0.0.1:8080/sign
curl -si http://127.0.0.1:8080/sign | grep -E '^HTTP|^allow'
400
HTTP/1.1 405 Method Not Allowed
allow: POST
7. See it survive
Stop the server (Ctrl+C) and start it again, this time with no key:
crisptastic dev --bind 127.0.0.1:8080
curl -s http://127.0.0.1:8080/ | grep -c article
2
The key is only for creating the store. You will want it once more in step 10, for the host's own store, so keep it somewhere safe.
8. More than a page
The list page asks for five at a time. Sign six more and look:
for who in Hedy Katherine Margaret Radia Barbara Frances; do curl -s -o /dev/null -d "name=$who&message=Hello" http://127.0.0.1:8080/sign; done
curl -s http://127.0.0.1:8080/ | grep -E 'article|Older' | cut -c1-60
<article><h2>Frances</h2><p>Hello</p></article>
<article><h2>Barbara</h2><p>Hello</p></article>
<article><h2>Radia</h2><p>Hello</p></article>
<article><h2>Margaret</h2><p>Hello</p></article>
<article><h2>Katherine</h2><p>Hello</p></article>
<p><a href="/?after=525343312e9f902f02a94f2d8f2b6313c611
Follow the link for the next five:
curl -s "http://127.0.0.1:8080/$(curl -s http://127.0.0.1:8080/ | grep -o '?after=[0-9a-f]*')" | grep -E 'article|Older' | cut -c1-60
<article><h2>Hedy</h2><p>Hello</p></article>
<article><h2>Grace</h2><p><b>Second</b></p><
<article><h2>Ada</h2><p>First!</p></article>
A bad cursor is one of the failures the list page checks for, and @status page.status passes on the code that came back:
curl -si 'http://127.0.0.1:8080/?after=nonsense' | grep -E '^HTTP|cannot be read'
HTTP/1.1 400 Bad Request
<p>The guestbook cannot be read right now (400).</p>
Now have six people sign at the same moment:
seq 1 6 | xargs -P 6 -I{} curl -s -o /dev/null -w '%{http_code}\n' -d 'name=Burst{}&message=Hello' http://127.0.0.1:8080/sign | sort | uniq -c
1 303
5 409
Your split will differ. Every 303 is an entry, and every 409 is someone who
lost the race for the counter and was asked to try again, so no entry was
overwritten. That is the price of the counter: one shared record that every
signature has to change. It is no trouble for a guestbook. For something busier,
use random ids from secure_token(8) and do without the order.
9. Check it
crisptastic check .
crisptastic routes .
ok: 2 routes, 2 partials
/
/sign
10. On a host
A host keeps its own copy of the store, beneath its own folder, and hands it to each new version of your site. It creates the store the first time it activates the site, so the Capsule has to carry a key for that one occasion. Stop the server (Ctrl+C) and change the configuration:
[deployment]
domains = ["guestbook.example.com"]
[env_from_process]
GUESTBOOK_UNSEAL_KEY = "GUESTBOOK_UNSEAL_KEY"
[data]
provider = "residiuum-embedded-development"
application_id = "guestbook"
records = ["entries", "counters"]
memory_budget = "128M"
unseal_secret = "GUESTBOOK_UNSEAL_KEY"
[routes."/sign"]
methods = ["POST"]
body = "form"
max_body_bytes = 2048
capabilities = ["data"]
rate = [{ per = "client", limit = 20, window = "1m" }]
[env_from_process] reads the variable from your shell when you run deploy,
and seals its value inside the encrypted Capsule. unseal_secret says which
secure variable holds the key. A side effect: dev also insists on a variable
named in [env_from_process], so from now on export GUESTBOOK_UNSEAL_KEY
before you run dev here.
Make the host's key and your signing key, and build the Capsule:
age-keygen -o ../host.agekey
crisptastic capsule keygen --private ../release.pk8 --public ../release.pub
GUESTBOOK_UNSEAL_KEY=$(cat ../guestbook.key) crisptastic deploy . --recipient "$(age-keygen -y ../host.agekey)" --signing-key ../release.pk8 --out ../guestbook.age
Public key: age13xrr2x7l5mg4dcksj2ng037rwz0f5294ztud5398v5yklp895yrskauh0m
private ../release.pk8
public ../release.pub
signer 71987214540235c24b0346cc5446c266
built encrypted deployment ../guestbook.age (5 files, 1 secure variables, 3352 bytes)
sha256 fc7dd6df49c0f8c7307f9726ab408d5636950705ecbc3db4c553c646077ea4be
signer 71987214540235c24b0346cc5446c266
domain guestbook.example.com
secure variable GUESTBOOK_UNSEAL_KEY
secure variable lists the name and never the value. Start a local trial host in
the other terminal:
crisptastic host ../guestbook.age --identity ../host.agekey --trusted-signer ../release.pub --development-process-profile --bind 127.0.0.1:8080 --locked-secret-memory-bytes 65536 --deployments-root ../trial-host/deployments
The small --locked-secret-memory-bytes is because the default of 64 MiB is more
than an ordinary user may usually lock; ulimit -l shows your limit in KiB.
--deployments-root puts the host's files outside the site folder. Without it they
go in .crisptastic/ under the current folder, and the site folder treats every
.crisp file beneath it as a page, hidden folders included.
--development-process-profile is what makes this a trial: it skips the hardening
checks and the requirement for a reverse proxy. Ship it runs
the real thing.
A host chooses the site by domain, so name it in the request:
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: guestbook.example.com' -d 'name=Mary&message=Hello from the host' http://127.0.0.1:8080/sign
curl -s -H 'Host: guestbook.example.com' http://127.0.0.1:8080/ | grep article
303
<article><h2>Mary</h2><p>Hello from the host</p></article>
Only Mary is there. The host's store is not the one dev was using: a Capsule
carries your pages, never your data. A trial host keeps its state beside its
deployments, so this store is in ../trial-host/stores/guestbook/.
Stop the host (Ctrl+C), start it again the same way, and the entry is still there:
crisptastic host ../guestbook.age --identity ../host.agekey --trusted-signer ../release.pub --development-process-profile --bind 127.0.0.1:8080 --locked-secret-memory-bytes 65536 --deployments-root ../trial-host/deployments
curl -s -H 'Host: guestbook.example.com' http://127.0.0.1:8080/ | grep -c article
1
Before you rely on it
- One host. The store lives on one machine, and a Fabric cluster refuses a
site that declares
[data]. - Back it up. A site's records live only in the host's store. See Backup and recovery.
- One site, one store. A different site claiming the same
application_idis refused. See Sites with an embedded store for the other rules, including whatmemory_budgetdoes on a host. - Anyone can sign. A form with no session has no CSRF check, so the
ratelimit is what stands between you and a spam script. See Keep a form honest.
What next
- Change and remove records with
data.entries_get,data.entries_replaceanddata.entries_delete: Capabilities and integrations. - Take the site to a production host: Ship it.