From 98f77309139356c4a67704657a15b162bf368e02 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:51:26 -0600 Subject: [PATCH] Basking: sign in with Authentik only, pages that nest and link Sign-in is now OpenID Connect against Authentik (authorization code flow with PKCE, state and nonce). The password, API tokens and the add command are gone; each person who signs in gets a private notebook in a folder named from a hash of who they are. An optional allowed list is checked on every request. Notes become pages with titles, a tree of subpages, [[links]] with backlinks that follow renames, a Ctrl+K finder, slash commands, list continuation, and Write and Read modes. Autosave keeps the text through a dropped connection or an expired session. Export opens as an Obsidian vault. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 198 ++++--- cmd/basking/main.go | 217 +++----- deploy/docker-compose.yml | 13 +- deploy/install.sh | 36 +- docs/design.md | 13 +- internal/config/config.go | 241 ++++----- internal/oidc/oidc.go | 234 +++++++++ internal/store/store.go | 659 ++++++++++++++++++++--- internal/store/store_test.go | 359 ++++++++++--- internal/web/api.go | 176 ++----- internal/web/auth.go | 180 +++++++ internal/web/server.go | 235 +++------ internal/web/static/app.js | 900 ++++++++++++++------------------ internal/web/static/editor.js | 276 ++++++++++ internal/web/static/index.html | 3 + internal/web/static/markdown.js | 81 +++ internal/web/static/style.css | 94 +++- internal/web/static/toolkit.js | 173 ++++++ internal/web/web_test.go | 781 ++++++++++++++++++--------- 19 files changed, 3291 insertions(+), 1578 deletions(-) create mode 100644 internal/oidc/oidc.go create mode 100644 internal/web/auth.go create mode 100644 internal/web/static/editor.js create mode 100644 internal/web/static/markdown.js create mode 100644 internal/web/static/toolkit.js diff --git a/README.md b/README.md index b244950..fd915c8 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,59 @@ # Basking -A place to dump thoughts, jot notes and keep a journal. Named for the basking shark, which swims -along with its mouth open and takes in whatever drifts by. One small binary, no database, no -dependencies. +A private notebook in the spirit of Obsidian and Notion, for people who would rather run their own. +Pages that nest inside each other, `[[links]]` between them with backlinks, a journal page for each +day, tags, and quick search, all kept as plain markdown files. One small binary, no database, no +dependencies. You sign in with **Authentik** and nothing else: there are no passwords in Basking. -Your notes are plain markdown files in a folder you own. Basking is just a fast, comfortable way to -write them and find them again, from a browser on your phone or your desk. +Named for the basking shark, which swims along with its mouth open and takes in whatever drifts by. ## What it does -- **Dump first, sort later.** The box at the top of Notes is always ready. Type, press Ctrl+Enter, - keep typing. An unsaved draft survives a closed tab. -- **Tags by typing.** Write `#errands` or `#projects/basking` anywhere in a note and it becomes a tag. - The sidebar lists them. Numbers such as `#12` and anything inside code are left alone. -- **A page for each day.** Today opens the day's journal page, with an Insert time button to stamp - `20:15 ` where you are typing. Move between days, or read back over the Journal. -- **Search** across everything as you type: every word must match, and `#tag` words filter by tag. -- **Markdown**, rendered safely: headings, lists, checkboxes you can tick in place, quotes, code, - links and bold and italic. -- **Pin** what matters. **Delete** goes to a trash first, with an Undo, and nothing is lost until you - empty it. -- **Autosave** while you edit, and a warning (not a silent overwrite) if the same note was changed on - another device. -- **Send notes from anywhere.** Make an API token and post text from a script, a phone shortcut or the - `basking add` command. -- **Take it with you.** Download everything as a zip of `.md` files, or just copy the folder. +- **Pages in a tree.** Give a page a title and it appears in the sidebar. Add a subpage with the `+` + next to it, or move a page with Move. Delete moves a page to the trash with an Undo, and any + subpages move up a level so nothing is lost with it. +- **Link pages with `[[`.** Type `[[` and pick a page, or type a new title to create it. A dashed link + is a page that does not exist yet; click it and it is made. Each page lists what links to it. + Rename a page and every `[[link]]` to it is updated. +- **Type `/` for commands**: headings, to-do lists, quotes, code blocks, dividers, today's date, the + time. Lists continue when you press Enter and indent with Tab. Ctrl+B and Ctrl+I work as you expect. +- **Write or Read.** Write shows the markdown; Read shows the page as it will look, with tasks you can + tick and links you can follow. Click a page in Read to start writing (Ctrl+E switches). +- **Quick find with Ctrl+K** searches titles and text as you type, and makes a new page if there is no + match. +- **A journal.** Today opens the page for the day, with an Insert time button. Days link to each + other: `[[2026-10-31]]` goes to that day. +- **Quick notes.** The box on Home takes a thought in two keystrokes (`n`, then Ctrl+Enter). They stay + out of the sidebar until you give them a title. +- **Tags by typing.** Write `#errands` or `#projects/basking` anywhere. Numbers such as `#12` and + anything in code are left alone. +- **Autosave that does not lose things.** Changes save as you type. If the connection drops, Basking + 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. +- **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. + +## Signing in with Authentik + +Basking never sees a password. Who may open it is decided in Authentik, and each person gets a +notebook that only they can read. + +1. In Authentik, go to **Applications → Providers → Create** and choose **OAuth2/OpenID Provider**. + Set the client type to **Confidential**, add the redirect URI (strict) + `https://notes.example.com/auth/callback` (use the address you will open Basking at), and keep the + default scopes (openid, profile, email). +2. **Applications → Create**: name it Basking and pick that provider. To limit who can open it, bind a + user or group to the application. +3. Copy the provider's **Client ID** and **Client Secret**, and note its **OpenID Configuration + Issuer**, which looks like `https://auth.example.com/application/o/basking/`. +4. Give Basking those four things (see below), then run `basking check`. It tells you whether + Authentik answers and what redirect URI to allow. + +Basking asks for the `openid profile email` scopes and uses the authorization code flow with PKCE, a +`state` and a `nonce`. Whoever Authentik lets into the application can sign in; to be stricter, list +people under `allowed` (emails or usernames), and Basking turns anyone else away even if Authentik +lets them through. ## Installing on Linux (systemd) @@ -32,123 +61,126 @@ write them and find them again, from a browser on your phone or your desk. curl -fsSL https://git.veillax.com/Veillax/basking/raw/branch/main/deploy/install.sh | sudo sh ``` -This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/basking`, creates -a `basking` user, `/etc/basking/` (settings) and `/var/lib/basking/` (your notes), and writes the -systemd service and `/etc/basking/basking.env`. It then tells you what to do next: +This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/basking`, creates a +`basking` user, `/etc/basking/` (settings) and `/var/lib/basking/` (your notes), and writes the +systemd service and `/etc/basking/basking.env`. Then: ```sh -sudoedit /etc/basking/basking.env # optional: the address and where notes are kept +sudoedit /etc/basking/basking.env # fill in the four Authentik lines at the top sudo systemctl start basking -journalctl -u basking -f # the first start prints your password here, once +journalctl -u basking -f # it says at once if a setting is missing or wrong ``` -Run it again to upgrade; your settings and notes are kept and a running service is restarted. +Basking listens on `127.0.0.1:8490`. Put an HTTPS reverse proxy (or +[Hammerhead](https://git.veillax.com/Veillax/hammerhead)) in front of it and open the address you gave +as `BASKING_PUBLIC_URL`. Authentik will only send people back to an address you allowed, and cookies +are marked Secure when that address is https. -Options: `--start` (start the service straight away), `--version v0.1.0`, `--listen 0.0.0.0:8490`, -`--source` (build from the repository with Go instead), and `--uninstall` (add `--purge` to delete -settings and notes). Download the script and read it first if you prefer: it is +Run the installer again to upgrade; your settings and notes are kept and a running service is +restarted. Options: `--start`, `--version v0.1.0`, `--listen 127.0.0.1:8490`, `--source` (build with Go +instead) and `--uninstall` (add `--purge` to delete settings and notes). Read it first if you like: [`deploy/install.sh`](deploy/install.sh). -## Running it by hand +## Running it by hand or in Docker ```sh -# download a release, or: go build ./cmd/basking +export BASKING_PUBLIC_URL=https://notes.example.com +export BASKING_OIDC_ISSUER=https://auth.example.com/application/o/basking/ +export BASKING_OIDC_CLIENT_ID=... +export BASKING_OIDC_CLIENT_SECRET=... +./basking check # optional: test the settings ./basking serve ``` -On first run it writes `basking.json` and prints a password **once** in the log. Open -http://127.0.0.1:8490 and sign in. Notes go in `./data/notes/`. - -By default it only listens on `127.0.0.1`. To reach it from your phone, put it behind an HTTPS -reverse proxy, or expose it through [Hammerhead](https://git.veillax.com/Veillax/hammerhead), and set -`"cookie_secure": true` and `"trust_proxy": true` in `basking.json`. Don't put it on the open -internet over plain HTTP: it holds your private thoughts. - Docker: see [`Dockerfile`](Dockerfile) and [`deploy/docker-compose.yml`](deploy/docker-compose.yml). ### Commands ``` -basking serve [-config basking.json] [-listen addr] [-data dir] -basking password [-config ...] set a new password (BASKING_PASSWORD or stdin); restart afterwards -basking token [-config ...] NAME make an API token (printed once) -basking add [TEXT...] send a note to a running Basking (needs BASKING_URL and BASKING_TOKEN) +basking serve [-config basking.json] [-listen addr] [-data dir] +basking check [-config basking.json] test the Authentik settings without starting +basking version ``` ## Settings -`basking.json` holds `listen` (default `127.0.0.1:8490`), `data_dir` (default `data`), the password -hash, API token hashes, a signing secret, and `cookie_secure` and `trust_proxy` for use behind a -proxy. The environment variables `BASKING_CONFIG`, `BASKING_LISTEN` and `BASKING_DATA` override the -file location, address and notes folder. +Everything can be set with environment variables, which win over the settings file and are never +written to it: + +| Setting | Variable | | +| --- | --- | --- | +| `public_url` | `BASKING_PUBLIC_URL` | where you open Basking, no path | +| `oidc.issuer` | `BASKING_OIDC_ISSUER` | the provider's issuer | +| `oidc.client_id` | `BASKING_OIDC_CLIENT_ID` | | +| `oidc.client_secret` | `BASKING_OIDC_CLIENT_SECRET` | | +| `allowed` | `BASKING_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated | +| `listen` | `BASKING_LISTEN` | default `127.0.0.1:8490` | +| `data_dir` | `BASKING_DATA` | default `data` | + +`basking.json` also holds a `secret` that signs session cookies. It is made on first run and written +with mode `0600`. Changing it signs everyone out. ## Where your notes are ``` -data/ - notes/20261031-143205-a1b2c3.md one file per note +data/users// + owner.txt your name and email, so the folder can be matched to a person + notes/20261031-143205-a1b2c3.md one file per page notes/daily-2026-10-31.md the journal page for a day - trash/ deleted notes, until the trash is emptied + trash/ deleted pages, until the trash is emptied ``` -A note file looks like this, so you can edit it with anything: +A page file looks like this, so you can edit it with anything: ``` --- +title: "Trip to Lisbon" +parent: 20261030-091200-ffee11 created: 2026-10-31T14:32:05.123Z updated: 2026-10-31T14:35:00.456Z pinned: true --- -Call the dentist #errands +Flights and hotels, see [[Packing list]] #travel ``` -A plain `.md` file you drop into `notes/` is picked up too, with its file time as its date (name it -with lower-case letters, numbers and dashes). Restart Basking to see files added by hand. - -## API - -Sign in from a browser, or send `Authorization: Bearer bsk_...`. - -```sh -curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: text/plain" \ - -d "Call the dentist #errands" http://127.0.0.1:8490/api/notes -``` - -`GET /api/notes?q=&tag=&view=all|pinned|daily|trash&offset=&limit=`, `POST /api/notes` (JSON -`{"body": "..."}` or plain text), `GET|PUT|DELETE /api/notes/{id}`, `POST /api/notes/{id}/restore`, -`DELETE /api/trash`, `POST /api/daily` (`{"date": "2026-10-31"}`), `GET /api/summary`, -`GET /api/export`. +A plain `.md` file dropped into `notes/` is picked up too, with its file time as its date (name it with +lower-case letters, numbers and dashes). Restart Basking to see files added by hand. ## Security notes -- The password is stored as a PBKDF2-SHA256 hash and API tokens as SHA-256 hashes. `basking.json` is - written `0600`. Failed sign-ins are rate-limited per address. -- Sessions are `HttpOnly`, `SameSite=Strict` cookies that last 30 days, signed with a secret from the - settings file. They are not kept on the server, so a restart does not sign you out, and signing out - only clears this browser's cookie. Changing the password signs out every browser. -- Every browser change needs a header a page on another site can't send. The page has a strict - Content-Security-Policy and notes are never rendered as HTML. -- Notes are **not encrypted** on disk. Anyone who can read the folder can read your notes. +- Sign-in is Authentik only: the authorization code flow with PKCE, `state` and `nonce`, over https. The + ID token comes straight from Authentik's token endpoint, and its issuer, audience, expiry and nonce + are checked. Authentik and Basking must be reachable over https unless both are on the same machine. +- Sessions are `HttpOnly`, `SameSite=Lax` cookies that last 14 days, signed with the secret in the + settings file. Nothing is kept on the server, so a restart does not sign anyone out. The `allowed` + list is checked on every request. +- Each person's folder is named from a hash of who they are, never from anything they typed. One + person's requests can only ever reach their own folder. +- Every change from the browser needs a header a page on another site can't send. The page has a strict + Content-Security-Policy, and pages are never rendered as HTML. +- Pages are **not encrypted** on disk. Anyone who can read the server's disk can read them. ## Honest limits -- One person, one password. There are no accounts, sharing or real-time collaboration. -- No attachments or images, and no offline mode: it needs the server to be reachable. -- Searching holds every note in memory, which is comfortable for tens of thousands of notes and not +- No sharing or real-time collaboration: each notebook has one reader. +- No attachments or images, and no offline mode; it needs the server to be reachable. +- No scripts or tokens for posting notes from elsewhere: Authentik is the only way in. +- Searching holds every page in memory, which is comfortable for tens of thousands of pages and not built for millions. -- Notes are only reloaded from disk when Basking starts. +- Pages are only reloaded from disk when Basking starts. +- Write mode is plain markdown, not a block editor. Read mode shows how it looks. ## Design -The dashboard follows the Lightwell design language, the same one Hammerhead and Megalodon use. See +The interface follows the Lightwell design language, the same one Hammerhead and Megalodon use. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules a machine can check. ## Development ```sh go test ./... -go run ./cmd/basking serve +go run ./cmd/basking serve # with the BASKING_* variables set ``` Releases are built by the Gitea workflow when a `v*` tag is pushed. diff --git a/cmd/basking/main.go b/cmd/basking/main.go index 93001c9..14eaa16 100644 --- a/cmd/basking/main.go +++ b/cmd/basking/main.go @@ -1,16 +1,12 @@ -// Command basking is a place to dump thoughts, jot notes and keep a journal. Notes are plain -// markdown files; the dashboard is one small binary. +// Command basking is a private notebook: pages you can link, nest and search, kept as plain +// markdown files. You sign in with Authentik; there is no other way in. package main import ( - "bufio" - "bytes" "context" - "encoding/json" "errors" "flag" "fmt" - "io" "log" "net/http" "os" @@ -20,26 +16,32 @@ import ( "time" "git.veillax.com/Veillax/basking/internal/config" - "git.veillax.com/Veillax/basking/internal/store" + "git.veillax.com/Veillax/basking/internal/oidc" "git.veillax.com/Veillax/basking/internal/web" ) var version = "dev" -const usage = `basking is a place to dump thoughts, jot notes and keep a journal. +const usage = `basking is a private notebook that you sign in to with Authentik. - basking serve [-config basking.json] [-listen addr] [-data dir] - run the dashboard - basking password [-config ...] set the password (reads BASKING_PASSWORD or a line on stdin) - basking token [-config ...] NAME create an API token for scripts (shown once) - basking add [TEXT...] send a note to a running Basking; reads stdin if no text + basking serve [-config basking.json] [-listen addr] [-data dir] + run the notebook + basking check [-config basking.json] test the Authentik settings without starting basking version -"add" needs BASKING_URL (such as http://127.0.0.1:8490) and BASKING_TOKEN. -The settings path can also be set with BASKING_CONFIG, the address with BASKING_LISTEN and the -notes folder with BASKING_DATA. +Settings come from the settings file and can be overridden by environment variables: + BASKING_PUBLIC_URL where you open Basking, such as https://notes.example.com + BASKING_OIDC_ISSUER the Authentik provider's OpenID configuration issuer + BASKING_OIDC_CLIENT_ID the provider's Client ID + BASKING_OIDC_CLIENT_SECRET the provider's Client Secret + BASKING_ALLOWED optional: only these emails or usernames may sign in (comma separated) + BASKING_LISTEN, BASKING_DATA, BASKING_CONFIG ` +// exitConfig is the exit status for settings that need fixing by hand, so a service manager does +// not keep restarting something that cannot start. +const exitConfig = 2 + func main() { log.SetFlags(log.LstdFlags) if len(os.Args) < 2 { @@ -50,12 +52,8 @@ func main() { switch os.Args[1] { case "serve": err = serve(os.Args[2:]) - case "password": - err = passwordCmd(os.Args[2:]) - case "token": - err = tokenCmd(os.Args[2:]) - case "add": - err = addCmd(os.Args[2:]) + case "check": + err = check(os.Args[2:]) case "version", "-v", "--version": fmt.Println("basking", version) case "help", "-h", "--help": @@ -66,10 +64,16 @@ func main() { } if err != nil { fmt.Fprintln(os.Stderr, "basking:", err) + var ce configError + if errors.As(err, &ce) { + os.Exit(exitConfig) + } os.Exit(1) } } +type configError struct{ error } + func configFlag(fs *flag.FlagSet) *string { def := os.Getenv("BASKING_CONFIG") if def == "" { @@ -81,43 +85,34 @@ func configFlag(fs *flag.FlagSet) *string { func serve(args []string) error { fs := flag.NewFlagSet("serve", flag.ExitOnError) path := configFlag(fs) - listen := fs.String("listen", os.Getenv("BASKING_LISTEN"), "override the listen address") - data := fs.String("data", os.Getenv("BASKING_DATA"), "override the folder the notes are kept in") + listen := fs.String("listen", "", "override the listen address") + data := fs.String("data", "", "override the folder the notes are kept in") fs.Parse(args) + if *listen != "" { + os.Setenv("BASKING_LISTEN", *listen) + } + if *data != "" { + os.Setenv("BASKING_DATA", *data) + } h, err := config.Load(*path) if err != nil { - return err + return configError{err} } - // First run: make a password so the dashboard can be opened, and show it once. - if h.Get().PasswordHash == "" { - pw := config.RandomPassword() - hash, err := config.HashPassword(pw) - if err != nil { - return err + if !h.Exists() { // writing it now is what makes the generated secret stick + if err := h.Save(); err != nil { + return fmt.Errorf("could not write %s: %w", *path, err) } - if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil { - return err - } - log.Printf("first run: your password is %s (change it in Settings; written to %s)", pw, *path) } cfg := h.Get() - if *listen != "" { - cfg.Listen = *listen - } - if *data != "" { - cfg.DataDir = *data - } - - st, err := store.Open(cfg.DataDir) + web, err := web.New(h, version) if err != nil { - return err + return configError{err} } - counts, _ := st.Summary() srv := &http.Server{ Addr: cfg.Listen, - Handler: web.New(h, st, version).Handler(), + Handler: web.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, @@ -127,10 +122,21 @@ func serve(args []string) error { defer stop() errc := make(chan error, 1) go func() { errc <- srv.ListenAndServe() }() - log.Printf("basking %s: %d note(s) in %s; open http://%s", version, counts.All, cfg.DataDir, cfg.Listen) - if !strings.HasPrefix(cfg.Listen, "127.") && !strings.HasPrefix(cfg.Listen, "localhost") && !strings.HasPrefix(cfg.Listen, "[::1]") { - log.Print("note: this is not a loopback address; put it behind HTTPS (a reverse proxy or Hammerhead) and set cookie_secure") + log.Printf("basking %s: notes are kept in %s; open %s", version, cfg.DataDir, cfg.PublicURL) + log.Printf("sign-in goes through %s; the redirect URI to allow there is %s", cfg.OIDC.Issuer, cfg.RedirectURL()) + if len(cfg.Allowed) == 0 { + log.Print("anyone Authentik lets into this application gets a notebook of their own; set \"allowed\" to limit it") } + if !cfg.Secure() && !strings.Contains(cfg.PublicURL, "localhost") && !strings.Contains(cfg.PublicURL, "127.0.0.1") { + log.Print("warning: public_url is not https, and session cookies will not be marked Secure") + } + go func() { // say early if Authentik cannot be reached, instead of at the first sign-in + c, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if _, err := web.OIDC().Discover(c); err != nil { + log.Printf("warning: %v (sign-in will work once Authentik is reachable)", err) + } + }() select { case err := <-errc: @@ -142,104 +148,33 @@ func serve(args []string) error { return srv.Shutdown(sctx) } -func readPassword() (string, error) { - if pw := os.Getenv("BASKING_PASSWORD"); pw != "" { - return pw, nil - } - fmt.Fprint(os.Stderr, "new password: ") - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil && line == "" { - return "", errors.New("no password given") - } - return strings.TrimRight(line, "\r\n"), nil -} - -func passwordCmd(args []string) error { - fs := flag.NewFlagSet("password", flag.ExitOnError) +func check(args []string) error { + fs := flag.NewFlagSet("check", flag.ExitOnError) path := configFlag(fs) fs.Parse(args) - pw, err := readPassword() - if err != nil { - return err - } - if len(pw) < 8 { - return errors.New("the password must be at least 8 characters") - } - hash, err := config.HashPassword(pw) - if err != nil { - return err - } h, err := config.Load(*path) + if err != nil { + return configError{err} + } + cfg := h.Get() + if err := cfg.Check(); err != nil { + return configError{err} + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + d, err := oidc.New(cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.ClientSecret, cfg.RedirectURL()).Discover(ctx) if err != nil { return err } - if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil { - return err + fmt.Println("Authentik answered at", cfg.OIDC.Issuer) + fmt.Println(" sign-in page:", d.AuthorizationEndpoint) + fmt.Println(" token address:", d.TokenEndpoint) + fmt.Println("Make sure the provider in Authentik allows this redirect URI exactly:") + fmt.Println(" " + cfg.RedirectURL()) + if len(cfg.Allowed) == 0 { + fmt.Println("Everyone Authentik lets into the application gets their own notebook.") + } else { + fmt.Println("Only these may sign in:", strings.Join(cfg.Allowed, ", ")) } - fmt.Println("password saved; restart Basking if it is running, and everyone will be signed out") - return nil -} - -func tokenCmd(args []string) error { - fs := flag.NewFlagSet("token", flag.ExitOnError) - path := configFlag(fs) - fs.Parse(args) - if fs.NArg() != 1 { - return errors.New("expected NAME") - } - token, hash, err := config.NewToken() - if err != nil { - return err - } - h, err := config.Load(*path) - if err != nil { - return err - } - err = h.Update(func(c *config.Config) error { - c.Tokens = append(c.Tokens, config.Token{Name: fs.Arg(0), Hash: hash, Created: time.Now().UTC()}) - return nil - }) - if err == nil { - fmt.Println(token) - fmt.Fprintln(os.Stderr, "this is the only time the token is shown; send it as 'Authorization: Bearer '") - fmt.Fprintln(os.Stderr, "a running Basking picks it up after a restart") - } - return err -} - -func addCmd(args []string) error { - url, token := os.Getenv("BASKING_URL"), os.Getenv("BASKING_TOKEN") - if url == "" || token == "" { - return errors.New("set BASKING_URL and BASKING_TOKEN (make a token in Settings)") - } - text := strings.Join(args, " ") - if text == "" { - b, err := io.ReadAll(os.Stdin) - if err != nil { - return err - } - text = string(b) - } - if strings.TrimSpace(text) == "" { - return errors.New("nothing to add") - } - body, _ := json.Marshal(map[string]string{"body": text}) - req, err := http.NewRequest("POST", strings.TrimRight(url, "/")+"/api/notes", bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - var e struct{ Error string } - json.NewDecoder(resp.Body).Decode(&e) - return fmt.Errorf("%s: %s", resp.Status, e.Error) - } - fmt.Println("saved") return nil } diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9b9bce9..ea20083 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,10 +1,17 @@ -# Basking. Reach it through a reverse proxy or Hammerhead (HTTPS), and set "cookie_secure": true -# and "trust_proxy": true in basking.json when you do. +# Basking, a private notebook you sign in to with Authentik. Reach it through an HTTPS reverse proxy +# (or Hammerhead) that forwards to 127.0.0.1:8490, and give Authentik the redirect URI +# https://notes.example.com/auth/callback (use your own address). services: basking: image: git.veillax.com/veillax/basking:latest # or: build: .. restart: unless-stopped ports: - "127.0.0.1:8490:8490" + environment: + BASKING_PUBLIC_URL: https://notes.example.com + BASKING_OIDC_ISSUER: https://auth.example.com/application/o/basking/ + BASKING_OIDC_CLIENT_ID: change-me + BASKING_OIDC_CLIENT_SECRET: change-me + # BASKING_ALLOWED: you@example.com # optional: limit who may sign in volumes: - - ./config:/config # basking.json and data/ (your notes) live here + - ./config:/config # basking.json (the signing secret) and data/ (your pages) live here diff --git a/deploy/install.sh b/deploy/install.sh index a1f0436..d471394 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -137,8 +137,25 @@ fi if [ ! -f "$ENV_FILE" ]; then cat > "$ENV_FILE" < Providers > Create > OAuth2/OpenID Provider (client type +# Confidential, scopes openid, profile and email), allow the redirect URI +# /auth/callback +# then create an Application that uses it. Check the result with: basking check -# Where the settings file is (it holds the password hash and API tokens). +# Where you open Basking in a browser, such as https://notes.example.com (no path). +BASKING_PUBLIC_URL= +# The provider's issuer, such as https://auth.example.com/application/o/basking/ +BASKING_OIDC_ISSUER= +BASKING_OIDC_CLIENT_ID= +BASKING_OIDC_CLIENT_SECRET= + +# Optional. Only these people may sign in (emails or usernames, separated by commas). Left empty, +# everyone Authentik lets into the application gets a private notebook of their own. +#BASKING_ALLOWED=you@example.com + +# Where the settings file is (it holds the signing secret). BASKING_CONFIG=$CONF_FILE # Optional. These override what the settings file says. @@ -158,7 +175,7 @@ fi cat > "$UNIT" < 0 { + return fmt.Errorf("Basking signs in with Authentik, and these settings are still empty:\n%s\n\nIn Authentik, make an OAuth2/OpenID provider with the redirect URI %s/auth/callback.\nSee the README for the steps", strings.Join(missing, "\n"), orPlaceholder(c.PublicURL)) + } + if err := checkURL("public_url", c.PublicURL); err != nil { + return err + } + if u, _ := url.Parse(c.PublicURL); u.Path != "" { + return fmt.Errorf("public_url must not have a path (got %q)", c.PublicURL) + } + return checkURL("oidc.issuer", c.OIDC.Issuer) +} + +func orPlaceholder(s string) string { + if s == "" { + return "https://your-address" + } + return s +} + +// Holder keeps the settings file. Environment variables override what is in the file, but are +// never written back to it. type Holder struct { path string mu sync.RWMutex - cfg Config + file Config } // Load reads the file. A missing file gives the defaults (and is created by the first Update). @@ -89,14 +132,11 @@ func Load(path string) (*Holder, error) { case err != nil: return nil, err default: - if err := json.Unmarshal(b, &h.cfg); err != nil { + if err := json.Unmarshal(b, &h.file); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } } - h.cfg.Defaults() - if err := h.cfg.Validate(); err != nil { - return nil, fmt.Errorf("%s: %w", path, err) - } + h.file.Defaults() return h, nil } @@ -105,108 +145,49 @@ func (h *Holder) Path() string { return h.path } // Exists says whether the file is on disk yet. func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil } -// Get returns a private copy; change it freely. +// Get returns the settings with the environment applied. func (h *Holder) Get() Config { h.mu.RLock() - defer h.mu.RUnlock() - return clone(h.cfg) -} - -func clone(c Config) Config { - b, _ := json.Marshal(c) - var out Config - json.Unmarshal(b, &out) - return out -} - -// Update applies fn to a copy, validates it, saves it and only then makes it current. -func (h *Holder) Update(fn func(*Config) error) error { - h.mu.Lock() - defer h.mu.Unlock() - c := clone(h.cfg) - if err := fn(&c); err != nil { - return err + c := h.file + c.Allowed = append([]string{}, c.Allowed...) + h.mu.RUnlock() + env := func(name string, to *string) { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + *to = v + } + } + env("BASKING_LISTEN", &c.Listen) + env("BASKING_DATA", &c.DataDir) + env("BASKING_PUBLIC_URL", &c.PublicURL) + env("BASKING_OIDC_ISSUER", &c.OIDC.Issuer) + env("BASKING_OIDC_CLIENT_ID", &c.OIDC.ClientID) + env("BASKING_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret) + if v := os.Getenv("BASKING_ALLOWED"); strings.TrimSpace(v) != "" { + c.Allowed = nil + for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) { + if a = strings.TrimSpace(a); a != "" { + c.Allowed = append(c.Allowed, a) + } + } } c.Defaults() - if err := c.Validate(); err != nil { - return err - } - if err := save(h.path, c); err != nil { - return err - } - h.cfg = c - return nil + return c } -func save(path string, c Config) error { - b, err := json.MarshalIndent(c, "", " ") +// Save writes the file as it is now, which is what makes the generated secret stick. +func (h *Holder) Save() error { + h.mu.Lock() + defer h.mu.Unlock() + b, err := json.MarshalIndent(h.file, "", " ") if err != nil { return err } - if dir := filepath.Dir(path); dir != "." { + if dir := filepath.Dir(h.path); dir != "." { os.MkdirAll(dir, 0o755) } - tmp := path + ".tmp" + tmp := h.path + ".tmp" if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { return err } - return os.Rename(tmp, path) -} - -// ---- passwords and tokens ---- - -const pbkdf2Rounds = 600_000 - -// HashPassword returns "pbkdf2$rounds$salt$key". -func HashPassword(password string) (string, error) { - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - return "", err - } - key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32) - if err != nil { - return "", err - } - return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Rounds, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil -} - -// CheckPassword compares in constant time. An unparseable hash never matches. -func CheckPassword(hash, password string) bool { - parts := strings.Split(hash, "$") - if len(parts) != 4 || parts[0] != "pbkdf2" { - return false - } - rounds, err := strconv.Atoi(parts[1]) - if err != nil || rounds < 1 || rounds > 10_000_000 { - return false - } - salt, err1 := base64.RawStdEncoding.DecodeString(parts[2]) - want, err2 := base64.RawStdEncoding.DecodeString(parts[3]) - if err1 != nil || err2 != nil { - return false - } - got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want)) - return err == nil && subtle.ConstantTimeCompare(got, want) == 1 -} - -// NewToken makes an API token and the hash to store for it. The token itself is shown once. -func NewToken() (token, hash string, err error) { - b := make([]byte, 32) - if _, err = rand.Read(b); err != nil { - return - } - token = "bsk_" + base64.RawURLEncoding.EncodeToString(b) - return token, HashToken(token), nil -} - -func HashToken(token string) string { - sum := sha256.Sum256([]byte(token)) - return hex.EncodeToString(sum[:]) -} - -// RandomPassword makes a password for a first run. -func RandomPassword() string { - b := make([]byte, 12) - rand.Read(b) - return base64.RawURLEncoding.EncodeToString(b) + return os.Rename(tmp, h.path) } diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go new file mode 100644 index 0000000..fe7666b --- /dev/null +++ b/internal/oidc/oidc.go @@ -0,0 +1,234 @@ +// Package oidc is the small slice of OpenID Connect Basking needs: the authorization code flow +// with PKCE against one provider (Authentik), using only the standard library. +// +// The ID token is taken straight from the provider's token endpoint over TLS, which is the case +// OpenID Connect Core section 3.1.3.7 allows to skip checking its signature. Its issuer, audience, +// expiry and nonce are still checked. +package oidc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "git.veillax.com/Veillax/basking/internal/config" +) + +// ErrUnavailable means the provider could not be reached or answered nonsense, as opposed to +// rejecting the sign-in. +var ErrUnavailable = errors.New("the sign-in provider is not reachable") + +type Client struct { + issuer, id, secret, redirect string + http *http.Client + + mu sync.Mutex + disco *Discovery + fetched time.Time +} + +// Discovery is the part of the provider's metadata that is used. +type Discovery struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` +} + +type Identity struct { + Sub string + Email string + Name string + Username string +} + +func New(issuer, clientID, clientSecret, redirect string) *Client { + return &Client{ + issuer: strings.TrimRight(issuer, "/"), id: clientID, secret: clientSecret, redirect: redirect, + http: &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + } +} + +// Random returns n random bytes as URL-safe text. +func Random(n int) string { + b := make([]byte, n) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// Challenge is the PKCE S256 challenge for a verifier. +func Challenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func secureEnough(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return false + } + return u.Scheme == "https" || (u.Scheme == "http" && config.IsLocal(u)) +} + +// Discover fetches (and for an hour remembers) the provider's metadata. +func (c *Client) Discover(ctx context.Context) (*Discovery, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.disco != nil && time.Since(c.fetched) < time.Hour { + return c.disco, nil + } + req, _ := http.NewRequestWithContext(ctx, "GET", c.issuer+"/.well-known/openid-configuration", nil) + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %s answered %s (is the issuer address right?)", ErrUnavailable, req.URL, resp.Status) + } + var d Discovery + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&d); err != nil { + return nil, fmt.Errorf("%w: unreadable metadata: %v", ErrUnavailable, err) + } + if strings.TrimRight(d.Issuer, "/") != c.issuer { + return nil, fmt.Errorf("%w: the provider says its issuer is %q, not %q", ErrUnavailable, d.Issuer, c.issuer) + } + if !secureEnough(d.AuthorizationEndpoint) || !secureEnough(d.TokenEndpoint) { + return nil, fmt.Errorf("%w: the provider's endpoints are missing or not https", ErrUnavailable) + } + c.disco, c.fetched = &d, time.Now() + return c.disco, nil +} + +// AuthURL is where to send the browser to sign in. +func (c *Client) AuthURL(ctx context.Context, state, nonce, challenge string) (string, error) { + d, err := c.Discover(ctx) + if err != nil { + return "", err + } + u, _ := url.Parse(d.AuthorizationEndpoint) + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", c.id) + q.Set("redirect_uri", c.redirect) + q.Set("scope", "openid profile email") + q.Set("state", state) + q.Set("nonce", nonce) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + u.RawQuery = q.Encode() + return u.String(), nil +} + +type audience []string + +func (a *audience) UnmarshalJSON(b []byte) error { + var one string + if json.Unmarshal(b, &one) == nil { + *a = audience{one} + return nil + } + var many []string + if err := json.Unmarshal(b, &many); err != nil { + return err + } + *a = many + return nil +} + +type claims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud audience `json:"aud"` + Azp string `json:"azp"` + Exp float64 `json:"exp"` + Nonce string `json:"nonce"` + Email string `json:"email"` + Name string `json:"name"` + Username string `json:"preferred_username"` +} + +// Exchange trades the code from the redirect for who signed in. +func (c *Client) Exchange(ctx context.Context, code, verifier, nonce string) (Identity, error) { + d, err := c.Discover(ctx) + if err != nil { + return Identity{}, err + } + form := url.Values{ + "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {c.redirect}, + "client_id": {c.id}, "client_secret": {c.secret}, "code_verifier": {verifier}, + } + req, _ := http.NewRequestWithContext(ctx, "POST", d.TokenEndpoint, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return Identity{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer resp.Body.Close() + var tok struct { + IDToken string `json:"id_token"` + Error string `json:"error"` + Desc string `json:"error_description"` + } + json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok) + if resp.StatusCode != http.StatusOK || tok.Error != "" { + if resp.StatusCode >= 500 { + return Identity{}, fmt.Errorf("%w: token endpoint answered %s", ErrUnavailable, resp.Status) + } + return Identity{}, fmt.Errorf("the provider refused the code: %s %s (%s)", tok.Error, tok.Desc, resp.Status) + } + return c.verify(tok.IDToken, nonce, time.Now()) +} + +func (c *Client) verify(idToken, nonce string, now time.Time) (Identity, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return Identity{}, errors.New("the provider sent no ID token (is the openid scope enabled?)") + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) + if err != nil { + return Identity{}, errors.New("the ID token is not readable") + } + var cl claims + if err := json.Unmarshal(raw, &cl); err != nil { + return Identity{}, errors.New("the ID token is not readable") + } + switch { + case strings.TrimRight(cl.Iss, "/") != c.issuer: + return Identity{}, fmt.Errorf("the ID token is from %q, not %q", cl.Iss, c.issuer) + case !contains(cl.Aud, c.id): + return Identity{}, errors.New("the ID token is for a different application") + case len(cl.Aud) > 1 && cl.Azp != c.id: + return Identity{}, errors.New("the ID token was issued to a different application") + case cl.Exp == 0 || now.Unix() > int64(cl.Exp)+60: + return Identity{}, errors.New("the ID token has expired") + case nonce == "" || subtle.ConstantTimeCompare([]byte(cl.Nonce), []byte(nonce)) != 1: + return Identity{}, errors.New("the ID token does not match this sign-in attempt") + case cl.Sub == "": + return Identity{}, errors.New("the ID token has no subject") + } + return Identity{Sub: cl.Sub, Email: cl.Email, Name: cl.Name, Username: cl.Username}, nil +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/internal/store/store.go b/internal/store/store.go index 8ad3cee..b4b49ab 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,34 +1,44 @@ -// Package store keeps notes as plain markdown files, one per note, so they stay readable and -// portable with or without Basking. An in-memory index makes listing and searching quick. +// Package store keeps pages as plain markdown files, one per page, so they stay readable and +// portable with or without Basking. An in-memory index makes listing, searching and linking quick. package store import ( "archive/zip" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" + "slices" "sort" "strings" "sync" "time" + "unicode" + "unicode/utf8" ) -// MaxBody is the longest note, in bytes. -const MaxBody = 256 << 10 +// MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters. +const ( + MaxBody = 256 << 10 + MaxTitle = 120 +) var ( - ErrNotFound = errors.New("note not found") - ErrBadID = errors.New("bad note id") - ErrEmpty = errors.New("a note needs some text") - ErrTooLong = errors.New("that note is too long") - ErrConflict = errors.New("this note changed somewhere else") - ErrBadDate = errors.New("the date must look like 2026-10-31") - ErrNotTrash = errors.New("only a note in the trash can be deleted for good") + ErrNotFound = errors.New("page not found") + ErrBadID = errors.New("bad page id") + ErrEmpty = errors.New("a page needs a title or some text") + ErrTooLong = errors.New("that page is too long") + ErrConflict = errors.New("this page changed somewhere else") + ErrBadDate = errors.New("the date must look like 2026-10-31") + ErrNotTrash = errors.New("only a page in the trash can be deleted for good") + ErrTitleTaken = errors.New("another page already has that title") + ErrDailyTitle = errors.New("a journal day is named by its date") + ErrBadParent = errors.New("a page can only go inside another page, and not inside itself") ) type Note struct { @@ -36,12 +46,16 @@ type Note struct { Created time.Time `json:"created"` Updated time.Time `json:"updated"` Pinned bool `json:"pinned"` - Daily string `json:"daily,omitempty"` // the date, for a daily note + Daily string `json:"daily,omitempty"` // the date, for a journal day Trashed bool `json:"trashed,omitempty"` + Title string `json:"title"` + Name string `json:"name"` // what to call it: the title, else the first line + Parent string `json:"parent,omitempty"` Body string `json:"body"` Tags []string `json:"tags"` - lower string // lower-cased body, for searching + lower string // lower-cased title and body, for searching + links []string // lower-cased titles this page links to with [[...]] } type Store struct { @@ -50,12 +64,15 @@ type Store struct { by map[string]*Note } -var idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) +var ( + idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) + dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) -// ValidID says whether s can be used as a note id (and so as a file name). +// ValidID says whether s can be used as a page id (and so as a file name). func ValidID(s string) bool { return idRe.MatchString(s) } -// Open reads every note under dir, creating the folders if needed. +// Open reads every page under dir, creating the folders if needed. func Open(dir string) (*Store, error) { s := &Store{dir: dir, by: map[string]*Note{}} for _, sub := range []string{"notes", "trash"} { @@ -85,9 +102,32 @@ func Open(dir string) (*Store, error) { s.by[id] = n } } + s.mend() return s, nil } +// mend drops parents that point nowhere, or in a circle, so a hand-edited file cannot break the tree. +func (s *Store) mend() { + for _, n := range s.by { + if n.Parent == "" { + continue + } + p, ok := s.by[n.Parent] + if !ok || (p.Trashed && !n.Trashed) || n.Daily != "" { + n.Parent = "" + continue + } + seen := map[string]bool{n.ID: true} + for cur := p; cur != nil; cur = s.by[cur.Parent] { + if seen[cur.ID] { + n.Parent = "" + break + } + seen[cur.ID] = true + } + } +} + func (s *Store) path(n *Note) string { sub := "notes" if n.Trashed { @@ -99,17 +139,26 @@ func (s *Store) path(n *Note) string { // ---- file format ---- // // --- +// title: "Trip to Lisbon" +// parent: 20261031-143205-a1b2c3 // created: 2026-10-31T14:32:05.123Z // updated: 2026-10-31T14:35:00.456Z // pinned: true // daily: 2026-10-31 // --- // -// the note, in markdown +// the page, in markdown func format(n *Note) []byte { var b strings.Builder b.WriteString("---\n") + if n.Title != "" && n.Title != n.Daily { + t, _ := json.Marshal(n.Title) + fmt.Fprintf(&b, "title: %s\n", t) + } + if n.Parent != "" { + fmt.Fprintf(&b, "parent: %s\n", n.Parent) + } fmt.Fprintf(&b, "created: %s\n", n.Created.UTC().Format(time.RFC3339Nano)) fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339Nano)) if n.Pinned { @@ -124,7 +173,7 @@ func format(n *Note) []byte { return []byte(b.String()) } -// parse reads a note file. A file with no header is still a note: it takes its time from the file. +// parse reads a page file. A file with no header is still a page: it takes its time from the file. func parse(id, text string, mod time.Time) *Note { text = strings.ReplaceAll(text, "\r\n", "\n") n := &Note{ID: id, Created: mod.UTC(), Updated: mod.UTC()} @@ -137,6 +186,17 @@ func parse(id, text string, mod time.Time) *Note { k, v, _ := strings.Cut(line, ":") v = strings.TrimSpace(v) switch strings.TrimSpace(k) { + case "title": + var t string + if strings.HasPrefix(v, `"`) && json.Unmarshal([]byte(v), &t) == nil { + n.Title = CleanTitle(t) + } else { + n.Title = CleanTitle(v) + } + case "parent": + if ValidID(v) { + n.Parent = v + } case "created": if t, err := time.Parse(time.RFC3339Nano, v); err == nil { n.Created = t.UTC() @@ -156,25 +216,74 @@ func parse(id, text string, mod time.Time) *Note { text = strings.TrimPrefix(body, "\n") } } + if n.Daily != "" { + n.Title = n.Daily + n.Parent = "" + } n.set(text) return n } -// set stores the body (without trailing blank space) and works out the tags. +// CleanTitle makes a title safe to link to: one line, no brackets or bars, not too long. +func CleanTitle(s string) string { + s = strings.Map(func(r rune) rune { + switch { + case r == '[' || r == ']' || r == '|': + return -1 + case unicode.IsControl(r): + return ' ' + } + return r + }, s) + s = strings.Join(strings.Fields(s), " ") + if utf8.RuneCountInString(s) > MaxTitle { + s = strings.TrimSpace(string([]rune(s)[:MaxTitle])) + } + return s +} + +// set stores the body (without trailing blank space) and works out what is derived from it. func (n *Note) set(body string) { n.Body = strings.TrimRight(strings.ReplaceAll(body, "\r\n", "\n"), " \t\n") - n.lower = strings.ToLower(n.Body) + n.refresh() +} + +func (n *Note) refresh() { + n.lower = strings.ToLower(n.Title + "\n" + n.Body) n.Tags = Tags(n.Body) + n.links = Links(n.Body) + n.Name = nameOf(n) +} + +var leadRe = regexp.MustCompile(`^(?:#{1,6}\s+|>\s*|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)+`) + +func nameOf(n *Note) string { + if n.Title != "" { + return n.Title + } + for _, l := range strings.Split(n.Body, "\n") { + l = strings.TrimSpace(leadRe.ReplaceAllString(strings.TrimSpace(l), "")) + if l == "" || strings.HasPrefix(l, "```") { + continue + } + l = strings.NewReplacer("[[", "", "]]", "", "**", "", "`", "").Replace(l) + if r := []rune(l); len(r) > 60 { + l = strings.TrimSpace(string(r[:60])) + "..." + } + return l + } + return "Untitled" } var ( tagRe = regexp.MustCompile(`(?:^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)`) inlineCodeRe = regexp.MustCompile("`[^`\n]*`") + linkRe = regexp.MustCompile(`\[\[([^\[\]|\n]+?)(?:\|[^\[\]\n]*)?\]\]`) ) -// Tags returns the #tags in a note, lower-cased and without repeats. Code is skipped, and so are -// numbers such as #12. -func Tags(body string) []string { +// withoutCode is the body with fenced and inline code taken out, so tags and links inside code +// are not counted. +func withoutCode(body string) string { var lines []string fence := false for _, l := range strings.Split(body, "\n") { @@ -186,9 +295,15 @@ func Tags(body string) []string { lines = append(lines, inlineCodeRe.ReplaceAllString(l, "")) } } + return strings.Join(lines, "\n") +} + +// Tags returns the #tags in a page, lower-cased and without repeats. Code is skipped, and so are +// numbers such as #12. +func Tags(body string) []string { seen := map[string]bool{} out := []string{} - for _, m := range tagRe.FindAllStringSubmatch(strings.Join(lines, "\n"), -1) { + for _, m := range tagRe.FindAllStringSubmatch(withoutCode(body), -1) { t := strings.ToLower(strings.TrimRight(m[1], "/-")) if t == "" || len(t) > 40 || isNumber(t) || seen[t] { continue @@ -199,6 +314,19 @@ func Tags(body string) []string { return out } +// Links returns the titles a page links to with [[Title]] or [[Title|text]], lower-cased and +// without repeats. +func Links(body string) []string { + var out []string + for _, m := range linkRe.FindAllStringSubmatch(withoutCode(body), -1) { + t := strings.ToLower(strings.TrimSpace(m[1])) + if t != "" && !slices.Contains(out, t) { + out = append(out, t) + } + } + return out +} + func isNumber(s string) bool { for _, r := range s { if r < '0' || r > '9' { @@ -236,21 +364,81 @@ func checkBody(body string) error { return nil } +// moved keeps updated moving forward, so a stale tab is always noticed. +func moved(prev time.Time) time.Time { + now := time.Now().UTC() + if !now.After(prev) { + return prev.Add(time.Microsecond) + } + return now +} + // ---- changes ---- -// Create saves a new note. -func (s *Store) Create(body string) (Note, error) { - if err := checkBody(body); err != nil { +// titleTaken says whether another live page has this title. Dates belong to the journal. +func (s *Store) titleTaken(title, except string) bool { + l := strings.ToLower(title) + if dateRe.MatchString(title) && !strings.HasPrefix(except, "daily-") { + return true + } + for id, n := range s.by { + if id != except && !n.Trashed && n.Title != "" && strings.ToLower(n.Title) == l { + return true + } + } + return false +} + +// underneath says whether id is parent or lies somewhere below it. +func (s *Store) underneath(id, parent string) bool { + for cur, hops := parent, 0; cur != "" && hops < 200; hops++ { + if cur == id { + return true + } + n, ok := s.by[cur] + if !ok { + return false + } + cur = n.Parent + } + return false +} + +func (s *Store) checkParent(id, parent string) error { + if parent == "" { + return nil + } + p, ok := s.by[parent] + if !ok || p.Trashed || p.Daily != "" || s.underneath(id, parent) { + return ErrBadParent + } + return nil +} + +type NewNote struct { + Title, Body, Parent string +} + +// Create saves a new page. +func (s *Store) Create(in NewNote) (Note, error) { + if err := checkBody(in.Body); err != nil { return Note{}, err } - if strings.TrimSpace(body) == "" { + title := CleanTitle(in.Title) + if title == "" && strings.TrimSpace(in.Body) == "" { return Note{}, ErrEmpty } now := time.Now().UTC() - n := &Note{ID: newID(now), Created: now, Updated: now} - n.set(body) + n := &Note{ID: newID(now), Created: now, Updated: now, Title: title, Parent: in.Parent} + n.set(in.Body) s.mu.Lock() defer s.mu.Unlock() + if title != "" && s.titleTaken(title, n.ID) { + return Note{}, ErrTitleTaken + } + if err := s.checkParent(n.ID, n.Parent); err != nil { + return Note{}, err + } if err := s.write(n); err != nil { return Note{}, err } @@ -258,7 +446,7 @@ func (s *Store) Create(body string) (Note, error) { return n.copy(), nil } -// Daily returns the note for a date (like 2026-10-31), making it if it is not there yet. +// Daily returns the page for a date (like 2026-10-31), making it if it is not there yet. func (s *Store) Daily(date string) (Note, error) { if _, err := time.Parse("2006-01-02", date); err != nil { return Note{}, ErrBadDate @@ -270,15 +458,17 @@ func (s *Store) Daily(date string) (Note, error) { if n.Trashed { // asking for the day brings it back old := s.path(n) n.Trashed = false - if err := os.Rename(old, s.path(n)); err != nil { + if err := s.write(n); err != nil { n.Trashed = true return Note{}, err } + os.Remove(old) } return n.copy(), nil } now := time.Now().UTC() - n := &Note{ID: id, Created: now, Updated: now, Daily: date} + n := &Note{ID: id, Created: now, Updated: now, Daily: date, Title: date} + n.refresh() if err := s.write(n); err != nil { return Note{}, err } @@ -296,41 +486,143 @@ func (s *Store) Get(id string) (Note, error) { return n.copy(), nil } -// Update changes the text and/or the pin. When base is given, the text is only replaced if the -// note has not been changed since then; otherwise the current note comes back with ErrConflict. -func (s *Store) Update(id string, body *string, pinned *bool, base time.Time) (Note, error) { +// Change is an update; fields that are nil stay as they are. +type Change struct { + Title, Body, Parent *string + Pinned *bool +} + +// Update changes a page. When base is given, the title and text are only replaced if the page has +// not been changed since then; otherwise the current page comes back with ErrConflict. Everything is +// checked before anything is written. +func (s *Store) Update(id string, ch Change, base time.Time) (Note, error) { s.mu.Lock() defer s.mu.Unlock() n, ok := s.by[id] if !ok { return Note{}, ErrNotFound } - if body != nil { - if err := checkBody(*body); err != nil { + next := n.copy() + content := false + if ch.Body != nil { + if err := checkBody(*ch.Body); err != nil { return Note{}, err } - if !base.IsZero() && !n.Updated.Equal(base) { - return n.copy(), ErrConflict + content = true + } + if ch.Title != nil { + t := CleanTitle(*ch.Title) + if t != n.Title { + if n.Daily != "" { + return Note{}, ErrDailyTitle + } + if t != "" && s.titleTaken(t, id) { + return Note{}, ErrTitleTaken + } + next.Title = t + content = true } } - next := n.copy() - if body != nil { - next.set(*body) - next.Updated = time.Now().UTC() - if !next.Updated.After(n.Updated) { // keep updated moving forward, so a stale tab is always noticed - next.Updated = n.Updated.Add(time.Microsecond) - } + if content && !base.IsZero() && !n.Updated.Equal(base) { + return n.copy(), ErrConflict } - if pinned != nil { - next.Pinned = *pinned + if ch.Parent != nil { + if n.Daily != "" && *ch.Parent != "" { + return Note{}, ErrBadParent + } + if err := s.checkParent(id, *ch.Parent); err != nil { + return Note{}, err + } + next.Parent = *ch.Parent + } + if ch.Pinned != nil { + next.Pinned = *ch.Pinned + } + if ch.Body != nil { + next.set(*ch.Body) + } else { + next.refresh() + } + if content { + next.Updated = moved(n.Updated) } if err := s.write(&next); err != nil { return Note{}, err } + old := n.Title *n = next + if old != "" && n.Title != old { + if err := s.relink(old, n.Title, id); err != nil { + return n.copy(), err + } + } return n.copy(), nil } +// relink points the [[old]] links of other pages at a renamed page. It rewrites nothing inside code. +func (s *Store) relink(oldTitle, newTitle, except string) error { + if newTitle == "" { + return nil + } + old := strings.ToLower(oldTitle) + for _, m := range s.by { + if m.ID == except || m.Trashed || !slices.Contains(m.links, old) { + continue + } + body := rewriteLinks(m.Body, old, newTitle) + 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 +} + +func rewriteLinks(body, old, newTitle string) string { + lines := strings.Split(body, "\n") + fence := false + for i, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "```") { + fence = !fence + continue + } + if fence { + continue + } + lines[i] = linkRe.ReplaceAllStringFunc(l, func(x string) string { + sub := linkRe.FindStringSubmatch(x) + if strings.ToLower(strings.TrimSpace(sub[1])) != old { + return x + } + alias := "" + if k := strings.Index(x, "|"); k >= 0 { + alias = x[k : len(x)-2] + } + return "[[" + newTitle + alias + "]]" + }) + } + return strings.Join(lines, "\n") +} + +// free finds a title that no live page has, by adding a number if it must. +func (s *Store) free(title, except string) string { + if title == "" || !s.titleTaken(title, except) { + return title + } + for i := 2; ; i++ { + t := fmt.Sprintf("%s %d", title, i) + if !s.titleTaken(t, except) { + return t + } + } +} + func (s *Store) move(id string, toTrash bool) (Note, error) { s.mu.Lock() defer s.mu.Unlock() @@ -342,19 +634,44 @@ func (s *Store) move(id string, toTrash bool) (Note, error) { return n.copy(), nil } old := s.path(n) + prev := n.copy() n.Trashed = toTrash - if err := os.Rename(old, s.path(n)); err != nil { - n.Trashed = !toTrash + if toTrash { + // Pages inside it move up a level, so nothing is lost with it. + for _, c := range s.by { + if c.Parent == id && !c.Trashed { + next := c.copy() + next.Parent = n.Parent + if err := s.write(&next); err != nil { + *n = prev + return Note{}, err + } + *c = next + } + } + } else { + if p, ok := s.by[n.Parent]; n.Parent != "" && (!ok || p.Trashed) { + n.Parent = "" + } + n.Title = s.free(n.Title, id) + if n.Daily != "" { + n.Title = n.Daily + } + n.refresh() + } + if err := s.write(n); err != nil { + *n = prev return Note{}, err } + os.Remove(old) return n.copy(), nil } -// Trash moves a note to the trash. Restore brings it back. +// Trash moves a page to the trash. Restore brings it back. func (s *Store) Trash(id string) (Note, error) { return s.move(id, true) } func (s *Store) Restore(id string) (Note, error) { return s.move(id, false) } -// Purge deletes a note that is already in the trash, for good. +// Purge deletes a page that is already in the trash, for good. func (s *Store) Purge(id string) error { s.mu.Lock() defer s.mu.Unlock() @@ -372,7 +689,7 @@ func (s *Store) Purge(id string) error { return nil } -// EmptyTrash deletes everything in the trash for good and says how many notes that was. +// EmptyTrash deletes everything in the trash for good and says how many pages that was. func (s *Store) EmptyTrash() (int, error) { s.mu.Lock() defer s.mu.Unlock() @@ -400,16 +717,9 @@ type Query struct { Limit int } -// List returns one page of matching notes, newest first, and how many matched in all. +// List returns one page of matching pages, newest first, and how many matched in all. func (s *Store) List(q Query) ([]Note, int) { - var words, tags []string - for _, w := range strings.Fields(strings.ToLower(q.Text)) { - if t := strings.TrimPrefix(w, "#"); t != w && t != "" { - tags = append(tags, t) - } else { - words = append(words, w) - } - } + words, tags := splitQuery(q.Text) if t := strings.ToLower(strings.TrimPrefix(q.Tag, "#")); t != "" { tags = append(tags, t) } @@ -449,6 +759,17 @@ func (s *Store) List(q Query) ([]Note, int) { return out, total } +func splitQuery(text string) (words, tags []string) { + for _, w := range strings.Fields(strings.ToLower(text)) { + if t := strings.TrimPrefix(w, "#"); t != w && t != "" { + tags = append(tags, t) + } else { + words = append(words, w) + } + } + return +} + func matches(n *Note, words, tags []string) bool { for _, w := range words { if !strings.Contains(n.lower, w) { @@ -470,6 +791,162 @@ func matches(n *Note, words, tags []string) bool { return true } +// Ref is a page as the sidebar and link pickers see it: no text. +type Ref struct { + ID string `json:"id"` + Title string `json:"title"` + Name string `json:"name"` + Parent string `json:"parent,omitempty"` + Daily string `json:"daily,omitempty"` + Pinned bool `json:"pinned,omitempty"` +} + +func ref(n *Note) Ref { + return Ref{ID: n.ID, Title: n.Title, Name: n.Name, Parent: n.Parent, Daily: n.Daily, Pinned: n.Pinned} +} + +// Pages lists the pages worth showing in navigation: those with a title, those inside another page or +// holding one, the pinned ones, and journal days with something written. Untitled quick notes are left +// out, so jotting things down never clutters the sidebar. +func (s *Store) Pages() []Ref { + s.mu.RLock() + defer s.mu.RUnlock() + holders := map[string]bool{} + for _, n := range s.by { + if !n.Trashed && n.Parent != "" { + holders[n.Parent] = true + } + } + out := []Ref{} + for _, n := range s.by { + switch { + case n.Trashed: + case n.Daily != "": + if n.Body != "" { + out = append(out, ref(n)) + } + case n.Title != "" || n.Parent != "" || n.Pinned || holders[n.ID]: + out = append(out, ref(n)) + } + } + sort.Slice(out, func(i, j int) bool { + a, b := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name) + if a != b { + return a < b + } + return out[i].ID < out[j].ID + }) + return out +} + +// Backlinks lists the pages that link to this one with [[its title]]. +func (s *Store) Backlinks(id string) []Ref { + s.mu.RLock() + defer s.mu.RUnlock() + n, ok := s.by[id] + out := []Ref{} + if !ok || n.Title == "" { + return out + } + want := strings.ToLower(n.Title) + for _, m := range s.by { + if m.ID != id && !m.Trashed && slices.Contains(m.links, want) { + out = append(out, ref(m)) + } + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) + return out +} + +// Hit is a search result for the quick switcher. +type Hit struct { + Ref + Snippet string `json:"snippet,omitempty"` +} + +// Find searches titles and text for the quick switcher and the [[ picker. Title matches come first. +// With no query it returns the pages touched most recently. +func (s *Store) Find(q string, limit int) []Hit { + words, _ := splitQuery(q) + whole := strings.ToLower(strings.TrimSpace(q)) + type scored struct { + n *Note + score int + } + s.mu.RLock() + defer s.mu.RUnlock() + var hits []scored + for _, n := range s.by { + if n.Trashed { + continue + } + if len(words) == 0 { + hits = append(hits, scored{n, 0}) + continue + } + if !matches(n, words, nil) { + continue + } + t, score := strings.ToLower(n.Name), 3 + switch { + case t == whole: + score = 0 + case strings.HasPrefix(t, whole): + score = 1 + case strings.Contains(t, whole) || allIn(t, words): + score = 2 + } + hits = append(hits, scored{n, score}) + } + sort.Slice(hits, func(i, j int) bool { + a, b := hits[i], hits[j] + if a.score != b.score { + return a.score < b.score + } + if !a.n.Updated.Equal(b.n.Updated) { + return a.n.Updated.After(b.n.Updated) + } + return a.n.ID < b.n.ID + }) + if limit > 0 && len(hits) > limit { + hits = hits[:limit] + } + out := make([]Hit, len(hits)) + for i, h := range hits { + out[i] = Hit{Ref: ref(h.n)} + if h.score == 3 { + out[i].Snippet = snippet(h.n.Body, words) + } + } + return out +} + +func allIn(s string, words []string) bool { + for _, w := range words { + if !strings.Contains(s, w) { + return false + } + } + return true +} + +// snippet is the first line that has one of the words in it, cut to a sensible length. +func snippet(body string, words []string) string { + for _, l := range strings.Split(body, "\n") { + low := strings.ToLower(l) + for _, w := range words { + if strings.Contains(low, w) { + l = strings.TrimSpace(l) + if r := []rune(l); len(r) > 100 { + l = string(r[:100]) + "..." + } + return l + } + } + } + return "" +} + type TagCount struct { Tag string `json:"tag"` Count int `json:"count"` @@ -482,7 +959,7 @@ type Counts struct { Trash int `json:"trash"` } -// Summary counts the notes and the tags in use (trashed notes do not count towards tags). +// Summary counts the pages and the tags in use (trashed pages do not count towards tags). func (s *Store) Summary() (Counts, []TagCount) { s.mu.RLock() defer s.mu.RUnlock() @@ -517,27 +994,63 @@ func (s *Store) Summary() (Counts, []TagCount) { return c, tags } -// WriteZip writes every note that is not in the trash as a .md file in a zip archive. +var badFile = strings.NewReplacer("/", "-", `\`, "-", ":", "-", "*", "-", "?", "-", `"`, "-", "<", "-", ">", "-", "|", "-", "#", "-", "^", "-") + +func fileName(n *Note) string { + name := strings.Trim(badFile.Replace(n.Name), ". ") + if name == "" { + name = "Untitled" + } + if n.Daily != "" { + return "Journal/" + n.Daily + } + return name +} + +// WriteZip writes every page that is not in the trash as a .md file in a zip archive that opens as a +// vault in Obsidian: a page's file name is its title, so [[links]] keep working, and journal days sit +// in a Journal folder. func (s *Store) WriteZip(w io.Writer) error { s.mu.RLock() defer s.mu.RUnlock() - ids := make([]string, 0, len(s.by)) - for id, n := range s.by { + all := make([]*Note, 0, len(s.by)) + for _, n := range s.by { if !n.Trashed { - ids = append(ids, id) + all = append(all, n) } } - sort.Strings(ids) + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + used := map[string]bool{} zw := zip.NewWriter(w) - for _, id := range ids { - n := s.by[id] - f, err := zw.CreateHeader(&zip.FileHeader{Name: "basking/" + id + ".md", Method: zip.Deflate, Modified: n.Updated}) + for _, n := range all { + base := fileName(n) + name := base + for i := 2; used[strings.ToLower(name)]; i++ { + name = fmt.Sprintf("%s %d", base, i) + } + used[strings.ToLower(name)] = true + f, err := zw.CreateHeader(&zip.FileHeader{Name: "basking/" + name + ".md", Method: zip.Deflate, Modified: n.Updated}) if err != nil { return err } - if _, err := f.Write(format(n)); err != nil { + if _, err := f.Write(exportFormat(n)); err != nil { return err } } return zw.Close() } + +// exportFormat is the page with a header Obsidian understands. +func exportFormat(n *Note) []byte { + var b strings.Builder + b.WriteString("---\n") + fmt.Fprintf(&b, "created: %s\n", n.Created.UTC().Format(time.RFC3339)) + fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339)) + if n.Pinned { + b.WriteString("pinned: true\n") + } + b.WriteString("---\n\n") + b.WriteString(n.Body) + b.WriteString("\n") + return []byte(b.String()) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0e77d5c..d546e05 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -22,6 +22,17 @@ func open(t *testing.T) (*Store, string) { return s, dir } +func mk(t *testing.T, s *Store, title, body string) Note { + t.Helper() + n, err := s.Create(NewNote{Title: title, Body: body}) + if err != nil { + t.Fatal(err) + } + return n +} + +func str(s string) *string { return &s } + func TestTags(t *testing.T) { cases := []struct { in string @@ -41,14 +52,47 @@ func TestTags(t *testing.T) { } } +func TestLinks(t *testing.T) { + got := Links("see [[Trip Plan]] and [[trip plan|the plan]] and [[ Ideas ]], not `[[code]]` or\n```\n[[fenced]]\n```\n[[]] [[a|b]]") + want := []string{"trip plan", "ideas", "a"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Links = %v, want %v", got, want) + } +} + +func TestNameComesFromTitleOrFirstLine(t *testing.T) { + s, _ := open(t) + cases := []struct{ title, body, want string }{ + {"Trip", "ignored", "Trip"}, + {"", "# Heading line\nmore", "Heading line"}, + {"", "- [ ] buy **milk** [[Shop]]", "buy milk Shop"}, + {"", "\n\n plain \n", "plain"}, + {"", strings.Repeat("word ", 30), strings.TrimSpace(strings.Repeat("word ", 30)[:60]) + "..."}, + } + for _, c := range cases { + n := mk(t, s, c.title, c.body) + if n.Name != c.want { + t.Errorf("name of %q/%q = %q, want %q", c.title, c.body, n.Name, c.want) + } + } + if _, err := s.Create(NewNote{}); !errors.Is(err, ErrEmpty) { + t.Fatalf("empty page: %v", err) + } + only := mk(t, s, "Just a title", "") + if only.Name != "Just a title" { + t.Fatal("a page can be only a title") + } +} + func TestCreateRoundTripsThroughDisk(t *testing.T) { s, dir := open(t) - n, err := s.Create("first thought #idea\n\nwith a second paragraph \n\n") + parent := mk(t, s, "Projects", "") + n, err := s.Create(NewNote{Title: " Basking: a \"notebook\" [x]|y ", Body: "first thought #idea\n\nwith a second paragraph \n\n", Parent: parent.ID}) if err != nil { t.Fatal(err) } - if n.Body != "first thought #idea\n\nwith a second paragraph" { - t.Fatalf("body %q", n.Body) + if n.Body != "first thought #idea\n\nwith a second paragraph" || n.Title != `Basking: a "notebook" xy` { + t.Fatalf("clean-up: %q %q", n.Body, n.Title) } if !ValidID(n.ID) { t.Fatalf("id %q", n.ID) @@ -56,7 +100,6 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil { t.Fatal(err) } - s2, err := Open(dir) if err != nil { t.Fatal(err) @@ -65,23 +108,20 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) { if err != nil { t.Fatal(err) } - if got.Body != n.Body || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) { + if got.Body != n.Body || got.Title != n.Title || got.Parent != parent.ID || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) { t.Fatalf("reloaded %+v, want %+v", got, n) } } -func TestCreateRejectsEmptyAndHuge(t *testing.T) { +func TestCreateRejectsHuge(t *testing.T) { s, _ := open(t) - if _, err := s.Create(" \n\t"); !errors.Is(err, ErrEmpty) { - t.Fatalf("empty: %v", err) - } - if _, err := s.Create(strings.Repeat("x", MaxBody+1)); !errors.Is(err, ErrTooLong) { + if _, err := s.Create(NewNote{Body: strings.Repeat("x", MaxBody+1)}); !errors.Is(err, ErrTooLong) { t.Fatalf("huge: %v", err) } } -func TestPlainMarkdownFileIsANote(t *testing.T) { - s, dir := open(t) +func TestPlainMarkdownFileIsAPage(t *testing.T) { + _, dir := open(t) path := filepath.Join(dir, "notes", "from-elsewhere.md") os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644) mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC) @@ -94,44 +134,151 @@ func TestPlainMarkdownFileIsANote(t *testing.T) { if err != nil { t.Fatal(err) } - if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) { + if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) || n.Name != "Dropped in" { t.Fatalf("%+v", n) } - _ = s } func TestUpdateConflictAndPin(t *testing.T) { s, _ := open(t) - n, _ := s.Create("one") - body := "two" - n2, err := s.Update(n.ID, &body, nil, n.Updated) + n := mk(t, s, "", "one") + n2, err := s.Update(n.ID, Change{Body: str("two")}, n.Updated) if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) { t.Fatalf("update: %v %+v", err, n2) } - stale := "three" - cur, err := s.Update(n.ID, &stale, nil, n.Updated) + cur, err := s.Update(n.ID, Change{Body: str("three")}, n.Updated) if !errors.Is(err, ErrConflict) || cur.Body != "two" { t.Fatalf("stale write: %v %+v", err, cur) } + if _, err := s.Update(n.ID, Change{Title: str("Late title")}, n.Updated); !errors.Is(err, ErrConflict) { + t.Fatalf("a stale title should conflict too: %v", err) + } yes := true - n3, err := s.Update(n.ID, nil, &yes, time.Time{}) + n3, err := s.Update(n.ID, Change{Pinned: &yes}, time.Time{}) if err != nil || !n3.Pinned || !n3.Updated.Equal(n2.Updated) { t.Fatalf("pin should not move updated: %v %+v", err, n3) } - // a write without a base always goes through (scripts appending) - if _, err := s.Update(n.ID, &stale, nil, time.Time{}); err != nil { + if _, err := s.Update(n.ID, Change{Body: str("four")}, time.Time{}); err != nil { + t.Fatal(err) // a write without a base always goes through + } + if _, err := s.Update("nope", Change{Body: str("x")}, time.Time{}); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing: %v", err) + } +} + +func TestTitlesAreUnique(t *testing.T) { + s, _ := open(t) + a := mk(t, s, "Trip Plan", "") + if _, err := s.Create(NewNote{Title: "trip plan"}); !errors.Is(err, ErrTitleTaken) { + t.Fatalf("same title, other case: %v", err) + } + b := mk(t, s, "Other", "") + if _, err := s.Update(b.ID, Change{Title: str("TRIP PLAN")}, time.Time{}); !errors.Is(err, ErrTitleTaken) { + t.Fatalf("rename onto a taken title: %v", err) + } + if got, _ := s.Get(b.ID); got.Title != "Other" { + t.Fatal("a refused rename must change nothing") + } + if _, err := s.Update(a.ID, Change{Title: str("trip plan")}, time.Time{}); err != nil { + t.Fatalf("a page may change the case of its own title: %v", err) + } + if _, err := s.Create(NewNote{Title: "2026-10-31"}); !errors.Is(err, ErrTitleTaken) { + t.Fatalf("dates belong to the journal: %v", err) + } + if _, err := s.Update(b.ID, Change{Title: str("")}, time.Time{}); err != nil { + t.Fatalf("clearing a title: %v", err) + } +} + +func TestRenameRewritesLinks(t *testing.T) { + s, _ := open(t) + target := mk(t, s, "Trip Plan", "details") + a := mk(t, s, "", "see [[Trip Plan]] and [[trip plan|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Trip Plan]]`") + b := mk(t, s, "", "nothing here, [[Elsewhere]]") + if _, err := s.Update(target.ID, Change{Title: str("Lisbon Trip")}, time.Time{}); err != nil { t.Fatal(err) } - if _, err := s.Update("nope", &stale, nil, time.Time{}); !errors.Is(err, ErrNotFound) { - t.Fatalf("missing: %v", err) + got, _ := s.Get(a.ID) + want := "see [[Lisbon Trip]] and [[Lisbon Trip|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Lisbon Trip]]`" + if got.Body != want { + t.Fatalf("body:\n%s\nwant:\n%s", got.Body, want) + } + if !got.Updated.After(a.Updated) { + t.Fatal("a rewritten page should move forward so open editors notice") + } + if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) { + t.Fatal("unrelated pages must not be touched") + } + s2, _ := Open(s.dir) + if again, _ := s2.Get(a.ID); again.Body != want { + t.Fatal("rewrite was not saved") + } +} + +func TestBacklinks(t *testing.T) { + s, _ := open(t) + target := mk(t, s, "Ideas", "") + mk(t, s, "Zeta", "mentions [[ideas]]") + mk(t, s, "Alpha", "also [[Ideas|my ideas]]") + mk(t, s, "Nope", "[[Something else]]") + gone := mk(t, s, "Deleted", "[[Ideas]]") + s.Trash(gone.ID) + got := s.Backlinks(target.ID) + if len(got) != 2 || got[0].Name != "Alpha" || got[1].Name != "Zeta" { + t.Fatalf("backlinks %+v", got) + } + if b := s.Backlinks(mk(t, s, "", "no title").ID); len(b) != 0 { + t.Fatal("an untitled page cannot be linked to") + } +} + +func TestTreeParentsAndCycles(t *testing.T) { + s, dir := open(t) + a := mk(t, s, "A", "") + b, _ := s.Create(NewNote{Title: "B", Parent: a.ID}) + c, _ := s.Create(NewNote{Title: "C", Parent: b.ID}) + if _, err := s.Update(a.ID, Change{Parent: &c.ID}, time.Time{}); !errors.Is(err, ErrBadParent) { + t.Fatalf("a page inside its own descendant: %v", err) + } + if _, err := s.Update(a.ID, Change{Parent: &a.ID}, time.Time{}); !errors.Is(err, ErrBadParent) { + t.Fatalf("a page inside itself: %v", err) + } + if _, err := s.Create(NewNote{Title: "D", Parent: "nope"}); !errors.Is(err, ErrBadParent) { + t.Fatalf("a missing parent: %v", err) + } + root := "" + if got, err := s.Update(c.ID, Change{Parent: &root}, time.Time{}); err != nil || got.Parent != "" { + t.Fatalf("moving to the top level: %v %+v", err, got) + } + + // a hand-edited file with a circle of parents is mended on load + os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644) + os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644) + os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644) + s2, err := Open(dir) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"x1", "x2", "x3"} { + n, _ := s2.Get(id) + if id == "x3" && n.Parent != "" { + t.Errorf("%s keeps a parent that does not exist", id) + } + } + x1, _ := s2.Get("x1") + x2, _ := s2.Get("x2") + if x1.Parent != "" && x2.Parent != "" { + t.Error("a circle of parents must be broken") } } func TestTrashRestorePurge(t *testing.T) { s, dir := open(t) - n, _ := s.Create("to be thrown away") + parent := mk(t, s, "Parent", "") + n, _ := s.Create(NewNote{Title: "Child", Body: "to be thrown away", Parent: parent.ID}) + kid, _ := s.Create(NewNote{Title: "Grandchild", Parent: n.ID}) if err := s.Purge(n.ID); !errors.Is(err, ErrNotTrash) { - t.Fatalf("purge of a live note: %v", err) + t.Fatalf("purge of a live page: %v", err) } if _, err := s.Trash(n.ID); err != nil { t.Fatal(err) @@ -139,35 +286,38 @@ func TestTrashRestorePurge(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, "trash", n.ID+".md")); err != nil { t.Fatal("not moved to trash:", err) } - if list, _ := s.List(Query{}); len(list) != 0 { - t.Fatal("trashed note still listed") + if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err == nil { + t.Fatal("the live copy should be gone") + } + if got, _ := s.Get(kid.ID); got.Parent != parent.ID { + t.Fatalf("pages inside a trashed page move up a level, got parent %q", got.Parent) } if list, total := s.List(Query{View: "trash"}); len(list) != 1 || total != 1 { t.Fatal("trash view empty") } - // survives a restart in the trash - s2, _ := Open(dir) - if list, _ := s2.List(Query{View: "trash"}); len(list) != 1 { - t.Fatal("trash lost on reload") - } - if _, err := s2.Restore(n.ID); err != nil { + // a new page takes the title while it is away; restoring must not make two + if _, err := s.Create(NewNote{Title: "Child"}); err != nil { t.Fatal(err) } - if list, _ := s2.List(Query{}); len(list) != 1 { - t.Fatal("restore failed") + s2, _ := Open(dir) + back, err := s2.Restore(n.ID) + if err != nil || back.Title != "Child 2" || back.Parent != parent.ID { + t.Fatalf("restore: %v %+v", err, back) + } + s2.Trash(parent.ID) + s2.Trash(n.ID) + again, _ := s2.Restore(n.ID) + if again.Parent != "" { + t.Fatal("restoring under a parent that is gone should go to the top level") } s2.Trash(n.ID) if err := s2.Purge(n.ID); err != nil { t.Fatal(err) } if _, err := s2.Get(n.ID); !errors.Is(err, ErrNotFound) { - t.Fatal("purged note is still there") + t.Fatal("purged page is still there") } - a, _ := s2.Create("a") - b, _ := s2.Create("b") - s2.Trash(a.ID) - s2.Trash(b.ID) - if k, err := s2.EmptyTrash(); err != nil || k != 2 { + if k, err := s2.EmptyTrash(); err != nil || k != 1 { t.Fatalf("empty trash: %d %v", k, err) } } @@ -175,18 +325,21 @@ func TestTrashRestorePurge(t *testing.T) { func TestDaily(t *testing.T) { s, _ := open(t) d1, err := s.Daily("2026-10-31") - if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" { + if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" || d1.Title != "2026-10-31" { t.Fatalf("%v %+v", err, d1) } d2, _ := s.Daily("2026-10-31") if d1.ID != d2.ID || !d1.Created.Equal(d2.Created) { - t.Fatal("asking twice should give the same note") + t.Fatal("asking twice should give the same page") } for _, bad := range []string{"", "2026-13-01", "../etc/passwd", "today"} { if _, err := s.Daily(bad); !errors.Is(err, ErrBadDate) { t.Errorf("%q: %v", bad, err) } } + if _, err := s.Update(d1.ID, Change{Title: str("Renamed")}, time.Time{}); !errors.Is(err, ErrDailyTitle) { + t.Fatalf("renaming a day: %v", err) + } s.Trash(d1.ID) d3, _ := s.Daily("2026-10-31") if d3.Trashed { @@ -197,24 +350,38 @@ func TestDaily(t *testing.T) { if len(list) != 2 || list[0].Daily != "2026-10-31" { t.Fatalf("daily view order: %+v", list) } + linker := mk(t, s, "", "on [[2026-10-31]] I wrote") + if got := s.Backlinks(d1.ID); len(got) != 1 || got[0].ID != linker.ID { + t.Fatalf("a day can be linked by its date: %+v", got) + } + // reload keeps the title from the date, and an empty day stays out of navigation + s2, _ := Open(s.dir) + if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" { + t.Fatalf("title after reload: %q", n.Title) + } + for _, r := range s2.Pages() { + if r.Daily != "" { + t.Fatalf("an empty day should not be listed: %+v", r) + } + } } func TestSearchAndTags(t *testing.T) { s, _ := open(t) - s.Create("Buy oat MILK #errands") + s.Create(NewNote{Body: "Buy oat MILK #errands"}) time.Sleep(2 * time.Millisecond) - s.Create("Call the dentist #errands #health") + s.Create(NewNote{Body: "Call the dentist #errands #health"}) time.Sleep(2 * time.Millisecond) - s.Create("Idea: a #projects/basking dashboard") + s.Create(NewNote{Title: "Roadmap", Body: "Idea: a #projects/basking dashboard"}) if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 { t.Fatalf("case-insensitive search: %d", len(list)) } - if list, _ := s.List(Query{Text: "the dentist"}); len(list) != 1 { - t.Fatalf("all words must match: %d", len(list)) + if list, _ := s.List(Query{Text: "roadmap"}); len(list) != 1 { + t.Fatalf("titles are searched: %d", len(list)) } if list, _ := s.List(Query{Text: "milk dentist"}); len(list) != 0 { - t.Fatalf("words in different notes: %d", len(list)) + t.Fatalf("words in different pages: %d", len(list)) } if list, _ := s.List(Query{Tag: "errands"}); len(list) != 2 { t.Fatalf("tag filter: %d", len(list)) @@ -226,39 +393,93 @@ func TestSearchAndTags(t *testing.T) { t.Fatalf("a parent tag finds its children: %d", len(list)) } all, total := s.List(Query{Limit: 2}) - if len(all) != 2 || total != 3 || !strings.HasPrefix(all[0].Body, "Idea") { - t.Fatalf("newest first with a page size: %d of %d, first %q", len(all), total, all[0].Body) + if len(all) != 2 || total != 3 || all[0].Title != "Roadmap" { + t.Fatalf("newest first with a page size: %d of %d", len(all), total) } - if page2, _ := s.List(Query{Limit: 2, Offset: 2}); len(page2) != 1 { - t.Fatalf("second page: %d", len(page2)) - } - counts, tags := s.Summary() if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 { t.Fatalf("summary %+v %+v", counts, tags) } } +func TestFind(t *testing.T) { + s, _ := open(t) + mk(t, s, "Trip to Lisbon", "flights and hotels") + time.Sleep(2 * time.Millisecond) + mk(t, s, "Packing list", "passport, charger\nlisbon weather is warm") + time.Sleep(2 * time.Millisecond) + mk(t, s, "Lis", "") + time.Sleep(2 * time.Millisecond) + mk(t, s, "Unrelated", "nothing") + + hits := s.Find("lis", 10) + var names []string + for _, h := range hits { + names = append(names, h.Name) + } + if !reflect.DeepEqual(names, []string{"Lis", "Packing list", "Trip to Lisbon"}) { + t.Fatalf("exact title first, then newer titles ahead of older: %v", names) + } + body := s.Find("warm", 10) + if len(body) != 1 || body[0].Name != "Packing list" || body[0].Snippet != "lisbon weather is warm" { + t.Fatalf("body match: %+v", body) + } + if recent := s.Find("", 2); len(recent) != 2 || recent[0].Name != "Unrelated" { + t.Fatalf("no query lists recent pages: %+v", recent) + } +} + +func TestPagesListsWhatBelongsInNavigation(t *testing.T) { + s, _ := open(t) + titled := mk(t, s, "Titled", "") + quick := mk(t, s, "", "a quick thought") + holder := mk(t, s, "", "untitled but holds a page") + child, _ := s.Create(NewNote{Body: "child", Parent: holder.ID}) + pinned := mk(t, s, "", "pinned thought") + yes := true + s.Update(pinned.ID, Change{Pinned: &yes}, time.Time{}) + gone := mk(t, s, "Gone", "") + s.Trash(gone.ID) + + ids := map[string]bool{} + for _, r := range s.Pages() { + ids[r.ID] = true + } + for _, id := range []string{titled.ID, holder.ID, child.ID, pinned.ID} { + if !ids[id] { + t.Errorf("%s should be listed", id) + } + } + for _, id := range []string{quick.ID, gone.ID} { + if ids[id] { + t.Errorf("%s should not be listed", id) + } + } +} + func TestIDsCannotEscape(t *testing.T) { for _, id := range []string{"", "..", "../x", "a/b", `a\b`, "A", ".hidden", strings.Repeat("a", 65)} { if ValidID(id) { t.Errorf("%q should not be a valid id", id) } } - s, dir := open(t) + _, dir := open(t) os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644) s2, _ := Open(dir) if list, _ := s2.List(Query{}); len(list) != 0 { t.Fatal("a file whose name is not a valid id should be ignored") } - _ = s } -func TestZip(t *testing.T) { +func TestZipIsAnObsidianVault(t *testing.T) { s, _ := open(t) - a, _ := s.Create("keep me") - b, _ := s.Create("bin me") - s.Trash(b.ID) + mk(t, s, "Trip: Lisbon/Porto", "see [[Packing]]") + mk(t, s, "Packing", "") + mk(t, s, "", "# A quick one") + mk(t, s, "", "# A quick one") + bin := mk(t, s, "Bin me", "") + s.Trash(bin.ID) + s.Daily("2026-10-31") var buf bytes.Buffer if err := s.WriteZip(&buf); err != nil { t.Fatal(err) @@ -267,7 +488,17 @@ func TestZip(t *testing.T) { if err != nil { t.Fatal(err) } - if len(zr.File) != 1 || zr.File[0].Name != "basking/"+a.ID+".md" { - t.Fatalf("zip has %d files", len(zr.File)) + var names []string + for _, f := range zr.File { + names = append(names, f.Name) + } + want := map[string]bool{"basking/Trip- Lisbon-Porto.md": true, "basking/Packing.md": true, "basking/A quick one.md": true, "basking/A quick one 2.md": true, "basking/Journal/2026-10-31.md": true} + if len(names) != len(want) { + t.Fatalf("zip has %v", names) + } + for _, n := range names { + if !want[n] { + t.Errorf("unexpected %q in %v", n, names) + } } } diff --git a/internal/web/api.go b/internal/web/api.go index 7364e54..0532ab0 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -3,24 +3,16 @@ package web import ( "errors" "fmt" - "io" - "mime" "net/http" "strconv" - "strings" "time" - "git.veillax.com/Veillax/basking/internal/config" "git.veillax.com/Veillax/basking/internal/store" ) -func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) { - writeJSON(w, map[string]any{"version": s.version, "api": p.API}) -} - -func (s *Server) summary(w http.ResponseWriter, r *http.Request, p principal) { - counts, tags := s.st.Summary() - writeJSON(w, map[string]any{"counts": counts, "tags": tags}) +func (s *Server) pages(w http.ResponseWriter, r *http.Request, p principal) { + counts, tags := p.st.Summary() + writeJSON(w, map[string]any{"pages": p.st.Pages(), "counts": counts, "tags": tags}) } func (s *Server) listNotes(w http.ResponseWriter, r *http.Request, p principal) { @@ -34,29 +26,24 @@ func (s *Server) listNotes(w http.ResponseWriter, r *http.Request, p principal) if len(text) > 200 { text = text[:200] } - notes, total := s.st.List(store.Query{Text: text, Tag: q.Get("tag"), View: q.Get("view"), Offset: offset, Limit: limit}) + notes, total := p.st.List(store.Query{Text: text, Tag: q.Get("tag"), View: q.Get("view"), Offset: offset, Limit: limit}) writeJSON(w, map[string]any{"notes": notes, "total": total}) } -// createNote takes {"body": "..."} as JSON, or the note itself as plain text, so a script can -// just post what it has: curl -d 'buy milk' -H 'Content-Type: text/plain' ... -func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) { - var body string - if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt == "application/json" { - var in struct{ Body, Text string } - if !decode(w, r, &in) { - return - } - body = in.Body + in.Text - } else { - b, err := io.ReadAll(r.Body) - if err != nil { - fail(w, http.StatusRequestEntityTooLarge, "request too large") - return - } - body = string(b) +func (s *Server) find(w http.ResponseWriter, r *http.Request, p principal) { + q := r.URL.Query().Get("q") + if len(q) > 200 { + q = q[:200] } - n, err := s.st.Create(body) + writeJSON(w, map[string]any{"hits": p.st.Find(q, 12)}) +} + +func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) { + var in struct{ Title, Body, Parent string } + if !decode(w, r, &in) { + return + } + n, err := p.st.Create(store.NewNote{Title: in.Title, Body: in.Body, Parent: in.Parent}) if err != nil { storeFail(w, err) return @@ -64,19 +51,27 @@ func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) writeJSONStatus(w, http.StatusCreated, n) } +// page is a note with the pages that link to it. +type page struct { + store.Note + Backlinks []store.Ref `json:"backlinks"` +} + func (s *Server) getNote(w http.ResponseWriter, r *http.Request, p principal) { - n, err := s.st.Get(r.PathValue("id")) + n, err := p.st.Get(r.PathValue("id")) if err != nil { storeFail(w, err) return } - writeJSON(w, n) + writeJSON(w, page{n, p.st.Backlinks(n.ID)}) } func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal) { var in struct { + Title *string `json:"title"` Body *string `json:"body"` Pinned *bool `json:"pinned"` + Parent *string `json:"parent"` Base string `json:"base"` } if !decode(w, r, &in) { @@ -86,30 +81,30 @@ func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal) if in.Base != "" { t, err := time.Parse(time.RFC3339Nano, in.Base) if err != nil { - fail(w, http.StatusBadRequest, "base must be the note's updated time") + fail(w, http.StatusBadRequest, "base must be the page's updated time") return } base = t } - n, err := s.st.Update(r.PathValue("id"), in.Body, in.Pinned, base) + n, err := p.st.Update(r.PathValue("id"), store.Change{Title: in.Title, Body: in.Body, Pinned: in.Pinned, Parent: in.Parent}, base) if errors.Is(err, store.ErrConflict) { - writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This note changed somewhere else", "note": n}) + writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This page changed somewhere else", "note": n}) return } if err != nil { storeFail(w, err) return } - writeJSON(w, n) + writeJSON(w, page{n, p.st.Backlinks(n.ID)}) } func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal) { id := r.PathValue("id") var err error if r.URL.Query().Get("purge") == "1" { - err = s.st.Purge(id) + err = p.st.Purge(id) } else { - _, err = s.st.Trash(id) + _, err = p.st.Trash(id) } if err != nil { storeFail(w, err) @@ -119,7 +114,7 @@ func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal) } func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal) { - n, err := s.st.Restore(r.PathValue("id")) + n, err := p.st.Restore(r.PathValue("id")) if err != nil { storeFail(w, err) return @@ -128,7 +123,7 @@ func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal } func (s *Server) emptyTrash(w http.ResponseWriter, r *http.Request, p principal) { - n, err := s.st.EmptyTrash() + n, err := p.st.EmptyTrash() if err != nil { storeFail(w, err) return @@ -141,114 +136,19 @@ func (s *Server) daily(w http.ResponseWriter, r *http.Request, p principal) { if !decode(w, r, &in) { return } - n, err := s.st.Daily(in.Date) + n, err := p.st.Daily(in.Date) if err != nil { storeFail(w, err) return } - writeJSON(w, n) + writeJSON(w, page{n, p.st.Backlinks(n.ID)}) } func (s *Server) export(w http.ResponseWriter, r *http.Request, p principal) { w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="basking-%s.zip"`, time.Now().Format("2006-01-02"))) - if err := s.st.WriteZip(w); err != nil { + if err := p.st.WriteZip(w); err != nil { // The download has started, so all that can be done is to cut it short. panic(http.ErrAbortHandler) } } - -// ---- password and tokens ---- - -func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p principal) { - var in struct{ Current, New string } - if !decode(w, r, &in) { - return - } - if !config.CheckPassword(s.cfg.Get().PasswordHash, in.Current) { - fail(w, http.StatusForbidden, "the current password is wrong") - return - } - if len(in.New) < 8 { - fail(w, http.StatusBadRequest, "the new password must be at least 8 characters") - return - } - hash, err := config.HashPassword(in.New) - if err != nil { - fail(w, http.StatusInternalServerError, err.Error()) - return - } - if err := s.cfg.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil { - fail(w, http.StatusInternalServerError, err.Error()) - return - } - s.startSession(w, r) // the old cookie no longer matches, so give this browser a new one - writeJSON(w, map[string]bool{"ok": true}) -} - -func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, p principal) { - type row struct { - Name string `json:"name"` - Created time.Time `json:"created"` - } - out := []row{} - for _, t := range s.cfg.Get().Tokens { - out = append(out, row{t.Name, t.Created}) - } - writeJSON(w, out) -} - -func (s *Server) newToken(w http.ResponseWriter, r *http.Request, p principal) { - if p.API { - fail(w, http.StatusForbidden, "tokens are managed from the dashboard") - return - } - var in struct{ Name string } - if !decode(w, r, &in) { - return - } - token, hash, err := config.NewToken() - if err != nil { - fail(w, http.StatusInternalServerError, err.Error()) - return - } - name := strings.TrimSpace(in.Name) - err = s.cfg.Update(func(c *config.Config) error { - c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now().UTC()}) - return nil - }) - if err != nil { - fail(w, http.StatusBadRequest, err.Error()) - return - } - writeJSON(w, map[string]string{"name": name, "token": token}) -} - -func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request, p principal) { - if p.API { - fail(w, http.StatusForbidden, "tokens are managed from the dashboard") - return - } - name := r.PathValue("name") - found := false - err := s.cfg.Update(func(c *config.Config) error { - keep := c.Tokens[:0] - for _, t := range c.Tokens { - if t.Name == name { - found = true - continue - } - keep = append(keep, t) - } - c.Tokens = keep - return nil - }) - switch { - case err != nil: - fail(w, http.StatusInternalServerError, err.Error()) - case !found: - fail(w, http.StatusNotFound, "no such token") - default: - writeJSON(w, map[string]bool{"ok": true}) - } -} diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..93579ff --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,180 @@ +package web + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "log" + "net/http" + "regexp" + "strings" + "time" + + "git.veillax.com/Veillax/basking/internal/config" + "git.veillax.com/Veillax/basking/internal/oidc" +) + +const ( + sessionCookie = "basking_session" + flowCookie = "basking_signin" + sessionTTL = 14 * 24 * time.Hour + flowTTL = 10 * time.Minute +) + +// A cookie is "payload.signature": the payload is JSON, the signature is an HMAC with the secret from +// the settings file. Nothing is kept on the server, so a restart does not sign anyone out. +func seal(c config.Config, kind string, v any) string { + raw, _ := json.Marshal(v) + payload := base64.RawURLEncoding.EncodeToString(raw) + return payload + "." + mac(c, kind, payload) +} + +func mac(c config.Config, kind, payload string) string { + m := hmac.New(sha256.New, []byte(c.Secret)) + m.Write([]byte(kind + "|" + payload)) + return hex.EncodeToString(m.Sum(nil)) +} + +func unseal(c config.Config, kind, value string, v any) bool { + payload, sig, ok := strings.Cut(value, ".") + if !ok || !hmac.Equal([]byte(sig), []byte(mac(c, kind, payload))) { + return false + } + raw, err := base64.RawURLEncoding.DecodeString(payload) + return err == nil && json.Unmarshal(raw, v) == nil +} + +type sessionData struct { + Sub string `json:"s"` + Name string `json:"n"` + Email string `json:"e"` + User string `json:"u"` + Exp int64 `json:"x"` +} + +type flowData struct { + State string `json:"s"` + Nonce string `json:"n"` + Verifier string `json:"v"` + Next string `json:"t"` + Exp int64 `json:"x"` +} + +// session reads the sign-in cookie. The allowed list is checked on every request, so taking someone +// off it takes effect at once. +func (s *Server) session(c config.Config, r *http.Request) (sessionData, bool) { + var d sessionData + ck, err := r.Cookie(sessionCookie) + if err != nil || !unseal(c, "session", ck.Value, &d) || d.Sub == "" || time.Now().Unix() > d.Exp { + return sessionData{}, false + } + return d, allowed(c, d) +} + +func allowed(c config.Config, d sessionData) bool { + if len(c.Allowed) == 0 { + return true + } + for _, a := range c.Allowed { + for _, who := range []string{d.Email, d.User, d.Sub} { + if who != "" && strings.EqualFold(strings.TrimSpace(a), who) { + return true + } + } + } + return false +} + +func setCookie(w http.ResponseWriter, c config.Config, name, value, path string, ttl time.Duration) { + ck := &http.Cookie{Name: name, Value: value, Path: path, HttpOnly: true, Secure: c.Secure(), SameSite: http.SameSiteLaxMode} + if value == "" { + ck.MaxAge = -1 + } else { + ck.MaxAge = int(ttl.Seconds()) + } + http.SetCookie(w, ck) +} + +// Where to go after signing in: only a place inside Basking, such as #/p/abc. +var nextRe = regexp.MustCompile(`^#/[A-Za-z0-9/_.~%-]{0,300}$`) + +func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) { + c := s.cfg.Get() + next := r.URL.Query().Get("next") + if !nextRe.MatchString(next) { + next = "" + } + f := flowData{State: oidc.Random(18), Nonce: oidc.Random(18), Verifier: oidc.Random(40), Next: next, Exp: time.Now().Add(flowTTL).Unix()} + to, err := s.oidc.AuthURL(r.Context(), f.State, f.Nonce, oidc.Challenge(f.Verifier)) + if err != nil { + log.Printf("sign-in: %v", err) + http.Redirect(w, r, "/?signin=unavailable", http.StatusSeeOther) + return + } + setCookie(w, c, flowCookie, seal(c, "flow", f), "/auth/", flowTTL) + http.Redirect(w, r, to, http.StatusSeeOther) +} + +func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) { + c := s.cfg.Get() + back := func(why string) { http.Redirect(w, r, "/?signin="+why, http.StatusSeeOther) } + + var f flowData + ck, err := r.Cookie(flowCookie) + setCookie(w, c, flowCookie, "", "/auth/", 0) // a sign-in attempt is used once + if err != nil || !unseal(c, "flow", ck.Value, &f) || time.Now().Unix() > f.Exp { + back("expired") + return + } + q := r.URL.Query() + if e := q.Get("error"); e != "" { + log.Printf("sign-in: the provider answered %q: %s", e, q.Get("error_description")) + back("denied") + return + } + if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(f.State)) != 1 || q.Get("code") == "" { + back("failed") + return + } + id, err := s.oidc.Exchange(r.Context(), q.Get("code"), f.Verifier, f.Nonce) + if err != nil { + log.Printf("sign-in: %v", err) + if errors.Is(err, oidc.ErrUnavailable) { + back("unavailable") + } else { + back("failed") + } + return + } + d := sessionData{Sub: id.Sub, Name: firstOf(id.Name, id.Username, id.Email, "You"), Email: id.Email, User: id.Username, + Exp: time.Now().Add(sessionTTL).Unix()} + if !allowed(c, d) { + log.Printf("sign-in: %s (%s) is not on the allowed list", d.Name, firstOf(d.Email, d.User, d.Sub)) + back("denied") + return + } + setCookie(w, c, sessionCookie, seal(c, "session", d), "/", sessionTTL) + http.Redirect(w, r, "/"+f.Next, http.StatusSeeOther) +} + +func firstOf(s ...string) string { + for _, x := range s { + if strings.TrimSpace(x) != "" { + return x + } + } + return "" +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) { + setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0) + writeJSON(w, map[string]bool{"ok": true}) +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) { + writeJSON(w, map[string]any{"name": p.Name, "email": p.Email, "version": s.version}) +} diff --git a/internal/web/server.go b/internal/web/server.go index 8f3c7b6..0415b4a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -2,62 +2,79 @@ package web import ( - "crypto/hmac" "crypto/sha256" - "crypto/subtle" "embed" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" - "net" "net/http" - "strconv" + "os" + "path/filepath" "strings" "sync" - "time" "git.veillax.com/Veillax/basking/internal/config" + "git.veillax.com/Veillax/basking/internal/oidc" "git.veillax.com/Veillax/basking/internal/store" ) //go:embed static var staticFiles embed.FS -const ( - cookieName = "basking_session" - sessionTTL = 30 * 24 * time.Hour - maxLoginFails = 6 - loginFailSpan = 5 * time.Minute - maxRequestBody = store.MaxBody + 4096 -) +const maxRequestBody = store.MaxBody + 4096 type Server struct { cfg *config.Holder - st *store.Store + oidc *oidc.Client version string mu sync.Mutex - fails map[string]failure - - dummyOnce sync.Once - dummyHash string + books map[string]*store.Store } -type failure struct { - n int - until time.Time -} - -// principal is who is making a request. +// principal is who is making a request, with the notebook that is theirs alone. type principal struct { - Name string - API bool // came with an API token rather than a browser session + Sub, Name, Email string + st *store.Store } -func New(h *config.Holder, st *store.Store, version string) *Server { - return &Server{cfg: h, st: st, version: version, fails: map[string]failure{}} +// New checks that Authentik is set up and returns the server. +func New(h *config.Holder, version string) (*Server, error) { + c := h.Get() + if err := c.Check(); err != nil { + return nil, err + } + return &Server{cfg: h, version: version, books: map[string]*store.Store{}, + 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 } + +// 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]) + s.mu.Lock() + defer s.mu.Unlock() + if st, ok := s.books[key]; ok { + return st, nil + } + dir := filepath.Join(c.DataDir, "users", key) + st, err := store.Open(dir) + if err != nil { + return nil, err + } + // A note for whoever looks after the server, so a folder can be matched to a person. + who := fmt.Sprintf("%s <%s>\n", name, email) + if b, _ := os.ReadFile(filepath.Join(dir, "owner.txt")); string(b) != who { + os.WriteFile(filepath.Join(dir, "owner.txt"), []byte(who), 0o644) + } + s.books[key] = st + return st, nil } // Handler returns the whole site. @@ -65,10 +82,11 @@ func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) - mux.HandleFunc("POST /api/login", s.login) + mux.HandleFunc("GET /auth/login", s.authLogin) + mux.HandleFunc("GET /auth/callback", s.authCallback) mux.HandleFunc("POST /api/logout", s.api(s.logout)) mux.HandleFunc("GET /api/me", s.api(s.me)) - mux.HandleFunc("GET /api/summary", s.api(s.summary)) + mux.HandleFunc("GET /api/pages", s.api(s.pages)) mux.HandleFunc("GET /api/notes", s.api(s.listNotes)) mux.HandleFunc("POST /api/notes", s.api(s.createNote)) @@ -78,168 +96,60 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote)) 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("POST /api/password", s.api(s.changePassword)) - mux.HandleFunc("GET /api/tokens", s.api(s.listTokens)) - mux.HandleFunc("POST /api/tokens", s.api(s.newToken)) - mux.HandleFunc("DELETE /api/tokens/{name}", s.api(s.deleteToken)) - static, _ := fs.Sub(staticFiles, "static") mux.Handle("GET /", http.FileServerFS(static)) - return secure(mux) + return s.secure(mux) } // secure adds headers that keep the page from being framed, sniffed or fed outside scripts. -func secure(next http.Handler) http.Handler { +func (s *Server) secure(next http.Handler) http.Handler { + hsts := s.cfg.Get().Secure() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") - if strings.HasPrefix(r.URL.Path, "/api/") { + h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + if hsts { + h.Set("Strict-Transport-Security", "max-age=31536000") + } + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/auth/") { h.Set("Cache-Control", "no-store") } next.ServeHTTP(w, r) }) } -// ---- authentication ---- - type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal) // api wraps a handler with sign-in and CSRF protection. func (s *Server) api(fn handlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - p, ok := s.authenticate(r) + c := s.cfg.Get() + ses, ok := s.session(c, r) if !ok { fail(w, http.StatusUnauthorized, "sign in required") return } - // Browser sessions must send a header a web page on another site cannot add. - if !p.API && r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" { + // A browser must send a header a web page on another site cannot add. + if r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" { fail(w, http.StatusForbidden, "missing X-Basking header") return } + st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email) + if err != nil { + fail(w, http.StatusInternalServerError, "could not open your notes: "+err.Error()) + return + } r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) - fn(w, r, p) + fn(w, r, principal{Sub: ses.Sub, Name: ses.Name, Email: ses.Email, st: st}) } } -// A session cookie is "expiry.signature". The signature covers the password hash, so changing -// the password signs every browser out, and nothing has to be remembered on the server. -func sign(c config.Config, exp int64) string { - m := hmac.New(sha256.New, []byte(c.Secret)) - fmt.Fprintf(m, "session|%d|%s", exp, c.PasswordHash) - return hex.EncodeToString(m.Sum(nil)) -} - -func (s *Server) authenticate(r *http.Request) (principal, bool) { - c := s.cfg.Get() - if c.PasswordHash == "" { - return principal{}, false - } - if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { - sum := config.HashToken(strings.TrimPrefix(h, "Bearer ")) - for _, t := range c.Tokens { - if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(sum)) == 1 { - return principal{Name: "token:" + t.Name, API: true}, true - } - } - return principal{}, false - } - ck, err := r.Cookie(cookieName) - if err != nil { - return principal{}, false - } - expStr, sig, ok := strings.Cut(ck.Value, ".") - exp, err := strconv.ParseInt(expStr, 10, 64) - if !ok || err != nil || time.Now().Unix() > exp { - return principal{}, false - } - if !hmac.Equal([]byte(sig), []byte(sign(c, exp))) { - return principal{}, false - } - return principal{Name: "you"}, true -} - -func (s *Server) clientIP(r *http.Request) string { - if s.cfg.Get().TrustProxy { - if f := r.Header.Get("X-Forwarded-For"); f != "" { - return strings.TrimSpace(strings.Split(f, ",")[0]) - } - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} - -func (s *Server) secureCookie(r *http.Request) bool { - c := s.cfg.Get() - return c.CookieSecure || r.TLS != nil || (c.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https") -} - -func (s *Server) startSession(w http.ResponseWriter, r *http.Request) { - c := s.cfg.Get() - exp := time.Now().Add(sessionTTL).Unix() - http.SetCookie(w, &http.Cookie{Name: cookieName, Value: fmt.Sprintf("%d.%s", exp, sign(c, exp)), Path: "/", HttpOnly: true, - SameSite: http.SameSiteStrictMode, Secure: s.secureCookie(r), MaxAge: int(sessionTTL.Seconds())}) -} - -func (s *Server) login(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 4096) - if r.Header.Get("X-Basking") != "1" { - fail(w, http.StatusForbidden, "missing X-Basking header") - return - } - var in struct{ Password string } - if json.NewDecoder(r.Body).Decode(&in) != nil { - fail(w, http.StatusBadRequest, "bad request") - return - } - ip := s.clientIP(r) - s.mu.Lock() - f := s.fails[ip] - if f.n >= maxLoginFails && time.Now().Before(f.until) { - s.mu.Unlock() - fail(w, http.StatusTooManyRequests, "too many failed attempts; wait a few minutes") - return - } - s.mu.Unlock() - - hash := s.cfg.Get().PasswordHash - if hash == "" { - // Spend the same time as for a real check. - s.dummyOnce.Do(func() { s.dummyHash, _ = config.HashPassword("dummy") }) - hash = s.dummyHash - } - ok := config.CheckPassword(hash, in.Password) && s.cfg.Get().PasswordHash != "" - s.mu.Lock() - if !ok { - if time.Now().After(f.until) { - f = failure{} - } - f.n++ - f.until = time.Now().Add(loginFailSpan) - s.fails[ip] = f - s.mu.Unlock() - fail(w, http.StatusUnauthorized, "wrong password") - return - } - delete(s.fails, ip) - s.mu.Unlock() - s.startSession(w, r) - writeJSON(w, map[string]bool{"ok": true}) -} - -func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) { - http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) - writeJSON(w, map[string]bool{"ok": true}) -} - // ---- helpers ---- func writeJSON(w http.ResponseWriter, v any) { writeJSONStatus(w, http.StatusOK, v) } @@ -252,12 +162,14 @@ func writeJSONStatus(w http.ResponseWriter, code int, v any) { // fail answers with an error. Messages are capitalized here, once, so they read as sentences. func fail(w http.ResponseWriter, code int, msg string) { + failCode(w, code, msg, "") +} + +func failCode(w http.ResponseWriter, code int, msg, kind string) { if r := []rune(msg); len(r) > 0 { msg = strings.ToUpper(string(r[:1])) + string(r[1:]) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - json.NewEncoder(w).Encode(map[string]string{"error": msg}) + writeJSONStatus(w, code, map[string]string{"error": msg, "code": kind}) } func decode(w http.ResponseWriter, r *http.Request, v any) bool { @@ -280,7 +192,10 @@ func storeFail(w http.ResponseWriter, err error) { fail(w, http.StatusNotFound, err.Error()) case errors.Is(err, store.ErrConflict): fail(w, http.StatusConflict, err.Error()) - case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID): + case errors.Is(err, store.ErrTitleTaken): + failCode(w, http.StatusUnprocessableEntity, err.Error(), "title_taken") + case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID), + errors.Is(err, store.ErrDailyTitle), errors.Is(err, store.ErrBadParent): fail(w, http.StatusBadRequest, err.Error()) case errors.Is(err, store.ErrTooLong): fail(w, http.StatusRequestEntityTooLarge, err.Error()) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index a412eb2..1717658 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -1,307 +1,66 @@ 'use strict'; -// ---------- tiny toolkit ---------- -// h builds an element. Strings become text nodes, never HTML, so note text can never inject markup. -function h(tag, props, ...kids) { - const el = document.createElement(tag); - let value; - for (const [k, v] of Object.entries(props || {})) { - if (v == null || v === false) continue; - if (k.startsWith('on')) el.addEventListener(k.slice(2), v); - else if (k === 'class') el.className = v; - else if (k === 'value') value = v; - else if (k === 'checked') el.checked = true; - else el.setAttribute(k, v === true ? '' : v); - } - for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k))); - if (value !== undefined) el.value = value; - return el; -} -const $app = document.getElementById('app'); - -// theme: follows the system unless the person picked one -try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* storage is off */ } -function toggleTheme() { - const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; - const next = dark ? 'light' : 'dark'; - document.documentElement.dataset.theme = next; - try { localStorage.setItem('theme', next); } catch (e) { /* storage is off */ } -} -const store = { - get(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } }, - set(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) { /* storage is off */ } }, -}; - -// short confirmations at the bottom right; errors stay longer and are announced as alerts -let toastBox; -function toast(msg, bad, action) { - if (!toastBox) { toastBox = h('div', { class: 'toasts', 'aria-live': 'polite' }); document.body.append(toastBox); } - const t = h('div', { class: 'toast' + (bad ? ' toast-error' : ''), role: bad ? 'alert' : 'status' }, h('span', {}, msg), - action ? h('button', { class: 'toast-act', onclick: () => { t.remove(); action.fn(); } }, action.label) : null); - toastBox.append(t); - setTimeout(() => t.remove(), bad ? 7000 : action ? 6000 : 3000); -} -const cap = s => s ? s[0].toUpperCase() + s.slice(1) : s; -const SIGNED_OUT = 'Your session ended. Sign in again to continue.'; - -async function api(method, url, body) { - const opt = { method, headers: { 'X-Basking': '1' }, credentials: 'same-origin' }; - if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } - const r = await fetch(url, opt); - const j = await r.json().catch(() => ({})); - if (r.status === 401 && url !== '/api/login' && url !== '/api/me') { - if (S.ready) { S.ready = false; boot(); } - throw new Error(SIGNED_OUT); - } - if (!r.ok) throw Object.assign(new Error(cap(j.error || r.statusText)), { status: r.status, data: j }); - return j; -} -// run an action; show errors as a toast instead of throwing into the void -const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); } }; - -const pad = n => String(n).padStart(2, '0'); -const hhmm = d => { const t = new Date(d); return `${pad(t.getHours())}:${pad(t.getMinutes())}`; }; -const fmtTime = d => new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); -const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; -const parseDay = s => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); }; -const addDays = (s, n) => { const d = parseDay(s); d.setDate(d.getDate() + n); return isoDay(d); }; -function dayLabel(d) { - const day = isoDay(d instanceof Date ? d : parseDay(d)), now = new Date(); - if (day === isoDay(now)) return 'Today'; - if (day === addDays(isoDay(now), -1)) return 'Yesterday'; - const dt = parseDay(day); - return dt.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: dt.getFullYear() === now.getFullYear() ? undefined : 'numeric' }); -} -const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', { class: 'sub' }, hint) : null); -const chk = (label, checked) => { const i = h('input', { type: 'checkbox', checked: !!checked }); return Object.assign(h('label', { class: 'chk' }, i, label), { box: i }); }; -// status pill: color always travels with a text label -const badge = (text, tone) => h('span', { class: 'badge' + (tone ? ' badge-' + tone : '') }, text); -const emptyState = (name, title, text, action) => h('div', { class: 'empty' }, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null); -// placeholder rows while a page loads, so the layout does not jump -const skeleton = () => h('div', { class: 'card', role: 'status', 'aria-busy': 'true' }, h('span', { class: 'sr-only' }, 'Loading'), - ['w60', 'w100', 'w90', 'w100', 'w75'].map(w => h('div', { class: 'skeleton ' + w }))); -// replaceChildren stringifies null and arrays, so everything goes through here -const fill = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter(k => k != null && k !== false)); -const card = (title, ...kids) => h('div', { class: 'card' }, title ? h('h2', {}, title) : null, ...kids); - -// ---------- icons (lucide-style, static strings; see docs/design.md for the approved names) ---------- -const ICONS = { - fin: '', - notes: '', - today: '', - journal: '', - pin: '', - tag: '', - trash: '', - edit: '', - restore: '', - clock: '', - view: '', - download: '', - copy: '', - prev: '', - next: '', - settings: '', - out: '', - menu: '', - close: '', - add: '', - check: '', - light: '', - dark: '', - alert: '', -}; -function icon(name, size = 18) { - const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' }); - s.innerHTML = `${ICONS[name] || ''}`; - return s; -} -const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Basking'); -const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label); - -// ---------- dialogs ---------- -// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it. -let dialogSeq = 0; -function modal(title, body, opts = {}) { - const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq); - const shade = h('div', { class: 'shade' }); - const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' }, h('h2', { id }, title), body); - let closed = false; - const close = () => { - if (closed) return; closed = true; - document.removeEventListener('keydown', onKey, true); shade.remove(); - if (opener && opener.isConnected && opener.focus) opener.focus(); - if (opts.onClose) opts.onClose(); - }; - const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null); - function onKey(e) { - if ([...document.querySelectorAll('.shade')].pop() !== shade) return; - if (e.key === 'Escape') { e.stopPropagation(); close(); } - else if (e.key === 'Tab') { - const f = focusables(); if (!f.length) { e.preventDefault(); return; } - const first = f[0], last = f[f.length - 1]; - if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); } - else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } - } - } - shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); }); - shade.append(box); - document.body.append(shade); - document.addEventListener('keydown', onKey, true); - (box.querySelector('input,select,textarea') || focusables()[0] || box).focus(); - return close; -} -const modalOpen = () => !!document.querySelector('.shade'); - -// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed. -function confirmDialog({ title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true }) { - return new Promise(resolve => { - let close; - const done = v => { resolve(v); close(); }; - close = modal(title, h('div', { class: 'stack' }, h('p', { class: 'dim' }, body), - h('div', { class: 'actions form-actions' }, h('button', { class: 'ghost', onclick: () => done(false) }, cancel), - h('button', { class: danger ? 'ghost danger' : 'btn', onclick: () => done(true) }, confirm))), { onClose: () => resolve(false) }); - }); -} - -// formDialog shows a form and resolves with its values, or null if it was cancelled. -// Field types: text, password, textarea, note. -function formDialog({ title, intro, fields, submit = 'Save' }) { - return new Promise(resolve => { - const parts = {}; - const err = h('p', { class: 'err', role: 'alert' }); - const rows = fields.map(f => { - if (f.type === 'note') return h('p', { class: 'dim' }, f.text); - let el, get; - if (f.type === 'textarea') { - el = h('textarea', { placeholder: f.placeholder, rows: 5 }); el.value = f.value || ''; get = () => el.value.trim(); - } else { - el = h('input', { type: f.type || 'text', placeholder: f.placeholder, autocomplete: f.type === 'password' ? 'new-password' : 'off' }); - el.value = f.value ?? ''; get = () => f.type === 'password' ? el.value : el.value.trim(); - } - parts[f.name] = { f, get, el }; - return field(f.label, el, f.hint); - }); - let close; - const send = () => { - const out = {}; - for (const [name, p] of Object.entries(parts)) { - const v = p.get(); - if (p.f.required && !v) { err.textContent = `${p.f.label} is required.`; p.el.setAttribute('aria-invalid', 'true'); p.el.focus(); return; } - out[name] = v; - } - done(out); - }; - const done = v => { resolve(v); close(); }; - const form = h('form', { class: 'stack', onsubmit: e => { e.preventDefault(); send(); } }, intro ? h('p', { class: 'dim' }, intro) : null, rows, err, - h('div', { class: 'actions form-actions' }, h('button', { type: 'button', class: 'ghost', onclick: () => done(null) }, 'Cancel'), h('button', { type: 'submit', class: 'btn' }, submit))); - close = modal(title, form, { sticky: true, onClose: () => resolve(null) }); - }); -} - -// ---------- markdown ---------- -// A small, safe renderer. It builds elements (never HTML strings), and only http(s) links are links. -const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(? h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t); -const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label); - -function inline(text) { - 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(extLink(m[5], m[4])); - else if (m[6] != null) out.push(extLink(m[6], m[6])); - else { - const t = m[7].replace(/[/-]+$/, ''); - if (!t || /^\d+$/.test(t)) out.push('#' + m[7]); - else out.push(tagChip(t.toLowerCase()), m[7].slice(t.length)); - } - } - if (last < text.length) out.push(text.slice(last)); - return out; -} - -// md renders a note. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only. -function md(src, onTask) { - const root = h('div', { class: 'md' }), 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)); }); - root.append(p); para = []; - }; - while (i < lines.length) { - const line = lines[i]; - if (/^\s*```/.test(line)) { - flush(); const code = []; i++; - while (i < lines.length && !/^\s*```/.test(lines[i])) code.push(lines[i++]); - 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 (/^\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; - } - if (/^(\s*)([-*+]|\d+[.)])\s+/.test(line)) { - flush(); const ordered = /^\s*\d/.test(line), list = h(ordered ? 'ol' : 'ul'); - while (i < lines.length && (m = /^(\s*)([-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.*)$/.exec(lines[i]))) { - const lvl = Math.min(3, Math.floor(m[1].replace(/\t/g, ' ').length / 2)), at = i; - const li = h('li', { class: 'ind-' + lvl + (m[3] != null ? ' task' : '') }); - 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])); - list.append(li); i++; - } - root.append(list); continue; - } - para.push(line); i++; - } - flush(); - return root; -} - // ---------- state ---------- -const S = { ready: false, counts: { all: 0, pinned: 0, daily: 0, trash: 0 }, tags: [], view: null, q: '', pending: new Set(), nav: {} }; +const S = { + ready: false, me: null, seq: 0, view: null, + pages: [], byId: new Map(), byTitle: new Map(), counts: { all: 0, pinned: 0, daily: 0, trash: 0 }, tags: [], + editors: new Set(), +}; +const pageByTitle = t => S.byTitle.get(t.trim().toLowerCase()); +const hrefOf = n => n.daily ? '#/day/' + n.daily : '#/p/' + n.id; +const childrenOf = id => S.pages.filter(p => p.parent === id && !p.daily); -async function refreshSummary() { - const r = await api('GET', '/api/summary'); - S.counts = r.counts; S.tags = r.tags; updateNav(); +async function refreshPages() { + const r = await api('GET', '/api/pages'); + S.pages = r.pages; S.counts = r.counts; S.tags = r.tags; + S.byId = new Map(r.pages.map(p => [p.id, p])); + S.byTitle = new Map(); + for (const p of r.pages) if (p.title && !S.byTitle.has(p.title.toLowerCase())) S.byTitle.set(p.title.toLowerCase(), p); + updateNav(); + S.view?.pagesChanged?.(); } -// Anything still waiting to autosave is saved before the page changes or the tab is hidden. -const flushAll = () => Promise.all([...S.pending].map(f => f())); +let refreshTimer = null; +const refreshSoon = () => { clearTimeout(refreshTimer); refreshTimer = setTimeout(() => refreshPages().catch(() => { }), 2500); }; + +// Anything still waiting to autosave is saved before the page changes or the tab is hidden or closed. +const flushAll = () => Promise.all([...S.editors].map(e => e.save())); document.addEventListener('visibilitychange', () => { if (document.hidden) flushAll(); }); window.addEventListener('pagehide', () => { flushAll(); }); +window.addEventListener('beforeunload', e => { if ([...S.editors].some(x => x.dirty())) { e.preventDefault(); e.returnValue = ''; } }); + +// A 401 while someone is writing must not throw their text away: the editor keeps it and offers to sign in again. +onSignedOut = () => { if (S.ready && ![...S.editors].some(e => e.dirty())) { S.ready = false; showLogin(); } }; + +// ---------- signing in ---------- +const SIGNIN_MESSAGES = { + denied: 'This account is not allowed to open this notebook. Ask whoever runs Basking to add you.', + failed: 'Signing in did not finish. Please try again.', + expired: 'That sign-in took too long. Please try again.', + unavailable: 'Basking could not reach Authentik. Try again in a moment.', +}; async function boot() { - S.view = null; S.pending.clear(); - try { await api('GET', '/api/me'); } catch { return $app.replaceChildren(loginView()); } - await refreshSummary().catch(() => { }); + const why = new URLSearchParams(location.search).get('signin'); + if (why) history.replaceState(null, '', location.pathname + location.hash); + try { S.me = await api('GET', '/api/me'); } + catch (e) { + 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(() => { }); S.ready = true; - render(); + buildShell(); + show(); } -function loginView() { - const pw = h('input', { type: 'password', autocomplete: 'current-password' }); - const go = act(async () => { - await api('POST', '/api/login', { password: pw.value }); - if (toastBox) toastBox.replaceChildren(); // an earlier failed attempt no longer applies - boot(); - }); - pw.addEventListener('keydown', e => e.key === 'Enter' && go()); - setTimeout(() => pw.focus()); - return h('div', { class: 'center' }, card('', brand(), h('h2', {}, 'Sign in'), h('div', { class: 'stack' }, - field('Password', pw, 'Basking printed it in its log the first time it started.'), h('button', { class: 'btn', onclick: go }, 'Sign in')))); +function showLogin(why) { + S.editors.clear(); S.view = null; document.title = 'Sign in · Basking'; + const next = location.hash.length > 2 ? location.hash : ''; + $app.replaceChildren(h('main', { class: 'center' }, card('', brand(), + h('h1', { class: 'login-h' }, 'Your private notebook'), + h('p', { class: 'dim center-text' }, 'Pages, notes and a journal that only you can open. Sign in with your Authentik account to continue.'), + SIGNIN_MESSAGES[why] ? h('div', { class: 'notice notice-warning', role: 'alert' }, icon('alert', 16), SIGNIN_MESSAGES[why]) : null, + h('a', { class: 'btn-link primary wide', href: '/auth/login' + (next ? '?next=' + encodeURIComponent(next) : '') }, icon('lock', 16), 'Continue with Authentik')))); } // ---------- shell ---------- @@ -313,179 +72,357 @@ function setDrawer(open) { } document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) document.getElementById('sidebar')?.classList.contains('open') && setDrawer(false); }); -const route = () => { const p = location.hash.slice(2).split('/'); return { name: p[0] || 'notes', arg: decodeURIComponent(p.slice(1).join('/')) }; }; -const routeKey = r => r.name === 'tag' ? 'tag/' + r.arg : r.name === 'day' ? (r.arg === isoDay(new Date()) ? 'today' : 'journal') : r.name; +const route = () => { + const p = location.hash.slice(2).split('/'); + let arg = p.slice(1).join('/'); + try { arg = decodeURIComponent(arg); } catch { /* keep it as typed */ } + return { name: p[0] || 'home', arg }; +}; +const routeKey = r => r.name === 'tag' ? 'tag/' + r.arg : r.name === 'p' ? 'p/' + r.arg : r.name === 'day' ? (r.arg === isoDay(new Date()) ? 'today' : 'journal') : r.name; function navItem(href, key, label, ic, count) { const cur = routeKey(route()) === key; - const el = h('a', { class: 'navitem' + (cur ? ' on' : ''), href, 'aria-current': cur ? 'page' : null, onclick: () => setDrawer(false) }, - icon(ic), h('span', { class: 'label' }, label), count == null ? null : h('span', { class: 'count' }, count)); - return el; + return h('a', { class: 'navitem' + (cur ? ' on' : ''), href, 'aria-current': cur ? 'page' : null, onclick: () => setDrawer(false) }, + ic ? icon(ic) : null, h('span', { class: 'label' }, label), count == null ? null : h('span', { class: 'count' }, count)); } -// The sidebar's lists and counts change as notes do, so they are rebuilt on their own. + +const openSet = () => { try { return new Set(JSON.parse(store.get('open') || '[]')); } catch { return new Set(); } }; +function toggleOpen(id) { const s = openSet(); s.has(id) ? s.delete(id) : s.add(id); store.set('open', JSON.stringify([...s])); } + +// The pages in the sidebar, nested the way they were made. A page holding the one you are on stays open. +function tree(here) { + const kids = new Map(); + for (const p of S.pages) { + if (p.daily) continue; + const par = p.parent && S.byId.has(p.parent) && !S.byId.get(p.parent).daily ? p.parent : ''; + if (!kids.has(par)) kids.set(par, []); + kids.get(par).push(p); + } + const open = openSet(), forced = new Set(), cur = here.startsWith('p/') ? here.slice(2) : ''; + for (let id = S.byId.get(cur)?.parent, n = 0; id && n < 50; id = S.byId.get(id)?.parent, n++) forced.add(id); + const rows = []; + (function walk(par, depth) { + for (const p of kids.get(par) || []) { + const has = (kids.get(p.id) || []).length > 0, isOpen = open.has(p.id) || forced.has(p.id), on = cur === p.id; + rows.push(h('div', { class: 'treerow d' + Math.min(depth, 6) }, + has ? h('button', { class: 'twist' + (isOpen ? ' open' : ''), 'aria-label': (isOpen ? 'Collapse ' : 'Expand ') + p.name, 'aria-expanded': String(isOpen), onclick: () => { toggleOpen(p.id); updateNav(); } }, icon('chev', 16)) : h('span', { class: 'twist-gap' }), + h('a', { class: 'navitem tree-link' + (on ? ' on' : ''), href: '#/p/' + p.id, 'aria-current': on ? 'page' : null, onclick: () => setDrawer(false) }, h('span', { class: 'label' }, p.name)), + h('button', { class: 'sub-add', 'aria-label': 'Add a subpage to ' + p.name, title: 'Add a subpage', onclick: () => { setDrawer(false); location.hash = '#/new/' + p.id; } }, icon('add', 16)))); + if (has && isOpen && depth < 30) walk(p.id, depth + 1); + } + })('', 0); + return rows.length ? rows : h('p', { class: 'navhint' }, 'Pages you title show up here. Use + to make one.'); +} + +// The sidebar's lists and counts change as pages do, so they are rebuilt on their own. function updateNav() { const box = document.getElementById('nav-dynamic'); if (!box) return; + const here = routeKey(route()), pinned = S.pages.filter(p => p.pinned && !p.daily); fill(box, - navItem('#/', 'notes', 'Notes', 'notes', S.counts.all), + h('button', { class: 'navitem find', onclick: () => { setDrawer(false); quickFind(); } }, icon('search'), h('span', { class: 'label' }, 'Search'), h('kbd', {}, 'Ctrl K')), + navItem('#/', 'home', 'Home', 'home'), navItem('#/today', 'today', 'Today', 'today'), - navItem('#/journal', 'journal', 'Journal', 'journal', S.counts.daily), - navItem('#/pinned', 'pinned', 'Pinned', 'pin', S.counts.pinned), - S.tags.length ? h('div', { class: 'navsec' }, 'Tags') : null, - S.tags.map(t => navItem('#/tag/' + encodeURIComponent(t.tag), 'tag/' + t.tag, t.tag, 'tag', t.count)), + navItem('#/journal', 'journal', 'Journal', 'journal', S.counts.daily || null), + pinned.length ? [h('div', { class: 'navsec' }, 'Pinned'), pinned.map(p => navItem('#/p/' + p.id, 'p/' + p.id, p.name, 'pin'))] : null, + h('div', { class: 'navsec navsec-row' }, 'Pages', h('button', { class: 'ghost quiet btn-icon tiny', 'aria-label': 'New page', title: 'New page (c)', onclick: () => { setDrawer(false); location.hash = '#/new'; } }, icon('add', 16))), + 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)); } -async function render() { - await flushAll(); - S.pending.clear(); +function buildShell() { const main = h('main', { id: 'view', tabindex: '-1' }); $app.replaceChildren(h('a', { class: 'skip', href: '#view', onclick: e => { e.preventDefault(); main.focus(); } }, 'Skip to content'), h('div', { class: 'shell' }, - h('div', { class: 'topbar' }, brand(), h('button', { class: 'ghost quiet btn-icon', id: 'menu-btn', 'aria-label': 'Open menu', 'aria-controls': 'sidebar', 'aria-expanded': 'false', onclick: () => setDrawer(true) }, icon('menu'))), + h('div', { class: 'topbar' }, brand(), h('div', { class: 'actions' }, + h('button', { class: 'ghost quiet btn-icon', 'aria-label': 'Search', onclick: () => quickFind() }, icon('search')), + h('button', { class: 'ghost quiet btn-icon', id: 'menu-btn', 'aria-label': 'Open menu', 'aria-controls': 'sidebar', 'aria-expanded': 'false', onclick: () => setDrawer(true) }, icon('menu')))), h('div', { class: 'backdrop', id: 'backdrop', onclick: () => setDrawer(false) }), h('aside', { class: 'sidebar', id: 'sidebar', 'aria-label': 'Main' }, h('div', { class: 'sidebar-head' }, brand(), h('button', { class: 'ghost quiet btn-icon sidebar-close', 'aria-label': 'Close menu', onclick: () => setDrawer(false) }, icon('close'))), - h('nav', { id: 'nav-dynamic', class: 'nav', 'aria-label': 'Notes' }), + h('nav', { id: 'nav-dynamic', class: 'nav', 'aria-label': 'Pages' }), h('div', { class: 'grow' }), h('div', { class: 'userbox' }, - navItemLink('#/settings', 'settings', 'Settings', 'settings'), + h('div', { class: 'who', title: S.me.email || '' }, S.me.name), + navItem('#/settings', 'settings', 'Settings', 'settings'), h('button', { class: 'navitem', onclick: toggleTheme }, icon('dark'), 'Switch theme'), - h('button', { class: 'navitem', onclick: act(async () => { await flushAll(); await api('POST', '/api/logout'); S.ready = false; boot(); }) }, icon('out'), 'Sign out'))), + h('button', { class: 'navitem', onclick: act(async () => { await flushAll(); await api('POST', '/api/logout'); S.ready = false; S.editors.clear(); showLogin(); }) }, icon('out'), 'Sign out'))), h('div', { class: 'content' }, main))); updateNav(); - show(); } -function navItemLink(href, key, label, ic) { return navItem(href, key, label, ic); } -window.addEventListener('hashchange', () => { if (S.ready) render(); }); +window.addEventListener('hashchange', () => { if (S.ready) { updateNav(); show(); } }); function pageHeader(title, desc, primary) { return h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, title), desc ? h('p', {}, desc) : null), primary ? h('button', { class: 'btn', onclick: primary[1] }, icon(primary[2] || 'add', 16), primary[0]) : null); } -function show() { +async function show() { + const mine = ++S.seq; + await flushAll(); + if (mine !== S.seq) return; + S.view?.dispose?.(); S.editors.clear(); const r = route(), main = document.getElementById('view'); - S.q = ''; let page; switch (r.name) { case 'today': location.replace('#/day/' + isoDay(new Date())); return; - case 'day': page = /^\d{4}-\d{2}-\d{2}$/.test(r.arg) ? dayPage(r.arg) : notesPage({ view: 'all' }); break; + case 'day': page = isDay(r.arg) ? pageView({ date: r.arg }) : feed({ view: 'all', composer: true, title: 'Home' }); break; + case 'p': page = pageView({ id: r.arg }); break; + case 'new': page = pageView({ parent: r.arg }); break; + case 'link': page = linkPage(r.arg); break; case 'journal': page = journalPage(); break; - case 'pinned': page = notesPage({ view: 'pinned', title: 'Pinned', desc: 'Notes you pinned to keep close.' }); break; - case 'tag': page = notesPage({ view: 'all', tag: r.arg, title: '#' + r.arg, desc: 'Every note with this tag.' }); break; - case 'trash': page = notesPage({ view: 'trash', title: 'Trash', desc: 'Deleted notes wait here until you empty the trash.' }); 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 'trash': page = feed({ view: 'trash', title: 'Trash', desc: 'Deleted pages wait here until you empty the trash.' }); break; case 'settings': page = settingsPage(); break; - default: page = notesPage({ view: 'all', composer: true, title: 'Notes', desc: 'Write anything down. Sort it out 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.' }); } S.view = page; main.replaceChildren(page.el); document.title = (page.title ? page.title + ' · ' : '') + 'Basking'; + window.scrollTo(0, 0); page.start?.(); } -// ---------- the editor: autosaves, and notices when a note changed somewhere else ---------- -function makeEditor(note, { rows = 4, onSaved, big } = {}) { - let cur = note, dirty = false, saving = false, queued = false, blocked = false, timer = null; - const ta = h('textarea', { class: 'write' + (big ? ' big' : ''), rows, 'aria-label': 'Note text', placeholder: big ? 'Write about your day...' : '' }); - ta.value = note.body; - const status = h('span', { class: 'sub', role: 'status' }); - const notice = h('div', { class: 'notice notice-warning', hidden: true }); - const fit = () => { if (big) return; ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight + 2, window.innerHeight * 0.7) + 'px'; }; +// ---------- a page: title, text, subpages and what links here ---------- +function pageView({ id, date, parent = '' }) { + const root = h('div', { class: 'page' }); + let ed = null, note = null, backlinks = [], dead = false; + const crumbs = h('nav', { class: 'crumbs', 'aria-label': 'Where this page is' }); + const actions = h('div', { class: 'page-actions' }); + const tagsRow = h('div', { class: 'tagrow' }); + const asideBox = h('div', { class: 'aside' }); + const meta = h('p', { class: 'sub page-meta' }); + const body = h('div'); + const view = { el: root, title: date ? dayLabel(date) : '', dispose() { dead = true; ed?.dispose(); }, pagesChanged() { if (ed) { drawCrumbs(); drawAside(); } } }; - async function save() { - clearTimeout(timer); timer = null; - if (!dirty || blocked) return; - if (saving) { queued = true; return; } - saving = true; dirty = false; status.textContent = 'Saving'; - try { - cur = await api('PUT', '/api/notes/' + cur.id, { body: ta.value, base: cur.updated }); - if (!dirty) status.textContent = 'Saved'; - onSaved?.(cur); - } catch (e) { - dirty = true; - if (e.status === 409) conflict(e.data.note); - else { status.textContent = 'Not saved'; if (e.message !== SIGNED_OUT) toast(e.message, true); } - } finally { - saving = false; - if (queued) { queued = false; save(); } - } + const parentId = () => note ? note.parent : parent; + function drawCrumbs() { + const chain = []; + for (let pid = parentId(), n = 0; pid && S.byId.has(pid) && n < 30; pid = S.byId.get(pid).parent, n++) chain.unshift(S.byId.get(pid)); + fill(crumbs, date ? h('a', { href: '#/journal' }, 'Journal') : chain.map((p, k) => [k ? h('span', { class: 'sep', 'aria-hidden': 'true' }, '/') : null, h('a', { href: '#/p/' + p.id }, p.name)])); } - function conflict(theirs) { - blocked = true; status.textContent = 'Not saved'; - notice.hidden = false; - notice.replaceChildren(icon('alert', 16), h('span', { class: 'grow' }, 'This note changed somewhere else, probably on another device.'), - h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; notice.hidden = true; save(); } }, 'Keep my version'), - h('button', { class: 'ghost', onclick: () => { cur = theirs; ta.value = theirs.body; dirty = false; blocked = false; notice.hidden = true; status.textContent = 'Saved'; fit(); onSaved?.(cur); } }, 'Use the other version')); + function drawTags() { + const tags = ed?.note?.tags || note?.tags || []; + fill(tagsRow, tags.map(t => tagChip(t))); } - ta.addEventListener('input', () => { - dirty = true; status.textContent = 'Unsaved'; fit(); - clearTimeout(timer); timer = setTimeout(save, 700); + function drawAside() { + const kids = note ? childrenOf(note.id) : []; + fill(asideBox, + kids.length ? h('section', { class: 'aside-sec' }, h('h2', {}, 'Subpages'), + h('ul', { class: 'linklist' }, kids.map(k => h('li', {}, h('a', { href: '#/p/' + k.id }, icon('notes', 16), k.name))))) : null, + note ? h('section', { class: 'aside-sec' }, h('h2', {}, 'Linked from'), backlinks.length + ? h('ul', { class: 'linklist' }, backlinks.map(b => h('li', {}, h('a', { href: hrefOf(b) }, icon(b.daily ? 'today' : 'link', 16), b.daily ? dayLabel(b.daily) : b.name)))) + : h('p', { class: 'sub' }, note.title ? 'No page links here yet. Type [[ in another page and pick this one.' : 'Give this page a title so other pages can link to it.')) : null); + } + function drawMeta() { meta.textContent = note ? 'Edited ' + fmtTime(note.updated) : ''; } + + function drawActions() { + const have = !!(note && note.id); + const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'View' }, + h('button', { class: 'seg-b' + (ed.mode === 'write' ? ' on' : ''), 'aria-pressed': String(ed.mode === 'write'), onclick: () => ed.setMode('write') }, 'Write'), + h('button', { class: 'seg-b' + (ed.mode === 'read' ? ' on' : ''), 'aria-pressed': String(ed.mode === 'read'), onclick: () => ed.setMode('read') }, 'Read')); + fill(actions, ed.status, h('span', { class: 'grow' }), seg, + date ? [btn('Insert time', 'clock', () => ed.stamp())] : null, + btn(note?.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { + if (!have) return toast('Type something first, then you can pin the page'); + note = await api('PUT', '/api/notes/' + note.id, { pinned: !note.pinned }); drawActions(); refreshPages(); + })), + 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('Delete', 'trash', act(trashIt), 'ghost danger')); + } + ed = null; + + async function trashIt() { + if (!note) { location.hash = '#/'; return; } + await ed.flush(); + const n = note, kids = childrenOf(n.id).length; + await api('DELETE', '/api/notes/' + n.id); + toast(kids ? 'Moved to the trash. Its subpages moved up a level.' : 'Moved to the trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `/api/notes/${n.id}/restore`); await refreshPages(); location.hash = hrefOf(n); }) }); + await refreshPages(); + location.hash = n.parent && S.byId.has(n.parent) ? '#/p/' + n.parent : '#/'; + } + + function build(n) { + note = n; + view.title = date ? dayLabel(date) : n?.name || 'New page'; + ed = pageEditor({ + note: n, parent, fixedTitle: date ? parseDay(date).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : undefined, + onCreated(c) { note = c; history.replaceState(null, '', '#/p/' + c.id); document.title = (c.name || 'New page') + ' · Basking'; drawActions(); drawCrumbs(); refreshPages(); }, + onSaved(c, { renamed }) { + note = c; if (c.backlinks) backlinks = c.backlinks; + drawTags(); drawAside(); drawMeta(); drawActions(); + document.title = (c.name || 'New page') + ' · Basking'; + renamed ? refreshPages().catch(() => { }) : refreshSoon(); + }, + }); + ed.onMode = () => drawActions(); + view.toggleMode = () => ed.setMode(ed.mode === 'write' ? 'read' : 'write'); + if (n?.backlinks) backlinks = n.backlinks; + const pref = store.get('mode'); + const start = n && n.body.trim() ? pref || 'read' : 'write'; // an empty page is for writing + const title = date ? null : h('div', { class: 'title-wrap' }, ed.titleEl, ed.titleErr); + fill(body, ed.notice, title, date ? h('h1', { class: 'day-title' }, ed.titleEl.value) : null, ed.wrap, tagsRow, asideBox, meta); + ed.setMode(start); + drawCrumbs(); drawTags(); drawAside(); drawMeta(); drawActions(); + document.title = view.title + ' · Basking'; + // keep the person's choice of Write or Read once they have made one + const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); }; + if (!n) setTimeout(() => ed.titleEl.focus()); + else if (!date && !n.body.trim() && !n.title) setTimeout(() => ed.titleEl.focus()); + else if (date && !n.body.trim() && start === 'write') setTimeout(() => ed.ta.focus()); + } + + function trashedView(n) { + view.title = n.name; + fill(body, h('div', { class: 'notice notice-warning' }, icon('trash', 16), h('span', { class: 'grow' }, 'This page is in the trash.'), + btn('Restore', 'restore', act(async () => { await api('POST', `/api/notes/${n.id}/restore`); await refreshPages(); show(); })), + 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', `/api/notes/${n.id}?purge=1`); await refreshPages(); location.hash = '#/trash'; + }), 'ghost danger')), + h('h1', { class: 'day-title' }, n.name), md(n.body, null, true)); + } + + view.start = act(async () => { + fill(body, skeleton()); + if (id || date) { + let n; + try { n = date ? await api('POST', '/api/daily', { date }) : await api('GET', '/api/notes/' + encodeURIComponent(id)); } + catch (e) { + if (e.status === 404) { fill(body, emptyState('notes', 'This page is not here', 'It may have been deleted for good, or the link is wrong.', btn('Back to Home', 'home', () => { location.hash = '#/'; }, 'btn'))); return; } + throw e; + } + if (dead) return; + if (n.trashed) return trashedView(n); + build(n); + } else build(null); }); - S.pending.add(save); + + if (date) { + const today = isoDay(new Date()); + fill(root, h('div', { class: 'page-top' }, crumbs, actions), body); + const nav = h('div', { class: 'actions daynav' }, + btn('Previous day', 'prev', () => { location.hash = '#/day/' + addDays(date, -1); }), + date === today ? null : btn('Today', 'today', () => { location.hash = '#/day/' + today; }), + date >= today ? null : btn('Next day', 'next', () => { location.hash = '#/day/' + addDays(date, 1); })); + root.insertBefore(nav, body); + } else fill(root, h('div', { class: 'page-top' }, crumbs, actions), body); + return view; +} + +// A [[link]] to a page that does not exist yet: make it, then go there. +function linkPage(title) { return { - ta, status, notice, save, - get note() { return cur; }, - flush: async () => { await save(); }, - close() { S.pending.delete(save); }, - fit, + title, el: h('div', {}, skeleton()), + start: act(async () => { + if (isDay(title)) { location.replace('#/day/' + title); return; } + let p = pageByTitle(title); + if (!p) { await refreshPages(); p = pageByTitle(title); } + if (!p) { + try { const n = await api('POST', '/api/notes', { title }); await refreshPages(); location.replace('#/p/' + n.id); return; } + catch (e) { if (e.data?.code !== 'title_taken') throw e; await refreshPages(); p = pageByTitle(title); } + } + if (p) location.replace(hrefOf(p)); else location.replace('#/'); + }), }; } -// ---------- note cards ---------- +// Pick where a page goes: a list of every other page it can sit inside, or the top level. +function movePage(n, done) { + const below = new Set([n.id]); + for (let grew = true; grew;) { grew = false; for (const p of S.pages) if (!below.has(p.id) && below.has(p.parent)) { below.add(p.id); grew = true; } } + const input = h('input', { type: 'search', placeholder: 'Search pages', 'aria-label': 'Search pages' }), list = h('div', { class: 'finder-list' }); + let close; + const pick = act(async parentId => { + close(); + await api('PUT', '/api/notes/' + n.id, { parent: parentId }); + toast(parentId ? 'Moved inside ' + S.byId.get(parentId).name : 'Moved to the top level'); + await done(); + }); + function draw() { + const q = input.value.trim().toLowerCase(); + const rows = S.pages.filter(p => !p.daily && !below.has(p.id) && (!q || p.name.toLowerCase().includes(q))); + fill(list, !q ? h('button', { class: 'finder-row', onclick: () => pick('') }, icon('home', 16), h('span', { class: 'finder-name' }, 'Top level'), n.parent ? null : h('span', { class: 'sub' }, 'now')) : null, + rows.slice(0, 40).map(p => h('button', { class: 'finder-row', onclick: () => pick(p.id) }, icon('notes', 16), h('span', { class: 'finder-name' }, p.name), p.id === n.parent ? h('span', { class: 'sub' }, 'now') : null)), + !rows.length && q ? h('p', { class: 'sub pad' }, 'No page matches.') : null); + } + input.addEventListener('input', draw); + close = modal('Move "' + n.name + '"', h('div', { class: 'stack' }, input, list), { wide: true }); + draw(); +} + +// ---------- quick find: Ctrl+K, or / when not typing ---------- +function quickFind() { + if (document.querySelector('.finder')) return; + const input = h('input', { type: 'search', class: 'finder-input', placeholder: 'Search pages, or type a title to make a new one', 'aria-label': 'Search pages', autocomplete: 'off' }); + const list = h('div', { class: 'finder-list', role: 'listbox' }); + let hits = [], sel = 0, timer, token = 0, close; + const items = () => { + const q = input.value.trim(); + const out = hits.map(x => ({ name: x.daily ? dayLabel(x.daily) : x.name, sub: x.snippet || (x.daily ? 'Journal' : S.byId.get(x.parent)?.name || ''), href: hrefOf(x), ic: x.daily ? 'today' : 'notes' })); + if (q && !hits.some(x => x.title && x.title.toLowerCase() === q.toLowerCase())) out.push({ name: 'Create "' + q + '"', sub: 'New page', href: '#/link/' + encodeURIComponent(q), ic: 'add' }); + return out; + }; + const go = it => { close(); location.hash = it.href; }; + function draw() { + const rows = items(); + sel = Math.min(sel, Math.max(0, rows.length - 1)); + fill(list, rows.length ? rows.map((it, i) => h('div', { class: 'finder-row' + (i === sel ? ' on' : ''), role: 'option', 'aria-selected': String(i === sel), onmousedown: e => { e.preventDefault(); go(it); } }, + icon(it.ic, 16), h('span', { class: 'finder-name' }, it.name), it.sub ? h('span', { class: 'sub finder-sub' }, it.sub) : null)) + : h('p', { class: 'sub pad' }, 'Nothing yet. Write a page and it will show up here.')); + list.querySelector('.on')?.scrollIntoView({ block: 'nearest' }); + } + const load = act(async () => { + const mine = ++token; + const r = await api('GET', '/api/find?q=' + encodeURIComponent(input.value.trim())); + if (mine !== token) return; + hits = r.hits; sel = 0; draw(); + }); + input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 120); }); + input.addEventListener('keydown', e => { + const rows = items(); + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (rows.length) { sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length; draw(); } } + else if (e.key === 'Enter' && rows[sel]) { e.preventDefault(); go(rows[sel]); } + }); + close = modal('Search', h('div', { class: 'finder' }, input, list), { wide: true, top: true, hideTitle: true }); + draw(); load(); +} + +// ---------- note cards (Home, Pinned, tags, Trash) ---------- const LONG_LINES = 14, LONG_CHARS = 1200; function noteCard(n, ctx) { - const el = h('article', { class: 'note' + (n.pinned ? ' pinned' : ''), 'data-id': n.id }); - let ed = null; - + const el = h('article', { class: 'note' + (n.pinned ? ' pinned' : '') + (n.trashed ? '' : ' openable'), 'data-id': n.id }); const toggleTask = act(async (line, checked) => { const lines = n.body.split('\n'); lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); n = await api('PUT', '/api/notes/' + n.id, { body: lines.join('\n'), base: n.updated }); - ctx.changed(n, true); + ctx.changed(n); }); - - function view() { - ed = null; - const long = n.body.split('\n').length > LONG_LINES || n.body.length > LONG_CHARS; - let open = false; - const body = n.body.trim() ? md(n.body, n.trashed ? null : toggleTask) : h('p', { class: 'dim' }, 'Empty note'); - const wrap = h('div', { class: 'note-body' + (long ? ' clamp' : '') }, body); - const more = long ? h('button', { class: 'ghost quiet small-btn', 'aria-expanded': 'false', onclick: e => { - open = !open; wrap.classList.toggle('clamp', !open); e.currentTarget.setAttribute('aria-expanded', open); e.currentTarget.textContent = open ? 'Show less' : 'Show more'; - } }, 'Show more') : null; - const when = n.daily ? h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily)) : h('time', { datetime: n.created, title: 'Written ' + fmtTime(n.created) + (n.updated !== n.created ? '. Edited ' + fmtTime(n.updated) : '') }, hhmm(n.created)); - const actions = n.trashed - ? [btn('Restore', 'restore', act(() => ctx.restore(n))), btn('Delete for good', 'trash', act(() => ctx.purge(n)), 'ghost danger')] - : [btn('Edit', 'edit', edit), btn(n.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { n = await api('PUT', '/api/notes/' + n.id, { pinned: !n.pinned }); ctx.changed(n, true); })), btn('Delete', 'trash', act(() => ctx.trash(n)))]; - el.className = 'note' + (n.pinned ? ' pinned' : ''); - fill(el, wrap, more, h('div', { class: 'note-foot' }, when, n.pinned ? badge('Pinned', 'accent') : null, h('span', { class: 'grow' }), h('div', { class: 'actions' }, actions))); - } - - function edit() { - ed = makeEditor(n, { rows: 3, onSaved: s => { n = s; ctx.changed(n, false); } }); - el.className = 'note editing'; - el.replaceChildren(ed.notice, ed.ta, h('div', { class: 'note-foot' }, ed.status, h('span', { class: 'grow' }), h('div', { class: 'actions' }, - btn('Done', 'check', act(done), 'btn')))); - ed.fit(); ed.ta.focus(); ed.ta.setSelectionRange(ed.ta.value.length, ed.ta.value.length); - ed.ta.addEventListener('keydown', e => { - if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { e.preventDefault(); e.stopPropagation(); done(); } - }); - } - async function done() { - const e = ed; await e.flush(); e.close(); n = e.note; - view(); ctx.changed(n, true); el.querySelector('button')?.focus(); - } - - view(); - el.edit = edit; + const long = n.body.split('\n').length > LONG_LINES || n.body.length > LONG_CHARS; + let open = false; + const body = n.body.trim() ? md(n.body, n.trashed ? null : toggleTask) : h('p', { class: 'dim' }, 'Empty page'); + const wrap = h('div', { class: 'note-body' + (long ? ' clamp' : '') }, body); + const more = long ? h('button', { class: 'ghost quiet small-btn', 'aria-expanded': 'false', onclick: e => { + open = !open; wrap.classList.toggle('clamp', !open); e.currentTarget.setAttribute('aria-expanded', open); e.currentTarget.textContent = open ? 'Show less' : 'Show more'; + } }, 'Show more') : null; + const heading = n.daily ? dayLabel(n.daily) : n.title; + const when = h('time', { datetime: n.created, title: 'Written ' + fmtTime(n.created) + (n.updated !== n.created ? '. Edited ' + fmtTime(n.updated) : '') }, hhmm(n.created)); + const actions = n.trashed + ? [btn('Restore', 'restore', act(() => ctx.restore(n))), btn('Delete for good', 'trash', act(() => ctx.purge(n)), 'ghost danger')] + : [btn('Open', 'edit', () => { location.hash = hrefOf(n); }), btn(n.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { n = await api('PUT', '/api/notes/' + n.id, { pinned: !n.pinned }); ctx.changed(n); })), btn('Delete', 'trash', act(() => ctx.trash(n)))]; + fill(el, heading ? h('h3', { class: 'note-title' }, n.trashed ? heading : h('a', { href: hrefOf(n) }, heading)) : null, wrap, more, + h('div', { class: 'note-foot' }, when, n.pinned ? badge('Pinned', 'accent') : null, h('span', { class: 'grow' }), h('div', { class: 'actions' }, actions))); + if (!n.trashed) el.addEventListener('click', e => { if (!e.target.closest('a,input,button') && !inSelection()) location.hash = hrefOf(n); }); return el; } -// ---------- the composer ---------- +// ---------- the quick-note box ---------- function composer(onSaved) { - const ta = h('textarea', { class: 'write', rows: 3, placeholder: 'What is on your mind?', 'aria-label': 'New note' }); + const ta = h('textarea', { class: 'write', rows: 3, placeholder: 'Jot something down. Press Ctrl+Enter to save.', 'aria-label': 'New note' }); ta.value = store.get('draft'); const fit = () => { ta.style.height = 'auto'; ta.style.height = Math.min(Math.max(ta.scrollHeight + 2, 84), window.innerHeight * 0.5) + 'px'; }; const go = h('button', { class: 'btn' }, icon('check', 16), 'Save note'); @@ -497,49 +434,50 @@ function composer(onSaved) { await api('POST', '/api/notes', { body: text }); ta.value = ''; store.set('draft', ''); fit(); await onSaved(); - } catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); } + } catch (e) { if (e.status !== 401) toast(e.message, true); } go.disabled = false; ta.focus(); }; go.addEventListener('click', save); ta.addEventListener('input', () => { store.set('draft', ta.value); fit(); }); ta.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); } }); setTimeout(fit); - return Object.assign(h('div', { class: 'card composer' }, ta, h('div', { class: 'note-foot' }, h('span', { class: 'sub' }, 'Ctrl+Enter saves. Add #tags to find things later.'), h('span', { class: 'grow' }), go)), { ta }); + return Object.assign(h('div', { class: 'card composer' }, ta, h('div', { class: 'note-foot' }, + h('span', { class: 'sub' }, 'A quick note. Add #tags to find it later, or open it to give it a title.'), h('span', { class: 'grow' }), + btn('New page', 'add', () => { location.hash = '#/new'; }), go)), { ta }); } -// ---------- notes list ---------- -function notesPage({ view, tag = '', title, desc, composer: withComposer }) { +// ---------- lists of notes ---------- +function feed({ view, tag = '', title, desc, composer: withComposer }) { const list = h('div', { class: 'notes' }), moreBox = h('div', { class: 'more' }); - const search = h('input', { type: 'search', placeholder: 'Search notes, or type #tag', 'aria-label': 'Search notes' }); - let items = [], total = 0, loaded = false, timer = null, token = 0; + const search = h('input', { type: 'search', placeholder: 'Filter these notes, or type #tag', 'aria-label': 'Filter notes' }); + let items = [], total = 0, timer = null, token = 0; async function load(reset) { const mine = ++token, offset = reset ? 0 : items.length; const q = new URLSearchParams({ view, tag, q: search.value.trim(), offset, limit: 50 }); const r = await api('GET', '/api/notes?' + q); if (mine !== token) return; - items = reset ? r.notes : items.concat(r.notes); total = r.total; loaded = true; S.q = search.value.trim(); + items = reset ? r.notes : items.concat(r.notes); total = r.total; draw(); } - const reload = act(() => Promise.all([load(true), refreshSummary()])); + const reload = act(() => Promise.all([load(true), refreshPages()])); const ctx = { - changed(n, redraw) { + changed(n) { const k = items.findIndex(x => x.id === n.id); if (k >= 0) items[k] = n; if (view === 'pinned' && !n.pinned) { items.splice(k, 1); total--; } - if (redraw) { draw(); } refreshSummary().catch(() => { }); + draw(); refreshPages().catch(() => { }); }, async trash(n) { await api('DELETE', '/api/notes/' + n.id); toast('Moved to the trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `/api/notes/${n.id}/restore`); await reload(); }) }); await reload(); }, - async restore(n) { await api('POST', `/api/notes/${n.id}/restore`); toast('Note restored'); await reload(); }, + async restore(n) { await api('POST', `/api/notes/${n.id}/restore`); toast('Restored'); await reload(); }, async purge(n) { - const ok = await confirmDialog({ title: 'Delete this note for good?', body: `"${preview(n.body)}" will be deleted and cannot be brought back.`, confirm: 'Delete note' }); - if (!ok) return; - await api('DELETE', `/api/notes/${n.id}?purge=1`); toast('Note deleted'); await reload(); + if (!await confirmDialog({ title: 'Delete this for good?', body: `"${n.name}" will be deleted and cannot be brought back.`, confirm: 'Delete' })) return; + await api('DELETE', `/api/notes/${n.id}?purge=1`); toast('Deleted'); await reload(); }, }; @@ -547,10 +485,10 @@ function notesPage({ view, tag = '', title, desc, composer: withComposer }) { list.replaceChildren(); moreBox.replaceChildren(); if (!items.length) { const searching = search.value.trim() || tag; - const [ic, t, p] = view === 'trash' ? ['trash', 'The trash is empty', 'Notes you delete wait here until you empty the trash.'] - : view === 'pinned' ? ['pin', 'Nothing pinned', 'Pin a note to keep it here.'] - : searching ? ['view', 'No notes match', 'Try fewer words, or clear the search.'] - : ['notes', 'No notes yet', 'Write anything in the box above. It is saved when you press Ctrl+Enter.']; + const [ic, t, p] = view === 'trash' ? ['trash', 'The trash is empty', 'Pages you delete wait here until you empty the trash.'] + : view === 'pinned' ? ['pin', 'Nothing pinned', 'Pin a page to keep it here.'] + : searching ? ['view', 'Nothing matches', 'Try fewer words, or clear the filter.'] + : ['notes', 'Nothing here yet', 'Jot something in the box above, or make a page with New page.']; list.append(emptyState(ic, t, p)); } else { let day = '', group; @@ -566,23 +504,21 @@ function notesPage({ view, tag = '', title, desc, composer: withComposer }) { if (items.length < total) moreBox.append(h('button', { class: 'ghost', onclick: act(() => load(false)) }, `Show more (${total - items.length} left)`)); } - const comp = withComposer ? composer(async () => { search.value = ''; await load(true); await refreshSummary(); }) : null; + const comp = withComposer ? composer(async () => { search.value = ''; await load(true); await refreshPages(); }) : null; const emptyBtn = view === 'trash' ? btn('Empty trash', 'trash', act(async () => { if (!total) { toast('The trash is already empty'); return; } - const ok = await confirmDialog({ title: 'Empty the trash?', body: `${total} ${total === 1 ? 'note' : 'notes'} will be deleted for good. This cannot be undone.`, confirm: 'Empty trash' }); - if (!ok) return; + if (!await confirmDialog({ title: 'Empty the trash?', body: `${total} ${total === 1 ? 'page' : 'pages'} will be deleted for good. This cannot be undone.`, confirm: 'Empty trash' })) return; await api('DELETE', '/api/trash'); toast('Trash emptied'); await reload(); }), 'ghost danger') : null; search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(act(() => load(true)), 250); }); search.addEventListener('keydown', e => { if (e.key === 'Escape') { search.value = ''; load(true); search.blur(); } }); - const el = h('div', {}, pageHeader(title || 'Notes', desc), comp, + const el = h('div', {}, pageHeader(title || 'Home', desc), comp, h('div', { class: 'toolbar' }, search, emptyBtn ? h('span', { class: 'push' }, emptyBtn) : null), list, moreBox); list.append(skeleton()); - return { el, title, search, composer: comp, start: act(() => load(true)), newNote: () => comp?.ta.focus() }; + return { el, title, search, composer: comp, start: act(() => load(true)) }; } -const preview = s => { const t = s.replace(/\s+/g, ' ').trim(); return t.length > 60 ? t.slice(0, 60) + '...' : t; }; // ---------- journal ---------- function journalPage() { @@ -596,7 +532,7 @@ function journalPage() { const filled = r.notes.filter(n => n.body.trim()); if (!filled.length) { list.append(emptyState('journal', 'No journal entries yet', 'Open today and write a few lines. Each day gets its own page.')); return; } for (const n of filled) { - list.append(h('article', { class: 'note' }, h('h2', { class: 'day-h' }, h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily))), + list.append(h('article', { class: 'note openable' }, h('h2', { class: 'day-h' }, h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily))), h('div', { class: 'note-body clamp' }, md(n.body)), h('div', { class: 'note-foot' }, h('span', { class: 'grow' }), h('div', { class: 'actions' }, btn('Open', 'edit', () => { location.hash = '#/day/' + n.daily; }))))); } @@ -604,106 +540,38 @@ function journalPage() { }; } -// ---------- one day ---------- -function dayPage(date) { - const body = h('div'), today = isoDay(new Date()); - let ed = null, previewing = false; - const nav = h('div', { class: 'actions' }, - btn('Previous day', 'prev', () => { location.hash = '#/day/' + addDays(date, -1); }), - date === today ? null : btn('Today', 'today', () => { location.hash = '#/day/' + today; }), - date >= today ? null : btn('Next day', 'next', () => { location.hash = '#/day/' + addDays(date, 1); })); - - function stamp() { - const t = ed.ta, at = t.selectionStart, before = t.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1); - const text = (line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : '') + hhmm(new Date()) + ' '; - t.setRangeText(text, at, t.selectionEnd, 'end'); t.focus(); t.dispatchEvent(new Event('input')); - } - function draw(n) { - ed = makeEditor(n, { rows: 14, big: true }); - const rendered = h('div', { class: 'card note-body', hidden: true }); - const toggle = btn('Preview', 'view', () => { - previewing = !previewing; - if (previewing) { rendered.replaceChildren(ed.ta.value.trim() ? md(ed.ta.value) : h('p', { class: 'dim' }, 'Nothing written yet')); } - rendered.hidden = !previewing; ed.ta.hidden = previewing; - toggle.replaceChildren(icon(previewing ? 'edit' : 'view', 16), previewing ? 'Edit' : 'Preview'); - }); - body.replaceChildren(ed.notice, ed.ta, rendered, h('div', { class: 'note-foot' }, ed.status, h('span', { class: 'grow' }), - h('div', { class: 'actions' }, btn('Insert time', 'clock', stamp), toggle))); - setTimeout(() => ed.ta.focus()); - } - return { - title: dayLabel(date), - el: h('div', {}, pageHeader(dayLabel(date), date === today ? parseDay(date).toLocaleDateString(undefined, { dateStyle: 'full' }) : 'Your page for this day. It saves as you type.'), - h('div', { class: 'toolbar' }, nav), h('div', { class: 'card' }, body)), - start: act(async () => { body.replaceChildren(skeleton()); draw(await api('POST', '/api/daily', { date })); }), - }; -} - // ---------- settings ---------- -async function copyText(text, input) { - try { await navigator.clipboard.writeText(text); toast('Copied'); } - catch { input.select(); toast('Press Ctrl+C to copy'); } -} - function settingsPage() { - const tokens = h('div'); - const loadTokens = act(async () => { - const rows = await api('GET', '/api/tokens'); - tokens.replaceChildren(rows.length ? h('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Created'), h('th', {}))), - h('tbody', {}, rows.map(t => h('tr', {}, h('td', {}, t.name), h('td', {}, fmtTime(t.created)), h('td', { class: 'actions-cell' }, btn('Revoke', null, act(async () => { - const ok = await confirmDialog({ title: `Revoke ${t.name}?`, body: 'Anything using this token stops working at once. You can make another one later.', confirm: 'Revoke token' }); - if (!ok) return; - await api('DELETE', '/api/tokens/' + encodeURIComponent(t.name)); toast('Token revoked'); loadTokens(); - }), 'ghost danger'))))))) : emptyState('tag', 'No tokens yet', 'Make a token to send notes from a script or a phone shortcut.')); - }); - - const newToken = act(async () => { - const r = await formDialog({ title: 'New API token', submit: 'Make token', fields: [{ name: 'name', label: 'Name', required: true, placeholder: 'phone', hint: 'Something that tells you where it is used.' }] }); - if (!r) return; - const t = await api('POST', '/api/tokens', r); - const box = h('input', { type: 'text', readonly: true, class: 'mono' }); box.value = t.token; - let close; - close = modal('Your new token', h('div', { class: 'stack' }, h('p', { class: 'dim' }, 'This is the only time it is shown. Copy it now.'), box, - h('div', { class: 'actions form-actions' }, btn('Copy token', 'copy', () => copyText(t.token, box)), h('button', { class: 'btn', onclick: () => close() }, 'Done'))), { sticky: true }); - box.select(); loadTokens(); - }); - - const changePassword = act(async () => { - const r = await formDialog({ title: 'Change password', submit: 'Change password', fields: [ - { name: 'current', label: 'Current password', type: 'password', required: true }, - { name: 'next', label: 'New password', type: 'password', required: true, hint: 'At least 8 characters.' }, - { name: 'again', label: 'New password again', type: 'password', required: true }] }); - if (!r) return; - if (r.next !== r.again) { toast('The two new passwords do not match', true); return; } - await api('POST', '/api/password', { current: r.current, new: r.next }); - toast('Password changed'); - }); - - const origin = location.origin; - const curl = `curl -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: text/plain" \\\n -d "Call the dentist #errands" \\\n ${origin}/api/notes`; + const row = (k, v) => h('li', {}, h('kbd', {}, k), ' ', v); return { title: 'Settings', - el: h('div', {}, pageHeader('Settings', 'Your password, tokens for scripts, and a copy of everything.'), - card('Password', h('p', { class: 'dim' }, 'Changing it signs out every other browser.'), btn('Change password', 'edit', changePassword)), - card('', h('div', { class: 'card-h' }, h('h2', {}, 'API tokens'), btn('New token', 'add', newToken)), - h('p', { class: 'dim' }, 'A token lets a script or a phone shortcut add notes without signing in.'), tokens), - card('Send a note from anywhere', h('p', { class: 'dim' }, 'With a token, post the text of a note to Basking:'), h('pre', { class: 'snippet' }, h('code', {}, curl)), - h('p', { class: 'dim' }, 'Or, from a terminal with the basking command:'), h('pre', { class: 'snippet' }, h('code', {}, `BASKING_URL=${origin} BASKING_TOKEN=... basking add "an idea"`))), - card('Your notes', h('p', { class: 'dim' }, 'Every note is a plain markdown file in the folder Basking keeps its data in, so you can back that folder up or sync it as it is. This downloads the same files as one zip.'), - btn('Download all notes', 'download', () => { location.href = '/api/export'; }))), - start: loadTokens, + el: h('div', {}, pageHeader('Settings', 'Who you are signed in as, and a copy of everything.'), + card('Account', h('p', {}, 'You are signed in as ', h('strong', {}, S.me.name), S.me.email ? ' (' + S.me.email + ')' : '', ' through Authentik.'), + h('p', { class: 'dim' }, 'Basking has no password of its own. Who can sign in is decided in Authentik, and your pages are visible to you alone.'), + btn('Sign out', 'out', act(async () => { await flushAll(); await api('POST', '/api/logout'); S.ready = false; S.editors.clear(); showLogin(); }))), + card('Your notes', h('p', { class: 'dim' }, 'Download every page as a folder of markdown files. It opens as a vault in Obsidian, with your [[links]] and journal intact.'), + btn('Download all pages', 'download', () => { location.href = '/api/export'; })), + card('Shortcuts', h('ul', { class: 'keys' }, + 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 Write and Read'), 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 Basking, under a folder only you map to. They are not encrypted on disk, so anyone who can read that machine can read them.'))), }; } // ---------- keyboard ---------- document.addEventListener('keydown', e => { - if (!S.ready || modalOpen() || e.ctrlKey || e.metaKey || e.altKey) return; - const t = e.target; - if (t.matches?.('input,textarea,select,[contenteditable]')) return; - if (e.key === '/' && S.view?.search) { e.preventDefault(); S.view.search.focus(); } + if (!S.ready) return; + const mod = e.ctrlKey || e.metaKey; + if (mod && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'o')) { e.preventDefault(); if (!modalOpen()) quickFind(); return; } + if (mod && !e.altKey && !e.shiftKey && e.key === 'e' && S.view?.toggleMode) { e.preventDefault(); S.view.toggleMode(); return; } + if (modalOpen() || mod || e.altKey) return; + if (e.target.matches?.('input,textarea,select,[contenteditable]')) return; + if (e.key === '/') { e.preventDefault(); quickFind(); } + else if (e.key === 'c') { e.preventDefault(); location.hash = '#/new'; } else if (e.key === 'n') { e.preventDefault(); - if (S.view?.composer) S.view.composer.ta.focus(); else { location.hash = '#/'; setTimeout(() => S.view?.composer?.ta.focus(), 400); } + if (S.view?.composer) S.view.composer.ta.focus(); else { location.hash = '#/'; setTimeout(() => S.view?.composer?.ta.focus(), 500); } } }); diff --git a/internal/web/static/editor.js b/internal/web/static/editor.js new file mode 100644 index 0000000..f621a2c --- /dev/null +++ b/internal/web/static/editor.js @@ -0,0 +1,276 @@ +'use strict'; + +// ---------- text helpers: edits go through the browser so Undo keeps working ---------- +function replaceRange(ta, from, to, text) { + ta.focus(); ta.setSelectionRange(from, to); + const ok = text === '' ? document.execCommand('delete') : document.execCommand('insertText', false, text); + if (!ok) { ta.setRangeText(text, from, to, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })); } +} +const lineStart = (v, pos) => v.lastIndexOf('\n', pos - 1) + 1; +const lineEnd = (v, pos) => { const k = v.indexOf('\n', pos); return k < 0 ? v.length : k; }; + +// Where the caret is, in pixels inside the text box, found by laying the same text out in a hidden copy. +function caretXY(ta, pos) { + const cs = getComputedStyle(ta), mirror = h('div', { class: 'mirror' }), mark = h('span', {}, '.'); + for (const p of ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'paddingTop', 'paddingLeft', 'paddingRight', 'tabSize']) + mirror.style[p] = cs[p]; + mirror.style.width = ta.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) + 'px'; + mirror.append(ta.value.slice(0, pos), mark); + document.body.append(mirror); + const at = { x: mark.offsetLeft, y: mark.offsetTop, lh: parseFloat(cs.lineHeight) || 24 }; + mirror.remove(); + return at; +} + +// ---------- the "/" commands and the "[[" page picker ---------- +const SLASH = [ + { name: 'Heading 1', hint: '#', keys: 'h1 heading title', text: '# ' }, + { name: 'Heading 2', hint: '##', keys: 'h2 heading', text: '## ' }, + { name: 'Heading 3', hint: '###', keys: 'h3 heading', text: '### ' }, + { name: 'To-do list', hint: '- [ ]', keys: 'todo task checkbox check', text: '- [ ] ' }, + { name: 'Bulleted list', hint: '-', keys: 'bullet list ul', text: '- ' }, + { name: 'Numbered list', hint: '1.', keys: 'number list ol', text: '1. ' }, + { 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: 'Current time', hint: () => hhmm(new Date()), keys: 'time clock now', text: () => hhmm(new Date()) + ' ' }, +]; + +function makePopup(ta, wrap) { + const el = h('div', { class: 'popup', role: 'listbox', 'aria-label': 'Suggestions', hidden: true }); + wrap.append(el); + let items = [], sel = 0, onPick = null; + function draw() { + fill(el, items.map((it, i) => h('div', { + class: 'popup-row' + (i === sel ? ' on' : ''), role: 'option', 'aria-selected': String(i === sel), + onmousedown: e => { e.preventDefault(); sel = i; pop.pick(); }, + }, h('span', { class: 'popup-name' }, it.name), it.hint ? h('span', { class: 'popup-hint' }, typeof it.hint === 'function' ? it.hint() : it.hint) : null))); + el.querySelector('.on')?.scrollIntoView({ block: 'nearest' }); + } + const pop = { + get open() { return !el.hidden; }, + show(list, pick, pos) { + if (!list.length) return pop.hide(); + 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'; + el.style.top = (ta.offsetTop + c.y + c.lh) + 'px'; + }, + hide() { el.hidden = true; items = []; }, + move(d) { sel = (sel + d + items.length) % items.length; draw(); }, + pick() { const it = items[sel]; pop.hide(); if (it) onPick(it); }, + }; + return pop; +} + +function pageChoices(q) { + const lq = q.trim().toLowerCase(); + const rank = p => { const t = p.title.toLowerCase(); return t === lq ? 0 : t.startsWith(lq) ? 1 : 2; }; + const list = S.pages.filter(p => p.title && (!lq || p.title.toLowerCase().includes(lq))) + .sort((a, b) => rank(a) - rank(b) || a.title.localeCompare(b.title)).slice(0, 7) + .map(p => ({ name: p.title, hint: p.daily ? 'Journal' : S.byId.get(p.parent)?.name || '' })); + if (lq && !S.byTitle.has(lq)) list.push({ name: 'Create "' + q.trim() + '"', hint: 'New page', create: q.trim() }); + return list; +} + +function enhance(ta, wrap) { + const pop = makePopup(ta, wrap); + + function check() { + if (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))) { + const from = pos - m[1].length; + pop.show(pageChoices(m[1]), it => { + const closed = ta.value.startsWith(']]', pos); + replaceRange(ta, from, closed ? pos + 2 : pos, (it.create || it.name) + ']]'); + }, 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 => { + 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); + if (c.again) check(); + }, pos); + } else pop.hide(); + } + + function continueList(e) { + if (ta.selectionStart !== ta.selectionEnd) return false; + const pos = ta.selectionStart, v = ta.value, ls = lineStart(v, pos), le = lineEnd(v, pos), line = v.slice(ls, le); + const m = /^(\s*)([-*+]|(\d+)([.)]))\s+(\[[ xX]\]\s+)?/.exec(line); + if (!m || pos < ls + m[0].length) return false; + e.preventDefault(); + if (line.trim() === m[0].trim()) { replaceRange(ta, ls, le, ''); return true; } // an empty item ends the list + const marker = m[3] ? (Number(m[3]) + 1) + m[4] : m[2]; + replaceRange(ta, pos, pos, '\n' + m[1] + marker + ' ' + (m[5] ? '[ ] ' : '')); + return true; + } + function indentList(e) { + if (ta.selectionStart !== ta.selectionEnd) return false; + const pos = ta.selectionStart, v = ta.value, ls = lineStart(v, pos), line = v.slice(ls, lineEnd(v, pos)); + if (!/^\s*([-*+]|\d+[.)])\s/.test(line)) return false; // anywhere else, Tab keeps moving focus + e.preventDefault(); + if (!e.shiftKey) { replaceRange(ta, ls, ls, ' '); ta.setSelectionRange(pos + 2, pos + 2); } + else { + const n = line.startsWith(' ') ? 2 : /^[ \t]/.test(line) ? 1 : 0; + if (n) { replaceRange(ta, ls, ls + n, ''); ta.setSelectionRange(pos - n, pos - n); } + } + return true; + } + function wrapSelection(mark) { + const s = ta.selectionStart, e = ta.selectionEnd, sel = ta.value.slice(s, e); + replaceRange(ta, s, e, mark + sel + mark); + ta.setSelectionRange(s + mark.length, s + mark.length + sel.length); + } + + ta.addEventListener('keydown', e => { + if (pop.open) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); pop.move(e.key === 'ArrowDown' ? 1 : -1); return; } + if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pop.pick(); return; } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); pop.hide(); return; } + } + if (e.isComposing) return; + const plain = !e.ctrlKey && !e.metaKey && !e.altKey; + if (e.key === 'Enter' && plain && !e.shiftKey) continueList(e); + else if (e.key === 'Tab' && plain) indentList(e); + else if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'b' || e.key === 'i')) { e.preventDefault(); wrapSelection(e.key === 'b' ? '**' : '*'); } + }); + ta.addEventListener('input', check); + ta.addEventListener('click', check); + ta.addEventListener('keyup', e => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) check(); }); + ta.addEventListener('blur', () => setTimeout(pop.hide, 150)); + return pop; +} + +// ---------- 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 }) { + let cur = note || null, titleDirty = false, bodyDirty = false, saving = false, queued = false, blocked = false; + let timer = null, retry = null, badTitle = null, mode = 'write'; + + const titleEl = h('input', { class: 'title-input', type: 'text', placeholder: 'Untitled', 'aria-label': 'Page title', maxlength: 120, autocomplete: 'off', spellcheck: 'true' }); + titleEl.value = fixedTitle ?? note?.title ?? ''; + if (fixedTitle != null) { titleEl.readOnly = true; titleEl.tabIndex = -1; } + const titleErr = h('p', { class: 'err small', role: 'alert', hidden: true }); + 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 wrap = h('div', { class: 'editor-wrap' }, ta, 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); + + const dirty = () => titleDirty || bodyDirty; + const ctl = { save, dirty }; + S.editors.add(ctl); + + function flag(msg) { notice.hidden = !msg; if (msg) fill(notice, icon('alert', 16), msg); } + + async function save() { + clearTimeout(timer); timer = null; clearTimeout(retry); retry = null; + if (blocked) return; + const t = titleEl.value, b = ta.value; + const sendTitle = titleDirty && t !== badTitle, sendBody = bodyDirty; + if (!sendTitle && !sendBody) return; + if (saving) { queued = true; return; } + if (!cur && !t.trim() && !b.trim()) { titleDirty = bodyDirty = false; return; } // nothing worth keeping + 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 }); + 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 }); + } + const created = !cur; + cur = n; + if (sendTitle) { if (titleEl.value === t) titleDirty = false; badTitle = null; titleErr.hidden = true; } + if (sendBody && ta.value === b) bodyDirty = false; + flag(null); + say(dirty() ? 'Unsaved' : 'Saved'); + if (created) onCreated?.(cur); + onSaved?.(cur, { renamed: sendTitle }); + } catch (e) { + if (e.status === 409) conflict(e.data.note); + else if (e.status === 422 && e.data?.code === 'title_taken') { + badTitle = t; titleErr.textContent = 'Another page already has this title. Pick a different one.'; titleErr.hidden = false; + say('Unsaved'); queued = bodyDirty || !cur && !!b.trim(); + } else if (e.status === 401) { + say('Not saved'); + flag([h('span', { class: 'grow' }, 'Your session ended, so your latest changes are not saved yet. Your text is still here.'), + 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 { + say('Not saved'); + if (e.offline || e.status >= 500) { flag(e.message + ' Trying again.'); retry = setTimeout(save, 5000); } + else toast(e.message, true); + } + } finally { + saving = false; + if (queued) { queued = false; save(); } + } + } + + function conflict(theirs) { + blocked = true; say('Not saved'); + flag([h('span', { class: 'grow' }, 'This page changed somewhere else, probably on another device.'), + h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; titleDirty = titleEl.value !== theirs.title; bodyDirty = ta.value !== theirs.body; flag(null); save(); } }, 'Keep my version'), + h('button', { class: 'ghost', onclick: () => { + cur = theirs; blocked = false; titleDirty = bodyDirty = false; titleEl.value = theirs.title; ta.value = theirs.body; + flag(null); say('Saved'); fit(); if (mode === 'read') render(); onSaved?.(cur, { renamed: true }); + } }, 'Use the other version')]); + } + + const later = () => { clearTimeout(timer); timer = setTimeout(save, 700); }; + titleEl.addEventListener('input', () => { titleDirty = true; titleErr.hidden = true; if (badTitle !== null && titleEl.value !== badTitle) badTitle = null; say('Unsaved'); later(); }); + titleEl.addEventListener('blur', () => save()); + titleEl.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); setMode('write'); ta.focus(); ta.setSelectionRange(0, 0); } + }); + 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 ticked(line, checked) { + const lines = ta.value.split('\n'); + lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); + ta.value = lines.join('\n'); bodyDirty = true; say('Unsaved'); render(); later(); + } + function setMode(m) { + mode = m; + if (m === 'read') { render(); fit(); } + ta.hidden = m === 'read'; view.hidden = m === 'write'; + if (m === 'write') fit(); + ctl.onMode?.(m); + } + view.addEventListener('click', e => { + if (e.target.closest('a,input,button') || inSelection()) return; + setMode('write'); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); + }); + + window.addEventListener('resize', fit); + // Getters must be defined on the object itself; Object.assign would copy their values once. + Object.defineProperties(ctl, { mode: { get: () => mode }, note: { get: () => cur }, body: { get: () => ta.value } }); + return Object.assign(ctl, { + titleEl, titleErr, ta, view, wrap, status, notice, setMode, + fit, flush: () => save(), + // Insert the time where the caret is, on a line of its own. + stamp() { + setMode('write'); ta.focus(); + const at = ta.selectionStart, before = ta.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1); + const lead = line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : ''; + replaceRange(ta, at, ta.selectionEnd, lead + hhmm(new Date()) + ' '); + }, + dispose() { S.editors.delete(ctl); window.removeEventListener('resize', fit); clearTimeout(timer); clearTimeout(retry); }, + }); +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 4ec4d1c..276ab94 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -10,6 +10,9 @@
+ + + diff --git a/internal/web/static/markdown.js b/internal/web/static/markdown.js new file mode 100644 index 0000000..b85988a --- /dev/null +++ b/internal/web/static/markdown.js @@ -0,0 +1,81 @@ +'use strict'; + +// ---------- markdown ---------- +// A small, safe renderer. It builds elements (never HTML strings), and only http(s) links are links. +// [[Page title]] and [[Page title|text]] link to other pages; pageByTitle (app.js) says which exist. +const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(? h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t); +const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label); + +function wikiLink(target, text) { + const title = target.trim(), 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) { + 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[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]); + else out.push(tagChip(t.toLowerCase()), m[9].slice(t.length)); + } + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + +// md renders a page. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only. +function md(src, onTask, big) { + 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)); }); + root.append(p); para = []; + }; + while (i < lines.length) { + const line = lines[i]; + if (/^\s*```/.test(line)) { + flush(); const code = []; i++; + while (i < lines.length && !/^\s*```/.test(lines[i])) code.push(lines[i++]); + 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 (/^\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; + } + if (/^(\s*)([-*+]|\d+[.)])\s+/.test(line)) { + flush(); const ordered = /^\s*\d/.test(line), list = h(ordered ? 'ol' : 'ul'); + while (i < lines.length && (m = /^(\s*)([-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.*)$/.exec(lines[i]))) { + const lvl = Math.min(3, Math.floor(m[1].replace(/\t/g, ' ').length / 2)), at = i; + const li = h('li', { class: 'ind-' + lvl + (m[3] != null ? ' task' : '') }); + 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])); + list.append(li); i++; + } + root.append(list); continue; + } + para.push(line); i++; + } + flush(); + return root; +} diff --git a/internal/web/static/style.css b/internal/web/static/style.css index af240d4..073ee1b 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -14,7 +14,7 @@ --font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5; - --sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px; + --sidebar-w:272px; --content-max:1120px; --touch:44px; --control-h:38px; --z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease; color-scheme:light; } @@ -219,8 +219,6 @@ .toast-act { background:transparent; border:0; color:inherit; font:inherit; font-weight:600; text-decoration:underline; cursor:pointer; padding:0; } .toolbar .push { margin-left:auto; } .toolbar input[type=search] { max-width:360px; } - .snippet { margin:0 0 var(--s4); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; } - .snippet code { white-space:pre; } /* rendered markdown */ .md > *:first-child { margin-top:0; } .md > *:last-child { margin-bottom:0; } @@ -237,3 +235,93 @@ .md pre { margin:0 0 var(--s3); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; } .md code { background:var(--surface-2); padding:1px 4px; border-radius:4px; } .md pre code { background:none; padding:0; } .md hr { border:0; border-top:var(--border-w) solid var(--border); margin:var(--s4) 0; } + + /* sign in */ + .btn-link { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; text-decoration:none; white-space:nowrap; background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); } + .btn-link:hover { background:var(--surface-2); text-decoration:none; } + .btn-link.primary { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); } + .btn-link.primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); } + .btn-link.wide { display:flex; width:100%; min-height:var(--touch); margin-top:var(--s4); } + .login-h { font-size:var(--text-xl); text-align:center; margin-bottom:var(--s2); } + .center-text { text-align:center; } + kbd { font:inherit; font-family:var(--font-mono); font-size:var(--text-xs); padding:1px 6px; border:var(--border-w) solid var(--border-strong); border-radius:4px; background:var(--surface-2); color:var(--text-muted); white-space:nowrap; } + + /* sidebar: search, pages tree */ + .navitem.find { border:var(--border-w) solid var(--border); margin-bottom:var(--s2); } + .navitem.find kbd { margin-left:auto; } + .navsec-row { display:flex; align-items:center; justify-content:space-between; padding-right:var(--s1); } + button.tiny { width:26px; min-height:26px; height:26px; } + .treerow { display:flex; align-items:center; gap:2px; } + .treerow.d1 { padding-left:var(--s4); } .treerow.d2 { padding-left:var(--s5); } .treerow.d3 { padding-left:calc(var(--s5) + var(--s3)); } + .treerow.d4 { padding-left:var(--s6); } .treerow.d5 { padding-left:calc(var(--s6) + var(--s3)); } .treerow.d6 { padding-left:calc(var(--s6) + var(--s4)); } + .tree-link { flex:1 1 auto; min-width:0; min-height:32px; padding:var(--s1) var(--s2); } + .twist, .twist-gap { width:24px; height:24px; flex:none; } + .twist { display:inline-flex; align-items:center; justify-content:center; border:0; background:none; color:var(--text-faint); cursor:pointer; border-radius:var(--radius); padding:0; } + .twist:hover { background:var(--surface-2); color:var(--text); } + .twist .icon { transition:transform var(--ease); } + .twist.open .icon { transform:rotate(90deg); } + .sub-add { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; flex:none; border:0; background:none; border-radius:var(--radius); color:var(--text-faint); cursor:pointer; opacity:0; padding:0; } + .treerow:hover .sub-add, .sub-add:focus-visible { opacity:1; } + .sub-add:hover { background:var(--surface-2); color:var(--text); } + .navhint { margin:0; padding:var(--s1) var(--s3); font-size:var(--text-sm); color:var(--text-muted); } + @media (pointer: coarse) { .sub-add { opacity:1; } .navitem.find kbd { display:none; } } + + /* a page */ + .page { max-width:780px; margin:0 auto; } + .page-top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; margin-bottom:var(--s5); } + .crumbs { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-sm); flex-wrap:wrap; min-width:0; } + .crumbs a { color:var(--text-muted); } + .crumbs .sep { color:var(--text-faint); } + .page-actions { display:flex; align-items:center; gap:var(--s2); flex-wrap:wrap; flex:1 1 auto; justify-content:flex-end; } + .page-actions .sub { min-width:4.5em; } + .daynav { margin-bottom:var(--s4); } + .seg { display:inline-flex; border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; } + .seg-b { min-height:30px; padding:0 var(--s3); font:inherit; font-size:var(--text-sm); font-weight:500; background:var(--surface); color:var(--text-muted); border:0; cursor:pointer; } + .seg-b + .seg-b { border-left:var(--border-w) solid var(--border-strong); } + .seg-b.on { background:var(--accent-soft); color:var(--accent-text); } + .title-wrap { margin-bottom:var(--s2); } + .title-input { font-size:var(--text-2xl); font-weight:700; letter-spacing:-.02em; line-height:1.25; border:0; padding:0; background:transparent; min-height:0; width:100%; } + .title-input:focus { outline:none; border:0; } + .title-input::placeholder { color:var(--text-faint); } + .day-title { font-size:var(--text-2xl); letter-spacing:-.02em; margin-bottom:var(--s2); } + .editor-wrap { position:relative; margin-top:var(--s3); } + .page textarea.write.big { min-height:45vh; line-height:1.7; font-family:var(--font); font-size:var(--text-base); resize:none; overflow:hidden; } + .view { min-height:45vh; cursor:text; overflow-wrap:anywhere; } + .md-big { font-size:var(--text-base); line-height:1.7; } + .md-big h3.md-h { font-size:var(--text-xl); margin-top:var(--s5); } .md-big h4.md-h { font-size:var(--text-lg); margin-top:var(--s4); } + .tagrow { display:flex; flex-wrap:wrap; gap:var(--s2); margin-top:var(--s4); } + .tagrow:empty { display:none; } + .aside { margin-top:var(--s6); display:flex; flex-direction:column; gap:var(--s5); } + .aside:empty { display:none; } + .aside-sec h2 { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); margin-bottom:var(--s2); } + .linklist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px; } + .linklist a { display:inline-flex; align-items:center; gap:var(--s2); padding:var(--s1) 0; } + .page-meta { margin-top:var(--s6); } + .wikilink { text-decoration:none; border-bottom:var(--border-w) solid var(--accent-text); } + .wikilink:hover { background:var(--accent-soft); } + .wikilink.missing { color:var(--text-muted); border-bottom-style:dashed; border-bottom-color:var(--text-faint); } + .popup { position:absolute; z-index:var(--z-sticky); min-width:240px; max-width:320px; max-height:260px; overflow-y:auto; padding:var(--s1); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius-lg); box-shadow:var(--shadow); } + .popup-row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); padding:var(--s2) var(--s3); border-radius:var(--radius); cursor:pointer; } + .popup-row.on, .popup-row:hover { background:var(--accent-soft); } + .popup-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .popup-hint { color:var(--text-faint); font-size:var(--text-xs); font-family:var(--font-mono); white-space:nowrap; } + .mirror { position:absolute; left:-9999px; top:0; visibility:hidden; white-space:pre-wrap; overflow-wrap:break-word; box-sizing:content-box; } + .notice .btn-link { min-height:30px; } + + /* quick find and the move picker */ + .shade-top { align-items:start; padding-top:12vh; } + .finder-input { font-size:var(--text-lg); min-height:44px; margin-bottom:var(--s2); } + .finder-list { display:flex; flex-direction:column; gap:2px; max-height:50vh; overflow-y:auto; } + .finder-row { display:flex; align-items:center; gap:var(--s3); width:100%; padding:var(--s2) var(--s3); border:0; border-radius:var(--radius); background:none; color:var(--text); font:inherit; text-align:left; cursor:pointer; } + .finder-row.on, .finder-row:hover { background:var(--accent-soft); } + .finder-name { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .finder-sub { flex:1 1 0; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; } + .pad { padding:var(--s3); margin:0; } + + /* note cards and settings */ + .note.openable { cursor:pointer; } + .note-title { font-size:var(--text-lg); margin-bottom:var(--s2); } + .note-title a { color:inherit; text-decoration:none; } + .note-title a:hover { text-decoration:underline; } + .keys { list-style:none; margin:0; padding:0; display:grid; gap:var(--s2); } + @media (max-width:640px) { .page-actions { justify-content:flex-start; } .shade-top { padding-top:var(--s4); } } diff --git a/internal/web/static/toolkit.js b/internal/web/static/toolkit.js new file mode 100644 index 0000000..e5d7dd3 --- /dev/null +++ b/internal/web/static/toolkit.js @@ -0,0 +1,173 @@ +'use strict'; + +// ---------- tiny toolkit ---------- +// h builds an element. Strings become text nodes, never HTML, so page text can never inject markup. +function h(tag, props, ...kids) { + const el = document.createElement(tag); + let value; + for (const [k, v] of Object.entries(props || {})) { + if (v == null || v === false) continue; + if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else if (k === 'class') el.className = v; + else if (k === 'value') value = v; + else if (k === 'checked') el.checked = true; + else el.setAttribute(k, v === true ? '' : v); + } + for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k))); + if (value !== undefined) el.value = value; + return el; +} +const $app = document.getElementById('app'); + +// theme: follows the system unless the person picked one +try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* storage is off */ } +function toggleTheme() { + const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; + const next = dark ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + try { localStorage.setItem('theme', next); } catch (e) { /* storage is off */ } +} +const store = { + get(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } }, + set(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) { /* storage is off */ } }, +}; + +// short confirmations at the bottom right; errors stay longer and are announced as alerts +let toastBox; +function toast(msg, bad, action) { + if (!toastBox) { toastBox = h('div', { class: 'toasts', 'aria-live': 'polite' }); document.body.append(toastBox); } + const t = h('div', { class: 'toast' + (bad ? ' toast-error' : ''), role: bad ? 'alert' : 'status' }, h('span', {}, msg), + action ? h('button', { class: 'toast-act', onclick: () => { t.remove(); action.fn(); } }, action.label) : null); + toastBox.append(t); + setTimeout(() => t.remove(), bad ? 7000 : action ? 6000 : 3000); +} +const cap = s => s ? s[0].toUpperCase() + s.slice(1) : s; +const SIGNED_OUT = 'Your session ended. Sign in again to continue.'; + +// onSignedOut is set by the app; it decides what a 401 means while someone is writing +let onSignedOut = () => { }; +async function api(method, url, body, opt = {}) { + const init = { method, headers: { 'X-Basking': '1' }, credentials: 'same-origin', keepalive: !!opt.keepalive }; + if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } + let r; + try { r = await fetch(url, init); } + catch { throw Object.assign(new Error('Could not reach Basking. Check your connection.'), { offline: true }); } + const j = await r.json().catch(() => ({})); + if (r.status === 401) { onSignedOut(); throw Object.assign(new Error(SIGNED_OUT), { status: 401 }); } + if (!r.ok) throw Object.assign(new Error(cap(j.error || r.statusText)), { status: r.status, data: j }); + return j; +} +// run an action; show errors as a toast instead of throwing into the void +const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.status !== 401) toast(e.message, true); } }; + +const pad = n => String(n).padStart(2, '0'); +const hhmm = d => { const t = new Date(d); return `${pad(t.getHours())}:${pad(t.getMinutes())}`; }; +const fmtTime = d => new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); +const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const parseDay = s => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); }; +const addDays = (s, n) => { const d = parseDay(s); d.setDate(d.getDate() + n); return isoDay(d); }; +function dayLabel(d) { + const day = isoDay(d instanceof Date ? d : parseDay(d)), now = new Date(); + if (day === isoDay(now)) return 'Today'; + if (day === addDays(isoDay(now), -1)) return 'Yesterday'; + const dt = parseDay(day); + return dt.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: dt.getFullYear() === now.getFullYear() ? undefined : 'numeric' }); +} +const isDay = s => /^\d{4}-\d{2}-\d{2}$/.test(s); +// status pill: color always travels with a text label +const badge = (text, tone) => h('span', { class: 'badge' + (tone ? ' badge-' + tone : '') }, text); +const emptyState = (name, title, text, action) => h('div', { class: 'empty' }, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null); +// placeholder rows while a page loads, so the layout does not jump +const skeleton = () => h('div', { class: 'card', role: 'status', 'aria-busy': 'true' }, h('span', { class: 'sr-only' }, 'Loading'), + ['w60', 'w100', 'w90', 'w100', 'w75'].map(w => h('div', { class: 'skeleton ' + w }))); +// replaceChildren stringifies null and arrays, so everything goes through here +const fill = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter(k => k != null && k !== false)); +const card = (title, ...kids) => h('div', { class: 'card' }, title ? h('h2', {}, title) : null, ...kids); +const inSelection = () => { const s = getSelection(); return s && !s.isCollapsed; }; + +// ---------- icons (lucide-style, static strings; see docs/design.md for the approved names) ---------- +const ICONS = { + fin: '', + notes: '', + home: '', + today: '', + journal: '', + pin: '', + tag: '', + trash: '', + edit: '', + restore: '', + clock: '', + view: '', + download: '', + copy: '', + prev: '', + next: '', + chev: '', + search: '', + link: '', + move: '', + settings: '', + out: '', + menu: '', + close: '', + add: '', + check: '', + light: '', + dark: '', + alert: '', + lock: '', +}; +function icon(name, size = 18) { + const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' }); + s.innerHTML = `${ICONS[name] || ''}`; + return s; +} +const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Basking'); +const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label); + +// ---------- dialogs ---------- +// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it. +let dialogSeq = 0; +function modal(title, body, opts = {}) { + const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq); + const shade = h('div', { class: 'shade' + (opts.top ? ' shade-top' : '') }); + const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' }, + h('h2', { id, class: opts.hideTitle ? 'sr-only' : '' }, title), body); + let closed = false; + const close = () => { + if (closed) return; closed = true; + document.removeEventListener('keydown', onKey, true); shade.remove(); + if (opener && opener.isConnected && opener.focus) opener.focus(); + if (opts.onClose) opts.onClose(); + }; + const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null); + function onKey(e) { + if ([...document.querySelectorAll('.shade')].pop() !== shade) return; + if (e.key === 'Escape') { e.stopPropagation(); close(); } + else if (e.key === 'Tab') { + const f = focusables(); if (!f.length) { e.preventDefault(); return; } + const first = f[0], last = f[f.length - 1]; + if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + } + } + shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); }); + shade.append(box); + document.body.append(shade); + document.addEventListener('keydown', onKey, true); + (box.querySelector('input,select,textarea') || focusables()[0] || box).focus(); + return close; +} +const modalOpen = () => !!document.querySelector('.shade'); + +// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed. +function confirmDialog({ title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true }) { + return new Promise(resolve => { + let close; + const done = v => { resolve(v); close(); }; + close = modal(title, h('div', { class: 'stack' }, h('p', { class: 'dim' }, body), + h('div', { class: 'actions form-actions' }, h('button', { class: 'ghost', onclick: () => done(false) }, cancel), + h('button', { class: danger ? 'ghost danger' : 'btn', onclick: () => done(true) }, confirm))), { onClose: () => resolve(false) }); + }); +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index a9e7ca3..14e46e0 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -3,337 +3,612 @@ package web import ( "archive/zip" "bytes" + "encoding/base64" "encoding/json" - "io" + "fmt" "net/http" "net/http/httptest" + "net/url" + "os" "path/filepath" "strings" + "sync" "testing" + "time" "git.veillax.com/Veillax/basking/internal/config" - "git.veillax.com/Veillax/basking/internal/store" + "git.veillax.com/Veillax/basking/internal/oidc" ) -const password = "correct horse battery" - -type rig struct { - t *testing.T - srv *httptest.Server - cfg *config.Holder - cookie *http.Cookie +// idp is a stand-in for Authentik: it publishes its metadata and trades the code "good" for an ID +// token carrying whatever claims the test set. +type idp struct { + srv *httptest.Server + mu sync.Mutex + nonce string + challenge string + claims map[string]any + down bool } -func newRig(t *testing.T) *rig { +func newIdP(t *testing.T) *idp { + p := &idp{} + mux := http.NewServeMux() + mux.HandleFunc("/app/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"issuer": p.srv.URL + "/app/", "authorization_endpoint": p.srv.URL + "/authorize", "token_endpoint": p.srv.URL + "/token"}) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + defer p.mu.Unlock() + r.ParseForm() + if p.down { + http.Error(w, "boom", http.StatusBadGateway) + return + } + if r.Form.Get("code") != "good" || r.Form.Get("client_secret") != "shh" || r.Form.Get("grant_type") != "authorization_code" || + oidc.Challenge(r.Form.Get("code_verifier")) != p.challenge || r.Form.Get("redirect_uri") != "http://127.0.0.1:8490/auth/callback" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"}) + return + } + c := map[string]any{"iss": p.srv.URL + "/app/", "aud": "basking-client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": p.nonce} + for k, v := range p.claims { + c[k] = v + } + raw, _ := json.Marshal(c) + enc := base64.RawURLEncoding.EncodeToString + json.NewEncoder(w).Encode(map[string]string{"id_token": enc([]byte(`{"alg":"none"}`)) + "." + enc(raw) + ".sig", "access_token": "x"}) + }) + p.srv = httptest.NewServer(mux) + t.Cleanup(p.srv.Close) + return p +} + +type env struct { + t *testing.T + s *Server + h http.Handler + idp *idp + dir string + cfg config.Config +} + +func setup(t *testing.T, tweak func(map[string]any)) *env { t.Helper() + p := newIdP(t) dir := t.TempDir() - h, err := config.Load(filepath.Join(dir, "basking.json")) + conf := map[string]any{ + "data_dir": filepath.Join(dir, "data"), "public_url": "http://127.0.0.1:8490", "secret": "test-secret", + "oidc": map[string]string{"issuer": p.srv.URL + "/app/", "client_id": "basking-client", "client_secret": "shh"}, + } + if tweak != nil { + tweak(conf) + } + b, _ := json.Marshal(conf) + path := filepath.Join(dir, "basking.json") + os.WriteFile(path, b, 0o600) + h, err := config.Load(path) if err != nil { t.Fatal(err) } - hash, _ := config.HashPassword(password) - if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil { - t.Fatal(err) - } - st, err := store.Open(filepath.Join(dir, "data")) + s, err := New(h, "test") if err != nil { t.Fatal(err) } - srv := httptest.NewServer(New(h, st, "test").Handler()) - t.Cleanup(srv.Close) - return &rig{t: t, srv: srv, cfg: h} + return &env{t: t, s: s, h: s.Handler(), idp: p, dir: dir, cfg: h.Get()} } -func (r *rig) do(method, path, ctype, body string, headers ...string) (*http.Response, []byte) { - r.t.Helper() - req, _ := http.NewRequest(method, r.srv.URL+path, strings.NewReader(body)) - if ctype != "" { - req.Header.Set("Content-Type", ctype) +func (e *env) do(method, path string, body any, cookies []*http.Cookie, hdr ...string) *httptest.ResponseRecorder { + e.t.Helper() + var rd *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } else { + rd = bytes.NewReader(nil) } - for i := 0; i+1 < len(headers); i += 2 { - req.Header.Set(headers[i], headers[i+1]) + r := httptest.NewRequest(method, path, rd) + if body != nil { + r.Header.Set("Content-Type", "application/json") } - if r.cookie != nil { - req.AddCookie(r.cookie) + if method != http.MethodGet { + r.Header.Set("X-Basking", "1") } - resp, err := http.DefaultClient.Do(req) - if err != nil { - r.t.Fatal(err) - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - return resp, b -} - -// js sends JSON as a browser would, with the CSRF header. -func (r *rig) js(method, path string, v any) (*http.Response, map[string]any) { - r.t.Helper() - var body string - if v != nil { - b, _ := json.Marshal(v) - body = string(b) - } - resp, b := r.do(method, path, "application/json", body, "X-Basking", "1") - var out map[string]any - json.Unmarshal(b, &out) - return resp, out -} - -func (r *rig) signIn() { - r.t.Helper() - resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}) - if resp.StatusCode != 200 { - r.t.Fatalf("login: %d", resp.StatusCode) - } - for _, c := range resp.Cookies() { - if c.Name == cookieName { - r.cookie = c + for i := 0; i+1 < len(hdr); i += 2 { + if hdr[i+1] == "" { + r.Header.Del(hdr[i]) + } else { + r.Header.Set(hdr[i], hdr[i+1]) } } - if r.cookie == nil { - r.t.Fatal("no session cookie") + for _, c := range cookies { + r.AddCookie(c) } + w := httptest.NewRecorder() + e.h.ServeHTTP(w, r) + return w } -func TestRequiresSignIn(t *testing.T) { - r := newRig(t) - for _, p := range []string{"/api/notes", "/api/summary", "/api/export", "/api/tokens", "/api/me"} { - if resp, _ := r.do("GET", p, "", ""); resp.StatusCode != 401 { - t.Errorf("GET %s without signing in: %d", p, resp.StatusCode) +func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie { + for _, c := range w.Result().Cookies() { + if c.Name == name { + return c } } - if resp, _ := r.do("GET", "/healthz", "", ""); resp.StatusCode != 200 { - t.Error("healthz should be open") - } - if resp, _ := r.do("GET", "/", "", ""); resp.StatusCode != 200 { - t.Error("the page itself should load") + return nil +} + +func jsonOf(t *testing.T, w *httptest.ResponseRecorder, v any) { + t.Helper() + if err := json.Unmarshal(w.Body.Bytes(), v); err != nil { + t.Fatalf("bad json %q: %v", w.Body.String(), err) } } -func TestLoginAndHeaders(t *testing.T) { - r := newRig(t) - if resp, _ := r.do("POST", "/api/login", "application/json", `{"password":"`+password+`"}`); resp.StatusCode != 403 { - t.Errorf("login without the CSRF header: %d", resp.StatusCode) +// begin starts a sign-in and returns the redirect the browser would follow and the flow cookie. +func (e *env) begin(next string) (*url.URL, *http.Cookie) { + e.t.Helper() + w := e.do("GET", "/auth/login?next="+url.QueryEscape(next), nil, nil) + if w.Code != http.StatusSeeOther { + e.t.Fatalf("login: %d %s", w.Code, w.Body) } - if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 { - t.Errorf("wrong password: %d", resp.StatusCode) + to, _ := url.Parse(w.Header().Get("Location")) + return to, cookieNamed(w, flowCookie) +} + +// signIn runs the whole flow for a person and returns their session cookie. +func (e *env) signIn(claims map[string]any) []*http.Cookie { + e.t.Helper() + to, flow := e.begin("") + e.idp.mu.Lock() + e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), claims + e.idp.mu.Unlock() + w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow}) + ck := cookieNamed(w, sessionCookie) + if w.Code != http.StatusSeeOther || ck == nil { + e.t.Fatalf("callback: %d %s -> %s", w.Code, w.Body, w.Header().Get("Location")) } - r.signIn() - resp, _ := r.do("GET", "/api/me", "", "") - if resp.StatusCode != 200 { - t.Fatalf("me: %d", resp.StatusCode) + return []*http.Cookie{ck} +} + +var alice = map[string]any{"sub": "alice-sub", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice"} +var bob = map[string]any{"sub": "bob-sub", "email": "bob@example.com", "name": "Bob", "preferred_username": "bob"} + +func TestNothingWorksWithoutSigningIn(t *testing.T) { + e := setup(t, nil) + for _, path := range []string{"/api/me", "/api/pages", "/api/notes", "/api/notes/abc", "/api/find?q=x", "/api/export"} { + if w := e.do("GET", path, nil, nil); w.Code != http.StatusUnauthorized { + t.Errorf("GET %s without a session: %d", path, w.Code) + } } - if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") { - t.Errorf("CSP %q", csp) + if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, nil); w.Code != http.StatusUnauthorized { + t.Errorf("POST without a session: %d", w.Code) } - if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode { - t.Errorf("cookie flags %+v", r.cookie) + if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Basking") { + t.Errorf("the sign-in page itself must load: %d", w.Code) } - // a browser write needs the header - if resp, _ := r.do("POST", "/api/notes", "application/json", `{"body":"x"}`); resp.StatusCode != 403 { - t.Errorf("write without the header: %d", resp.StatusCode) + if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK { + t.Errorf("healthz: %d", w.Code) } - // a forged cookie does nothing - r.cookie = &http.Cookie{Name: cookieName, Value: "9999999999.deadbeef"} - if resp, _ := r.do("GET", "/api/me", "", ""); resp.StatusCode != 401 { - t.Errorf("forged cookie: %d", resp.StatusCode) + if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: "garbage"}}); w.Code != http.StatusUnauthorized { + t.Errorf("garbage cookie: %d", w.Code) } } -func TestLoginIsRateLimited(t *testing.T) { - r := newRig(t) - var last int - for i := 0; i < maxLoginFails+2; i++ { - resp, _ := r.js("POST", "/api/login", map[string]string{"password": "nope"}) - last = resp.StatusCode +func TestSignInFlow(t *testing.T) { + e := setup(t, nil) + to, flow := e.begin("#/p/abc-123") + q := to.Query() + if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") { + t.Fatalf("goes to Authentik: %s", to) } - if last != 429 { - t.Fatalf("after many failures got %d", last) + for k, want := range map[string]string{"response_type": "code", "client_id": "basking-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback", + "scope": "openid profile email", "code_challenge_method": "S256"} { + if q.Get(k) != want { + t.Errorf("%s = %q, want %q", k, q.Get(k), want) + } } - if resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}); resp.StatusCode != 429 { - t.Fatalf("even the right password waits: %d", resp.StatusCode) + for _, k := range []string{"state", "nonce", "code_challenge"} { + if len(q.Get(k)) < 16 { + t.Errorf("%s is too short: %q", k, q.Get(k)) + } + } + if !flow.HttpOnly || flow.SameSite != http.SameSiteLaxMode || flow.Path != "/auth/" { + t.Errorf("flow cookie: %+v", flow) + } + + e.idp.nonce, e.idp.challenge, e.idp.claims = q.Get("nonce"), q.Get("code_challenge"), alice + w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(q.Get("state")), nil, []*http.Cookie{flow}) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/#/p/abc-123" { + t.Fatalf("callback: %d -> %s", w.Code, w.Header().Get("Location")) + } + ses := cookieNamed(w, sessionCookie) + if ses == nil || !ses.HttpOnly || ses.SameSite != http.SameSiteLaxMode || ses.Secure { + t.Fatalf("session cookie: %+v", ses) + } + if c := cookieNamed(w, flowCookie); c == nil || c.MaxAge >= 0 { + t.Error("the flow cookie should be cleared after use") + } + var me struct{ Name, Email string } + jsonOf(t, e.do("GET", "/api/me", nil, []*http.Cookie{ses}), &me) + if me.Name != "Alice" || me.Email != "alice@example.com" { + t.Fatalf("me: %+v", me) + } + + // the same flow cookie cannot be used twice by a browser that kept it, and a stale state is refused + if w := e.do("GET", "/auth/callback?code=good&state=wrong", nil, []*http.Cookie{flow}); w.Header().Get("Location") != "/?signin=failed" { + t.Errorf("wrong state: %s", w.Header().Get("Location")) } } -func TestNoteLifecycle(t *testing.T) { - r := newRig(t) - r.signIn() - - resp, n := r.js("POST", "/api/notes", map[string]string{"body": "remember the milk #errands"}) - if resp.StatusCode != 201 { - t.Fatalf("create: %d %v", resp.StatusCode, n) +func TestSignInIsRefusedWhenAnythingIsOff(t *testing.T) { + e := setup(t, nil) + try := func(name string, mutate func(to *url.URL, flow **http.Cookie) string, want string) { + t.Helper() + to, flow := e.begin("") + e.idp.mu.Lock() + e.idp.nonce, e.idp.challenge, e.idp.claims, e.idp.down = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice, false + e.idp.mu.Unlock() + target := mutate(to, &flow) + var cks []*http.Cookie + if flow != nil { + cks = append(cks, flow) + } + w := e.do("GET", target, nil, cks) + if got := w.Header().Get("Location"); got != "/?signin="+want || cookieNamed(w, sessionCookie) != nil { + t.Errorf("%s: went to %q (%d), session %v; want %s", name, got, w.Code, cookieNamed(w, sessionCookie), want) + } } - id, _ := n["id"].(string) - if id == "" || n["body"] != "remember the milk #errands" { - t.Fatalf("created %v", n) - } - if resp, _ := r.js("POST", "/api/notes", map[string]string{"body": " "}); resp.StatusCode != 400 { - t.Errorf("empty note: %d", resp.StatusCode) + good := func(to *url.URL) string { + return "/auth/callback?code=good&state=" + url.QueryEscape(to.Query().Get("state")) } - _, list := r.js("GET", "/api/notes?q=milk", nil) - if list["total"] != float64(1) { - t.Fatalf("search: %v", list) - } - _, list = r.js("GET", "/api/notes?tag=errands", nil) - if list["total"] != float64(1) { - t.Fatalf("tag: %v", list) - } + try("no flow cookie", func(to *url.URL, f **http.Cookie) string { *f = nil; return good(to) }, "expired") + try("tampered flow cookie", func(to *url.URL, f **http.Cookie) string { c := *f; c.Value = "x" + c.Value; return good(to) }, "expired") + try("wrong state", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?code=good&state=nope" }, "failed") + try("no code", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?state=" + to.Query().Get("state") }, "failed") + try("denied at Authentik", func(to *url.URL, f **http.Cookie) string { + return "/auth/callback?error=access_denied&state=" + to.Query().Get("state") + }, "denied") + try("bad code", func(to *url.URL, f **http.Cookie) string { + return "/auth/callback?code=stolen&state=" + url.QueryEscape(to.Query().Get("state")) + }, "failed") + try("wrong nonce", func(to *url.URL, f **http.Cookie) string { + e.idp.mu.Lock() + e.idp.nonce = "other" + e.idp.mu.Unlock() + return good(to) + }, "failed") + try("wrong audience", func(to *url.URL, f **http.Cookie) string { + e.idp.mu.Lock() + e.idp.claims = map[string]any{"sub": "x", "aud": "someone-else"} + e.idp.mu.Unlock() + return good(to) + }, "failed") + try("expired token", func(to *url.URL, f **http.Cookie) string { + e.idp.mu.Lock() + e.idp.claims = map[string]any{"sub": "x", "exp": time.Now().Add(-time.Hour).Unix()} + e.idp.mu.Unlock() + return good(to) + }, "failed") + try("wrong issuer", func(to *url.URL, f **http.Cookie) string { + e.idp.mu.Lock() + e.idp.claims = map[string]any{"sub": "x", "iss": "https://evil.example.com/"} + e.idp.mu.Unlock() + return good(to) + }, "failed") + try("no subject", func(to *url.URL, f **http.Cookie) string { + e.idp.mu.Lock() + e.idp.claims = map[string]any{"sub": ""} + e.idp.mu.Unlock() + return good(to) + }, "failed") + try("Authentik is down", func(to *url.URL, f **http.Cookie) string { + e.idp.mu.Lock() + e.idp.down = true + e.idp.mu.Unlock() + return good(to) + }, "unavailable") - // edit with the version we have, then again with a stale one - resp, n2 := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "remember the oat milk", "base": n["updated"]}) - if resp.StatusCode != 200 || n2["body"] != "remember the oat milk" { - t.Fatalf("update: %d %v", resp.StatusCode, n2) - } - resp, c := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "stale", "base": n["updated"]}) - if resp.StatusCode != 409 { - t.Fatalf("stale update: %d", resp.StatusCode) - } - if cur, _ := c["note"].(map[string]any); cur["body"] != "remember the oat milk" { - t.Errorf("conflict should return the current note: %v", c) - } - if resp, _ := r.js("PUT", "/api/notes/"+id, map[string]any{"pinned": true}); resp.StatusCode != 200 { - t.Errorf("pin: %d", resp.StatusCode) - } - _, list = r.js("GET", "/api/notes?view=pinned", nil) - if list["total"] != float64(1) { - t.Errorf("pinned view: %v", list) - } - - if resp, _ := r.js("DELETE", "/api/notes/"+id+"?purge=1", nil); resp.StatusCode != 400 { - t.Errorf("purge of a live note: %d", resp.StatusCode) - } - if resp, _ := r.js("DELETE", "/api/notes/"+id, nil); resp.StatusCode != 200 { - t.Errorf("trash: %d", resp.StatusCode) - } - _, sum := r.js("GET", "/api/summary", nil) - if counts := sum["counts"].(map[string]any); counts["trash"] != float64(1) || counts["all"] != float64(0) { - t.Errorf("summary after trashing: %v", sum) - } - if resp, _ := r.js("POST", "/api/notes/"+id+"/restore", nil); resp.StatusCode != 200 { - t.Errorf("restore: %d", resp.StatusCode) - } - r.js("DELETE", "/api/notes/"+id, nil) - if resp, _ := r.js("DELETE", "/api/trash", nil); resp.StatusCode != 200 { - t.Errorf("empty trash: %d", resp.StatusCode) - } - if resp, _ := r.js("GET", "/api/notes/"+id, nil); resp.StatusCode != 404 { - t.Errorf("gone: %d", resp.StatusCode) - } - for _, bad := range []string{"..%2f..%2fbasking.json", "UPPER", "a.b"} { - if resp, _ := r.js("GET", "/api/notes/"+bad, nil); resp.StatusCode != 404 { - t.Errorf("odd id %q: %d", bad, resp.StatusCode) + // and a next address that points anywhere but inside Basking is dropped + for _, bad := range []string{"https://evil.example.com", "//evil.example.com", "/api/export", "#/