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 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 22:51:26 -06:00
1 parent b548faa258
commit 98f7730913
19 files changed
+3266 -1553

No files matched your search

+114 -82
View File
@@ -1,30 +1,59 @@
# Basking # Basking
A place to dump thoughts, jot notes and keep a journal. Named for the basking shark, which swims A private notebook in the spirit of Obsidian and Notion, for people who would rather run their own.
along with its mouth open and takes in whatever drifts by. One small binary, no database, no Pages that nest inside each other, `[[links]]` between them with backlinks, a journal page for each
dependencies. 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 Named for the basking shark, which swims along with its mouth open and takes in whatever drifts by.
write them and find them again, from a browser on your phone or your desk.
## What it does ## What it does
- **Dump first, sort later.** The box at the top of Notes is always ready. Type, press Ctrl+Enter, - **Pages in a tree.** Give a page a title and it appears in the sidebar. Add a subpage with the `+`
keep typing. An unsaved draft survives a closed tab. next to it, or move a page with Move. Delete moves a page to the trash with an Undo, and any
- **Tags by typing.** Write `#errands` or `#projects/basking` anywhere in a note and it becomes a tag. subpages move up a level so nothing is lost with it.
The sidebar lists them. Numbers such as `#12` and anything inside code are left alone. - **Link pages with `[[`.** Type `[[` and pick a page, or type a new title to create it. A dashed link
- **A page for each day.** Today opens the day's journal page, with an Insert time button to stamp is a page that does not exist yet; click it and it is made. Each page lists what links to it.
`20:15 ` where you are typing. Move between days, or read back over the Journal. Rename a page and every `[[link]]` to it is updated.
- **Search** across everything as you type: every word must match, and `#tag` words filter by tag. - **Type `/` for commands**: headings, to-do lists, quotes, code blocks, dividers, today's date, the
- **Markdown**, rendered safely: headings, lists, checkboxes you can tick in place, quotes, code, time. Lists continue when you press Enter and indent with Tab. Ctrl+B and Ctrl+I work as you expect.
links and bold and italic. - **Write or Read.** Write shows the markdown; Read shows the page as it will look, with tasks you can
- **Pin** what matters. **Delete** goes to a trash first, with an Undo, and nothing is lost until you tick and links you can follow. Click a page in Read to start writing (Ctrl+E switches).
empty it. - **Quick find with Ctrl+K** searches titles and text as you type, and makes a new page if there is no
- **Autosave** while you edit, and a warning (not a silent overwrite) if the same note was changed on match.
another device. - **A journal.** Today opens the page for the day, with an Insert time button. Days link to each
- **Send notes from anywhere.** Make an API token and post text from a script, a phone shortcut or the other: `[[2026-10-31]]` goes to that day.
`basking add` command. - **Quick notes.** The box on Home takes a thought in two keystrokes (`n`, then Ctrl+Enter). They stay
- **Take it with you.** Download everything as a zip of `.md` files, or just copy the folder. 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) ## 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 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 This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/basking`, creates a
a `basking` user, `/etc/basking/` (settings) and `/var/lib/basking/` (your notes), and writes the `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: systemd service and `/etc/basking/basking.env`. Then:
```sh ```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 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`, Run the installer again to upgrade; your settings and notes are kept and a running service is
`--source` (build from the repository with Go instead), and `--uninstall` (add `--purge` to delete restarted. Options: `--start`, `--version v0.1.0`, `--listen 127.0.0.1:8490`, `--source` (build with Go
settings and notes). Download the script and read it first if you prefer: it is instead) and `--uninstall` (add `--purge` to delete settings and notes). Read it first if you like:
[`deploy/install.sh`](deploy/install.sh). [`deploy/install.sh`](deploy/install.sh).
## Running it by hand ## Running it by hand or in Docker
```sh ```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 ./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). Docker: see [`Dockerfile`](Dockerfile) and [`deploy/docker-compose.yml`](deploy/docker-compose.yml).
### Commands ### Commands
``` ```
basking serve [-config basking.json] [-listen addr] [-data dir] basking serve [-config basking.json] [-listen addr] [-data dir]
basking password [-config ...] set a new password (BASKING_PASSWORD or stdin); restart afterwards basking check [-config basking.json] test the Authentik settings without starting
basking token [-config ...] NAME make an API token (printed once) basking version
basking add [TEXT...] send a note to a running Basking (needs BASKING_URL and BASKING_TOKEN)
``` ```
## Settings ## Settings
`basking.json` holds `listen` (default `127.0.0.1:8490`), `data_dir` (default `data`), the password Everything can be set with environment variables, which win over the settings file and are never
hash, API token hashes, a signing secret, and `cookie_secure` and `trust_proxy` for use behind a written to it:
proxy. The environment variables `BASKING_CONFIG`, `BASKING_LISTEN` and `BASKING_DATA` override the
file location, address and notes folder. | 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 ## Where your notes are
``` ```
data/ data/users/<hash of who you are>/
notes/20261031-143205-a1b2c3.md one file per note 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 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 created: 2026-10-31T14:32:05.123Z
updated: 2026-10-31T14:35:00.456Z updated: 2026-10-31T14:35:00.456Z
pinned: true 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 A plain `.md` file dropped into `notes/` is picked up too, with its file time as its date (name it with
with lower-case letters, numbers and dashes). Restart Basking to see files added by hand. 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`.
## Security notes ## Security notes
- The password is stored as a PBKDF2-SHA256 hash and API tokens as SHA-256 hashes. `basking.json` is - Sign-in is Authentik only: the authorization code flow with PKCE, `state` and `nonce`, over https. The
written `0600`. Failed sign-ins are rate-limited per address. ID token comes straight from Authentik's token endpoint, and its issuer, audience, expiry and nonce
- Sessions are `HttpOnly`, `SameSite=Strict` cookies that last 30 days, signed with a secret from the are checked. Authentik and Basking must be reachable over https unless both are on the same machine.
settings file. They are not kept on the server, so a restart does not sign you out, and signing out - Sessions are `HttpOnly`, `SameSite=Lax` cookies that last 14 days, signed with the secret in the
only clears this browser's cookie. Changing the password signs out every browser. settings file. Nothing is kept on the server, so a restart does not sign anyone out. The `allowed`
- Every browser change needs a header a page on another site can't send. The page has a strict list is checked on every request.
Content-Security-Policy and notes are never rendered as HTML. - Each person's folder is named from a hash of who they are, never from anything they typed. One
- Notes are **not encrypted** on disk. Anyone who can read the folder can read your notes. 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 ## Honest limits
- One person, one password. There are no accounts, sharing or real-time collaboration. - 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 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 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. 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 ## 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. [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules a machine can check.
## Development ## Development
```sh ```sh
go test ./... 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. Releases are built by the Gitea workflow when a `v*` tag is pushed.
+75 -140
View File
@@ -1,16 +1,12 @@
// Command basking is a place to dump thoughts, jot notes and keep a journal. Notes are plain // Command basking is a private notebook: pages you can link, nest and search, kept as plain
// markdown files; the dashboard is one small binary. // markdown files. You sign in with Authentik; there is no other way in.
package main package main
import ( import (
"bufio"
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -20,26 +16,32 @@ import (
"time" "time"
"git.veillax.com/Veillax/basking/internal/config" "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" "git.veillax.com/Veillax/basking/internal/web"
) )
var version = "dev" 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] basking serve [-config basking.json] [-listen addr] [-data dir]
run the dashboard run the notebook
basking password [-config ...] set the password (reads BASKING_PASSWORD or a line on stdin) basking check [-config basking.json] test the Authentik settings without starting
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 version basking version
"add" needs BASKING_URL (such as http://127.0.0.1:8490) and BASKING_TOKEN. Settings come from the settings file and can be overridden by environment variables:
The settings path can also be set with BASKING_CONFIG, the address with BASKING_LISTEN and the BASKING_PUBLIC_URL where you open Basking, such as https://notes.example.com
notes folder with BASKING_DATA. 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() { func main() {
log.SetFlags(log.LstdFlags) log.SetFlags(log.LstdFlags)
if len(os.Args) < 2 { if len(os.Args) < 2 {
@@ -50,12 +52,8 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "serve": case "serve":
err = serve(os.Args[2:]) err = serve(os.Args[2:])
case "password": case "check":
err = passwordCmd(os.Args[2:]) err = check(os.Args[2:])
case "token":
err = tokenCmd(os.Args[2:])
case "add":
err = addCmd(os.Args[2:])
case "version", "-v", "--version": case "version", "-v", "--version":
fmt.Println("basking", version) fmt.Println("basking", version)
case "help", "-h", "--help": case "help", "-h", "--help":
@@ -66,10 +64,16 @@ func main() {
} }
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "basking:", err) fmt.Fprintln(os.Stderr, "basking:", err)
var ce configError
if errors.As(err, &ce) {
os.Exit(exitConfig)
}
os.Exit(1) os.Exit(1)
} }
} }
type configError struct{ error }
func configFlag(fs *flag.FlagSet) *string { func configFlag(fs *flag.FlagSet) *string {
def := os.Getenv("BASKING_CONFIG") def := os.Getenv("BASKING_CONFIG")
if def == "" { if def == "" {
@@ -81,43 +85,34 @@ func configFlag(fs *flag.FlagSet) *string {
func serve(args []string) error { func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ExitOnError) fs := flag.NewFlagSet("serve", flag.ExitOnError)
path := configFlag(fs) path := configFlag(fs)
listen := fs.String("listen", os.Getenv("BASKING_LISTEN"), "override the listen address") listen := fs.String("listen", "", "override the listen address")
data := fs.String("data", os.Getenv("BASKING_DATA"), "override the folder the notes are kept in") data := fs.String("data", "", "override the folder the notes are kept in")
fs.Parse(args) fs.Parse(args)
if *listen != "" {
os.Setenv("BASKING_LISTEN", *listen)
}
if *data != "" {
os.Setenv("BASKING_DATA", *data)
}
h, err := config.Load(*path) h, err := config.Load(*path)
if err != nil { 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.Exists() { // writing it now is what makes the generated secret stick
if h.Get().PasswordHash == "" { if err := h.Save(); err != nil {
pw := config.RandomPassword() return fmt.Errorf("could not write %s: %w", *path, err)
hash, err := config.HashPassword(pw)
if err != nil {
return 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() cfg := h.Get()
if *listen != "" { web, err := web.New(h, version)
cfg.Listen = *listen
}
if *data != "" {
cfg.DataDir = *data
}
st, err := store.Open(cfg.DataDir)
if err != nil { if err != nil {
return err return configError{err}
} }
counts, _ := st.Summary()
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: web.New(h, st, version).Handler(), Handler: web.Handler(),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,
@@ -127,10 +122,21 @@ func serve(args []string) error {
defer stop() defer stop()
errc := make(chan error, 1) errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }() go func() { errc <- srv.ListenAndServe() }()
log.Printf("basking %s: %d note(s) in %s; open http://%s", version, counts.All, cfg.DataDir, cfg.Listen) log.Printf("basking %s: notes are kept in %s; open %s", version, cfg.DataDir, cfg.PublicURL)
if !strings.HasPrefix(cfg.Listen, "127.") && !strings.HasPrefix(cfg.Listen, "localhost") && !strings.HasPrefix(cfg.Listen, "[::1]") { log.Printf("sign-in goes through %s; the redirect URI to allow there is %s", cfg.OIDC.Issuer, cfg.RedirectURL())
log.Print("note: this is not a loopback address; put it behind HTTPS (a reverse proxy or Hammerhead) and set cookie_secure") 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 { select {
case err := <-errc: case err := <-errc:
@@ -142,104 +148,33 @@ func serve(args []string) error {
return srv.Shutdown(sctx) return srv.Shutdown(sctx)
} }
func readPassword() (string, error) { func check(args []string) error {
if pw := os.Getenv("BASKING_PASSWORD"); pw != "" { fs := flag.NewFlagSet("check", flag.ExitOnError)
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)
path := configFlag(fs) path := configFlag(fs)
fs.Parse(args) 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) 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 { if err != nil {
return err return err
} }
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil { fmt.Println("Authentik answered at", cfg.OIDC.Issuer)
return err 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 <token>'")
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 return nil
} }
+10 -3
View File
@@ -1,10 +1,17 @@
# Basking. Reach it through a reverse proxy or Hammerhead (HTTPS), and set "cookie_secure": true # Basking, a private notebook you sign in to with Authentik. Reach it through an HTTPS reverse proxy
# and "trust_proxy": true in basking.json when you do. # (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: services:
basking: basking:
image: git.veillax.com/veillax/basking:latest # or: build: .. image: git.veillax.com/veillax/basking:latest # or: build: ..
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:8490:8490" - "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: volumes:
- ./config:/config # basking.json and data/ (your notes) live here - ./config:/config # basking.json (the signing secret) and data/ (your pages) live here
+27 -9
View File
@@ -137,8 +137,25 @@ fi
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF cat > "$ENV_FILE" <<EOF
# Environment for the basking service. Restart it after editing: systemctl restart basking # Environment for the basking service. Restart it after editing: systemctl restart basking
#
# Basking has no passwords. You sign in with Authentik, so fill in the four lines below.
# In Authentik: Applications > Providers > Create > OAuth2/OpenID Provider (client type
# Confidential, scopes openid, profile and email), allow the redirect URI
# <BASKING_PUBLIC_URL>/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 BASKING_CONFIG=$CONF_FILE
# Optional. These override what the settings file says. # Optional. These override what the settings file says.
@@ -158,7 +175,7 @@ fi
cat > "$UNIT" <<EOF cat > "$UNIT" <<EOF
[Unit] [Unit]
Description=Basking, a place to dump your thoughts Description=Basking, a private notebook
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -169,6 +186,8 @@ WorkingDirectory=$DATA_DIR
ExecStart=$BIN serve ExecStart=$BIN serve
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# Settings that need fixing by hand exit with status 2; do not loop on those.
RestartPreventExitStatus=2
# Basking only needs the network and its own folders. # Basking only needs the network and its own folders.
NoNewPrivileges=true NoNewPrivileges=true
@@ -194,18 +213,17 @@ if [ "$START" -eq 1 ] || systemctl is-active --quiet basking; then
if systemctl is-active --quiet basking; then if systemctl is-active --quiet basking; then
say "Basking is running." say "Basking is running."
else else
say "Basking did not start. Look at: journalctl -u basking -n 50" say "Basking did not start. If you have not filled in the Authentik settings in $ENV_FILE yet, that is why."
say "Look at: journalctl -u basking -n 50"
exit 1 exit 1
fi fi
fi fi
say "Installed $("$BIN" version)." say "Installed $("$BIN" version)."
say "Next:" say "Next:"
say " 1. sudoedit $ENV_FILE (optional: the address to listen on and where notes are kept)" say " 1. sudoedit $ENV_FILE (fill in the Authentik settings at the top)"
say " 2. systemctl start basking (or run this again with --start)" say " 2. systemctl start basking (or run this again with --start)"
say " 3. journalctl -u basking -f to watch it; a first install prints the password here, once" say " 3. journalctl -u basking -f to watch it; it says at once if a setting is missing or wrong"
say "" say ""
say "The dashboard is at http://$LISTEN" say "Basking listens on $LISTEN. Put an HTTPS reverse proxy (or Hammerhead) in front of it and open the"
case $LISTEN in say "address you set as BASKING_PUBLIC_URL; Authentik will not send people back to plain http."
127.*|localhost*|[::1]*) say "It only listens on this machine. To reach it elsewhere, put an HTTPS reverse proxy (or Hammerhead) in front of it." ;;
esac
+8 -5
View File
@@ -11,16 +11,19 @@ Basking follows the Lightwell design language, the same one Hammerhead and Megal
## Where Basking differs ## Where Basking differs
* **The writing box is the primary action.** Lightwell puts the one primary button in the page header. On Notes the header has none: the primary button, Save note, sits in the composer, because writing is the page. Elsewhere the rule holds: Write today on the Journal, Done while editing a note. * **The page is the interface.** Lightwell puts the one primary button in the page header. A page has none: the title and the text are the thing, and everything else (Pin, Move, Subpage, Delete) is a small ghost button above them. On Home the primary button, Save note, sits in the quick-note box. Elsewhere the rule holds: Write today on the Journal, Continue with Authentik on the sign-in page.
* **Deleting a note is not confirmed, it is undoable.** Delete moves a note to the trash and offers Undo. Only Delete for good and Empty trash ask first, naming what is lost. * **Saving is automatic and says so.** There is no Save button on a page. The status reads Saving, Saved, Unsaved or Not saved, and a problem (offline, session ended, changed elsewhere) is a notice above the text that keeps the text on screen.
* **Script and styles are separate files**, because the Content-Security-Policy forbids inline script and `style` attributes. Widths come from classes. The one use of `element.style` is sizing a text box to its text, which the policy allows. * **Deleting a page is not confirmed, it is undoable.** Delete moves a page to the trash and offers Undo. Only Delete for good and Empty trash ask first, naming what is lost.
* **Rendered notes** use a small safe markdown renderer in `app.js`. It builds elements, never HTML, and only `http(s)` links are links. * **Navigation is a tree.** The sidebar nests pages the way they were made. A page that holds the one you are on stays open. Quick notes with no title stay out of it.
* **Script and styles are separate files**, because the Content-Security-Policy forbids inline script and `style` attributes. Widths and indents come from classes. `element.style` is used only to size a text box to its text and to place the suggestion list at the caret, which the policy allows.
* **Rendered pages** use a small safe markdown renderer in `markdown.js`. It builds elements, never HTML, and only `http(s)` links are links. `[[Page]]` links are internal; a dashed one is a page that does not exist yet.
* **Edits go through the browser** (`execCommand`), not by assigning the text box's value, so Undo keeps working after a list continues or a suggestion is picked.
## Icons ## Icons
Lucide style, outline only, stroke 1.75, round caps, `currentColor`, sizes 16, 18 and 22. An icon goes with a text label, and icon-only buttons carry an `aria-label`. Lucide style, outline only, stroke 1.75, round caps, `currentColor`, sizes 16, 18 and 22. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
Approved names: `fin`, `notes`, `today`, `journal`, `pin`, `tag`, `trash`, `edit`, `restore`, `clock`, `view`, `download`, `copy`, `prev`, `next`, `settings`, `out`, `menu`, `close`, `add`, `check`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together. Approved names: `fin`, `notes`, `home`, `today`, `journal`, `pin`, `tag`, `trash`, `edit`, `restore`, `clock`, `view`, `download`, `copy`, `prev`, `next`, `chev`, `search`, `link`, `move`, `lock`, `settings`, `out`, `menu`, `close`, `add`, `check`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
## Checking a change ## Checking a change
+106 -125
View File
@@ -1,49 +1,44 @@
// Package config holds Basking's settings file: where to listen, where notes live, the password // Package config holds Basking's settings: where to listen, where notes live and how to reach
// hash and the API tokens. The notes themselves are not in here. // Authentik. The notes themselves are not in here.
package config package config
import ( import (
"crypto/pbkdf2"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time"
) )
type Token struct { // OIDC is the OAuth client Authentik gives you for Basking.
Name string `json:"name"` type OIDC struct {
Hash string `json:"hash"` Issuer string `json:"issuer"`
Created time.Time `json:"created"` ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
} }
type Config struct { type Config struct {
Listen string `json:"listen"` Listen string `json:"listen"`
DataDir string `json:"data_dir"` DataDir string `json:"data_dir"`
// PasswordHash is the one password that opens the dashboard. // PublicURL is the address you open Basking at, such as https://notes.example.com. The sign-in
PasswordHash string `json:"password_hash,omitempty"` // redirect is PublicURL + /auth/callback.
PublicURL string `json:"public_url"`
OIDC OIDC `json:"oidc"`
// Allowed limits who may sign in, by email, username or Authentik id. Empty means anyone Authentik
// lets into the application, each with a notebook of their own.
Allowed []string `json:"allowed,omitempty"`
// Secret signs session cookies, so a restart does not sign you out. It is made on first run. // Secret signs session cookies, so a restart does not sign you out. It is made on first run.
Secret string `json:"secret,omitempty"` Secret string `json:"secret,omitempty"`
Tokens []Token `json:"tokens,omitempty"`
// CookieSecure and TrustProxy are for running behind an HTTPS reverse proxy.
CookieSecure bool `json:"cookie_secure,omitempty"`
TrustProxy bool `json:"trust_proxy,omitempty"`
} }
var tokenName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$`)
// Defaults fills in what is missing. // Defaults fills in what is missing.
func (c *Config) Defaults() { func (c *Config) Defaults() {
if c.Listen == "" { if c.Listen == "" {
@@ -57,27 +52,75 @@ func (c *Config) Defaults() {
rand.Read(b) rand.Read(b)
c.Secret = base64.RawURLEncoding.EncodeToString(b) c.Secret = base64.RawURLEncoding.EncodeToString(b)
} }
c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/")
c.OIDC.Issuer = strings.TrimSpace(c.OIDC.Issuer)
c.OIDC.ClientID = strings.TrimSpace(c.OIDC.ClientID)
c.OIDC.ClientSecret = strings.TrimSpace(c.OIDC.ClientSecret)
} }
func (c *Config) Validate() error { // RedirectURL is the address to enter in Authentik as the redirect URI.
seen := map[string]bool{} func (c Config) RedirectURL() string { return c.PublicURL + "/auth/callback" }
for _, t := range c.Tokens {
if !tokenName.MatchString(t.Name) { // Secure says whether Basking is reached over HTTPS, which decides whether cookies are Secure.
return fmt.Errorf("token name %q must be 1 to 40 letters, numbers, spaces, dots, dashes or underscores", t.Name) func (c Config) Secure() bool { return strings.HasPrefix(c.PublicURL, "https://") }
// IsLocal reports whether a URL points at this machine, where plain http is fine.
func IsLocal(u *url.URL) bool {
h := u.Hostname()
return h == "localhost" || h == "127.0.0.1" || h == "::1" || strings.HasSuffix(h, ".localhost")
} }
if seen[strings.ToLower(t.Name)] {
return fmt.Errorf("there is already a token called %q", t.Name) func checkURL(name, raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
return fmt.Errorf("%s must be a full address such as https://notes.example.com (got %q)", name, raw)
} }
seen[strings.ToLower(t.Name)] = true if u.Scheme == "http" && !IsLocal(u) {
return fmt.Errorf("%s must use https unless it points at this machine (got %q)", name, raw)
} }
return nil return nil
} }
// Holder keeps the live configuration and writes every change to disk. // Check says what is still missing before Basking can sign anyone in, in words a person can act on.
func (c Config) Check() error {
var missing []string
if c.PublicURL == "" {
missing = append(missing, " public_url (BASKING_PUBLIC_URL) where you open Basking, such as https://notes.example.com")
}
if c.OIDC.Issuer == "" {
missing = append(missing, " oidc.issuer (BASKING_OIDC_ISSUER) from Authentik, such as https://auth.example.com/application/o/basking/")
}
if c.OIDC.ClientID == "" {
missing = append(missing, " oidc.client_id (BASKING_OIDC_CLIENT_ID) the Client ID of the Authentik provider")
}
if c.OIDC.ClientSecret == "" {
missing = append(missing, " oidc.client_secret (BASKING_OIDC_CLIENT_SECRET) the Client Secret of the Authentik provider")
}
if len(missing) > 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 { type Holder struct {
path string path string
mu sync.RWMutex mu sync.RWMutex
cfg Config file Config
} }
// Load reads the file. A missing file gives the defaults (and is created by the first Update). // 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: case err != nil:
return nil, err return nil, err
default: 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) return nil, fmt.Errorf("%s: %w", path, err)
} }
} }
h.cfg.Defaults() h.file.Defaults()
if err := h.cfg.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return h, nil return h, nil
} }
@@ -105,108 +145,49 @@ func (h *Holder) Path() string { return h.path }
// Exists says whether the file is on disk yet. // Exists says whether the file is on disk yet.
func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil } 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 { func (h *Holder) Get() Config {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() c := h.file
return clone(h.cfg) 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)
} }
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.Defaults() c.Defaults()
if err := c.Validate(); err != nil { return c
return err
}
if err := save(h.path, c); err != nil {
return err
}
h.cfg = c
return nil
} }
func save(path string, c Config) error { // Save writes the file as it is now, which is what makes the generated secret stick.
b, err := json.MarshalIndent(c, "", " ") func (h *Holder) Save() error {
h.mu.Lock()
defer h.mu.Unlock()
b, err := json.MarshalIndent(h.file, "", " ")
if err != nil { if err != nil {
return err return err
} }
if dir := filepath.Dir(path); dir != "." { if dir := filepath.Dir(h.path); dir != "." {
os.MkdirAll(dir, 0o755) os.MkdirAll(dir, 0o755)
} }
tmp := path + ".tmp" tmp := h.path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err return err
} }
return os.Rename(tmp, path) return os.Rename(tmp, h.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)
} }
+234
View File
@@ -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
}
+583 -70
View File
@@ -1,34 +1,44 @@
// Package store keeps notes as plain markdown files, one per note, so they stay readable and // 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 and searching quick. // portable with or without Basking. An in-memory index makes listing, searching and linking quick.
package store package store
import ( import (
"archive/zip" "archive/zip"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"unicode/utf8"
) )
// MaxBody is the longest note, in bytes. // MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters.
const MaxBody = 256 << 10 const (
MaxBody = 256 << 10
MaxTitle = 120
)
var ( var (
ErrNotFound = errors.New("note not found") ErrNotFound = errors.New("page not found")
ErrBadID = errors.New("bad note id") ErrBadID = errors.New("bad page id")
ErrEmpty = errors.New("a note needs some text") ErrEmpty = errors.New("a page needs a title or some text")
ErrTooLong = errors.New("that note is too long") ErrTooLong = errors.New("that page is too long")
ErrConflict = errors.New("this note changed somewhere else") ErrConflict = errors.New("this page changed somewhere else")
ErrBadDate = errors.New("the date must look like 2026-10-31") 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") 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 { type Note struct {
@@ -36,12 +46,16 @@ type Note struct {
Created time.Time `json:"created"` Created time.Time `json:"created"`
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
Pinned bool `json:"pinned"` 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"` 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"` Body string `json:"body"`
Tags []string `json:"tags"` 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 { type Store struct {
@@ -50,12 +64,15 @@ type Store struct {
by map[string]*Note 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) } 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) { func Open(dir string) (*Store, error) {
s := &Store{dir: dir, by: map[string]*Note{}} s := &Store{dir: dir, by: map[string]*Note{}}
for _, sub := range []string{"notes", "trash"} { for _, sub := range []string{"notes", "trash"} {
@@ -85,9 +102,32 @@ func Open(dir string) (*Store, error) {
s.by[id] = n s.by[id] = n
} }
} }
s.mend()
return s, nil 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 { func (s *Store) path(n *Note) string {
sub := "notes" sub := "notes"
if n.Trashed { if n.Trashed {
@@ -99,17 +139,26 @@ func (s *Store) path(n *Note) string {
// ---- file format ---- // ---- file format ----
// //
// --- // ---
// title: "Trip to Lisbon"
// parent: 20261031-143205-a1b2c3
// created: 2026-10-31T14:32:05.123Z // created: 2026-10-31T14:32:05.123Z
// updated: 2026-10-31T14:35:00.456Z // updated: 2026-10-31T14:35:00.456Z
// pinned: true // pinned: true
// daily: 2026-10-31 // daily: 2026-10-31
// --- // ---
// //
// the note, in markdown // the page, in markdown
func format(n *Note) []byte { func format(n *Note) []byte {
var b strings.Builder var b strings.Builder
b.WriteString("---\n") 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, "created: %s\n", n.Created.UTC().Format(time.RFC3339Nano))
fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339Nano)) fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339Nano))
if n.Pinned { if n.Pinned {
@@ -124,7 +173,7 @@ func format(n *Note) []byte {
return []byte(b.String()) 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 { func parse(id, text string, mod time.Time) *Note {
text = strings.ReplaceAll(text, "\r\n", "\n") text = strings.ReplaceAll(text, "\r\n", "\n")
n := &Note{ID: id, Created: mod.UTC(), Updated: mod.UTC()} 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, ":") k, v, _ := strings.Cut(line, ":")
v = strings.TrimSpace(v) v = strings.TrimSpace(v)
switch strings.TrimSpace(k) { 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": case "created":
if t, err := time.Parse(time.RFC3339Nano, v); err == nil { if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
n.Created = t.UTC() n.Created = t.UTC()
@@ -156,25 +216,74 @@ func parse(id, text string, mod time.Time) *Note {
text = strings.TrimPrefix(body, "\n") text = strings.TrimPrefix(body, "\n")
} }
} }
if n.Daily != "" {
n.Title = n.Daily
n.Parent = ""
}
n.set(text) n.set(text)
return n 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) { func (n *Note) set(body string) {
n.Body = strings.TrimRight(strings.ReplaceAll(body, "\r\n", "\n"), " \t\n") 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.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 ( var (
tagRe = regexp.MustCompile(`(?:^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)`) tagRe = regexp.MustCompile(`(?:^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)`)
inlineCodeRe = regexp.MustCompile("`[^`\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 // withoutCode is the body with fenced and inline code taken out, so tags and links inside code
// numbers such as #12. // are not counted.
func Tags(body string) []string { func withoutCode(body string) string {
var lines []string var lines []string
fence := false fence := false
for _, l := range strings.Split(body, "\n") { for _, l := range strings.Split(body, "\n") {
@@ -186,9 +295,15 @@ func Tags(body string) []string {
lines = append(lines, inlineCodeRe.ReplaceAllString(l, "")) 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{} seen := map[string]bool{}
out := []string{} 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], "/-")) t := strings.ToLower(strings.TrimRight(m[1], "/-"))
if t == "" || len(t) > 40 || isNumber(t) || seen[t] { if t == "" || len(t) > 40 || isNumber(t) || seen[t] {
continue continue
@@ -199,6 +314,19 @@ func Tags(body string) []string {
return out 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 { func isNumber(s string) bool {
for _, r := range s { for _, r := range s {
if r < '0' || r > '9' { if r < '0' || r > '9' {
@@ -236,21 +364,81 @@ func checkBody(body string) error {
return nil 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 ---- // ---- changes ----
// Create saves a new note. // titleTaken says whether another live page has this title. Dates belong to the journal.
func (s *Store) Create(body string) (Note, error) { func (s *Store) titleTaken(title, except string) bool {
if err := checkBody(body); err != nil { 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 return Note{}, err
} }
if strings.TrimSpace(body) == "" { title := CleanTitle(in.Title)
if title == "" && strings.TrimSpace(in.Body) == "" {
return Note{}, ErrEmpty return Note{}, ErrEmpty
} }
now := time.Now().UTC() now := time.Now().UTC()
n := &Note{ID: newID(now), Created: now, Updated: now} n := &Note{ID: newID(now), Created: now, Updated: now, Title: title, Parent: in.Parent}
n.set(body) n.set(in.Body)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { if err := s.write(n); err != nil {
return Note{}, err return Note{}, err
} }
@@ -258,7 +446,7 @@ func (s *Store) Create(body string) (Note, error) {
return n.copy(), nil 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) { func (s *Store) Daily(date string) (Note, error) {
if _, err := time.Parse("2006-01-02", date); err != nil { if _, err := time.Parse("2006-01-02", date); err != nil {
return Note{}, ErrBadDate 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 if n.Trashed { // asking for the day brings it back
old := s.path(n) old := s.path(n)
n.Trashed = false n.Trashed = false
if err := os.Rename(old, s.path(n)); err != nil { if err := s.write(n); err != nil {
n.Trashed = true n.Trashed = true
return Note{}, err return Note{}, err
} }
os.Remove(old)
} }
return n.copy(), nil return n.copy(), nil
} }
now := time.Now().UTC() 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 { if err := s.write(n); err != nil {
return Note{}, err return Note{}, err
} }
@@ -296,41 +486,143 @@ func (s *Store) Get(id string) (Note, error) {
return n.copy(), nil return n.copy(), nil
} }
// Update changes the text and/or the pin. When base is given, the text is only replaced if the // Change is an update; fields that are nil stay as they are.
// note has not been changed since then; otherwise the current note comes back with ErrConflict. type Change struct {
func (s *Store) Update(id string, body *string, pinned *bool, base time.Time) (Note, error) { 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() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
n, ok := s.by[id] n, ok := s.by[id]
if !ok { if !ok {
return Note{}, ErrNotFound return Note{}, ErrNotFound
} }
if body != nil { next := n.copy()
if err := checkBody(*body); err != nil { content := false
if ch.Body != nil {
if err := checkBody(*ch.Body); err != nil {
return Note{}, err return Note{}, err
} }
if !base.IsZero() && !n.Updated.Equal(base) { 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
}
}
if content && !base.IsZero() && !n.Updated.Equal(base) {
return n.copy(), ErrConflict return n.copy(), ErrConflict
} }
if ch.Parent != nil {
if n.Daily != "" && *ch.Parent != "" {
return Note{}, ErrBadParent
} }
next := n.copy() if err := s.checkParent(id, *ch.Parent); err != nil {
if body != nil { return Note{}, err
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)
} }
next.Parent = *ch.Parent
} }
if pinned != nil { if ch.Pinned != nil {
next.Pinned = *pinned 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 { if err := s.write(&next); err != nil {
return Note{}, err return Note{}, err
} }
old := n.Title
*n = next *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 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) { func (s *Store) move(id string, toTrash bool) (Note, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -342,19 +634,44 @@ func (s *Store) move(id string, toTrash bool) (Note, error) {
return n.copy(), nil return n.copy(), nil
} }
old := s.path(n) old := s.path(n)
prev := n.copy()
n.Trashed = toTrash n.Trashed = toTrash
if err := os.Rename(old, s.path(n)); err != nil { if toTrash {
n.Trashed = !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 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 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) Trash(id string) (Note, error) { return s.move(id, true) }
func (s *Store) Restore(id string) (Note, error) { return s.move(id, false) } 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 { func (s *Store) Purge(id string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -372,7 +689,7 @@ func (s *Store) Purge(id string) error {
return nil 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) { func (s *Store) EmptyTrash() (int, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -400,16 +717,9 @@ type Query struct {
Limit int 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) { func (s *Store) List(q Query) ([]Note, int) {
var words, tags []string words, tags := splitQuery(q.Text)
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)
}
}
if t := strings.ToLower(strings.TrimPrefix(q.Tag, "#")); t != "" { if t := strings.ToLower(strings.TrimPrefix(q.Tag, "#")); t != "" {
tags = append(tags, t) tags = append(tags, t)
} }
@@ -449,6 +759,17 @@ func (s *Store) List(q Query) ([]Note, int) {
return out, total 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 { func matches(n *Note, words, tags []string) bool {
for _, w := range words { for _, w := range words {
if !strings.Contains(n.lower, w) { if !strings.Contains(n.lower, w) {
@@ -470,6 +791,162 @@ func matches(n *Note, words, tags []string) bool {
return true 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 { type TagCount struct {
Tag string `json:"tag"` Tag string `json:"tag"`
Count int `json:"count"` Count int `json:"count"`
@@ -482,7 +959,7 @@ type Counts struct {
Trash int `json:"trash"` 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) { func (s *Store) Summary() (Counts, []TagCount) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -517,27 +994,63 @@ func (s *Store) Summary() (Counts, []TagCount) {
return c, tags 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 { func (s *Store) WriteZip(w io.Writer) error {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
ids := make([]string, 0, len(s.by)) all := make([]*Note, 0, len(s.by))
for id, n := range s.by { for _, n := range s.by {
if !n.Trashed { 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) zw := zip.NewWriter(w)
for _, id := range ids { for _, n := range all {
n := s.by[id] base := fileName(n)
f, err := zw.CreateHeader(&zip.FileHeader{Name: "basking/" + id + ".md", Method: zip.Deflate, Modified: n.Updated}) 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 { if err != nil {
return err return err
} }
if _, err := f.Write(format(n)); err != nil { if _, err := f.Write(exportFormat(n)); err != nil {
return err return err
} }
} }
return zw.Close() 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())
}
+295 -64
View File
@@ -22,6 +22,17 @@ func open(t *testing.T) (*Store, string) {
return s, dir 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) { func TestTags(t *testing.T) {
cases := []struct { cases := []struct {
in string 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) { func TestCreateRoundTripsThroughDisk(t *testing.T) {
s, dir := open(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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if n.Body != "first thought #idea\n\nwith a second paragraph" { if n.Body != "first thought #idea\n\nwith a second paragraph" || n.Title != `Basking: a "notebook" xy` {
t.Fatalf("body %q", n.Body) t.Fatalf("clean-up: %q %q", n.Body, n.Title)
} }
if !ValidID(n.ID) { if !ValidID(n.ID) {
t.Fatalf("id %q", 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 { if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
s2, err := Open(dir) s2, err := Open(dir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -65,23 +108,20 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("reloaded %+v, want %+v", got, n)
} }
} }
func TestCreateRejectsEmptyAndHuge(t *testing.T) { func TestCreateRejectsHuge(t *testing.T) {
s, _ := open(t) s, _ := open(t)
if _, err := s.Create(" \n\t"); !errors.Is(err, ErrEmpty) { if _, err := s.Create(NewNote{Body: strings.Repeat("x", MaxBody+1)}); !errors.Is(err, ErrTooLong) {
t.Fatalf("empty: %v", err)
}
if _, err := s.Create(strings.Repeat("x", MaxBody+1)); !errors.Is(err, ErrTooLong) {
t.Fatalf("huge: %v", err) t.Fatalf("huge: %v", err)
} }
} }
func TestPlainMarkdownFileIsANote(t *testing.T) { func TestPlainMarkdownFileIsAPage(t *testing.T) {
s, dir := open(t) _, dir := open(t)
path := filepath.Join(dir, "notes", "from-elsewhere.md") path := filepath.Join(dir, "notes", "from-elsewhere.md")
os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644) 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) mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
@@ -94,44 +134,151 @@ func TestPlainMarkdownFileIsANote(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("%+v", n)
} }
_ = s
} }
func TestUpdateConflictAndPin(t *testing.T) { func TestUpdateConflictAndPin(t *testing.T) {
s, _ := open(t) s, _ := open(t)
n, _ := s.Create("one") n := mk(t, s, "", "one")
body := "two" n2, err := s.Update(n.ID, Change{Body: str("two")}, n.Updated)
n2, err := s.Update(n.ID, &body, nil, n.Updated)
if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) { if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) {
t.Fatalf("update: %v %+v", err, n2) t.Fatalf("update: %v %+v", err, n2)
} }
stale := "three" cur, err := s.Update(n.ID, Change{Body: str("three")}, n.Updated)
cur, err := s.Update(n.ID, &stale, nil, n.Updated)
if !errors.Is(err, ErrConflict) || cur.Body != "two" { if !errors.Is(err, ErrConflict) || cur.Body != "two" {
t.Fatalf("stale write: %v %+v", err, cur) 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 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) { if err != nil || !n3.Pinned || !n3.Updated.Equal(n2.Updated) {
t.Fatalf("pin should not move updated: %v %+v", err, n3) 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, Change{Body: str("four")}, time.Time{}); err != nil {
if _, err := s.Update(n.ID, &stale, nil, 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) t.Fatal(err)
} }
if _, err := s.Update("nope", &stale, nil, time.Time{}); !errors.Is(err, ErrNotFound) { got, _ := s.Get(a.ID)
t.Fatalf("missing: %v", err) 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) { func TestTrashRestorePurge(t *testing.T) {
s, dir := open(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) { 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 { if _, err := s.Trash(n.ID); err != nil {
t.Fatal(err) 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 { if _, err := os.Stat(filepath.Join(dir, "trash", n.ID+".md")); err != nil {
t.Fatal("not moved to trash:", err) t.Fatal("not moved to trash:", err)
} }
if list, _ := s.List(Query{}); len(list) != 0 { if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err == nil {
t.Fatal("trashed note still listed") 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 { if list, total := s.List(Query{View: "trash"}); len(list) != 1 || total != 1 {
t.Fatal("trash view empty") t.Fatal("trash view empty")
} }
// survives a restart in the trash // a new page takes the title while it is away; restoring must not make two
s2, _ := Open(dir) if _, err := s.Create(NewNote{Title: "Child"}); err != nil {
if list, _ := s2.List(Query{View: "trash"}); len(list) != 1 {
t.Fatal("trash lost on reload")
}
if _, err := s2.Restore(n.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if list, _ := s2.List(Query{}); len(list) != 1 { s2, _ := Open(dir)
t.Fatal("restore failed") 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) s2.Trash(n.ID)
if err := s2.Purge(n.ID); err != nil { if err := s2.Purge(n.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := s2.Get(n.ID); !errors.Is(err, ErrNotFound) { 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") if k, err := s2.EmptyTrash(); err != nil || k != 1 {
b, _ := s2.Create("b")
s2.Trash(a.ID)
s2.Trash(b.ID)
if k, err := s2.EmptyTrash(); err != nil || k != 2 {
t.Fatalf("empty trash: %d %v", k, err) t.Fatalf("empty trash: %d %v", k, err)
} }
} }
@@ -175,18 +325,21 @@ func TestTrashRestorePurge(t *testing.T) {
func TestDaily(t *testing.T) { func TestDaily(t *testing.T) {
s, _ := open(t) s, _ := open(t)
d1, err := s.Daily("2026-10-31") 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) t.Fatalf("%v %+v", err, d1)
} }
d2, _ := s.Daily("2026-10-31") d2, _ := s.Daily("2026-10-31")
if d1.ID != d2.ID || !d1.Created.Equal(d2.Created) { 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"} { for _, bad := range []string{"", "2026-13-01", "../etc/passwd", "today"} {
if _, err := s.Daily(bad); !errors.Is(err, ErrBadDate) { if _, err := s.Daily(bad); !errors.Is(err, ErrBadDate) {
t.Errorf("%q: %v", bad, err) 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) s.Trash(d1.ID)
d3, _ := s.Daily("2026-10-31") d3, _ := s.Daily("2026-10-31")
if d3.Trashed { if d3.Trashed {
@@ -197,24 +350,38 @@ func TestDaily(t *testing.T) {
if len(list) != 2 || list[0].Daily != "2026-10-31" { if len(list) != 2 || list[0].Daily != "2026-10-31" {
t.Fatalf("daily view order: %+v", list) 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) { func TestSearchAndTags(t *testing.T) {
s, _ := open(t) s, _ := open(t)
s.Create("Buy oat MILK #errands") s.Create(NewNote{Body: "Buy oat MILK #errands"})
time.Sleep(2 * time.Millisecond) 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) 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 { if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 {
t.Fatalf("case-insensitive search: %d", len(list)) t.Fatalf("case-insensitive search: %d", len(list))
} }
if list, _ := s.List(Query{Text: "the dentist"}); len(list) != 1 { if list, _ := s.List(Query{Text: "roadmap"}); len(list) != 1 {
t.Fatalf("all words must match: %d", len(list)) t.Fatalf("titles are searched: %d", len(list))
} }
if list, _ := s.List(Query{Text: "milk dentist"}); len(list) != 0 { 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 { if list, _ := s.List(Query{Tag: "errands"}); len(list) != 2 {
t.Fatalf("tag filter: %d", len(list)) 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)) t.Fatalf("a parent tag finds its children: %d", len(list))
} }
all, total := s.List(Query{Limit: 2}) all, total := s.List(Query{Limit: 2})
if len(all) != 2 || total != 3 || !strings.HasPrefix(all[0].Body, "Idea") { if len(all) != 2 || total != 3 || all[0].Title != "Roadmap" {
t.Fatalf("newest first with a page size: %d of %d, first %q", len(all), total, all[0].Body) 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() counts, tags := s.Summary()
if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 { if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 {
t.Fatalf("summary %+v %+v", counts, tags) 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) { func TestIDsCannotEscape(t *testing.T) {
for _, id := range []string{"", "..", "../x", "a/b", `a\b`, "A", ".hidden", strings.Repeat("a", 65)} { for _, id := range []string{"", "..", "../x", "a/b", `a\b`, "A", ".hidden", strings.Repeat("a", 65)} {
if ValidID(id) { if ValidID(id) {
t.Errorf("%q should not be a valid id", 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) os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644)
s2, _ := Open(dir) s2, _ := Open(dir)
if list, _ := s2.List(Query{}); len(list) != 0 { if list, _ := s2.List(Query{}); len(list) != 0 {
t.Fatal("a file whose name is not a valid id should be ignored") 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) s, _ := open(t)
a, _ := s.Create("keep me") mk(t, s, "Trip: Lisbon/Porto", "see [[Packing]]")
b, _ := s.Create("bin me") mk(t, s, "Packing", "")
s.Trash(b.ID) 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 var buf bytes.Buffer
if err := s.WriteZip(&buf); err != nil { if err := s.WriteZip(&buf); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -267,7 +488,17 @@ func TestZip(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(zr.File) != 1 || zr.File[0].Name != "basking/"+a.ID+".md" { var names []string
t.Fatalf("zip has %d files", len(zr.File)) 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)
}
} }
} }
+35 -135
View File
@@ -3,24 +3,16 @@ package web
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"mime"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store" "git.veillax.com/Veillax/basking/internal/store"
) )
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) { func (s *Server) pages(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, map[string]any{"version": s.version, "api": p.API}) counts, tags := p.st.Summary()
} writeJSON(w, map[string]any{"pages": p.st.Pages(), "counts": counts, "tags": tags})
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) listNotes(w http.ResponseWriter, r *http.Request, p principal) { 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 { if len(text) > 200 {
text = 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}) 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 func (s *Server) find(w http.ResponseWriter, r *http.Request, p principal) {
// just post what it has: curl -d 'buy milk' -H 'Content-Type: text/plain' ... q := r.URL.Query().Get("q")
if len(q) > 200 {
q = q[:200]
}
writeJSON(w, map[string]any{"hits": p.st.Find(q, 12)})
}
func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) { func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) {
var body string var in struct{ Title, Body, Parent string }
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt == "application/json" {
var in struct{ Body, Text string }
if !decode(w, r, &in) { if !decode(w, r, &in) {
return return
} }
body = in.Body + in.Text n, err := p.st.Create(store.NewNote{Title: in.Title, Body: in.Body, Parent: in.Parent})
} else {
b, err := io.ReadAll(r.Body)
if err != nil {
fail(w, http.StatusRequestEntityTooLarge, "request too large")
return
}
body = string(b)
}
n, err := s.st.Create(body)
if err != nil { if err != nil {
storeFail(w, err) storeFail(w, err)
return return
@@ -64,19 +51,27 @@ func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal)
writeJSONStatus(w, http.StatusCreated, n) 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) { 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 { if err != nil {
storeFail(w, err) storeFail(w, err)
return 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) { func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal) {
var in struct { var in struct {
Title *string `json:"title"`
Body *string `json:"body"` Body *string `json:"body"`
Pinned *bool `json:"pinned"` Pinned *bool `json:"pinned"`
Parent *string `json:"parent"`
Base string `json:"base"` Base string `json:"base"`
} }
if !decode(w, r, &in) { if !decode(w, r, &in) {
@@ -86,30 +81,30 @@ func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal)
if in.Base != "" { if in.Base != "" {
t, err := time.Parse(time.RFC3339Nano, in.Base) t, err := time.Parse(time.RFC3339Nano, in.Base)
if err != nil { 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 return
} }
base = t 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) { 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 return
} }
if err != nil { if err != nil {
storeFail(w, err) storeFail(w, err)
return 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) { func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal) {
id := r.PathValue("id") id := r.PathValue("id")
var err error var err error
if r.URL.Query().Get("purge") == "1" { if r.URL.Query().Get("purge") == "1" {
err = s.st.Purge(id) err = p.st.Purge(id)
} else { } else {
_, err = s.st.Trash(id) _, err = p.st.Trash(id)
} }
if err != nil { if err != nil {
storeFail(w, err) 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) { 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 { if err != nil {
storeFail(w, err) storeFail(w, err)
return 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) { 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 { if err != nil {
storeFail(w, err) storeFail(w, err)
return return
@@ -141,114 +136,19 @@ func (s *Server) daily(w http.ResponseWriter, r *http.Request, p principal) {
if !decode(w, r, &in) { if !decode(w, r, &in) {
return return
} }
n, err := s.st.Daily(in.Date) n, err := p.st.Daily(in.Date)
if err != nil { if err != nil {
storeFail(w, err) storeFail(w, err)
return 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) { func (s *Server) export(w http.ResponseWriter, r *http.Request, p principal) {
w.Header().Set("Content-Type", "application/zip") 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"))) 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. // The download has started, so all that can be done is to cut it short.
panic(http.ErrAbortHandler) 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})
}
}
+180
View File
@@ -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})
}
+75 -160
View File
@@ -2,62 +2,79 @@
package web package web
import ( import (
"crypto/hmac"
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"embed" "embed"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"strconv" "os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"git.veillax.com/Veillax/basking/internal/config" "git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/basking/internal/store" "git.veillax.com/Veillax/basking/internal/store"
) )
//go:embed static //go:embed static
var staticFiles embed.FS var staticFiles embed.FS
const ( const maxRequestBody = store.MaxBody + 4096
cookieName = "basking_session"
sessionTTL = 30 * 24 * time.Hour
maxLoginFails = 6
loginFailSpan = 5 * time.Minute
maxRequestBody = store.MaxBody + 4096
)
type Server struct { type Server struct {
cfg *config.Holder cfg *config.Holder
st *store.Store oidc *oidc.Client
version string version string
mu sync.Mutex mu sync.Mutex
fails map[string]failure books map[string]*store.Store
dummyOnce sync.Once
dummyHash string
} }
type failure struct { // principal is who is making a request, with the notebook that is theirs alone.
n int
until time.Time
}
// principal is who is making a request.
type principal struct { type principal struct {
Name string Sub, Name, Email string
API bool // came with an API token rather than a browser session st *store.Store
} }
func New(h *config.Holder, st *store.Store, version string) *Server { // New checks that Authentik is set up and returns the server.
return &Server{cfg: h, st: st, version: version, fails: map[string]failure{}} 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. // Handler returns the whole site.
@@ -65,10 +82,11 @@ func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) 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("POST /api/logout", s.api(s.logout))
mux.HandleFunc("GET /api/me", s.api(s.me)) 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("GET /api/notes", s.api(s.listNotes))
mux.HandleFunc("POST /api/notes", s.api(s.createNote)) 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("POST /api/notes/{id}/restore", s.api(s.restoreNote))
mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash)) mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash))
mux.HandleFunc("POST /api/daily", s.api(s.daily)) mux.HandleFunc("POST /api/daily", s.api(s.daily))
mux.HandleFunc("GET /api/find", s.api(s.find))
mux.HandleFunc("GET /api/export", s.api(s.export)) 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") static, _ := fs.Sub(staticFiles, "static")
mux.Handle("GET /", http.FileServerFS(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. // 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header() 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("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-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY") h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer") 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") h.Set("Cache-Control", "no-store")
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
// ---- authentication ----
type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal) type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal)
// api wraps a handler with sign-in and CSRF protection. // api wraps a handler with sign-in and CSRF protection.
func (s *Server) api(fn handlerFunc) http.HandlerFunc { func (s *Server) api(fn handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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 { if !ok {
fail(w, http.StatusUnauthorized, "sign in required") fail(w, http.StatusUnauthorized, "sign in required")
return return
} }
// Browser sessions must send a header a web page on another site cannot add. // A browser 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" { if r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" {
fail(w, http.StatusForbidden, "missing X-Basking header") fail(w, http.StatusForbidden, "missing X-Basking header")
return 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) 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 ---- // ---- helpers ----
func writeJSON(w http.ResponseWriter, v any) { writeJSONStatus(w, http.StatusOK, v) } 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. // fail answers with an error. Messages are capitalized here, once, so they read as sentences.
func fail(w http.ResponseWriter, code int, msg string) { 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 { if r := []rune(msg); len(r) > 0 {
msg = strings.ToUpper(string(r[:1])) + string(r[1:]) msg = strings.ToUpper(string(r[:1])) + string(r[1:])
} }
w.Header().Set("Content-Type", "application/json") writeJSONStatus(w, code, map[string]string{"error": msg, "code": kind})
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
} }
func decode(w http.ResponseWriter, r *http.Request, v any) bool { 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()) fail(w, http.StatusNotFound, err.Error())
case errors.Is(err, store.ErrConflict): case errors.Is(err, store.ErrConflict):
fail(w, http.StatusConflict, err.Error()) 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()) fail(w, http.StatusBadRequest, err.Error())
case errors.Is(err, store.ErrTooLong): case errors.Is(err, store.ErrTooLong):
fail(w, http.StatusRequestEntityTooLarge, err.Error()) fail(w, http.StatusRequestEntityTooLarge, err.Error())
+375 -507
View File
File diff suppressed because it is too large. Load diff
+276
View File
@@ -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); },
});
}
+3
View File
@@ -10,6 +10,9 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script src="toolkit.js"></script>
<script src="markdown.js"></script>
<script src="editor.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+81
View File
@@ -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]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^*\\n]*?)\\*(?![\\w*])|\\[\\[([^\\[\\]|\\n]+?)(?:\\|([^\\[\\]\\n]*))?\\]\\]|\\[([^\\]\\n]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)|(https?:\\/\\/[^\\s<]*[^\\s<.,;:!?)\\]\'"])|(?<=^|[\\s(])#([\\p{L}\\p{N}_][\\p{L}\\p{N}_/-]*)';
const tagChip = t => h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t);
const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label);
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;
}
+91 -3
View File
@@ -14,7 +14,7 @@
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; --font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --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; --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; --z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light; 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; } .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 .push { margin-left:auto; }
.toolbar input[type=search] { max-width:360px; } .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 */ /* rendered markdown */
.md > *:first-child { margin-top:0; } .md > *:last-child { margin-bottom:0; } .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 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 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; } .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); } }
+173
View File
@@ -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: '<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
notes: '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>',
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/>',
today: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
journal: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
pin: '<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
tag: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>',
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
restore: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
view: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
prev: '<path d="m15 18-6-6 6-6"/>',
next: '<path d="m9 18 6-6-6-6"/>',
chev: '<path d="m9 18 6-6-6-6"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
move: '<path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
out: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
close: '<path d="M18 6 6 18M6 6l12 12"/>',
add: '<path d="M5 12h14M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
};
function icon(name, size = 18) {
const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' });
s.innerHTML = `<svg class="icon icon-${size}" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
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) });
});
}
+525 -250
View File
@@ -3,337 +3,612 @@ package web
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"encoding/base64"
"encoding/json" "encoding/json"
"io" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time"
"git.veillax.com/Veillax/basking/internal/config" "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" // 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 rig struct { type idp struct {
t *testing.T
srv *httptest.Server srv *httptest.Server
cfg *config.Holder mu sync.Mutex
cookie *http.Cookie 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() t.Helper()
p := newIdP(t)
dir := t.TempDir() 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
hash, _ := config.HashPassword(password) s, err := New(h, "test")
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"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
srv := httptest.NewServer(New(h, st, "test").Handler()) return &env{t: t, s: s, h: s.Handler(), idp: p, dir: dir, cfg: h.Get()}
t.Cleanup(srv.Close)
return &rig{t: t, srv: srv, cfg: h}
} }
func (r *rig) do(method, path, ctype, body string, headers ...string) (*http.Response, []byte) { func (e *env) do(method, path string, body any, cookies []*http.Cookie, hdr ...string) *httptest.ResponseRecorder {
r.t.Helper() e.t.Helper()
req, _ := http.NewRequest(method, r.srv.URL+path, strings.NewReader(body)) var rd *bytes.Reader
if ctype != "" { if body != nil {
req.Header.Set("Content-Type", ctype) b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
} }
for i := 0; i+1 < len(headers); i += 2 { r := httptest.NewRequest(method, path, rd)
req.Header.Set(headers[i], headers[i+1]) if body != nil {
r.Header.Set("Content-Type", "application/json")
} }
if r.cookie != nil { if method != http.MethodGet {
req.AddCookie(r.cookie) r.Header.Set("X-Basking", "1")
} }
resp, err := http.DefaultClient.Do(req) for i := 0; i+1 < len(hdr); i += 2 {
if err != nil { if hdr[i+1] == "" {
r.t.Fatal(err) r.Header.Del(hdr[i])
} else {
r.Header.Set(hdr[i], hdr[i+1])
} }
defer resp.Body.Close() }
b, _ := io.ReadAll(resp.Body) for _, c := range cookies {
return resp, b r.AddCookie(c)
}
w := httptest.NewRecorder()
e.h.ServeHTTP(w, r)
return w
} }
// js sends JSON as a browser would, with the CSRF header. func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie {
func (r *rig) js(method, path string, v any) (*http.Response, map[string]any) { for _, c := range w.Result().Cookies() {
r.t.Helper() if c.Name == name {
var body string return c
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 return nil
json.Unmarshal(b, &out)
return resp, out
} }
func (r *rig) signIn() { func jsonOf(t *testing.T, w *httptest.ResponseRecorder, v any) {
r.t.Helper() t.Helper()
resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}) if err := json.Unmarshal(w.Body.Bytes(), v); err != nil {
if resp.StatusCode != 200 { t.Fatalf("bad json %q: %v", w.Body.String(), err)
r.t.Fatalf("login: %d", resp.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == cookieName {
r.cookie = c
}
}
if r.cookie == nil {
r.t.Fatal("no session cookie")
} }
} }
func TestRequiresSignIn(t *testing.T) { // begin starts a sign-in and returns the redirect the browser would follow and the flow cookie.
r := newRig(t) func (e *env) begin(next string) (*url.URL, *http.Cookie) {
for _, p := range []string{"/api/notes", "/api/summary", "/api/export", "/api/tokens", "/api/me"} { e.t.Helper()
if resp, _ := r.do("GET", p, "", ""); resp.StatusCode != 401 { w := e.do("GET", "/auth/login?next="+url.QueryEscape(next), nil, nil)
t.Errorf("GET %s without signing in: %d", p, resp.StatusCode) if w.Code != http.StatusSeeOther {
e.t.Fatalf("login: %d %s", w.Code, w.Body)
}
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"))
}
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 resp, _ := r.do("GET", "/healthz", "", ""); resp.StatusCode != 200 { if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, nil); w.Code != http.StatusUnauthorized {
t.Error("healthz should be open") t.Errorf("POST without a session: %d", w.Code)
} }
if resp, _ := r.do("GET", "/", "", ""); resp.StatusCode != 200 { if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Basking") {
t.Error("the page itself should load") t.Errorf("the sign-in page itself must load: %d", w.Code)
}
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
t.Errorf("healthz: %d", w.Code)
}
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 TestLoginAndHeaders(t *testing.T) { func TestSignInFlow(t *testing.T) {
r := newRig(t) e := setup(t, nil)
if resp, _ := r.do("POST", "/api/login", "application/json", `{"password":"`+password+`"}`); resp.StatusCode != 403 { to, flow := e.begin("#/p/abc-123")
t.Errorf("login without the CSRF header: %d", resp.StatusCode) q := to.Query()
if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") {
t.Fatalf("goes to Authentik: %s", to)
} }
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 { for k, want := range map[string]string{"response_type": "code", "client_id": "basking-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback",
t.Errorf("wrong password: %d", resp.StatusCode) "scope": "openid profile email", "code_challenge_method": "S256"} {
if q.Get(k) != want {
t.Errorf("%s = %q, want %q", k, q.Get(k), want)
} }
r.signIn()
resp, _ := r.do("GET", "/api/me", "", "")
if resp.StatusCode != 200 {
t.Fatalf("me: %d", resp.StatusCode)
} }
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") { for _, k := range []string{"state", "nonce", "code_challenge"} {
t.Errorf("CSP %q", csp) if len(q.Get(k)) < 16 {
t.Errorf("%s is too short: %q", k, q.Get(k))
} }
if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie flags %+v", r.cookie)
} }
// a browser write needs the header if !flow.HttpOnly || flow.SameSite != http.SameSiteLaxMode || flow.Path != "/auth/" {
if resp, _ := r.do("POST", "/api/notes", "application/json", `{"body":"x"}`); resp.StatusCode != 403 { t.Errorf("flow cookie: %+v", flow)
t.Errorf("write without the header: %d", resp.StatusCode)
} }
// a forged cookie does nothing
r.cookie = &http.Cookie{Name: cookieName, Value: "9999999999.deadbeef"} e.idp.nonce, e.idp.challenge, e.idp.claims = q.Get("nonce"), q.Get("code_challenge"), alice
if resp, _ := r.do("GET", "/api/me", "", ""); resp.StatusCode != 401 { w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(q.Get("state")), nil, []*http.Cookie{flow})
t.Errorf("forged cookie: %d", resp.StatusCode) 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 TestLoginIsRateLimited(t *testing.T) { func TestSignInIsRefusedWhenAnythingIsOff(t *testing.T) {
r := newRig(t) e := setup(t, nil)
var last int try := func(name string, mutate func(to *url.URL, flow **http.Cookie) string, want string) {
for i := 0; i < maxLoginFails+2; i++ { t.Helper()
resp, _ := r.js("POST", "/api/login", map[string]string{"password": "nope"}) to, flow := e.begin("")
last = resp.StatusCode 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)
} }
if last != 429 { w := e.do("GET", target, nil, cks)
t.Fatalf("after many failures got %d", last) 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)
} }
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)
} }
good := func(to *url.URL) string {
return "/auth/callback?code=good&state=" + url.QueryEscape(to.Query().Get("state"))
} }
func TestNoteLifecycle(t *testing.T) { try("no flow cookie", func(to *url.URL, f **http.Cookie) string { *f = nil; return good(to) }, "expired")
r := newRig(t) try("tampered flow cookie", func(to *url.URL, f **http.Cookie) string { c := *f; c.Value = "x" + c.Value; return good(to) }, "expired")
r.signIn() 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")
resp, n := r.js("POST", "/api/notes", map[string]string{"body": "remember the milk #errands"}) // and a next address that points anywhere but inside Basking is dropped
if resp.StatusCode != 201 { for _, bad := range []string{"https://evil.example.com", "//evil.example.com", "/api/export", "#/<script>", "javascript:alert(1)"} {
t.Fatalf("create: %d %v", resp.StatusCode, n) to, flow := e.begin(bad)
} e.idp.mu.Lock()
id, _ := n["id"].(string) e.idp.down = false
if id == "" || n["body"] != "remember the milk #errands" { e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice
t.Fatalf("created %v", n) e.idp.mu.Unlock()
} w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
if resp, _ := r.js("POST", "/api/notes", map[string]string{"body": " "}); resp.StatusCode != 400 { if w.Header().Get("Location") != "/" {
t.Errorf("empty note: %d", resp.StatusCode) t.Errorf("next %q led to %q", bad, w.Header().Get("Location"))
}
_, 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)
}
// 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)
} }
} }
} }
func TestDailyEndpoint(t *testing.T) { func TestSignInWhenAuthentikIsUnreachable(t *testing.T) {
r := newRig(t) e := setup(t, nil)
r.signIn() e.idp.srv.Close()
resp, n := r.js("POST", "/api/daily", map[string]string{"date": "2026-10-31"}) w := e.do("GET", "/auth/login", nil, nil)
if resp.StatusCode != 200 || n["id"] != "daily-2026-10-31" { if w.Header().Get("Location") != "/?signin=unavailable" {
t.Fatalf("%d %v", resp.StatusCode, n) t.Fatalf("went to %q", w.Header().Get("Location"))
}
if resp, _ := r.js("POST", "/api/daily", map[string]string{"date": "yesterday"}); resp.StatusCode != 400 {
t.Errorf("bad date: %d", resp.StatusCode)
} }
} }
func TestTokensAndPlainTextCapture(t *testing.T) { func TestAllowedList(t *testing.T) {
r := newRig(t) e := setup(t, func(c map[string]any) { c["allowed"] = []string{"ALICE@example.com", "carol"} })
r.signIn() to, flow := e.begin("")
resp, tok := r.js("POST", "/api/tokens", map[string]string{"name": "phone"}) e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), bob
if resp.StatusCode != 200 { w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
t.Fatalf("new token: %d %v", resp.StatusCode, tok) if w.Header().Get("Location") != "/?signin=denied" || cookieNamed(w, sessionCookie) != nil {
t.Fatalf("Bob should be turned away: %s", w.Header().Get("Location"))
} }
token := tok["token"].(string) ses := e.signIn(alice) // matched by email, whatever the case
if !strings.HasPrefix(token, "bsk_") { if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
t.Fatalf("token %q", token) t.Fatalf("Alice: %d", w.Code)
} }
if resp, _ := r.js("POST", "/api/tokens", map[string]string{"name": "PHONE"}); resp.StatusCode != 400 { if c := e.signIn(map[string]any{"sub": "carol-sub", "preferred_username": "Carol"}); c == nil { // matched by username
t.Errorf("duplicate name: %d", resp.StatusCode) t.Fatal("Carol should get in")
} }
if b, _ := json.Marshal(r.cfg.Get()); strings.Contains(string(b), token) { // taking someone off the list ends their session at once
t.Error("the token itself must not be stored") e2 := setup(t, func(c map[string]any) { c["allowed"] = []string{"someone-else"} })
} old := seal(e2.cfg, "session", sessionData{Sub: "alice-sub", Email: "alice@example.com", Exp: time.Now().Add(time.Hour).Unix()})
if w := e2.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: old}}); w.Code != http.StatusUnauthorized {
// a script with only the token: no cookie, no CSRF header, plain text body t.Fatalf("a valid cookie for someone no longer allowed: %d", w.Code)
api := &rig{t: t, srv: r.srv, cfg: r.cfg}
resp, b := api.do("POST", "/api/notes", "text/plain", "call mum #family", "Authorization", "Bearer "+token)
if resp.StatusCode != 201 {
t.Fatalf("capture: %d %s", resp.StatusCode, b)
}
if !strings.Contains(string(b), `"body":"call mum #family"`) {
t.Errorf("body %s", b)
}
if resp, _ := api.do("POST", "/api/notes", "text/plain", "x", "Authorization", "Bearer bsk_wrong"); resp.StatusCode != 401 {
t.Errorf("wrong token: %d", resp.StatusCode)
}
// a token cannot mint more tokens
if resp, _ := api.do("POST", "/api/tokens", "application/json", `{"name":"x"}`, "Authorization", "Bearer "+token); resp.StatusCode != 403 {
t.Errorf("token making a token: %d", resp.StatusCode)
}
_, list := r.js("GET", "/api/tokens", nil)
_ = list
if resp, _ := r.js("DELETE", "/api/tokens/phone", nil); resp.StatusCode != 200 {
t.Errorf("revoke: %d", resp.StatusCode)
}
if resp, _ := api.do("GET", "/api/notes", "", "", "Authorization", "Bearer "+token); resp.StatusCode != 401 {
t.Errorf("revoked token: %d", resp.StatusCode)
} }
} }
func TestChangePassword(t *testing.T) { func TestSessionsAreSignedAndExpire(t *testing.T) {
r := newRig(t) e := setup(t, nil)
r.signIn() ses := e.signIn(alice)
old := r.cookie if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": "wrong", "new": "a brand new password"}); resp.StatusCode != 403 { t.Fatal("valid session refused")
t.Errorf("wrong current: %d", resp.StatusCode)
} }
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "short"}); resp.StatusCode != 400 { payload, sig, _ := strings.Cut(ses[0].Value, ".")
t.Errorf("short: %d", resp.StatusCode) forged := base64.RawURLEncoding.EncodeToString([]byte(`{"s":"mallory","n":"Mallory","x":99999999999}`))
} for name, v := range map[string]string{"forged payload": forged + "." + sig, "no signature": payload, "empty": ""} {
resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "a brand new password"}) if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: v}}); w.Code != http.StatusUnauthorized {
if resp.StatusCode != 200 { t.Errorf("%s accepted: %d", name, w.Code)
t.Fatalf("change: %d", resp.StatusCode)
}
// the old cookie stops working, the new one (sent back in the response) works
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 401 {
t.Errorf("old cookie after a password change: %d", resp2.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == cookieName {
r.cookie = c
} }
} }
if r.cookie == old { expired := seal(e.cfg, "session", sessionData{Sub: "alice-sub", Exp: time.Now().Add(-time.Minute).Unix()})
t.Fatal("no new cookie") if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: expired}}); w.Code != http.StatusUnauthorized {
t.Errorf("expired session accepted: %d", w.Code)
} }
if resp2, _ := r.do("GET", "/api/me", "", ""); resp2.StatusCode != 200 { // a cookie for another purpose, or signed with another secret, is not a session
t.Errorf("new cookie: %d", resp2.StatusCode) other := e.cfg
other.Secret = "different"
for name, v := range map[string]string{
"flow cookie as session": seal(e.cfg, "flow", sessionData{Sub: "x", Exp: time.Now().Add(time.Hour).Unix()}),
"other secret": seal(other, "session", sessionData{Sub: "x", Exp: time.Now().Add(time.Hour).Unix()}),
} {
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: v}}); w.Code != http.StatusUnauthorized {
t.Errorf("%s accepted: %d", name, w.Code)
}
}
w := e.do("POST", "/api/logout", nil, ses)
if c := cookieNamed(w, sessionCookie); w.Code != 200 || c == nil || c.MaxAge >= 0 {
t.Fatalf("logout: %d %+v", w.Code, c)
} }
} }
func TestExport(t *testing.T) { func TestWritesNeedTheHeader(t *testing.T) {
r := newRig(t) e := setup(t, nil)
r.signIn() ses := e.signIn(alice)
r.js("POST", "/api/notes", map[string]string{"body": "one"}) if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
r.js("POST", "/api/notes", map[string]string{"body": "two"}) t.Fatalf("a write without the header: %d", w.Code)
resp, b := r.do("GET", "/api/export", "", "")
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Disposition"), "attachment") {
t.Fatalf("export: %d %v", resp.StatusCode, resp.Header)
} }
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) if w := e.do("DELETE", "/api/trash", nil, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
if err != nil || len(zr.File) != 2 { t.Fatalf("a delete without the header: %d", w.Code)
t.Fatalf("zip: %v, %d files", err, len(zr.File)) }
}
func TestEachPersonHasTheirOwnNotebook(t *testing.T) {
e := setup(t, nil)
a, b := e.signIn(alice), e.signIn(bob)
var mine struct{ ID string }
jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Alice's secret", "body": "dear diary"}, a), &mine)
if mine.ID == "" {
t.Fatal("no note made")
}
if w := e.do("GET", "/api/notes/"+mine.ID, nil, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob can see Alice's page: %d", w.Code)
}
var list struct{ Notes []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/notes", nil, b), &list)
if len(list.Notes) != 0 {
t.Fatalf("Bob's list: %+v", list)
}
var found struct{ Hits []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/find?q=diary", nil, b), &found)
if len(found.Hits) != 0 {
t.Fatalf("Bob can search Alice's notes: %+v", found)
}
if w := e.do("PUT", "/api/notes/"+mine.ID, map[string]string{"body": "hacked"}, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob can edit Alice's page: %d", w.Code)
}
// on disk each has a folder named from a hash, never from anything they chose
entries, _ := os.ReadDir(filepath.Join(e.dir, "data", "users"))
if len(entries) != 2 {
t.Fatalf("expected two notebooks, got %d", len(entries))
}
for _, d := range entries {
if strings.Contains(d.Name(), "alice") || strings.Contains(d.Name(), "bob") || len(d.Name()) != 32 {
t.Errorf("folder name %q", d.Name())
}
}
}
func TestPageLifecycle(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
type pg struct {
ID, Title, Name, Parent, Body, Updated string
Backlinks []struct{ ID, Name string }
Error, Code string
Note struct{ Body string }
}
call := func(method, path string, body any) (pg, int) {
w := e.do(method, path, body, ses)
var p pg
json.Unmarshal(w.Body.Bytes(), &p)
return p, w.Code
}
plans, code := call("POST", "/api/notes", map[string]string{"title": "Plans", "body": "first"})
if code != 201 || plans.Name != "Plans" {
t.Fatalf("create: %d %+v", code, plans)
}
if _, code := call("POST", "/api/notes", map[string]string{"title": "plans"}); code != 422 {
t.Fatalf("duplicate title on create: %d", code)
}
trip, _ := call("POST", "/api/notes", map[string]string{"title": "Trip", "body": "see [[Plans]]", "parent": plans.ID})
if trip.Parent != plans.ID {
t.Fatalf("subpage: %+v", trip)
}
if _, code := call("POST", "/api/notes", map[string]string{}); code != 400 {
t.Fatalf("empty create: %d", code)
}
got, _ := call("GET", "/api/notes/"+plans.ID, nil)
if len(got.Backlinks) != 1 || got.Backlinks[0].Name != "Trip" {
t.Fatalf("backlinks: %+v", got.Backlinks)
}
upd, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"body": "second", "base": plans.Updated})
if code != 200 || upd.Body != "second" {
t.Fatalf("update: %d %+v", code, upd)
}
stale, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"body": "third", "base": plans.Updated})
if code != 409 || stale.Note.Body != "second" {
t.Fatalf("stale update: %d %+v", code, stale)
}
bad, code := call("PUT", "/api/notes/"+trip.ID, map[string]any{"title": "PLANS"})
if code != 422 || bad.Code != "title_taken" || !strings.HasPrefix(bad.Error, "Another") {
t.Fatalf("taken title: %d %+v", code, bad)
}
if _, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"title": "Plans 2027"}); code != 200 {
t.Fatalf("rename: %d", code)
}
if moved, _ := call("GET", "/api/notes/"+trip.ID, nil); moved.Body != "see [[Plans 2027]]" {
t.Fatalf("links follow a rename: %q", moved.Body)
}
if _, code := call("PUT", "/api/notes/"+plans.ID, map[string]any{"parent": trip.ID}); code != 400 {
t.Fatalf("a page inside its own child: %d", code)
}
var found struct{ Hits []struct{ ID, Name string } }
jsonOf(t, e.do("GET", "/api/find?q=2027", nil, ses), &found)
if len(found.Hits) != 2 || found.Hits[0].ID != plans.ID { // Trip mentions it in a link, but the title match leads
t.Fatalf("find: %+v", found)
}
var tree struct {
Pages []struct{ ID, Parent string }
Counts struct{ All, Trash int }
}
jsonOf(t, e.do("GET", "/api/pages", nil, ses), &tree)
if len(tree.Pages) != 2 || tree.Counts.All != 2 {
t.Fatalf("pages: %+v", tree)
}
if _, code := call("DELETE", "/api/notes/"+plans.ID, nil); code != 200 {
t.Fatalf("trash: %d", code)
}
if kid, _ := call("GET", "/api/notes/"+trip.ID, nil); kid.Parent != "" {
t.Fatalf("subpages move up when their page is trashed: %+v", kid)
}
if _, code := call("DELETE", "/api/notes/"+trip.ID+"?purge=1", nil); code != 400 {
t.Fatalf("purging a live page: %d", code)
}
if _, code := call("POST", "/api/notes/"+plans.ID+"/restore", nil); code != 200 {
t.Fatalf("restore: %d", code)
}
if _, code := call("GET", "/api/notes/..%2f..%2fetc%2fpasswd", nil); code != 404 {
t.Fatalf("path tricks: %d", code)
}
d, code := call("POST", "/api/daily", map[string]string{"date": "2026-10-31"})
if code != 200 || d.Title != "2026-10-31" {
t.Fatalf("daily: %d %+v", code, d)
}
if _, code := call("POST", "/api/daily", map[string]string{"date": "nope"}); code != 400 {
t.Fatalf("bad date: %d", code)
}
w := e.do("GET", "/api/export", nil, ses)
zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
if err != nil || w.Header().Get("Content-Type") != "application/zip" {
t.Fatalf("export: %v %s", err, w.Header().Get("Content-Type"))
}
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
if fmt.Sprint(names) != "[basking/Journal/2026-10-31.md basking/Plans 2027.md basking/Trip.md]" && len(names) != 3 {
t.Fatalf("export has %v", names)
}
}
func TestSecurityHeaders(t *testing.T) {
e := setup(t, nil)
w := e.do("GET", "/", nil, nil)
csp := w.Header().Get("Content-Security-Policy")
for _, want := range []string{"default-src 'none'", "script-src 'self'", "style-src 'self'", "frame-ancestors 'none'"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP lacks %q: %s", want, csp)
}
}
if w.Header().Get("X-Frame-Options") != "DENY" || w.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Error("framing and sniffing headers missing")
}
if w.Header().Get("Strict-Transport-Security") != "" {
t.Error("no HSTS over plain http")
}
if got := e.do("GET", "/api/me", nil, nil).Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("API responses must not be cached: %q", got)
}
s := setup(t, func(c map[string]any) { c["public_url"] = "https://notes.example.com/" })
if s.do("GET", "/", nil, nil).Header().Get("Strict-Transport-Security") == "" {
t.Error("HSTS expected behind https")
}
if !s.cfg.Secure() || s.cfg.PublicURL != "https://notes.example.com" {
t.Errorf("public url: %+v", s.cfg)
}
}
func TestServerWillNotStartHalfConfigured(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "basking.json")
os.WriteFile(path, []byte(`{"public_url":"https://notes.example.com"}`), 0o600)
h, _ := config.Load(path)
_, err := New(h, "t")
if err == nil {
t.Fatal("started without Authentik settings")
}
for _, want := range []string{"oidc.issuer", "BASKING_OIDC_CLIENT_ID", "client_secret", "/auth/callback"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the message should mention %q:\n%v", want, err)
}
}
os.WriteFile(path, []byte(`{"public_url":"http://notes.example.com","oidc":{"issuer":"https://a.example.com/","client_id":"x","client_secret":"y"}}`), 0o600)
h, _ = config.Load(path)
if _, err := New(h, "t"); err == nil || !strings.Contains(err.Error(), "https") {
t.Errorf("plain http on a real host should be refused: %v", err)
}
t.Setenv("BASKING_PUBLIC_URL", "https://notes.example.com")
h, _ = config.Load(path)
if _, err := New(h, "t"); err != nil {
t.Errorf("environment settings should override the file: %v", err)
}
if b, _ := os.ReadFile(path); strings.Contains(string(b), "notes.example.com\"") && strings.Contains(string(b), "https://notes.example.com") {
t.Error("environment values must never be written to the file")
} }
} }
func TestFailCapitalizes(t *testing.T) { func TestFailCapitalizes(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
fail(w, 400, "écrit mal") fail(w, 400, "éclair is ready")
if !strings.Contains(w.Body.String(), "Écrit mal") { var out map[string]string
t.Errorf("%s", w.Body.String()) json.Unmarshal(w.Body.Bytes(), &out)
if out["error"] != "Éclair is ready" {
t.Fatalf("%q", out["error"])
} }
} }