6 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 994927c704 Move pages to and from the vault
CI / test (push) Successful in 2m12s
Release / release (push) Successful in 3m42s
A page, including a journal day, can be moved into the unlocked vault with its id kept. Links to it elsewhere become [[locked:id]], shown as Locked Page until the vault is unlocked, so its title is kept only in the vault. Move out reverses it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:58:29 -07:00
VeillaxandClaude Sonnet 5.5 c08a8b8718 Add a passphrase-locked vault
CI / test (push) Successful in 2m2s
Release / release (push) Successful in 2m52s
A second notebook whose pages are encrypted with a key made from a passphrase plus the server key. It is unlocked into memory only (15 minutes idle, Lock, or sign-out), is not in search, lists or downloads of the ordinary notebook, and limits wrong guesses.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:40:35 -07:00
VeillaxandClaude Sonnet 5.5 7ff424fa55 Settings: say pages are encrypted
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:15:51 -07:00
VeillaxandClaude Sonnet 5.5 e938367929 Fix the / and [[ menus in live preview
The page's hidden text box also heard the live view's edits and showed its own menu in the wrong place, and the live line's menu was dropped when the view redrew. Only the box in use suggests now, the menu re-attaches when shown, and the caret lands inside a new code block.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:10:54 -07:00
VeillaxandClaude Sonnet 5.5 adde7c54ce Live preview: select, delete and word-delete across lines
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:07:41 -07:00
VeillaxandClaude Sonnet 5.5 2536f223b7 Publish the container image on release
CI / test (push) Successful in 2m0s
Release / release (push) Successful in 3m33s
The compose file pulled git.veillax.com/veillax/mako:latest, which was never built. The release workflow now builds and pushes it, as Hammerhead's does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:35:41 -06:00
15 changed files with 1267 additions and 48 deletions

No files matched your search

+22
View File
@@ -32,3 +32,25 @@ jobs:
env: env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}" run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
# if this runner has no Docker: the release above is already published by then.
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
# with package write permission, for servers where the job token can't push.
- name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
run: |
host=${GITHUB_SERVER_URL#*://}
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z')
image="$host/$owner/mako"
echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin
docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release
tags="-t $image:$GITHUB_REF_NAME"
case "$GITHUB_REF_NAME" in
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
*) tags="$tags -t $image:latest" ;;
esac
docker buildx build --platform linux/amd64 $tags --push .
+18 -1
View File
@@ -33,6 +33,12 @@ Named for the mako shark, the fastest in the sea, because a notebook should keep
keeps your text and retries. If your Authentik session ends mid-sentence, it keeps the text, offers keeps your text and retries. If your Authentik session ends mid-sentence, it keeps the text, offers
to sign in again in another tab, and saves when you are back. If a page changed on another device, to sign in again in another tab, and saves when you are back. If a page changed on another device,
it asks which version to keep instead of overwriting. it asks which version to keep instead of overwriting.
- **A vault for what needs more.** Pages in the Vault open only with a passphrase of their own, on top of
signing in. The server cannot read them until you unlock it, and they are in no list, search or download
of your ordinary notebook. It locks itself after 15 minutes idle, when you press Lock, and when you
sign out. See Encryption below. Press To vault on any page, journal days included, and Move out on a
vault page to bring it back. A page that links to a vaulted page shows Locked Page in its place until
you unlock the vault, and the vaulted page's title is kept only in the vault.
- **Take it with you.** Download everything as a zip that opens as a vault in Obsidian: file names are - **Take it with you.** Download everything as a zip that opens as a vault in Obsidian: file names are
titles, so the links keep working, and journal days sit in a Journal folder. titles, so the links keep working, and journal days sit in a Journal folder.
@@ -139,6 +145,11 @@ markdown.
- **What it protects:** a stolen disk or backup of `data/`, and anyone who can read the notes folder but - **What it protects:** a stolen disk or backup of `data/`, and anyone who can read the notes folder but
not the settings. **What it does not:** someone who controls the running server, or who can read both not the settings. **What it does not:** someone who controls the running server, or who can read both
`mako.json` and `data/`. Who you are in Authentik is not secret, so it cannot be the key by itself. `mako.json` and `data/`. Who you are in Authentik is not secret, so it cannot be the key by itself.
- **The Vault** adds a second lock. Its pages are encrypted with a key made from your passphrase
(PBKDF2, 600,000 rounds) together with the server's key, so someone with the server's key still cannot
read them without the passphrase. The server holds the unlocked key in memory only, for as long as the
vault stays open. **There is no way to reset the passphrase**; forget it and those pages are gone.
Five wrong guesses in ten minutes lock out further tries for a while.
- **Upgrading from 0.1.0:** existing plain files are read and saved again encrypted the first time - **Upgrading from 0.1.0:** existing plain files are read and saved again encrypted the first time
that person signs in, and a `key` is added to your settings file. that person signs in, and a `key` is added to your settings file.
@@ -191,8 +202,14 @@ file time as its date, and is then encrypted (name it with lower-case letters, n
- Searching holds every page in memory, which is comfortable for tens of thousands of pages and not - Searching holds every page in memory, which is comfortable for tens of thousands of pages and not
built for millions. built for millions.
- Pages are only reloaded from disk when Mako starts. - Pages are only reloaded from disk when Mako starts.
- A page in the Vault cannot link to your ordinary pages or use their tags, and the vault is not in the zip
download. Moving a page in takes its subpages up a level but does not move them with it, and a journal day
moved in leaves that day empty, so starting it again makes a new page which cannot be moved in while
the first is there. You cannot change its passphrase yet.
- Live preview works a line at a time (a code block counts as one), so a table or a quote spread over - Live preview works a line at a time (a code block counts as one), so a table or a quote spread over
several lines shows rendered line by line. View shows the whole page as one. several lines shows rendered line by line. View shows the whole page as one. Selecting across lines
(Ctrl+A, Shift+Up or Down, Shift+click, or dragging) opens those whole lines as raw markdown, so a
selection that starts or ends partway through a line takes in the whole line.
## Design ## Design
+115 -3
View File
@@ -591,7 +591,7 @@ func (s *Store) relink(oldTitle, newTitle, except string) error {
if m.ID == except || m.Trashed || !slices.Contains(m.links, old) { if m.ID == except || m.Trashed || !slices.Contains(m.links, old) {
continue continue
} }
body := rewriteLinks(m.Body, old, newTitle) body := rewriteLinks(m.Body, old, newTitle, true)
if body == m.Body { if body == m.Body {
continue continue
} }
@@ -606,7 +606,8 @@ func (s *Store) relink(oldTitle, newTitle, except string) error {
return nil return nil
} }
func rewriteLinks(body, old, newTitle string) string { // rewriteLinks points the [[old]] links in body at newTitle, keeping their |text unless keepAlias is false.
func rewriteLinks(body, old, newTitle string, keepAlias bool) string {
lines := strings.Split(body, "\n") lines := strings.Split(body, "\n")
fence := false fence := false
for i, l := range lines { for i, l := range lines {
@@ -623,7 +624,7 @@ func rewriteLinks(body, old, newTitle string) string {
return x return x
} }
alias := "" alias := ""
if k := strings.Index(x, "|"); k >= 0 { if k := strings.Index(x, "|"); keepAlias && k >= 0 {
alias = x[k : len(x)-2] alias = x[k : len(x)-2]
} }
return "[[" + newTitle + alias + "]]" return "[[" + newTitle + alias + "]]"
@@ -1076,3 +1077,114 @@ func exportFormat(n *Note) []byte {
b.WriteString("\n") b.WriteString("\n")
return []byte(b.String()) return []byte(b.String())
} }
// ---- moving pages between notebooks (the vault) ----
// LockedPrefix starts the target of a [[link]] to a page that has been moved to the vault, so that the
// ordinary notebook keeps the page's id and never its title.
const LockedPrefix = "locked:"
var ErrExists = errors.New("there is already a page with that id")
// Import adds a page that was made in another notebook, keeping its id, dates and text. It is placed at
// the top level, its title is changed if another page has it, and it is written before anything else
// is touched, so a failure leaves the page where it came from.
func (s *Store) Import(n Note) (Note, error) {
if !ValidID(n.ID) {
return Note{}, ErrBadID
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.by[n.ID]; ok {
return Note{}, ErrExists
}
in := n.copy()
in.Parent, in.Trashed = "", false
if in.Daily != "" {
in.Title = in.Daily
} else {
in.Title = s.free(in.Title, in.ID)
}
in.refresh()
if err := s.write(&in); err != nil {
return Note{}, err
}
s.by[in.ID] = &in
return in.copy(), nil
}
// Discard removes a page with no trash and no questions. It undoes an Import that cannot be completed.
func (s *Store) Discard(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.by[id]
if !ok {
return ErrNotFound
}
if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
delete(s.by, id)
return nil
}
// Detach takes a page out of this notebook to be kept somewhere else. Pages inside it move up a level,
// and every [[link]] to it, even in the trash, becomes [[locked:id]], so its title is left nowhere.
func (s *Store) Detach(id string) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.by[id]
if !ok || n.Trashed {
return Note{}, ErrNotFound
}
gone := n.copy()
for _, c := range s.by {
if c.Parent == id {
next := c.copy()
next.Parent = n.Parent
if err := s.write(&next); err != nil {
return Note{}, err
}
*c = next
}
}
if err := s.relinkAll(strings.ToLower(n.Title), LockedPrefix+id, false); err != nil {
return Note{}, err
}
if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) {
return Note{}, err
}
delete(s.by, id)
return gone, nil
}
// Reveal points the [[locked:id]] links of a page that has come back at its title again.
func (s *Store) Reveal(id, title string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.relinkAll(LockedPrefix+id, title, true)
}
// relinkAll rewrites the links to old (lower-cased) in every page, trashed or not.
func (s *Store) relinkAll(old, newTarget string, keepAlias bool) error {
if old == "" || newTarget == "" {
return nil
}
for _, m := range s.by {
if !slices.Contains(m.links, old) {
continue
}
body := rewriteLinks(m.Body, old, newTarget, keepAlias)
if body == m.Body {
continue
}
next := m.copy()
next.set(body)
next.Updated = moved(m.Updated)
if err := s.write(&next); err != nil {
return err
}
*m = next
}
return nil
}
+111
View File
@@ -576,3 +576,114 @@ func TestWrongKeyRefusesInsteadOfLosingPages(t *testing.T) {
t.Fatal("opened with no key") t.Fatal("opened with no key")
} }
} }
func openAt(t *testing.T, dir string, box *vault.Cipher) *Store {
t.Helper()
s, err := Open(dir, box)
if err != nil {
t.Fatal(err)
}
return s
}
func TestDetachRewritesLinksAndImportKeepsThePage(t *testing.T) {
main, mdir := open(t)
other := openAt(t, t.TempDir(), testBox(t))
secret := mk(t, main, "Secret", "the plan #hush")
kid, _ := main.Create(NewNote{Title: "Kid", Parent: secret.ID})
b := mk(t, main, "Notes", "see [[Secret]] and [[secret|the plan]]\n`[[Secret]]` in code\n```\n[[Secret]]\n```")
gone := mk(t, main, "Old", "also [[Secret]]")
if _, err := main.Trash(gone.ID); err != nil {
t.Fatal(err)
}
got, err := other.Import(secret)
if err != nil || got.ID != secret.ID || got.Title != "Secret" || !got.Updated.Equal(secret.Updated) || got.Body != "the plan #hush" {
t.Fatalf("import: %+v %v", got, err)
}
if _, err := other.Import(secret); !errors.Is(err, ErrExists) {
t.Fatalf("a second import: %v", err)
}
if _, err := main.Detach(secret.ID); err != nil {
t.Fatal(err)
}
if _, err := main.Get(secret.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("the page is still in the notebook: %v", err)
}
nb, _ := main.Get(b.ID)
link := "[[" + LockedPrefix + secret.ID + "]]"
want := "see " + link + " and " + link + "\n`" + link + "` in code\n```\n[[Secret]]\n```" // only a fenced block is left as typed
if nb.Body != want {
t.Fatalf("links:\n%q\nwant\n%q", nb.Body, want)
}
if og, _ := main.Get(gone.ID); og.Body != "also "+link {
t.Fatalf("a link in the trash was left alone: %q", og.Body)
}
if k, _ := main.Get(kid.ID); k.Parent != "" {
t.Fatalf("a subpage still points at the moved page: %q", k.Parent)
}
// and it is the same after a reload, with nothing of the title in the page that linked to it
again := openAt(t, mdir, testBox(t))
if n2, _ := again.Get(b.ID); n2.Body != want {
t.Fatalf("after reload: %q", n2.Body)
}
if _, err := again.Get(secret.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("the page came back from disk: %v", err)
}
// coming back: the title is free again, and the links name it
back, err := main.Import(got)
if err != nil {
t.Fatal(err)
}
if err := main.Reveal(back.ID, back.Title); err != nil {
t.Fatal(err)
}
if nb, _ := main.Get(b.ID); !strings.Contains(nb.Body, "see [[Secret]] and [[Secret]]") {
t.Fatalf("links after coming back: %q", nb.Body)
}
if err := other.Discard(secret.ID); err != nil {
t.Fatal(err)
}
if _, err := other.Get(secret.ID); !errors.Is(err, ErrNotFound) {
t.Fatal("discard left the page")
}
}
func TestMovingAJournalDayAndATitleThatIsTaken(t *testing.T) {
main, _ := open(t)
other := openAt(t, t.TempDir(), testBox(t))
day, err := main.Daily("2026-10-31")
if err != nil {
t.Fatal(err)
}
day, _ = main.Update(day.ID, Change{Body: ptr("dear diary")}, time.Time{})
ref := mk(t, main, "Index", "that day: [[2026-10-31]]")
got, err := other.Import(day)
if err != nil || got.Daily != "2026-10-31" || got.Title != "2026-10-31" || got.Body != "dear diary" {
t.Fatalf("import day: %+v %v", got, err)
}
if _, err := main.Detach(day.ID); err != nil {
t.Fatal(err)
}
if n, _ := main.Get(ref.ID); n.Body != "that day: [["+LockedPrefix+"daily-2026-10-31]]" {
t.Fatalf("the link to the day: %q", n.Body)
}
again, _ := main.Daily("2026-10-31") // the day can be started afresh
if _, err := other.Import(again); !errors.Is(err, ErrExists) {
t.Fatalf("two pages for one day in the vault: %v", err)
}
other.Create(NewNote{Title: "Plans"})
p := mk(t, main, "Plans", "mine")
got, err = other.Import(p)
if err != nil || got.Title != "Plans 2" {
t.Fatalf("a taken title: %+v %v", got, err)
}
}
func ptr[T any](v T) *T { return &v }
+9
View File
@@ -8,8 +8,10 @@ import (
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/hkdf" "crypto/hkdf"
"crypto/pbkdf2"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
) )
@@ -50,6 +52,13 @@ func New(master, issuer, sub string) (*Cipher, error) {
return &Cipher{aead}, nil return &Cipher{aead}, nil
} }
// Passphrase stretches a passphrase into key material (hex) with PBKDF2-HMAC-SHA256, which is slow on
// purpose so that guessing passphrases is expensive.
func Passphrase(pass string, salt []byte, iterations int) (string, error) {
k, err := pbkdf2.Key(sha256.New, pass, salt, iterations, 32)
return hex.EncodeToString(k), err
}
// IsSealed says whether data is an encrypted file. // IsSealed says whether data is an encrypted file.
func IsSealed(data []byte) bool { return bytes.HasPrefix(data, magic) } func IsSealed(data []byte) bool { return bytes.HasPrefix(data, magic) }
+1
View File
@@ -171,6 +171,7 @@ func firstOf(s ...string) string {
} }
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) { func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
s.lockVault(w, r)
setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0) setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0)
writeJSON(w, map[string]bool{"ok": true}) writeJSON(w, map[string]bool{"ok": true})
} }
+30 -2
View File
@@ -15,6 +15,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"git.veillax.com/Veillax/mako/internal/config" "git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc" "git.veillax.com/Veillax/mako/internal/oidc"
@@ -34,6 +35,11 @@ type Server struct {
mu sync.Mutex mu sync.Mutex
books map[string]*store.Store books map[string]*store.Store
vmu sync.Mutex
vstate map[string]*vaultState // unlocked vaults, by the folder name of their owner
vtoken map[string]string // vault cookie value to that folder name
vfails map[string][]time.Time // recent wrong passphrases, by the same name
} }
// principal is who is making a request, with the notebook that is theirs alone. // principal is who is making a request, with the notebook that is theirs alone.
@@ -49,17 +55,23 @@ func New(h *config.Holder, version string) (*Server, error) {
return nil, err return nil, err
} }
return &Server{cfg: h, version: version, books: map[string]*store.Store{}, return &Server{cfg: h, version: version, books: map[string]*store.Store{},
vstate: map[string]*vaultState{}, vtoken: map[string]string{}, vfails: map[string][]time.Time{},
oidc: oidc.New(c.OIDC.Issuer, c.OIDC.ClientID, c.OIDC.ClientSecret, c.RedirectURL())}, nil oidc: oidc.New(c.OIDC.Issuer, c.OIDC.ClientID, c.OIDC.ClientSecret, c.RedirectURL())}, nil
} }
// OIDC is the sign-in client, for the check command. // OIDC is the sign-in client, for the check command.
func (s *Server) OIDC() *oidc.Client { return s.oidc } func (s *Server) OIDC() *oidc.Client { return s.oidc }
// userKey names a person's folder, from a hash of who they are.
func userKey(c config.Config, sub string) string {
sum := sha256.Sum256([]byte(c.OIDC.Issuer + "|" + sub))
return hex.EncodeToString(sum[:16])
}
// notebook opens the notes that belong to one person. Their folder is named from a hash of who they // notebook opens the notes that belong to one person. Their folder is named from a hash of who they
// are, so nothing a person controls ever becomes part of a path. // are, so nothing a person controls ever becomes part of a path.
func (s *Server) notebook(c config.Config, sub, name, email string) (*store.Store, error) { func (s *Server) notebook(c config.Config, sub, name, email string) (*store.Store, error) {
sum := sha256.Sum256([]byte(c.OIDC.Issuer + "|" + sub)) key := userKey(c, sub)
key := hex.EncodeToString(sum[:16])
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if st, ok := s.books[key]; ok { if st, ok := s.books[key]; ok {
@@ -99,11 +111,27 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PUT /api/notes/{id}", s.api(s.updateNote)) mux.HandleFunc("PUT /api/notes/{id}", s.api(s.updateNote))
mux.HandleFunc("DELETE /api/notes/{id}", s.api(s.deleteNote)) mux.HandleFunc("DELETE /api/notes/{id}", s.api(s.deleteNote))
mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote)) mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote))
mux.HandleFunc("POST /api/notes/{id}/vault", s.api(s.moveToVault))
mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash)) mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash))
mux.HandleFunc("POST /api/daily", s.api(s.daily)) mux.HandleFunc("POST /api/daily", s.api(s.daily))
mux.HandleFunc("GET /api/find", s.api(s.find)) mux.HandleFunc("GET /api/find", s.api(s.find))
mux.HandleFunc("GET /api/export", s.api(s.export)) mux.HandleFunc("GET /api/export", s.api(s.export))
mux.HandleFunc("GET /api/vault", s.api(s.vaultInfo))
mux.HandleFunc("POST /api/vault", s.api(s.vaultCreate))
mux.HandleFunc("POST /api/vault/unlock", s.api(s.vaultUnlock))
mux.HandleFunc("POST /api/vault/lock", s.api(s.vaultLock))
mux.HandleFunc("GET /api/vault/pages", s.vaultAPI(s.pages))
mux.HandleFunc("GET /api/vault/notes", s.vaultAPI(s.listNotes))
mux.HandleFunc("POST /api/vault/notes", s.vaultAPI(s.createNote))
mux.HandleFunc("GET /api/vault/notes/{id}", s.vaultAPI(s.getNote))
mux.HandleFunc("PUT /api/vault/notes/{id}", s.vaultAPI(s.updateNote))
mux.HandleFunc("DELETE /api/vault/notes/{id}", s.vaultAPI(s.deleteNote))
mux.HandleFunc("POST /api/vault/notes/{id}/restore", s.vaultAPI(s.restoreNote))
mux.HandleFunc("POST /api/vault/notes/{id}/out", s.vaultAPI(s.moveFromVault))
mux.HandleFunc("DELETE /api/vault/trash", s.vaultAPI(s.emptyTrash))
mux.HandleFunc("GET /api/vault/find", s.vaultAPI(s.find))
static, _ := fs.Sub(staticFiles, "static") static, _ := fs.Sub(staticFiles, "static")
mux.Handle("GET /", http.FileServerFS(static)) mux.Handle("GET /", http.FileServerFS(static))
return s.secure(mux) return s.secure(mux)
+21 -4
View File
@@ -47,14 +47,14 @@ async function boot() {
if (e.status === 401) return showLogin(why); if (e.status === 401) return showLogin(why);
return $app.replaceChildren(h('div', { class: 'center' }, card('', brand(), h('p', {}, e.message), btn('Try again', 'restore', boot, 'btn')))); return $app.replaceChildren(h('div', { class: 'center' }, card('', brand(), h('p', {}, e.message), btn('Try again', 'restore', boot, 'btn'))));
} }
await refreshPages().catch(() => { }); await Promise.all([refreshPages().catch(() => { }), refreshVaultNames()]);
S.ready = true; S.ready = true;
buildShell(); buildShell();
show(); show();
} }
function showLogin(why) { function showLogin(why) {
S.editors.clear(); S.view = null; document.title = 'Sign in · Mako'; S.editors.clear(); S.view = null; S.vaultNames = null; document.title = 'Sign in · Mako';
const next = location.hash.length > 2 ? location.hash : ''; const next = location.hash.length > 2 ? location.hash : '';
$app.replaceChildren(h('main', { class: 'center' }, card('', brand(), $app.replaceChildren(h('main', { class: 'center' }, card('', brand(),
h('h1', { class: 'login-h' }, 'Your private notebook'), h('h1', { class: 'login-h' }, 'Your private notebook'),
@@ -129,7 +129,8 @@ function updateNav() {
tree(here), tree(here),
S.tags.length ? [h('div', { class: 'navsec' }, 'Tags'), S.tags.map(t => navItem('#/tag/' + encodeURIComponent(t.tag), 'tag/' + t.tag, t.tag, 'tag', t.count))] : null, S.tags.length ? [h('div', { class: 'navsec' }, 'Tags'), S.tags.map(t => navItem('#/tag/' + encodeURIComponent(t.tag), 'tag/' + t.tag, t.tag, 'tag', t.count))] : null,
h('div', { class: 'navsec' }, 'Other'), h('div', { class: 'navsec' }, 'Other'),
navItem('#/trash', 'trash', 'Trash', 'trash', S.counts.trash || null)); navItem('#/trash', 'trash', 'Trash', 'trash', S.counts.trash || null),
navItem('#/vault', 'vault', 'Vault', 'lock'));
} }
function buildShell() { function buildShell() {
@@ -175,6 +176,7 @@ async function show() {
case 'pinned': page = feed({ view: 'pinned', title: 'Pinned', desc: 'Notes you pinned to keep close.' }); break; case 'pinned': page = feed({ view: 'pinned', title: 'Pinned', desc: 'Notes you pinned to keep close.' }); break;
case 'tag': page = feed({ view: 'all', tag: r.arg, title: '#' + r.arg, desc: 'Every page with this tag.' }); break; case 'tag': page = feed({ view: 'all', tag: r.arg, title: '#' + r.arg, desc: 'Every page with this tag.' }); break;
case 'trash': page = feed({ view: 'trash', title: 'Trash', desc: 'Deleted pages wait here until you empty the trash.' }); break; case 'trash': page = feed({ view: 'trash', title: 'Trash', desc: 'Deleted pages wait here until you empty the trash.' }); break;
case 'vault': page = vaultView(r.arg); break;
case 'settings': page = settingsPage(); break; case 'settings': page = settingsPage(); break;
default: page = feed({ view: 'all', composer: true, title: 'Home', desc: 'Write anything down. Give it a title and a place later, or never.' }); default: page = feed({ view: 'all', composer: true, title: 'Home', desc: 'Write anything down. Give it a title and a place later, or never.' });
} }
@@ -231,10 +233,25 @@ function pageView({ id, date, parent = '' }) {
})), })),
date ? null : btn('Move', 'move', () => have ? movePage(note, async () => { await refreshPages(); note = await api('GET', '/api/notes/' + note.id); drawCrumbs(); }) : toast('Type something first, then you can move the page')), date ? null : btn('Move', 'move', () => have ? movePage(note, async () => { await refreshPages(); note = await api('GET', '/api/notes/' + note.id); drawCrumbs(); }) : toast('Type something first, then you can move the page')),
date ? null : btn('Subpage', 'add', act(async () => { if (!have) return toast('Type something first, then you can add a subpage'); await ed.flush(); location.hash = '#/new/' + note.id; })), date ? null : btn('Subpage', 'add', act(async () => { if (!have) return toast('Type something first, then you can add a subpage'); await ed.flush(); location.hash = '#/new/' + note.id; })),
btn('To vault', 'lock', act(toVault), 'ghost'),
btn('Delete', 'trash', act(trashIt), 'ghost danger')); btn('Delete', 'trash', act(trashIt), 'ghost danger'));
} }
ed = null; ed = null;
// Moving a page to the vault needs the vault to be unlocked. Links to the page elsewhere read "Locked Page".
async function toVault() {
if (!note || !note.id) return toast('Type something first, then you can move the page to the vault');
await ed.flush();
const v = await api('GET', '/api/vault');
if (!v.unlocked) return toast(v.exists ? 'Unlock your vault first, then move the page.' : 'Make your vault first, then move the page.', false, { label: 'Open vault', fn: () => { location.hash = '#/vault'; } });
const n = note;
if (!await confirmDialog({ title: 'Move this page to the vault?', body: `"${n.daily ? dayLabel(n.daily) : n.name}" will be locked away in your vault. Pages that link to it will show "Locked Page" until you unlock the vault.`, confirm: 'Move to vault', danger: false })) return;
await api('POST', `/api/notes/${n.id}/vault`);
await Promise.all([refreshPages(), refreshVaultNames()]);
toast('Moved to the vault', false, { label: 'Undo', fn: act(async () => { await api('POST', `${VAULT}/notes/${n.id}/out`); await Promise.all([refreshPages(), refreshVaultNames()]); location.hash = hrefOf(n); }) });
location.hash = date ? '#/journal' : n.parent && S.byId.has(n.parent) ? '#/p/' + n.parent : '#/';
}
async function trashIt() { async function trashIt() {
if (!note) { location.hash = '#/'; return; } if (!note) { location.hash = '#/'; return; }
await ed.flush(); await ed.flush();
@@ -555,7 +572,7 @@ function settingsPage() {
row('Ctrl K', 'Search pages, or make a new one'), row('c', 'New page'), row('n', 'Jot a quick note'), row('Ctrl K', 'Search pages, or make a new one'), row('c', 'New page'), row('n', 'Jot a quick note'),
row('Ctrl E', 'Switch between editing and View'), row('/', 'In a page: commands. Anywhere else: search'), row('Ctrl E', 'Switch between editing and View'), row('/', 'In a page: commands. Anywhere else: search'),
row('[[', 'In a page: link to another page'), row('Ctrl B, Ctrl I', 'Bold, italic'), row('Tab', 'In a list: indent'))), row('[[', 'In a page: link to another page'), row('Ctrl B, Ctrl I', 'Bold, italic'), row('Tab', 'In a list: indent'))),
card('Privacy', h('p', { class: 'dim' }, 'Pages are stored as plain markdown files on the machine running Mako, under a folder only you map to. They are not encrypted on disk, so anyone who can read that machine can read them.'))), card('Privacy', h('p', { class: 'dim' }, 'Pages are encrypted on the machine running Mako, with a key made for you alone from the server\'s key and your Authentik account. A copy of the disk or a backup of the notes folder cannot be read without that key. Whoever runs the server can still read pages while it is running, and your download above is plain markdown.'))),
}; };
} }
+123 -26
View File
@@ -33,8 +33,8 @@ const SLASH = [
{ name: 'Quote', hint: '>', keys: 'quote callout', text: '> ' }, { name: 'Quote', hint: '>', keys: 'quote callout', text: '> ' },
{ name: 'Code block', hint: '```', keys: 'code pre', text: '```\n\n```', caret: 4 }, { name: 'Code block', hint: '```', keys: 'code pre', text: '```\n\n```', caret: 4 },
{ name: 'Divider', hint: '---', keys: 'divider line hr rule', text: '---\n' }, { name: 'Divider', hint: '---', keys: 'divider line hr rule', text: '---\n' },
{ name: 'Link to a page', hint: '[[', keys: 'link page wiki mention', text: '[[', again: true }, { name: 'Link to a page', hint: '[[', keys: 'link page wiki mention', text: '[[', again: true, link: true },
{ name: 'Link to today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]' }, { name: 'Link to today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]', link: true },
{ name: 'Current time', hint: () => hhmm(new Date()), keys: 'time clock now', text: () => hhmm(new Date()) + ' ' }, { name: 'Current time', hint: () => hhmm(new Date()), keys: 'time clock now', text: () => hhmm(new Date()) + ' ' },
]; ];
@@ -53,6 +53,7 @@ function makePopup(ta, wrap) {
get open() { return !el.hidden; }, get open() { return !el.hidden; },
show(list, pick, pos) { show(list, pick, pos) {
if (!list.length) return pop.hide(); if (!list.length) return pop.hide();
if (el.parentNode !== wrap) wrap.append(el); // a redraw of the live view can drop it
items = list; onPick = pick; sel = 0; draw(); el.hidden = false; items = list; onPick = pick; sel = 0; draw(); el.hidden = false;
const c = caretXY(ta, pos); const c = caretXY(ta, pos);
el.style.left = Math.max(0, Math.min(ta.offsetLeft + c.x, wrap.clientWidth - 270)) + 'px'; el.style.left = Math.max(0, Math.min(ta.offsetLeft + c.x, wrap.clientWidth - 270)) + 'px';
@@ -75,14 +76,16 @@ function pageChoices(q) {
return list; return list;
} }
function enhance(ta, wrap) { // With plain set (the vault) there are no page links, so no [[ picker and no link commands.
function enhance(ta, wrap, plain) {
const pop = makePopup(ta, wrap); const pop = makePopup(ta, wrap);
function check() { function check() {
if (ta.selectionStart !== ta.selectionEnd) return pop.hide(); // the page's own text box also hears the live view's edits (it holds the text); only the box in use may suggest
if (document.activeElement !== ta || ta.selectionStart !== ta.selectionEnd) return pop.hide();
const pos = ta.selectionStart, ls = lineStart(ta.value, pos), before = ta.value.slice(ls, pos); const pos = ta.selectionStart, ls = lineStart(ta.value, pos), before = ta.value.slice(ls, pos);
let m; let m;
if ((m = /\[\[([^\[\]|\n]*)$/.exec(before))) { if (!plain && (m = /\[\[([^\[\]|\n]*)$/.exec(before))) {
const from = pos - m[1].length; const from = pos - m[1].length;
pop.show(pageChoices(m[1]), it => { pop.show(pageChoices(m[1]), it => {
const closed = ta.value.startsWith(']]', pos); const closed = ta.value.startsWith(']]', pos);
@@ -90,10 +93,12 @@ function enhance(ta, wrap) {
}, pos); }, pos);
} else if ((m = /^(\s*)\/([a-z0-9]{0,16})$/i.exec(before))) { } else if ((m = /^(\s*)\/([a-z0-9]{0,16})$/i.exec(before))) {
const q = m[2].toLowerCase(), from = ls + m[1].length; const q = m[2].toLowerCase(), from = ls + m[1].length;
pop.show(SLASH.filter(c => !q || c.name.toLowerCase().includes(q) || c.keys.includes(q)), c => { pop.show(SLASH.filter(c => !(plain && c.link) && (!q || c.name.toLowerCase().includes(q) || c.keys.includes(q))), c => {
const text = typeof c.text === 'function' ? c.text() : c.text; const text = typeof c.text === 'function' ? c.text() : c.text;
replaceRange(ta, from, pos, text); replaceRange(ta, from, pos, text);
if (c.caret) ta.setSelectionRange(from + c.caret, from + c.caret); // in the live view a command that adds lines redraws it, and the box being typed in is a new one
const cur = ta.isConnected ? ta : wrap.querySelector('textarea.live-edit') || ta;
if (c.caret) cur.setSelectionRange(from + c.caret, from + c.caret);
if (c.again) check(); if (c.again) check();
}, pos); }, pos);
} else pop.hide(); } else pop.hide();
@@ -150,9 +155,11 @@ function enhance(ta, wrap) {
// ---------- live preview: every line shows rendered except the one being edited ---------- // ---------- live preview: every line shows rendered except the one being edited ----------
// The text itself stays in the hidden text box `ta`; this view splices each edit back into it, so saving, // The text itself stays in the hidden text box `ta`; this view splices each edit back into it, so saving,
// conflicts and the other modes need to know nothing about it. A fenced code block is edited as a whole. // conflicts and the other modes need to know nothing about it. A fenced code block is edited as a whole.
function makeLive(ta) { function makeLive(ta, plain) {
const el = h('div', { class: 'live', hidden: true }); const el = h('div', { class: 'live', hidden: true });
let lines = [], act = null; // act: the block starting at line s is being edited, caret at col // act: the block starting at line s is being edited, caret at col. With e set, lines s to e are edited
// together as raw text (a selection across lines), and sel is [start, end, direction] inside them.
let lines = [], act = null;
function blocks() { function blocks() {
const out = []; const out = [];
@@ -172,15 +179,53 @@ function makeLive(ta) {
function rendered(b) { function rendered(b) {
const text = lines.slice(b.s, b.e + 1).join('\n'), box = h('div', { class: 'live-line' }); const text = lines.slice(b.s, b.e + 1).join('\n'), box = h('div', { class: 'live-line' });
if (text.trim()) { if (text.trim()) {
const root = md(text, (_, checked) => { lines[b.s] = lines[b.s].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); commit(); draw(); }, true); const root = md(text, (_, checked) => { lines[b.s] = lines[b.s].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); commit(); draw(); }, true, plain);
const m = /^\s*(\d+)[.)]\s/.exec(text); const m = /^\s*(\d+)[.)]\s/.exec(text);
if (m) root.querySelector('ol')?.setAttribute('start', m[1]); if (m) root.querySelector('ol')?.setAttribute('start', m[1]);
box.append(root); box.append(root);
} }
box.addEventListener('click', e => { if (!e.target.closest('a,input,button') && !inSelection()) activate(b.s, Infinity, true); }); box.dataset.s = b.s; box.dataset.e = b.e;
box.addEventListener('click', e => {
if (e.target.closest('a,input,button')) return;
if (e.shiftKey && reach(b)) return;
if (!inSelection()) activate(b.s, Infinity, true);
});
return box; return box;
} }
// span edits lines s to e as one piece of raw text, so selecting, deleting and Ctrl+Backspace work across lines
function span(s, e, sel) {
lines = ta.value.split('\n');
act = { s, e, sel, focus: true };
draw();
}
// reach extends the selection being edited to a line that was shift-clicked
function reach(b) {
const t = el.querySelector('.live-edit');
if (!act || !t) return false;
const to = act.e ?? blockAt(act.s).e, back = t.selectionDirection === 'backward';
const a = back ? t.selectionEnd : t.selectionStart;
if (b.s > to) span(act.s, b.e, [a, lines.slice(act.s, b.e + 1).join('\n').length, 'forward']);
else if (b.e < act.s) span(b.s, to, [0, a + lines.slice(b.s, act.s).join('\n').length + 1, 'backward']);
else return false;
return true;
}
// grab turns lines selected with the mouse into one raw editor holding exactly those lines
function grab() {
const sel = getSelection();
if (el.hidden || !sel || sel.isCollapsed || !sel.rangeCount) return;
const hit = [...el.querySelectorAll('.live-line')].filter(x => sel.containsNode(x, true));
if (hit.length < 2) return;
const s = Math.min(...hit.map(x => +x.dataset.s)), e = Math.max(...hit.map(x => +x.dataset.e));
lines = ta.value.split('\n');
span(s, e, [0, lines.slice(s, e + 1).join('\n').length, 'forward']);
}
const onUp = () => {
if (!el.isConnected) { document.removeEventListener('mouseup', onUp); return; }
setTimeout(grab, 0);
};
document.addEventListener('mouseup', onUp);
function merge(at, col) { function merge(at, col) {
const len = lines[at].length; const len = lines[at].length;
lines.splice(at, 2, lines[at] + lines[at + 1]); lines.splice(at, 2, lines[at] + lines[at + 1]);
@@ -191,13 +236,15 @@ function makeLive(ta) {
const empty = lines.length === 1 && !lines[0]; const empty = lines.length === 1 && !lines[0];
const t = h('textarea', { class: 'live-edit', rows: 1, spellcheck: 'true', 'aria-label': 'Editing', placeholder: empty ? "Write something, or type '/' for commands, '[[' to link a page" : '' }); const t = h('textarea', { class: 'live-edit', rows: 1, spellcheck: 'true', 'aria-label': 'Editing', placeholder: empty ? "Write something, or type '/' for commands, '[[' to link a page" : '' });
t.value = lines.slice(b.s, b.e + 1).join('\n'); t.value = lines.slice(b.s, b.e + 1).join('\n');
enhance(t, el); enhance(t, el, plain);
const multi = b.fence || b.span; // a code block or a selection over lines: Enter just adds a line
t.addEventListener('input', () => { t.addEventListener('input', () => {
const fresh = t.value.split('\n'); const fresh = t.value.split('\n');
lines.splice(b.s, b.e - b.s + 1, ...fresh); lines.splice(b.s, b.e - b.s + 1, ...fresh);
b.e = b.s + fresh.length - 1; b.e = b.s + fresh.length - 1;
if (b.span && act) act.e = b.e;
commit(); commit();
if (!b.fence && fresh.length > 1) { // Enter: the line above is finished, the new one is being edited if (!multi && fresh.length > 1) { // Enter: the line above is finished, the new one is being edited
const before = t.value.slice(0, t.selectionStart); const before = t.value.slice(0, t.selectionStart);
place(b.s + before.split('\n').length - 1, before.length - before.lastIndexOf('\n') - 1, true); place(b.s + before.split('\n').length - 1, before.length - before.lastIndexOf('\n') - 1, true);
draw(); draw();
@@ -206,34 +253,76 @@ function makeLive(ta) {
t.addEventListener('keydown', e => { t.addEventListener('keydown', e => {
if (e.isComposing) return; if (e.isComposing) return;
if (e.key === 'Escape' && !e.defaultPrevented) { t.blur(); return; } if (e.key === 'Escape' && !e.defaultPrevented) { t.blur(); return; }
if (e.defaultPrevented || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || t.selectionStart !== t.selectionEnd) return; if (e.defaultPrevented) return;
const cmd = e.ctrlKey || e.metaKey;
if (extend(e, t, b, cmd)) return;
const word = cmd && !e.shiftKey && !e.altKey && (e.key === 'Backspace' || e.key === 'Delete'); // joins lines like Backspace does
if (e.shiftKey || (cmd && !word) || e.altKey || t.selectionStart !== t.selectionEnd) return;
const v = t.value, p = t.selectionStart, here = caretXY(t, p), lh = here.lh; const v = t.value, p = t.selectionStart, here = caretXY(t, p), lh = here.lh;
const firstRow = here.y < lh * 0.5, lastRow = caretXY(t, v.length).y - here.y < lh * 0.5, col = b.fence ? 0 : p; const firstRow = here.y < lh * 0.5, lastRow = caretXY(t, v.length).y - here.y < lh * 0.5, col = multi ? 0 : p;
if (e.key === 'ArrowUp' && firstRow && b.s > 0) { e.preventDefault(); activate(b.s - 1, col, true); } if (e.key === 'ArrowUp' && firstRow && b.s > 0) { e.preventDefault(); activate(b.s - 1, col, true); }
else if (e.key === 'ArrowDown' && lastRow && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, col, true); } else if (e.key === 'ArrowDown' && lastRow && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, col, true); }
else if (e.key === 'ArrowLeft' && p === 0 && b.s > 0) { e.preventDefault(); activate(b.s - 1, Infinity, true); } else if (e.key === 'ArrowLeft' && p === 0 && b.s > 0) { e.preventDefault(); activate(b.s - 1, Infinity, true); }
else if (e.key === 'ArrowRight' && p === v.length && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, 0, true); } else if (e.key === 'ArrowRight' && p === v.length && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, 0, true); }
else if (e.key === 'Backspace' && p === 0 && b.s > 0) { else if (e.key === 'Backspace' && p === 0 && b.s > 0) {
e.preventDefault(); e.preventDefault();
if (b.fence || blockAt(b.s - 1).fence) activate(b.s - 1, Infinity, true); else merge(b.s - 1); if (multi || blockAt(b.s - 1).fence) activate(b.s - 1, Infinity, true); else merge(b.s - 1);
} else if (e.key === 'Delete' && p === v.length && b.e < lines.length - 1) { } else if (e.key === 'Delete' && p === v.length && b.e < lines.length - 1) {
e.preventDefault(); e.preventDefault();
if (b.fence || blockAt(b.e + 1).fence) activate(b.e + 1, 0, true); else merge(b.s); if (multi || blockAt(b.e + 1).fence) activate(b.e + 1, 0, true); else merge(b.s);
} }
}); });
t.addEventListener('blur', () => setTimeout(() => { if (act && !el.contains(document.activeElement)) { act = null; draw(); } }, 0)); t.addEventListener('blur', () => setTimeout(() => { if (act && !el.contains(document.activeElement)) { act = null; draw(); } }, 0));
return t; return t;
} }
// extend turns Ctrl+A and Shift+Up or Down past the edge of a line into a selection over several lines
function extend(e, t, b, cmd) {
const v = t.value;
if (cmd && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'a') {
if (b.s === 0 && b.e === lines.length - 1) return false; // already everything: the browser selects it
e.preventDefault(); span(0, lines.length - 1, [0, lines.join('\n').length, 'forward']);
return true;
}
if (!e.shiftKey || cmd || e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return false;
const back = t.selectionDirection === 'backward';
const a = back ? t.selectionEnd : t.selectionStart, f = back ? t.selectionStart : t.selectionEnd;
const here = caretXY(t, f), col = f - (v.lastIndexOf('\n', f - 1) + 1);
if (e.key === 'ArrowUp' && here.y < here.lh * 0.5 && b.s > 0) {
const nb = blockAt(b.s - 1), add = lines.slice(nb.s, b.s).join('\n').length + 1;
e.preventDefault();
span(nb.s, b.e, [nb.fence ? 0 : Math.min(col, lines[nb.s].length), a + add, 'backward']);
return true;
}
if (e.key === 'ArrowDown' && caretXY(t, v.length).y - here.y < here.lh * 0.5 && b.e < lines.length - 1) {
const nb = blockAt(b.e + 1), at = v.length + 1;
e.preventDefault();
span(b.s, nb.e, [a, nb.fence ? at + lines.slice(nb.s, nb.e + 1).join('\n').length : at + Math.min(col, lines[nb.s].length), 'forward']);
return true;
}
return false;
}
function draw() { function draw() {
lines = ta.value.split('\n'); lines = ta.value.split('\n');
fill(el, blocks().map(b => act && act.s === b.s ? editor(b) : rendered(b)), h('div', { class: 'live-tail', onclick: () => activate(lines.length - 1, Infinity, true) })); const kids = [];
let placed = false;
for (const b of blocks()) {
if (act && act.e != null && b.e >= act.s && b.s <= act.e) { // lines edited together
if (!placed) kids.push(editor({ s: act.s, e: Math.min(act.e, lines.length - 1), fence: false, span: true }));
placed = true;
} else if (act && act.e == null && act.s === b.s) kids.push(editor(b));
else kids.push(rendered(b));
}
fill(el, kids, h('div', { class: 'live-tail', onclick: () => activate(lines.length - 1, Infinity, true) }));
const t = el.querySelector('.live-edit'); const t = el.querySelector('.live-edit');
if (!t) return; if (!t) return;
t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px';
if (act.focus) { if (act.focus) {
const c = Math.min(act.col, t.value.length); t.focus();
t.focus(); t.setSelectionRange(c, c); t.scrollIntoView({ block: 'nearest' }); if (act.sel) t.setSelectionRange(...act.sel);
else { const c = Math.min(act.col, t.value.length); t.setSelectionRange(c, c); }
t.scrollIntoView({ block: 'nearest' });
act.focus = false; act.focus = false;
} }
} }
@@ -269,7 +358,9 @@ function makeLive(ta) {
// ---------- the page editor: autosaves title and text, and notices when a page changed elsewhere ---------- // ---------- the page editor: autosaves title and text, and notices when a page changed elsewhere ----------
// With no note it starts a new page, which is only created once something is typed. // With no note it starts a new page, which is only created once something is typed.
function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) { // With vault set the page lives in the vault: its own API, and no links into the ordinary notebook.
function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved, vault = false }) {
const base = vault ? '/api/vault' : '/api';
let cur = note || null, titleDirty = false, bodyDirty = false, saving = false, queued = false, blocked = false; let cur = note || null, titleDirty = false, bodyDirty = false, saving = false, queued = false, blocked = false;
let timer = null, retry = null, badTitle = null, mode = 'live', lastEdit = 'live'; let timer = null, retry = null, badTitle = null, mode = 'live', lastEdit = 'live';
@@ -280,13 +371,13 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
const ta = h('textarea', { class: 'write big', 'aria-label': 'Page text', placeholder: "Write something, or type '/' for commands, '[[' to link a page" }); const ta = h('textarea', { class: 'write big', 'aria-label': 'Page text', placeholder: "Write something, or type '/' for commands, '[[' to link a page" });
ta.value = note?.body ?? ''; ta.value = note?.body ?? '';
const view = h('div', { class: 'view', hidden: true }); const view = h('div', { class: 'view', hidden: true });
const live = makeLive(ta); const live = makeLive(ta, vault);
const wrap = h('div', { class: 'editor-wrap' }, ta, live.el, view); const wrap = h('div', { class: 'editor-wrap' }, ta, live.el, view);
const status = h('span', { class: 'sub', role: 'status' }); const status = h('span', { class: 'sub', role: 'status' });
const notice = h('div', { class: 'notice notice-warning', hidden: true }); const notice = h('div', { class: 'notice notice-warning', hidden: true });
const say = t => { status.textContent = t; }; const say = t => { status.textContent = t; };
const fit = () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 2 + 'px'; }; const fit = () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 2 + 'px'; };
enhance(ta, wrap); enhance(ta, wrap, vault);
const dirty = () => titleDirty || bodyDirty; const dirty = () => titleDirty || bodyDirty;
const ctl = { save, dirty }; const ctl = { save, dirty };
@@ -305,12 +396,12 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
saving = true; say('Saving'); saving = true; say('Saving');
try { try {
let n; let n;
if (!cur) n = await api('POST', '/api/notes', { title: sendTitle ? t : '', body: b, parent }, { keepalive: b.length < 15000 }); if (!cur) n = await api('POST', base + '/notes', { title: sendTitle ? t : '', body: b, parent }, { keepalive: b.length < 15000 });
else { else {
const payload = { base: cur.updated }; const payload = { base: cur.updated };
if (sendTitle) payload.title = t; if (sendTitle) payload.title = t;
if (sendBody) payload.body = b; if (sendBody) payload.body = b;
n = await api('PUT', '/api/notes/' + cur.id, payload, { keepalive: b.length < 15000 }); n = await api('PUT', base + '/notes/' + cur.id, payload, { keepalive: b.length < 15000 });
} }
const created = !cur; const created = !cur;
cur = n; cur = n;
@@ -331,6 +422,12 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
h('a', { class: 'btn-link', href: '/auth/login?next=' + encodeURIComponent(location.hash), target: '_blank', rel: 'noopener' }, 'Sign in again'), h('a', { class: 'btn-link', href: '/auth/login?next=' + encodeURIComponent(location.hash), target: '_blank', rel: 'noopener' }, 'Sign in again'),
h('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]); h('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]);
retry = setTimeout(save, 4000); retry = setTimeout(save, 4000);
} else if (e.status === 423) {
say('Not saved');
flag([h('span', { class: 'grow' }, 'The vault locked itself, so your latest changes are not saved yet. Your text is still here.'),
h('a', { class: 'btn-link', href: '#/vault', target: '_blank', rel: 'noopener' }, 'Unlock the vault'),
h('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]);
retry = setTimeout(save, 4000);
} else { } else {
say('Not saved'); say('Not saved');
if (e.offline || e.status >= 500) { flag(e.message + ' Trying again.'); retry = setTimeout(save, 5000); } if (e.offline || e.status >= 500) { flag(e.message + ' Trying again.'); retry = setTimeout(save, 5000); }
@@ -361,7 +458,7 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
ta.addEventListener('input', () => { bodyDirty = true; say('Unsaved'); fit(); later(); }); ta.addEventListener('input', () => { bodyDirty = true; say('Unsaved'); fit(); later(); });
// Write shows the text box; Read shows the page as it will look, with tasks you can tick and links you can follow. // Write shows the text box; Read shows the page as it will look, with tasks you can tick and links you can follow.
function render() { view.replaceChildren(ta.value.trim() ? md(ta.value, ticked, true) : h('p', { class: 'dim' }, 'Nothing here yet. Click to start writing.')); } function render() { view.replaceChildren(ta.value.trim() ? md(ta.value, ticked, true, vault) : h('p', { class: 'dim' }, 'Nothing here yet. Click to start writing.')); }
function ticked(line, checked) { function ticked(line, checked) {
const lines = ta.value.split('\n'); const lines = ta.value.split('\n');
lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]');
+1
View File
@@ -13,6 +13,7 @@
<script src="toolkit.js"></script> <script src="toolkit.js"></script>
<script src="markdown.js"></script> <script src="markdown.js"></script>
<script src="editor.js"></script> <script src="editor.js"></script>
<script src="vault.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+24 -12
View File
@@ -7,27 +7,39 @@ const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^
const tagChip = t => h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t); const tagChip = t => h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t);
const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label); const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label);
// A link to a page that was moved to the vault reads [[locked:id]]. It says "Locked Page" until the vault is
// unlocked, and then shows the page's own name and opens it.
function lockedLink(id) {
const name = S.vaultNames?.get(id) ?? (S.vaultNames && id.startsWith('daily-') ? dayLabel(id.slice(6)) : null);
if (name != null) return h('a', { class: 'wikilink in-vault', href: '#/vault/' + id }, icon('lock', 16), name);
return h('a', { class: 'wikilink locked', href: '#/vault', title: 'This page is in your vault. Unlock the vault to open it.' }, icon('lock', 16), 'Locked Page');
}
function wikiLink(target, text) { function wikiLink(target, text) {
const title = target.trim(), page = pageByTitle(title); const title = target.trim();
if (/^locked:[a-z0-9-]+$/.test(title)) return lockedLink(title.slice(7));
const page = pageByTitle(title);
if (page) return h('a', { class: 'wikilink', href: '#/p/' + page.id }, text || title); if (page) return h('a', { class: 'wikilink', href: '#/p/' + page.id }, text || title);
return h('a', { class: 'wikilink missing', href: '#/link/' + encodeURIComponent(title), title: 'No page called "' + title + '" yet. Click to create it.' }, text || title); return h('a', { class: 'wikilink missing', href: '#/link/' + encodeURIComponent(title), title: 'No page called "' + title + '" yet. Click to create it.' }, text || title);
} }
function inline(text) { // With plain set (the vault) [[links]] and #tags stay as typed, so a page there never points into the other notebook.
// Only a link to another page in the vault, which is [[locked:id]], is shown as a link.
function inline(text, plain) {
const re = new RegExp(INLINE_SRC, 'gu'), out = []; const re = new RegExp(INLINE_SRC, 'gu'), out = [];
let last = 0, m; let last = 0, m;
while ((m = re.exec(text))) { while ((m = re.exec(text))) {
if (m.index > last) out.push(text.slice(last, m.index)); if (m.index > last) out.push(text.slice(last, m.index));
last = re.lastIndex; last = re.lastIndex;
if (m[1] != null) out.push(h('code', {}, m[1])); if (m[1] != null) out.push(h('code', {}, m[1]));
else if (m[2] != null) out.push(h('strong', {}, inline(m[2]))); else if (m[2] != null) out.push(h('strong', {}, inline(m[2], plain)));
else if (m[3] != null) out.push(h('em', {}, inline(m[3]))); else if (m[3] != null) out.push(h('em', {}, inline(m[3], plain)));
else if (m[4] != null) out.push(wikiLink(m[4], m[5])); else if (m[4] != null) out.push(plain && !m[4].trim().startsWith('locked:') ? m[0] : wikiLink(m[4], m[5]));
else if (m[6] != null) out.push(extLink(m[7], m[6])); else if (m[6] != null) out.push(extLink(m[7], m[6]));
else if (m[8] != null) out.push(extLink(m[8], m[8])); else if (m[8] != null) out.push(extLink(m[8], m[8]));
else { else {
const t = m[9].replace(/[/-]+$/, ''); const t = m[9].replace(/[/-]+$/, '');
if (!t || /^\d+$/.test(t)) out.push('#' + m[9]); if (plain || !t || /^\d+$/.test(t)) out.push('#' + m[9]);
else out.push(tagChip(t.toLowerCase()), m[9].slice(t.length)); else out.push(tagChip(t.toLowerCase()), m[9].slice(t.length));
} }
} }
@@ -36,13 +48,13 @@ function inline(text) {
} }
// md renders a page. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only. // md renders a page. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only.
function md(src, onTask, big) { function md(src, onTask, big, plain) {
const root = h('div', { class: 'md' + (big ? ' md-big' : '') }), lines = src.split('\n'); const root = h('div', { class: 'md' + (big ? ' md-big' : '') }), lines = src.split('\n');
let para = [], i = 0, m; let para = [], i = 0, m;
const flush = () => { const flush = () => {
if (!para.length) return; if (!para.length) return;
const p = h('p'); const p = h('p');
para.forEach((t, k) => { if (k) p.append(h('br')); p.append(...inline(t)); }); para.forEach((t, k) => { if (k) p.append(h('br')); p.append(...inline(t, plain)); });
root.append(p); para = []; root.append(p); para = [];
}; };
while (i < lines.length) { while (i < lines.length) {
@@ -53,12 +65,12 @@ function md(src, onTask, big) {
i++; root.append(h('pre', {}, h('code', {}, code.join('\n')))); continue; i++; root.append(h('pre', {}, h('code', {}, code.join('\n')))); continue;
} }
if (!line.trim()) { flush(); i++; continue; } if (!line.trim()) { flush(); i++; continue; }
if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) { flush(); root.append(h('h' + (m[1].length + 2), { class: 'md-h' }, inline(m[2]))); i++; continue; } if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) { flush(); root.append(h('h' + (m[1].length + 2), { class: 'md-h' }, inline(m[2], plain))); i++; continue; }
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); root.append(h('hr')); i++; continue; } if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); root.append(h('hr')); i++; continue; }
if (/^>/.test(line)) { if (/^>/.test(line)) {
flush(); const q = []; flush(); const q = [];
while (i < lines.length && /^>/.test(lines[i])) q.push(lines[i++].replace(/^>\s?/, '')); while (i < lines.length && /^>/.test(lines[i])) q.push(lines[i++].replace(/^>\s?/, ''));
const bq = h('blockquote'); q.forEach((t, k) => { if (k) bq.append(h('br')); bq.append(...inline(t)); }); root.append(bq); continue; const bq = h('blockquote'); q.forEach((t, k) => { if (k) bq.append(h('br')); bq.append(...inline(t, plain)); }); root.append(bq); continue;
} }
if (/^(\s*)([-*+]|\d+[.)])\s+/.test(line)) { if (/^(\s*)([-*+]|\d+[.)])\s+/.test(line)) {
flush(); const ordered = /^\s*\d/.test(line), list = h(ordered ? 'ol' : 'ul'); flush(); const ordered = /^\s*\d/.test(line), list = h(ordered ? 'ol' : 'ul');
@@ -68,8 +80,8 @@ function md(src, onTask, big) {
if (m[3] != null) { if (m[3] != null) {
const box = h('input', { type: 'checkbox', checked: m[3] !== ' ', disabled: !onTask, 'aria-label': m[4] || 'Task' }); const box = h('input', { type: 'checkbox', checked: m[3] !== ' ', disabled: !onTask, 'aria-label': m[4] || 'Task' });
if (onTask) box.addEventListener('change', () => onTask(at, box.checked)); if (onTask) box.addEventListener('change', () => onTask(at, box.checked));
li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4]))); li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4], plain)));
} else li.append(...inline(m[4])); } else li.append(...inline(m[4], plain));
list.append(li); i++; list.append(li); i++;
} }
root.append(list); continue; root.append(list); continue;
+13
View File
@@ -336,3 +336,16 @@
textarea.live-edit { display:block; width:100%; min-height:1.7em; margin:0 0 0 calc(-1 * (var(--s2) + 2px)); padding:0 0 0 var(--s2); border:0; border-left:2px solid var(--accent); border-radius:0; background:var(--accent-soft); font-family:var(--font); font-size:var(--text-base); line-height:1.7; resize:none; overflow:hidden; } textarea.live-edit { display:block; width:100%; min-height:1.7em; margin:0 0 0 calc(-1 * (var(--s2) + 2px)); padding:0 0 0 var(--s2); border:0; border-left:2px solid var(--accent); border-radius:0; background:var(--accent-soft); font-family:var(--font); font-size:var(--text-base); line-height:1.7; resize:none; overflow:hidden; }
textarea.live-edit:focus { outline:none; border-color:var(--accent); } textarea.live-edit:focus { outline:none; border-color:var(--accent); }
.live-tail { min-height:30vh; cursor:text; } .live-tail { min-height:30vh; cursor:text; }
/* the vault */
.vault-gate { display:flex; justify-content:center; padding-top:var(--s6); }
.vault-gate .card { width:100%; max-width:420px; display:flex; flex-direction:column; gap:var(--s3); }
.vault-icon { align-self:center; color:var(--accent); }
.vault-form { display:flex; flex-direction:column; gap:var(--s3); }
.vault-list { list-style:none; margin:0; padding:var(--s1); }
.vault-row { display:flex; align-items:center; gap:var(--s3); padding:var(--s2) var(--s3); border-radius:var(--radius); flex-wrap:wrap; }
.vault-row:hover { background:var(--accent-soft); }
.vault-name { display:flex; align-items:center; gap:var(--s2); flex:1; min-width:0; color:var(--text); text-decoration:none; }
.clip1 { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.wikilink .icon { vertical-align:-2px; margin-right:var(--s1); }
.wikilink.locked { color:var(--text-muted); border-bottom-style:dotted; border-bottom-color:var(--text-faint); }
+181
View File
@@ -0,0 +1,181 @@
'use strict';
// ---------- the vault: pages that open only with a passphrase ----------
// The server holds the vault's key only while it is unlocked, and forgets it when you lock it, sign out
// or leave it alone for a quarter of an hour. Its pages never show in the ordinary notebook's lists, search
// or download, and they cannot link to it.
const VAULT = '/api/vault';
// S.vaultNames maps the id of each vault page to its name while the vault is unlocked, so that a
// [[locked:id]] link elsewhere can say what it points at. It is null while the vault is locked.
async function refreshVaultNames() {
try {
const v = await api('GET', VAULT);
if (!v.unlocked) { S.vaultNames = null; return; }
const r = await api('GET', VAULT + '/pages');
S.vaultNames = new Map(r.pages.map(p => [p.id, p.name]));
} catch { S.vaultNames = null; }
}
function vaultView(arg) {
const root = h('div', { class: 'page vault' });
let ed = null, dead = false;
const view = { el: root, title: 'Vault', dispose() { dead = true; ed?.dispose?.(); } };
const lock = async () => {
await flushAll();
await api('POST', VAULT + '/lock');
S.vaultNames = null;
location.hash = '#/vault';
show();
};
function passField(label, autocomplete) {
const input = h('input', { type: 'password', class: 'vault-pass', autocomplete, 'aria-label': label, placeholder: label, required: true });
return input;
}
// one form for creating and for unlocking
function gate({ title, text, fields, submit, label, extra }) {
const err = h('p', { class: 'err small', role: 'alert', hidden: true });
const form = h('form', { class: 'vault-form', onsubmit: async e => {
e.preventDefault();
err.hidden = true;
try { await submit(); }
catch (x) { err.textContent = x.message; err.hidden = false; fields[0].select(); }
} }, ...fields, err, h('button', { class: 'btn', type: 'submit' }, icon('lock', 16), label));
fill(root, h('div', { class: 'vault-gate' }, card('', h('div', { class: 'vault-icon' }, icon('lock', 22)), h('h1', { class: 'login-h' }, title),
h('p', { class: 'dim center-text' }, text), form, extra)));
setTimeout(() => fields[0].focus());
}
function create() {
const a = passField('Passphrase', 'new-password'), b = passField('Type it again', 'new-password');
gate({
title: 'Make your vault',
text: 'A place for pages that need more than a sign-in. They are locked with a passphrase, and Mako cannot read them until you unlock them.',
fields: [a, b], label: 'Create vault',
extra: h('p', { class: 'notice notice-warning small' }, icon('alert', 16), 'If you forget the passphrase, the pages in the vault cannot be recovered. Nobody can reset it, not even whoever runs Mako.'),
async submit() {
if (a.value !== b.value) throw new Error('The two passphrases are different.');
await api('POST', VAULT, { passphrase: a.value });
a.value = b.value = '';
show();
},
});
}
function unlock() {
const a = passField('Passphrase', 'current-password');
gate({
title: 'The vault is locked',
text: 'Type your passphrase to open it. It locks again when you leave it alone for a while, or when you sign out.',
fields: [a], label: 'Unlock',
async submit() {
try { await api('POST', VAULT + '/unlock', { passphrase: a.value }); }
catch (e) { throw new Error(e.data?.code === 'wrong_passphrase' ? 'That is not the passphrase. Try again.' : e.message); }
a.value = '';
show();
},
});
}
// the pages in the vault, or its trash
async function list(trash) {
fill(root, skeleton());
const r = await api('GET', VAULT + '/notes?limit=200&view=' + (trash ? 'trash' : 'all'));
if (dead) return;
const rows = r.notes.map(n => h('li', { class: 'vault-row' },
h('a', { class: 'vault-name', href: '#/vault/' + n.id }, icon('notes', 16), h('span', { class: 'clip1' }, n.name)),
h('span', { class: 'sub' }, fmtTime(n.updated)),
trash ? [
btn('Restore', 'restore', act(async () => { await api('POST', `${VAULT}/notes/${n.id}/restore`); list(true); })),
btn('Delete for good', 'trash', act(async () => {
if (!await confirmDialog({ title: 'Delete this page for good?', body: `"${n.name}" will be deleted and cannot be brought back.`, confirm: 'Delete page' })) return;
await api('DELETE', `${VAULT}/notes/${n.id}?purge=1`); list(true);
}), 'ghost danger')] : null));
fill(root,
h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, trash ? 'Vault trash' : 'Vault'),
h('p', {}, trash ? 'Deleted vault pages wait here until you delete them for good.' : 'Unlocked. These pages are not in search, the sidebar or your download.')),
h('div', { class: 'actions' },
trash ? btn('Back to the vault', 'prev', () => list(false)) : [btn('Trash', 'trash', () => list(true)), btn('Lock', 'lock', act(lock))],
trash ? null : h('button', { class: 'btn', onclick: () => { location.hash = '#/vault/new'; } }, icon('add', 16), 'New page'))),
rows.length ? h('ul', { class: 'vault-list card' }, rows)
: emptyState(trash ? 'trash' : 'lock', trash ? 'The vault trash is empty' : 'Nothing in the vault yet', trash ? 'Pages you delete from the vault appear here.' : 'Make a page and it is locked away with the rest.',
trash ? null : h('button', { class: 'btn', onclick: () => { location.hash = '#/vault/new'; } }, icon('add', 16), 'New page')),
trash && rows.length ? btn('Empty trash', 'trash', act(async () => {
if (!await confirmDialog({ title: 'Empty the vault trash?', body: 'Every page in it will be deleted and cannot be brought back.', confirm: 'Empty trash' })) return;
await api('DELETE', VAULT + '/trash'); list(true);
}), 'ghost danger') : null);
}
// one page of the vault
async function page(id) {
fill(root, skeleton());
let note = null;
if (id !== 'new') {
try { note = await api('GET', `${VAULT}/notes/${encodeURIComponent(id)}`); }
catch (e) {
if (e.status === 404) return fill(root, emptyState('notes', 'This page is not in the vault', 'It may have been deleted for good, or the link is wrong.', btn('Back to the vault', 'prev', () => { location.hash = '#/vault'; }, 'btn')));
throw e;
}
if (dead) return;
if (note.trashed) return fill(root, h('div', { class: 'notice notice-warning' }, icon('trash', 16), h('span', { class: 'grow' }, 'This page is in the vault trash.'),
btn('Restore', 'restore', act(async () => { await api('POST', `${VAULT}/notes/${note.id}/restore`); show(); }))));
}
const actions = h('div', { class: 'page-actions' }), crumbs = h('nav', { class: 'crumbs', 'aria-label': 'Where this page is' },
h('a', { href: '#/vault' }, 'Vault'));
const meta = h('p', { class: 'sub page-meta' });
ed = pageEditor({
note, vault: true, fixedTitle: note?.daily ? note.title : undefined, // a journal day is named by its date
onCreated(c) { note = c; history.replaceState(null, '', '#/vault/' + c.id); document.title = (c.name || 'New page') + ' · Vault · Mako'; draw(); refreshVaultNames(); },
onSaved(c, { renamed }) { note = c; meta.textContent = 'Edited ' + fmtTime(c.updated); document.title = (c.name || 'New page') + ' · Vault · Mako'; if (renamed) refreshVaultNames(); },
});
ed.onMode = () => draw();
view.toggleMode = () => ed.setMode(ed.mode === 'read' ? ed.lastEdit : 'read');
function draw() {
const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'How the page is shown' },
[['live', 'Live', 'Rendered, except the line you are on'], ['write', 'Edit', 'Plain markdown'], ['read', 'View', 'The finished page']].map(([m, label, tip]) =>
h('button', { class: 'seg-b' + (ed.mode === m ? ' on' : ''), 'aria-pressed': String(ed.mode === m), title: tip, onclick: () => ed.setMode(m) }, label)));
fill(actions, ed.status, h('span', { class: 'grow' }), seg, btn('Lock', 'lock', act(lock)),
btn('Move out', 'move', act(async () => {
if (!note || !note.id) return toast('Type something first, then you can move the page out');
await ed.flush();
const n = note;
await api('POST', `${VAULT}/notes/${n.id}/out`);
await Promise.all([refreshPages(), refreshVaultNames()]);
toast('Moved out of the vault', false, { label: 'Undo', fn: act(async () => { await api('POST', `/api/notes/${n.id}/vault`); await Promise.all([refreshPages(), refreshVaultNames()]); location.hash = '#/vault/' + n.id; }) });
location.hash = hrefOf(n);
})),
btn('Delete', 'trash', act(async () => {
if (!note) { location.hash = '#/vault'; return; }
await ed.flush();
const n = note;
await api('DELETE', `${VAULT}/notes/${n.id}`);
toast('Moved to the vault trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `${VAULT}/notes/${n.id}/restore`); location.hash = '#/vault/' + n.id; show(); }) });
location.hash = '#/vault';
}), 'ghost danger'));
}
const pref = store.get('mode'), start = pref === 'read' && !(note && note.body.trim()) ? 'live' : pref || 'live';
fill(root, h('div', { class: 'page-top' }, crumbs, actions),
h('div', {}, ed.notice, h('div', { class: 'title-wrap' }, ed.titleEl, ed.titleErr), ed.wrap, meta));
ed.setMode(start);
const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); };
if (note) meta.textContent = 'Edited ' + fmtTime(note.updated);
draw();
view.title = note?.name || 'New page';
document.title = view.title + ' · Vault · Mako';
if (!note) setTimeout(() => ed.titleEl.focus());
}
view.start = act(async () => {
fill(root, skeleton());
const v = await api('GET', VAULT);
if (dead) return;
if (!v.exists) return create();
if (!v.unlocked) { S.vaultNames = null; return unlock(); }
refreshVaultNames();
return arg ? page(arg) : list(false);
});
return view;
}
+371
View File
@@ -0,0 +1,371 @@
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"path/filepath"
"time"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc"
"git.veillax.com/Veillax/mako/internal/store"
"git.veillax.com/Veillax/mako/internal/vault"
)
// The vault is a second notebook that opens only with a passphrase. Its pages are encrypted with a key
// made from that passphrase together with the server's key, so the server cannot read them until you
// unlock it, and they are in no list, search or download of the ordinary notebook. Unlocked, it lives
// in memory only until you lock it, sign out, or leave it alone for a quarter of an hour.
const (
vaultCookie = "mako_vault"
vaultIdle = 15 * time.Minute
vaultMinPass = 10
vaultTries = 5
vaultWindow = 10 * time.Minute
)
// vaultIterations is how slow it is to check one passphrase guess. Tests lower it.
var vaultIterations = 600_000
type vaultFile struct {
Salt string `json:"salt"`
Iter int `json:"iter"`
Check string `json:"check"` // a sealed word that only the right passphrase can open
}
// vaultState is an unlocked vault: one store shared by every browser holding a token for it.
type vaultState struct {
st *store.Store
tokens map[string]time.Time // cookie value to when it lapses
}
func vaultDir(c config.Config, sub string) string {
return filepath.Join(c.DataDir, "users", userKey(c, sub), "vault")
}
func readVaultFile(dir string) (vaultFile, bool) {
var vf vaultFile
b, err := os.ReadFile(filepath.Join(dir, "vault.json"))
if err != nil || json.Unmarshal(b, &vf) != nil || vf.Salt == "" || vf.Iter < 1 {
return vaultFile{}, false
}
return vf, true
}
func vaultBox(c config.Config, sub, pass string, vf vaultFile) (*vault.Cipher, error) {
salt, err := base64.RawURLEncoding.DecodeString(vf.Salt)
if err != nil {
return nil, err
}
dk, err := vault.Passphrase(pass, salt, vf.Iter)
if err != nil {
return nil, err
}
return vault.New(c.Key+"|"+dk, c.OIDC.Issuer+"|vault", sub)
}
// sweepVaults forgets tokens that have lapsed, and vaults nobody holds a token for.
func (s *Server) sweepVaults(now time.Time) {
for uk, vs := range s.vstate {
for tok, exp := range vs.tokens {
if now.After(exp) {
delete(vs.tokens, tok)
delete(s.vtoken, tok)
}
}
if len(vs.tokens) == 0 {
delete(s.vstate, uk)
}
}
}
// openVault returns the unlocked vault this browser holds the token for, and keeps it open a while longer.
func (s *Server) openVault(c config.Config, r *http.Request, sub string) (string, *store.Store, bool) {
ck, err := r.Cookie(vaultCookie)
if err != nil {
return "", nil, false
}
now := time.Now()
s.vmu.Lock()
defer s.vmu.Unlock()
s.sweepVaults(now)
uk := s.vtoken[ck.Value]
vs := s.vstate[uk]
if vs == nil || uk != userKey(c, sub) {
return "", nil, false
}
vs.tokens[ck.Value] = now.Add(vaultIdle)
return ck.Value, vs.st, true
}
// startVault hands this browser a token for the vault, opening it if no one else has.
func (s *Server) startVault(w http.ResponseWriter, c config.Config, sub string, box *vault.Cipher) error {
uk := userKey(c, sub)
s.vmu.Lock()
defer s.vmu.Unlock()
s.sweepVaults(time.Now())
vs := s.vstate[uk]
if vs == nil {
st, err := store.Open(vaultDir(c, sub), box)
if err != nil {
return err
}
vs = &vaultState{st: st, tokens: map[string]time.Time{}}
s.vstate[uk] = vs
}
tok := oidc.Random(32)
vs.tokens[tok] = time.Now().Add(vaultIdle)
s.vtoken[tok] = uk
setCookie(w, c, vaultCookie, tok, "/", vaultIdle)
return nil
}
// lockVault drops this browser's token. The vault leaves memory once nobody holds one.
func (s *Server) lockVault(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
if ck, err := r.Cookie(vaultCookie); err == nil {
s.vmu.Lock()
if uk, ok := s.vtoken[ck.Value]; ok {
delete(s.vtoken, ck.Value)
if vs := s.vstate[uk]; vs != nil {
delete(vs.tokens, ck.Value)
if len(vs.tokens) == 0 {
delete(s.vstate, uk)
}
}
}
s.vmu.Unlock()
}
setCookie(w, c, vaultCookie, "", "/", 0)
}
// vaultAPI is api for the pages in the vault: it needs the vault to be unlocked as well as a sign-in.
func (s *Server) vaultAPI(fn handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
ses, ok := s.session(c, r)
if !ok {
fail(w, http.StatusUnauthorized, "sign in required")
return
}
if r.Method != http.MethodGet && r.Header.Get("X-Mako") != "1" {
fail(w, http.StatusForbidden, "missing X-Mako header")
return
}
tok, st, ok := s.openVault(c, r, ses.Sub)
if !ok {
failCode(w, http.StatusLocked, "the vault is locked", "locked")
return
}
setCookie(w, c, vaultCookie, tok, "/", vaultIdle)
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
fn(w, r, principal{Sub: ses.Sub, Name: ses.Name, Email: ses.Email, st: st})
}
}
func (s *Server) vaultInfo(w http.ResponseWriter, r *http.Request, p principal) {
c := s.cfg.Get()
_, exists := readVaultFile(vaultDir(c, p.Sub))
_, _, unlocked := s.openVault(c, r, p.Sub)
writeJSON(w, map[string]bool{"exists": exists, "unlocked": unlocked})
}
type passphraseBody struct {
Passphrase string `json:"passphrase"`
}
func (s *Server) vaultCreate(w http.ResponseWriter, r *http.Request, p principal) {
var in passphraseBody
if !decode(w, r, &in) {
return
}
c := s.cfg.Get()
dir := vaultDir(c, p.Sub)
if _, exists := readVaultFile(dir); exists {
fail(w, http.StatusConflict, "you already have a vault; unlock it instead")
return
}
if len([]rune(in.Passphrase)) < vaultMinPass {
fail(w, http.StatusBadRequest, "pick a passphrase of at least 10 characters; a few words strung together is good")
return
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
vf := vaultFile{Salt: base64.RawURLEncoding.EncodeToString(salt), Iter: vaultIterations}
box, err := vaultBox(c, p.Sub, in.Passphrase, vf)
if err != nil {
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
vf.Check = base64.RawURLEncoding.EncodeToString(box.Seal([]byte("mako vault"), "check"))
b, _ := json.Marshal(vf)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
tmp := filepath.Join(dir, ".vault.json.tmp")
err = os.WriteFile(tmp, b, 0o600)
if err == nil {
err = os.Rename(tmp, filepath.Join(dir, "vault.json"))
}
if err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
if err := s.startVault(w, c, p.Sub, box); err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not open the vault; the server's log says why")
return
}
writeJSON(w, map[string]bool{"exists": true, "unlocked": true})
}
func (s *Server) vaultUnlock(w http.ResponseWriter, r *http.Request, p principal) {
var in passphraseBody
if !decode(w, r, &in) {
return
}
c := s.cfg.Get()
vf, exists := readVaultFile(vaultDir(c, p.Sub))
if !exists {
fail(w, http.StatusNotFound, "you have no vault yet")
return
}
uk, now := userKey(c, p.Sub), time.Now()
s.vmu.Lock()
recent := s.vfails[uk][:0]
for _, t := range s.vfails[uk] {
if now.Sub(t) < vaultWindow {
recent = append(recent, t)
}
}
s.vfails[uk] = recent
tooMany := len(recent) >= vaultTries
s.vmu.Unlock()
if tooMany {
failCode(w, http.StatusTooManyRequests, "too many wrong passphrases; try again in a few minutes", "too_many")
return
}
box, err := vaultBox(c, p.Sub, in.Passphrase, vf)
check, _ := base64.RawURLEncoding.DecodeString(vf.Check)
var word []byte
if err == nil {
word, err = box.Open(check, "check")
}
if err != nil || subtle.ConstantTimeCompare(word, []byte("mako vault")) != 1 {
s.vmu.Lock()
s.vfails[uk] = append(s.vfails[uk], now)
s.vmu.Unlock()
failCode(w, http.StatusForbidden, "that is not the passphrase", "wrong_passphrase")
return
}
s.vmu.Lock()
delete(s.vfails, uk)
s.vmu.Unlock()
if err := s.startVault(w, c, p.Sub, box); err != nil {
log.Printf("vault: %v", err)
if errors.Is(err, vault.ErrWrongKey) {
fail(w, http.StatusInternalServerError, "the vault's pages cannot be read with this key; the server's log says more")
} else {
fail(w, http.StatusInternalServerError, "could not open the vault; the server's log says why")
}
return
}
writeJSON(w, map[string]bool{"exists": true, "unlocked": true})
}
func (s *Server) vaultLock(w http.ResponseWriter, r *http.Request, p principal) {
s.lockVault(w, r)
writeJSON(w, map[string]bool{"exists": true, "unlocked": false})
}
// moveToVault takes a page out of the ordinary notebook and puts it in the vault, which must be unlocked.
// Links to it elsewhere become [[locked:id]], so the page's title stays in the vault only.
func (s *Server) moveToVault(w http.ResponseWriter, r *http.Request, p principal) {
c := s.cfg.Get()
_, vst, ok := s.openVault(c, r, p.Sub)
if !ok {
failCode(w, http.StatusLocked, "unlock the vault first", "locked")
return
}
id := r.PathValue("id")
n, err := p.st.Get(id)
if err == nil && n.Trashed {
err = store.ErrNotFound
}
if err != nil {
storeFail(w, err)
return
}
if _, err := vst.Import(n); err != nil {
if errors.Is(err, store.ErrExists) {
failCode(w, http.StatusConflict, existsMessage(n, "your vault"), "exists")
return
}
storeFail(w, err)
return
}
if _, err := p.st.Detach(id); err != nil {
vst.Discard(id) // the page stays where it was
storeFail(w, err)
return
}
writeJSON(w, map[string]string{"id": id})
}
// moveFromVault brings a page back out of the vault into the ordinary notebook and points the
// [[locked:id]] links to it at its title again.
func (s *Server) moveFromVault(w http.ResponseWriter, r *http.Request, p principal) {
main, err := s.notebook(s.cfg.Get(), p.Sub, p.Name, p.Email)
if err != nil {
fail(w, http.StatusInternalServerError, "could not open your notes; the server's log says why")
return
}
id := r.PathValue("id")
n, err := p.st.Get(id)
if err == nil && n.Trashed {
err = store.ErrNotFound
}
if err != nil {
storeFail(w, err)
return
}
in, err := main.Import(n)
if err != nil {
if errors.Is(err, store.ErrExists) {
failCode(w, http.StatusConflict, existsMessage(n, "your notebook"), "exists")
return
}
storeFail(w, err)
return
}
if err := main.Reveal(id, in.Title); err != nil {
main.Discard(id)
storeFail(w, err)
return
}
if err := p.st.Discard(id); err != nil {
log.Printf("vault: moved %s out but could not remove it from the vault: %v", id, err)
fail(w, http.StatusInternalServerError, "the page is back in your notebook, but a copy is still in the vault; the server's log says why")
return
}
writeJSON(w, map[string]string{"id": id})
}
func existsMessage(n store.Note, where string) string {
if n.Daily != "" {
return where + " already has a journal page for that day"
}
return where + " already has this page"
}
+227
View File
@@ -677,3 +677,230 @@ func TestKeyIsMadeOnceAndKept(t *testing.T) {
t.Fatalf("a short key was accepted: %v", err) t.Fatalf("a short key was accepted: %v", err)
} }
} }
// ---- the vault ----
func withVault(t *testing.T) *env {
t.Helper()
old := vaultIterations
vaultIterations = 1000
t.Cleanup(func() { vaultIterations = old })
return setup(t, nil)
}
// vaultCookies returns the sign-in cookies plus the vault token from a response.
func vaultCookies(ses []*http.Cookie, w *httptest.ResponseRecorder) []*http.Cookie {
if v := cookieNamed(w, vaultCookie); v != nil && v.Value != "" {
return append(append([]*http.Cookie{}, ses...), v)
}
return ses
}
func TestVaultLifecycle(t *testing.T) {
e := withVault(t)
a := e.signIn(alice)
var info struct{ Exists, Unlocked bool }
jsonOf(t, e.do("GET", "/api/vault", nil, a), &info)
if info.Exists || info.Unlocked {
t.Fatalf("a new person already has a vault: %+v", info)
}
if w := e.do("GET", "/api/vault/notes", nil, a); w.Code != http.StatusLocked {
t.Fatalf("a vault that does not exist is not locked: %d", w.Code)
}
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "short"}, a); w.Code != http.StatusBadRequest {
t.Fatalf("a short passphrase: %d", w.Code)
}
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
if w.Code != http.StatusOK {
t.Fatalf("create: %d %s", w.Code, w.Body)
}
tok := cookieNamed(w, vaultCookie)
if tok == nil || !tok.HttpOnly || tok.Value == "" {
t.Fatalf("vault cookie: %+v", tok)
}
open := vaultCookies(a, w)
// a page in the vault
var n struct{ ID string }
if w := e.do("POST", "/api/vault/notes", map[string]string{"title": "Bank codes", "body": "hunter2 and the safe number"}, open); w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("create page: %d %s", w.Code, w.Body)
} else {
jsonOf(t, w, &n)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, open); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hunter2") {
t.Fatalf("read page: %d %s", w.Code, w.Body)
}
// it is nowhere in the ordinary notebook
for _, path := range []string{"/api/notes", "/api/pages", "/api/find?q=hunter2", "/api/find?q=Bank"} {
if w := e.do("GET", path, nil, open); strings.Contains(w.Body.String(), "hunter2") || strings.Contains(w.Body.String(), "Bank codes") {
t.Fatalf("%s shows the vault: %s", path, w.Body)
}
}
if w := e.do("GET", "/api/notes/"+n.ID, nil, open); w.Code != http.StatusNotFound {
t.Fatalf("a vault page opens as an ordinary one: %d", w.Code)
}
if w := e.do("GET", "/api/export", nil, open); bytes.Contains(w.Body.Bytes(), []byte("hunter2")) {
t.Fatal("the vault is in the download")
}
var found struct{ Hits []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/vault/find?q=safe", nil, open), &found)
if len(found.Hits) != 1 {
t.Fatalf("vault search: %+v", found)
}
// locking, and the old cookie no longer opens anything
if w := e.do("POST", "/api/vault/lock", nil, open); w.Code != http.StatusOK {
t.Fatalf("lock: %d", w.Code)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, open); w.Code != http.StatusLocked {
t.Fatalf("the vault opened after locking: %d", w.Code)
}
// the wrong passphrase, then the right one
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "wrong horse battery"}, a); w.Code != http.StatusForbidden {
t.Fatalf("wrong passphrase: %d", w.Code)
}
w = e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, a)
if w.Code != http.StatusOK {
t.Fatalf("unlock: %d %s", w.Code, w.Body)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, vaultCookies(a, w)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hunter2") {
t.Fatalf("read after unlock: %d", w.Code)
}
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "another passphrase"}, a); w.Code != http.StatusConflict {
t.Fatalf("a second vault: %d", w.Code)
}
}
func TestVaultIsEncryptedOnDiskAndPrivate(t *testing.T) {
e := withVault(t)
a, b := e.signIn(alice), e.signIn(bob)
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
open := vaultCookies(a, w)
e.do("POST", "/api/vault/notes", map[string]string{"title": "Moonlight plan", "body": "tell nobody"}, open)
filepath.WalkDir(filepath.Join(e.dir, "data"), func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
raw, _ := os.ReadFile(p)
for _, s := range []string{"Moonlight", "tell nobody", "correct horse"} {
if bytes.Contains(raw, []byte(s)) {
t.Errorf("%s holds %q in the clear", p, s)
}
}
return nil
})
// Bob cannot use Alice's vault cookie, and has no vault of his own
stolen := append(append([]*http.Cookie{}, b...), cookieNamed(w, vaultCookie))
if w := e.do("GET", "/api/vault/notes", nil, stolen); w.Code != http.StatusLocked {
t.Fatalf("Bob opened Alice's vault with her cookie: %d", w.Code)
}
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob unlocking a vault he does not have: %d", w.Code)
}
// signing out locks it
e.do("POST", "/api/logout", nil, open)
if w := e.do("GET", "/api/vault/notes", nil, open); w.Code == http.StatusOK {
t.Fatal("the vault stayed open after signing out")
}
}
func TestVaultGuessingIsLimited(t *testing.T) {
e := withVault(t)
a := e.signIn(alice)
e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
e.do("POST", "/api/vault/lock", nil, a)
for i := 0; i < vaultTries; i++ {
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": fmt.Sprintf("guess number %d", i)}, a); w.Code != http.StatusForbidden {
t.Fatalf("guess %d: %d", i, w.Code)
}
}
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, a); w.Code != http.StatusTooManyRequests {
t.Fatalf("even the right passphrase is held back after too many guesses: %d", w.Code)
}
}
func TestVaultNeedsSignInAndHeader(t *testing.T) {
e := withVault(t)
if w := e.do("GET", "/api/vault", nil, nil); w.Code != http.StatusUnauthorized {
t.Fatalf("no sign-in: %d", w.Code)
}
a := e.signIn(alice)
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a, "X-Mako", ""); w.Code != http.StatusForbidden {
t.Fatalf("no header: %d", w.Code)
}
}
func TestMovePagesIntoAndOutOfTheVault(t *testing.T) {
e := withVault(t)
a := e.signIn(alice)
var secret, notes, day struct{ ID, Body, Title string }
jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Secret plan", "body": "the plan"}, a), &secret)
jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Notes", "body": "see [[Secret plan]] and [[secret plan|it]]"}, a), &notes)
jsonOf(t, e.do("POST", "/api/daily", map[string]string{"date": "2026-10-31"}, a), &day)
e.do("PUT", "/api/notes/"+day.ID, map[string]string{"body": "dear diary"}, a)
// there is nowhere to put it until the vault is open
if w := e.do("POST", "/api/notes/"+secret.ID+"/vault", nil, a); w.Code != http.StatusLocked {
t.Fatalf("moving with no vault: %d", w.Code)
}
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
open := vaultCookies(a, w)
for _, id := range []string{secret.ID, day.ID} {
if w := e.do("POST", "/api/notes/"+id+"/vault", nil, open); w.Code != http.StatusOK {
t.Fatalf("move %s in: %d %s", id, w.Code, w.Body)
}
}
if w := e.do("GET", "/api/notes/"+secret.ID, nil, open); w.Code != http.StatusNotFound {
t.Fatalf("the page is still in the notebook: %d", w.Code)
}
var in struct{ ID, Title, Body, Daily string }
jsonOf(t, e.do("GET", "/api/vault/notes/"+secret.ID, nil, open), &in)
if in.ID != secret.ID || in.Title != "Secret plan" || in.Body != "the plan" {
t.Fatalf("in the vault: %+v", in)
}
jsonOf(t, e.do("GET", "/api/vault/notes/"+day.ID, nil, open), &in)
if in.Daily != "2026-10-31" || in.Body != "dear diary" {
t.Fatalf("the day in the vault: %+v", in)
}
// the page that linked to it now shows a locked link and says nothing of the title
var n struct{ Body string }
jsonOf(t, e.do("GET", "/api/notes/"+notes.ID, nil, open), &n)
if want := "see [[locked:" + secret.ID + "]] and [[locked:" + secret.ID + "]]"; n.Body != want {
t.Fatalf("links: %q want %q", n.Body, want)
}
for _, path := range []string{"/api/notes", "/api/pages", "/api/find?q=Secret", "/api/find?q=diary"} {
if w := e.do("GET", path, nil, open); strings.Contains(w.Body.String(), "Secret plan") || strings.Contains(w.Body.String(), "diary") {
t.Fatalf("%s still shows the moved pages: %s", path, w.Body)
}
}
// the same day cannot be moved in twice
var again struct{ ID string }
jsonOf(t, e.do("POST", "/api/daily", map[string]string{"date": "2026-10-31"}, open), &again)
if w := e.do("POST", "/api/notes/"+again.ID+"/vault", nil, open); w.Code != http.StatusConflict {
t.Fatalf("a second journal page for the day: %d %s", w.Code, w.Body)
}
// and back out: the links name the page again
if w := e.do("POST", "/api/vault/notes/"+secret.ID+"/out", nil, open); w.Code != http.StatusOK {
t.Fatalf("move out: %d %s", w.Code, w.Body)
}
if w := e.do("GET", "/api/vault/notes/"+secret.ID, nil, open); w.Code != http.StatusNotFound {
t.Fatalf("the page is still in the vault: %d", w.Code)
}
jsonOf(t, e.do("GET", "/api/notes/"+secret.ID, nil, open), &in)
if in.Title != "Secret plan" || in.Body != "the plan" {
t.Fatalf("back in the notebook: %+v", in)
}
jsonOf(t, e.do("GET", "/api/notes/"+notes.ID, nil, open), &n)
if n.Body != "see [[Secret plan]] and [[Secret plan]]" {
t.Fatalf("links after coming back: %q", n.Body)
}
}