Compare commits
5
Commits
2536f223b7
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
994927c704 | ||
|
|
c08a8b8718 | ||
|
|
7ff424fa55 | ||
|
|
e938367929 | ||
|
|
adde7c54ce |
No files matched your search
@@ -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
|
||||
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.
|
||||
- **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
|
||||
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
|
||||
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.
|
||||
- **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
|
||||
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
|
||||
built for millions.
|
||||
- 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
|
||||
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
|
||||
|
||||
|
||||
+115
-3
@@ -591,7 +591,7 @@ func (s *Store) relink(oldTitle, newTitle, except string) error {
|
||||
if m.ID == except || m.Trashed || !slices.Contains(m.links, old) {
|
||||
continue
|
||||
}
|
||||
body := rewriteLinks(m.Body, old, newTitle)
|
||||
body := rewriteLinks(m.Body, old, newTitle, true)
|
||||
if body == m.Body {
|
||||
continue
|
||||
}
|
||||
@@ -606,7 +606,8 @@ func (s *Store) relink(oldTitle, newTitle, except string) error {
|
||||
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")
|
||||
fence := false
|
||||
for i, l := range lines {
|
||||
@@ -623,7 +624,7 @@ func rewriteLinks(body, old, newTitle string) string {
|
||||
return x
|
||||
}
|
||||
alias := ""
|
||||
if k := strings.Index(x, "|"); k >= 0 {
|
||||
if k := strings.Index(x, "|"); keepAlias && k >= 0 {
|
||||
alias = x[k : len(x)-2]
|
||||
}
|
||||
return "[[" + newTitle + alias + "]]"
|
||||
@@ -1076,3 +1077,114 @@ func exportFormat(n *Note) []byte {
|
||||
b.WriteString("\n")
|
||||
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
|
||||
}
|
||||
@@ -576,3 +576,114 @@ func TestWrongKeyRefusesInsteadOfLosingPages(t *testing.T) {
|
||||
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 }
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
@@ -50,6 +52,13 @@ func New(master, issuer, sub string) (*Cipher, error) {
|
||||
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.
|
||||
func IsSealed(data []byte) bool { return bytes.HasPrefix(data, magic) }
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ func firstOf(s ...string) string {
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
s.lockVault(w, r)
|
||||
setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0)
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
+30
-2
@@ -15,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/mako/internal/config"
|
||||
"git.veillax.com/Veillax/mako/internal/oidc"
|
||||
@@ -34,6 +35,11 @@ type Server struct {
|
||||
|
||||
mu sync.Mutex
|
||||
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.
|
||||
@@ -49,17 +55,23 @@ func New(h *config.Holder, version string) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
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 is the sign-in client, for the check command.
|
||||
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
|
||||
// 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) {
|
||||
sum := sha256.Sum256([]byte(c.OIDC.Issuer + "|" + sub))
|
||||
key := hex.EncodeToString(sum[:16])
|
||||
key := userKey(c, sub)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
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("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}/vault", s.api(s.moveToVault))
|
||||
mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash))
|
||||
mux.HandleFunc("POST /api/daily", s.api(s.daily))
|
||||
mux.HandleFunc("GET /api/find", s.api(s.find))
|
||||
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")
|
||||
mux.Handle("GET /", http.FileServerFS(static))
|
||||
return s.secure(mux)
|
||||
|
||||
@@ -47,14 +47,14 @@ async function boot() {
|
||||
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'))));
|
||||
}
|
||||
await refreshPages().catch(() => { });
|
||||
await Promise.all([refreshPages().catch(() => { }), refreshVaultNames()]);
|
||||
S.ready = true;
|
||||
buildShell();
|
||||
show();
|
||||
}
|
||||
|
||||
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 : '';
|
||||
$app.replaceChildren(h('main', { class: 'center' }, card('', brand(),
|
||||
h('h1', { class: 'login-h' }, 'Your private notebook'),
|
||||
@@ -129,7 +129,8 @@ function updateNav() {
|
||||
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,
|
||||
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() {
|
||||
@@ -175,6 +176,7 @@ async function show() {
|
||||
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 '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;
|
||||
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('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'));
|
||||
}
|
||||
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() {
|
||||
if (!note) { location.hash = '#/'; return; }
|
||||
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 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'))),
|
||||
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
@@ -33,8 +33,8 @@ const SLASH = [
|
||||
{ name: 'Quote', hint: '>', keys: 'quote callout', text: '> ' },
|
||||
{ name: 'Code block', hint: '```', keys: 'code pre', text: '```\n\n```', caret: 4 },
|
||||
{ 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 today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]' },
|
||||
{ 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()) + ']]', link: true },
|
||||
{ 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; },
|
||||
show(list, pick, pos) {
|
||||
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;
|
||||
const c = caretXY(ta, pos);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
let m;
|
||||
if ((m = /\[\[([^\[\]|\n]*)$/.exec(before))) {
|
||||
if (!plain && (m = /\[\[([^\[\]|\n]*)$/.exec(before))) {
|
||||
const from = pos - m[1].length;
|
||||
pop.show(pageChoices(m[1]), it => {
|
||||
const closed = ta.value.startsWith(']]', pos);
|
||||
@@ -90,10 +93,12 @@ function enhance(ta, wrap) {
|
||||
}, pos);
|
||||
} else if ((m = /^(\s*)\/([a-z0-9]{0,16})$/i.exec(before))) {
|
||||
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;
|
||||
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();
|
||||
}, pos);
|
||||
} else pop.hide();
|
||||
@@ -150,9 +155,11 @@ function enhance(ta, wrap) {
|
||||
// ---------- 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,
|
||||
// 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 });
|
||||
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() {
|
||||
const out = [];
|
||||
@@ -172,15 +179,53 @@ function makeLive(ta) {
|
||||
function rendered(b) {
|
||||
const text = lines.slice(b.s, b.e + 1).join('\n'), box = h('div', { class: 'live-line' });
|
||||
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);
|
||||
if (m) root.querySelector('ol')?.setAttribute('start', m[1]);
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const len = lines[at].length;
|
||||
lines.splice(at, 2, lines[at] + lines[at + 1]);
|
||||
@@ -191,13 +236,15 @@ function makeLive(ta) {
|
||||
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" : '' });
|
||||
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', () => {
|
||||
const fresh = t.value.split('\n');
|
||||
lines.splice(b.s, b.e - b.s + 1, ...fresh);
|
||||
b.e = b.s + fresh.length - 1;
|
||||
if (b.span && act) act.e = b.e;
|
||||
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);
|
||||
place(b.s + before.split('\n').length - 1, before.length - before.lastIndexOf('\n') - 1, true);
|
||||
draw();
|
||||
@@ -206,34 +253,76 @@ function makeLive(ta) {
|
||||
t.addEventListener('keydown', e => {
|
||||
if (e.isComposing) 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 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); }
|
||||
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 === '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) {
|
||||
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) {
|
||||
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));
|
||||
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() {
|
||||
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');
|
||||
if (!t) return;
|
||||
t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px';
|
||||
if (act.focus) {
|
||||
const c = Math.min(act.col, t.value.length);
|
||||
t.focus(); t.setSelectionRange(c, c); t.scrollIntoView({ block: 'nearest' });
|
||||
t.focus();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -269,7 +358,9 @@ function makeLive(ta) {
|
||||
|
||||
// ---------- 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.
|
||||
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 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" });
|
||||
ta.value = note?.body ?? '';
|
||||
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 status = h('span', { class: 'sub', role: 'status' });
|
||||
const notice = h('div', { class: 'notice notice-warning', hidden: true });
|
||||
const say = t => { status.textContent = t; };
|
||||
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 ctl = { save, dirty };
|
||||
@@ -305,12 +396,12 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
|
||||
saving = true; say('Saving');
|
||||
try {
|
||||
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 {
|
||||
const payload = { base: cur.updated };
|
||||
if (sendTitle) payload.title = t;
|
||||
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;
|
||||
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('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]);
|
||||
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 {
|
||||
say('Not saved');
|
||||
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(); });
|
||||
|
||||
// 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) {
|
||||
const lines = ta.value.split('\n');
|
||||
lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]');
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<script src="toolkit.js"></script>
|
||||
<script src="markdown.js"></script>
|
||||
<script src="editor.js"></script>
|
||||
<script src="vault.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,27 +7,39 @@ const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
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);
|
||||
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 = [];
|
||||
let last = 0, m;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
last = re.lastIndex;
|
||||
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[3] != null) out.push(h('em', {}, inline(m[3])));
|
||||
else if (m[4] != null) out.push(wikiLink(m[4], m[5]));
|
||||
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], plain)));
|
||||
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[8] != null) out.push(extLink(m[8], m[8]));
|
||||
else {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -36,13 +48,13 @@ function inline(text) {
|
||||
}
|
||||
|
||||
// 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');
|
||||
let para = [], i = 0, m;
|
||||
const flush = () => {
|
||||
if (!para.length) return;
|
||||
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 = [];
|
||||
};
|
||||
while (i < lines.length) {
|
||||
@@ -53,12 +65,12 @@ function md(src, onTask, big) {
|
||||
i++; root.append(h('pre', {}, h('code', {}, code.join('\n')))); 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 (/^>/.test(line)) {
|
||||
flush(); const q = [];
|
||||
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)) {
|
||||
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) {
|
||||
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));
|
||||
li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4])));
|
||||
} else li.append(...inline(m[4]));
|
||||
li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4], plain)));
|
||||
} else li.append(...inline(m[4], plain));
|
||||
list.append(li); i++;
|
||||
}
|
||||
root.append(list); continue;
|
||||
|
||||
@@ -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:focus { outline:none; border-color:var(--accent); }
|
||||
.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); }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -677,3 +677,230 @@ func TestKeyIsMadeOnceAndKept(t *testing.T) {
|
||||
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), ¬es)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user