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
+3291 -1578

No files matched your search

+115 -83
View File
@@ -1,30 +1,59 @@
# Basking
A place to dump thoughts, jot notes and keep a journal. Named for the basking shark, which swims
along with its mouth open and takes in whatever drifts by. One small binary, no database, no
dependencies.
A private notebook in the spirit of Obsidian and Notion, for people who would rather run their own.
Pages that nest inside each other, `[[links]]` between them with backlinks, a journal page for each
day, tags, and quick search, all kept as plain markdown files. One small binary, no database, no
dependencies. You sign in with **Authentik** and nothing else: there are no passwords in Basking.
Your notes are plain markdown files in a folder you own. Basking is just a fast, comfortable way to
write them and find them again, from a browser on your phone or your desk.
Named for the basking shark, which swims along with its mouth open and takes in whatever drifts by.
## What it does
- **Dump first, sort later.** The box at the top of Notes is always ready. Type, press Ctrl+Enter,
keep typing. An unsaved draft survives a closed tab.
- **Tags by typing.** Write `#errands` or `#projects/basking` anywhere in a note and it becomes a tag.
The sidebar lists them. Numbers such as `#12` and anything inside code are left alone.
- **A page for each day.** Today opens the day's journal page, with an Insert time button to stamp
`20:15 ` where you are typing. Move between days, or read back over the Journal.
- **Search** across everything as you type: every word must match, and `#tag` words filter by tag.
- **Markdown**, rendered safely: headings, lists, checkboxes you can tick in place, quotes, code,
links and bold and italic.
- **Pin** what matters. **Delete** goes to a trash first, with an Undo, and nothing is lost until you
empty it.
- **Autosave** while you edit, and a warning (not a silent overwrite) if the same note was changed on
another device.
- **Send notes from anywhere.** Make an API token and post text from a script, a phone shortcut or the
`basking add` command.
- **Take it with you.** Download everything as a zip of `.md` files, or just copy the folder.
- **Pages in a tree.** Give a page a title and it appears in the sidebar. Add a subpage with the `+`
next to it, or move a page with Move. Delete moves a page to the trash with an Undo, and any
subpages move up a level so nothing is lost with it.
- **Link pages with `[[`.** Type `[[` and pick a page, or type a new title to create it. A dashed link
is a page that does not exist yet; click it and it is made. Each page lists what links to it.
Rename a page and every `[[link]]` to it is updated.
- **Type `/` for commands**: headings, to-do lists, quotes, code blocks, dividers, today's date, the
time. Lists continue when you press Enter and indent with Tab. Ctrl+B and Ctrl+I work as you expect.
- **Write or Read.** Write shows the markdown; Read shows the page as it will look, with tasks you can
tick and links you can follow. Click a page in Read to start writing (Ctrl+E switches).
- **Quick find with Ctrl+K** searches titles and text as you type, and makes a new page if there is no
match.
- **A journal.** Today opens the page for the day, with an Insert time button. Days link to each
other: `[[2026-10-31]]` goes to that day.
- **Quick notes.** The box on Home takes a thought in two keystrokes (`n`, then Ctrl+Enter). They stay
out of the sidebar until you give them a title.
- **Tags by typing.** Write `#errands` or `#projects/basking` anywhere. Numbers such as `#12` and
anything in code are left alone.
- **Autosave that does not lose things.** Changes save as you type. If the connection drops, Basking
keeps your text and retries. If your Authentik session ends mid-sentence, it keeps the text, offers
to sign in again in another tab, and saves when you are back. If a page changed on another device,
it asks which version to keep instead of overwriting.
- **Take it with you.** Download everything as a zip that opens as a vault in Obsidian: file names are
titles, so the links keep working, and journal days sit in a Journal folder.
## Signing in with Authentik
Basking never sees a password. Who may open it is decided in Authentik, and each person gets a
notebook that only they can read.
1. In Authentik, go to **Applications → Providers → Create** and choose **OAuth2/OpenID Provider**.
Set the client type to **Confidential**, add the redirect URI (strict)
`https://notes.example.com/auth/callback` (use the address you will open Basking at), and keep the
default scopes (openid, profile, email).
2. **Applications → Create**: name it Basking and pick that provider. To limit who can open it, bind a
user or group to the application.
3. Copy the provider's **Client ID** and **Client Secret**, and note its **OpenID Configuration
Issuer**, which looks like `https://auth.example.com/application/o/basking/`.
4. Give Basking those four things (see below), then run `basking check`. It tells you whether
Authentik answers and what redirect URI to allow.
Basking asks for the `openid profile email` scopes and uses the authorization code flow with PKCE, a
`state` and a `nonce`. Whoever Authentik lets into the application can sign in; to be stricter, list
people under `allowed` (emails or usernames), and Basking turns anyone else away even if Authentik
lets them through.
## Installing on Linux (systemd)
@@ -32,123 +61,126 @@ write them and find them again, from a browser on your phone or your desk.
curl -fsSL https://git.veillax.com/Veillax/basking/raw/branch/main/deploy/install.sh | sudo sh
```
This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/basking`, creates
a `basking` user, `/etc/basking/` (settings) and `/var/lib/basking/` (your notes), and writes the
systemd service and `/etc/basking/basking.env`. It then tells you what to do next:
This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/basking`, creates a
`basking` user, `/etc/basking/` (settings) and `/var/lib/basking/` (your notes), and writes the
systemd service and `/etc/basking/basking.env`. Then:
```sh
sudoedit /etc/basking/basking.env # optional: the address and where notes are kept
sudoedit /etc/basking/basking.env # fill in the four Authentik lines at the top
sudo systemctl start basking
journalctl -u basking -f # the first start prints your password here, once
journalctl -u basking -f # it says at once if a setting is missing or wrong
```
Run it again to upgrade; your settings and notes are kept and a running service is restarted.
Basking listens on `127.0.0.1:8490`. Put an HTTPS reverse proxy (or
[Hammerhead](https://git.veillax.com/Veillax/hammerhead)) in front of it and open the address you gave
as `BASKING_PUBLIC_URL`. Authentik will only send people back to an address you allowed, and cookies
are marked Secure when that address is https.
Options: `--start` (start the service straight away), `--version v0.1.0`, `--listen 0.0.0.0:8490`,
`--source` (build from the repository with Go instead), and `--uninstall` (add `--purge` to delete
settings and notes). Download the script and read it first if you prefer: it is
Run the installer again to upgrade; your settings and notes are kept and a running service is
restarted. Options: `--start`, `--version v0.1.0`, `--listen 127.0.0.1:8490`, `--source` (build with Go
instead) and `--uninstall` (add `--purge` to delete settings and notes). Read it first if you like:
[`deploy/install.sh`](deploy/install.sh).
## Running it by hand
## Running it by hand or in Docker
```sh
# download a release, or: go build ./cmd/basking
export BASKING_PUBLIC_URL=https://notes.example.com
export BASKING_OIDC_ISSUER=https://auth.example.com/application/o/basking/
export BASKING_OIDC_CLIENT_ID=...
export BASKING_OIDC_CLIENT_SECRET=...
./basking check # optional: test the settings
./basking serve
```
On first run it writes `basking.json` and prints a password **once** in the log. Open
http://127.0.0.1:8490 and sign in. Notes go in `./data/notes/`.
By default it only listens on `127.0.0.1`. To reach it from your phone, put it behind an HTTPS
reverse proxy, or expose it through [Hammerhead](https://git.veillax.com/Veillax/hammerhead), and set
`"cookie_secure": true` and `"trust_proxy": true` in `basking.json`. Don't put it on the open
internet over plain HTTP: it holds your private thoughts.
Docker: see [`Dockerfile`](Dockerfile) and [`deploy/docker-compose.yml`](deploy/docker-compose.yml).
### Commands
```
basking serve [-config basking.json] [-listen addr] [-data dir]
basking password [-config ...] set a new password (BASKING_PASSWORD or stdin); restart afterwards
basking token [-config ...] NAME make an API token (printed once)
basking add [TEXT...] send a note to a running Basking (needs BASKING_URL and BASKING_TOKEN)
basking serve [-config basking.json] [-listen addr] [-data dir]
basking check [-config basking.json] test the Authentik settings without starting
basking version
```
## Settings
`basking.json` holds `listen` (default `127.0.0.1:8490`), `data_dir` (default `data`), the password
hash, API token hashes, a signing secret, and `cookie_secure` and `trust_proxy` for use behind a
proxy. The environment variables `BASKING_CONFIG`, `BASKING_LISTEN` and `BASKING_DATA` override the
file location, address and notes folder.
Everything can be set with environment variables, which win over the settings file and are never
written to it:
| Setting | Variable | |
| --- | --- | --- |
| `public_url` | `BASKING_PUBLIC_URL` | where you open Basking, no path |
| `oidc.issuer` | `BASKING_OIDC_ISSUER` | the provider's issuer |
| `oidc.client_id` | `BASKING_OIDC_CLIENT_ID` | |
| `oidc.client_secret` | `BASKING_OIDC_CLIENT_SECRET` | |
| `allowed` | `BASKING_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated |
| `listen` | `BASKING_LISTEN` | default `127.0.0.1:8490` |
| `data_dir` | `BASKING_DATA` | default `data` |
`basking.json` also holds a `secret` that signs session cookies. It is made on first run and written
with mode `0600`. Changing it signs everyone out.
## Where your notes are
```
data/
notes/20261031-143205-a1b2c3.md one file per note
data/users/<hash of who you are>/
owner.txt your name and email, so the folder can be matched to a person
notes/20261031-143205-a1b2c3.md one file per page
notes/daily-2026-10-31.md the journal page for a day
trash/ deleted notes, until the trash is emptied
trash/ deleted pages, until the trash is emptied
```
A note file looks like this, so you can edit it with anything:
A page file looks like this, so you can edit it with anything:
```
---
title: "Trip to Lisbon"
parent: 20261030-091200-ffee11
created: 2026-10-31T14:32:05.123Z
updated: 2026-10-31T14:35:00.456Z
pinned: true
---
Call the dentist #errands
Flights and hotels, see [[Packing list]] #travel
```
A plain `.md` file you drop into `notes/` is picked up too, with its file time as its date (name it
with lower-case letters, numbers and dashes). Restart Basking to see files added by hand.
## API
Sign in from a browser, or send `Authorization: Bearer bsk_...`.
```sh
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: text/plain" \
-d "Call the dentist #errands" http://127.0.0.1:8490/api/notes
```
`GET /api/notes?q=&tag=&view=all|pinned|daily|trash&offset=&limit=`, `POST /api/notes` (JSON
`{"body": "..."}` or plain text), `GET|PUT|DELETE /api/notes/{id}`, `POST /api/notes/{id}/restore`,
`DELETE /api/trash`, `POST /api/daily` (`{"date": "2026-10-31"}`), `GET /api/summary`,
`GET /api/export`.
A plain `.md` file dropped into `notes/` is picked up too, with its file time as its date (name it with
lower-case letters, numbers and dashes). Restart Basking to see files added by hand.
## Security notes
- The password is stored as a PBKDF2-SHA256 hash and API tokens as SHA-256 hashes. `basking.json` is
written `0600`. Failed sign-ins are rate-limited per address.
- Sessions are `HttpOnly`, `SameSite=Strict` cookies that last 30 days, signed with a secret from the
settings file. They are not kept on the server, so a restart does not sign you out, and signing out
only clears this browser's cookie. Changing the password signs out every browser.
- Every browser change needs a header a page on another site can't send. The page has a strict
Content-Security-Policy and notes are never rendered as HTML.
- Notes are **not encrypted** on disk. Anyone who can read the folder can read your notes.
- Sign-in is Authentik only: the authorization code flow with PKCE, `state` and `nonce`, over https. The
ID token comes straight from Authentik's token endpoint, and its issuer, audience, expiry and nonce
are checked. Authentik and Basking must be reachable over https unless both are on the same machine.
- Sessions are `HttpOnly`, `SameSite=Lax` cookies that last 14 days, signed with the secret in the
settings file. Nothing is kept on the server, so a restart does not sign anyone out. The `allowed`
list is checked on every request.
- Each person's folder is named from a hash of who they are, never from anything they typed. One
person's requests can only ever reach their own folder.
- Every change from the browser needs a header a page on another site can't send. The page has a strict
Content-Security-Policy, and pages are never rendered as HTML.
- Pages are **not encrypted** on disk. Anyone who can read the server's disk can read them.
## Honest limits
- One person, one password. There are no accounts, sharing or real-time collaboration.
- No attachments or images, and no offline mode: it needs the server to be reachable.
- Searching holds every note in memory, which is comfortable for tens of thousands of notes and not
- No sharing or real-time collaboration: each notebook has one reader.
- No attachments or images, and no offline mode; it needs the server to be reachable.
- No scripts or tokens for posting notes from elsewhere: Authentik is the only way in.
- Searching holds every page in memory, which is comfortable for tens of thousands of pages and not
built for millions.
- Notes are only reloaded from disk when Basking starts.
- Pages are only reloaded from disk when Basking starts.
- Write mode is plain markdown, not a block editor. Read mode shows how it looks.
## Design
The dashboard follows the Lightwell design language, the same one Hammerhead and Megalodon use. See
The interface follows the Lightwell design language, the same one Hammerhead and Megalodon use. See
[docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules a machine can check.
## Development
```sh
go test ./...
go run ./cmd/basking serve
go run ./cmd/basking serve # with the BASKING_* variables set
```
Releases are built by the Gitea workflow when a `v*` tag is pushed.
+76 -141
View File
@@ -1,16 +1,12 @@
// Command basking is a place to dump thoughts, jot notes and keep a journal. Notes are plain
// markdown files; the dashboard is one small binary.
// Command basking is a private notebook: pages you can link, nest and search, kept as plain
// markdown files. You sign in with Authentik; there is no other way in.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -20,26 +16,32 @@ import (
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/basking/internal/web"
)
var version = "dev"
const usage = `basking is a place to dump thoughts, jot notes and keep a journal.
const usage = `basking is a private notebook that you sign in to with Authentik.
basking serve [-config basking.json] [-listen addr] [-data dir]
run the dashboard
basking password [-config ...] set the password (reads BASKING_PASSWORD or a line on stdin)
basking token [-config ...] NAME create an API token for scripts (shown once)
basking add [TEXT...] send a note to a running Basking; reads stdin if no text
basking serve [-config basking.json] [-listen addr] [-data dir]
run the notebook
basking check [-config basking.json] test the Authentik settings without starting
basking version
"add" needs BASKING_URL (such as http://127.0.0.1:8490) and BASKING_TOKEN.
The settings path can also be set with BASKING_CONFIG, the address with BASKING_LISTEN and the
notes folder with BASKING_DATA.
Settings come from the settings file and can be overridden by environment variables:
BASKING_PUBLIC_URL where you open Basking, such as https://notes.example.com
BASKING_OIDC_ISSUER the Authentik provider's OpenID configuration issuer
BASKING_OIDC_CLIENT_ID the provider's Client ID
BASKING_OIDC_CLIENT_SECRET the provider's Client Secret
BASKING_ALLOWED optional: only these emails or usernames may sign in (comma separated)
BASKING_LISTEN, BASKING_DATA, BASKING_CONFIG
`
// exitConfig is the exit status for settings that need fixing by hand, so a service manager does
// not keep restarting something that cannot start.
const exitConfig = 2
func main() {
log.SetFlags(log.LstdFlags)
if len(os.Args) < 2 {
@@ -50,12 +52,8 @@ func main() {
switch os.Args[1] {
case "serve":
err = serve(os.Args[2:])
case "password":
err = passwordCmd(os.Args[2:])
case "token":
err = tokenCmd(os.Args[2:])
case "add":
err = addCmd(os.Args[2:])
case "check":
err = check(os.Args[2:])
case "version", "-v", "--version":
fmt.Println("basking", version)
case "help", "-h", "--help":
@@ -66,10 +64,16 @@ func main() {
}
if err != nil {
fmt.Fprintln(os.Stderr, "basking:", err)
var ce configError
if errors.As(err, &ce) {
os.Exit(exitConfig)
}
os.Exit(1)
}
}
type configError struct{ error }
func configFlag(fs *flag.FlagSet) *string {
def := os.Getenv("BASKING_CONFIG")
if def == "" {
@@ -81,43 +85,34 @@ func configFlag(fs *flag.FlagSet) *string {
func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
path := configFlag(fs)
listen := fs.String("listen", os.Getenv("BASKING_LISTEN"), "override the listen address")
data := fs.String("data", os.Getenv("BASKING_DATA"), "override the folder the notes are kept in")
listen := fs.String("listen", "", "override the listen address")
data := fs.String("data", "", "override the folder the notes are kept in")
fs.Parse(args)
if *listen != "" {
os.Setenv("BASKING_LISTEN", *listen)
}
if *data != "" {
os.Setenv("BASKING_DATA", *data)
}
h, err := config.Load(*path)
if err != nil {
return err
return configError{err}
}
// First run: make a password so the dashboard can be opened, and show it once.
if h.Get().PasswordHash == "" {
pw := config.RandomPassword()
hash, err := config.HashPassword(pw)
if err != nil {
return err
if !h.Exists() { // writing it now is what makes the generated secret stick
if err := h.Save(); err != nil {
return fmt.Errorf("could not write %s: %w", *path, err)
}
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
return err
}
log.Printf("first run: your password is %s (change it in Settings; written to %s)", pw, *path)
}
cfg := h.Get()
if *listen != "" {
cfg.Listen = *listen
}
if *data != "" {
cfg.DataDir = *data
}
st, err := store.Open(cfg.DataDir)
web, err := web.New(h, version)
if err != nil {
return err
return configError{err}
}
counts, _ := st.Summary()
srv := &http.Server{
Addr: cfg.Listen,
Handler: web.New(h, st, version).Handler(),
Handler: web.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
@@ -127,10 +122,21 @@ func serve(args []string) error {
defer stop()
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
log.Printf("basking %s: %d note(s) in %s; open http://%s", version, counts.All, cfg.DataDir, cfg.Listen)
if !strings.HasPrefix(cfg.Listen, "127.") && !strings.HasPrefix(cfg.Listen, "localhost") && !strings.HasPrefix(cfg.Listen, "[::1]") {
log.Print("note: this is not a loopback address; put it behind HTTPS (a reverse proxy or Hammerhead) and set cookie_secure")
log.Printf("basking %s: notes are kept in %s; open %s", version, cfg.DataDir, cfg.PublicURL)
log.Printf("sign-in goes through %s; the redirect URI to allow there is %s", cfg.OIDC.Issuer, cfg.RedirectURL())
if len(cfg.Allowed) == 0 {
log.Print("anyone Authentik lets into this application gets a notebook of their own; set \"allowed\" to limit it")
}
if !cfg.Secure() && !strings.Contains(cfg.PublicURL, "localhost") && !strings.Contains(cfg.PublicURL, "127.0.0.1") {
log.Print("warning: public_url is not https, and session cookies will not be marked Secure")
}
go func() { // say early if Authentik cannot be reached, instead of at the first sign-in
c, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, err := web.OIDC().Discover(c); err != nil {
log.Printf("warning: %v (sign-in will work once Authentik is reachable)", err)
}
}()
select {
case err := <-errc:
@@ -142,104 +148,33 @@ func serve(args []string) error {
return srv.Shutdown(sctx)
}
func readPassword() (string, error) {
if pw := os.Getenv("BASKING_PASSWORD"); pw != "" {
return pw, nil
}
fmt.Fprint(os.Stderr, "new password: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", errors.New("no password given")
}
return strings.TrimRight(line, "\r\n"), nil
}
func passwordCmd(args []string) error {
fs := flag.NewFlagSet("password", flag.ExitOnError)
func check(args []string) error {
fs := flag.NewFlagSet("check", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
pw, err := readPassword()
if err != nil {
return err
}
if len(pw) < 8 {
return errors.New("the password must be at least 8 characters")
}
hash, err := config.HashPassword(pw)
if err != nil {
return err
}
h, err := config.Load(*path)
if err != nil {
return configError{err}
}
cfg := h.Get()
if err := cfg.Check(); err != nil {
return configError{err}
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
d, err := oidc.New(cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.ClientSecret, cfg.RedirectURL()).Discover(ctx)
if err != nil {
return err
}
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
return err
fmt.Println("Authentik answered at", cfg.OIDC.Issuer)
fmt.Println(" sign-in page:", d.AuthorizationEndpoint)
fmt.Println(" token address:", d.TokenEndpoint)
fmt.Println("Make sure the provider in Authentik allows this redirect URI exactly:")
fmt.Println(" " + cfg.RedirectURL())
if len(cfg.Allowed) == 0 {
fmt.Println("Everyone Authentik lets into the application gets their own notebook.")
} else {
fmt.Println("Only these may sign in:", strings.Join(cfg.Allowed, ", "))
}
fmt.Println("password saved; restart Basking if it is running, and everyone will be signed out")
return nil
}
func tokenCmd(args []string) error {
fs := flag.NewFlagSet("token", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
if fs.NArg() != 1 {
return errors.New("expected NAME")
}
token, hash, err := config.NewToken()
if err != nil {
return err
}
h, err := config.Load(*path)
if err != nil {
return err
}
err = h.Update(func(c *config.Config) error {
c.Tokens = append(c.Tokens, config.Token{Name: fs.Arg(0), Hash: hash, Created: time.Now().UTC()})
return nil
})
if err == nil {
fmt.Println(token)
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; send it as 'Authorization: Bearer <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
}
+10 -3
View File
@@ -1,10 +1,17 @@
# Basking. Reach it through a reverse proxy or Hammerhead (HTTPS), and set "cookie_secure": true
# and "trust_proxy": true in basking.json when you do.
# Basking, a private notebook you sign in to with Authentik. Reach it through an HTTPS reverse proxy
# (or Hammerhead) that forwards to 127.0.0.1:8490, and give Authentik the redirect URI
# https://notes.example.com/auth/callback (use your own address).
services:
basking:
image: git.veillax.com/veillax/basking:latest # or: build: ..
restart: unless-stopped
ports:
- "127.0.0.1:8490:8490"
environment:
BASKING_PUBLIC_URL: https://notes.example.com
BASKING_OIDC_ISSUER: https://auth.example.com/application/o/basking/
BASKING_OIDC_CLIENT_ID: change-me
BASKING_OIDC_CLIENT_SECRET: change-me
# BASKING_ALLOWED: you@example.com # optional: limit who may sign in
volumes:
- ./config:/config # basking.json and data/ (your notes) live here
- ./config:/config # basking.json (the signing secret) and data/ (your pages) live here
+27 -9
View File
@@ -137,8 +137,25 @@ fi
if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
# 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
# Optional. These override what the settings file says.
@@ -158,7 +175,7 @@ fi
cat > "$UNIT" <<EOF
[Unit]
Description=Basking, a place to dump your thoughts
Description=Basking, a private notebook
After=network-online.target
Wants=network-online.target
@@ -169,6 +186,8 @@ WorkingDirectory=$DATA_DIR
ExecStart=$BIN serve
Restart=on-failure
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.
NoNewPrivileges=true
@@ -194,18 +213,17 @@ if [ "$START" -eq 1 ] || systemctl is-active --quiet basking; then
if systemctl is-active --quiet basking; then
say "Basking is running."
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
fi
fi
say "Installed $("$BIN" version)."
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 " 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 "The dashboard is at http://$LISTEN"
case $LISTEN in
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
say "Basking listens on $LISTEN. Put an HTTPS reverse proxy (or Hammerhead) in front of it and open the"
say "address you set as BASKING_PUBLIC_URL; Authentik will not send people back to plain http."
+8 -5
View File
@@ -11,16 +11,19 @@ Basking follows the Lightwell design language, the same one Hammerhead and Megal
## 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.
* **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.
* **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.
* **Rendered notes** use a small safe markdown renderer in `app.js`. It builds elements, never HTML, and only `http(s)` links are links.
* **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.
* **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.
* **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.
* **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
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
+111 -130
View File
@@ -1,49 +1,44 @@
// Package config holds Basking's settings file: where to listen, where notes live, the password
// hash and the API tokens. The notes themselves are not in here.
// Package config holds Basking's settings: where to listen, where notes live and how to reach
// Authentik. The notes themselves are not in here.
package config
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
type Token struct {
Name string `json:"name"`
Hash string `json:"hash"`
Created time.Time `json:"created"`
// OIDC is the OAuth client Authentik gives you for Basking.
type OIDC struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
type Config struct {
Listen string `json:"listen"`
DataDir string `json:"data_dir"`
// PasswordHash is the one password that opens the dashboard.
PasswordHash string `json:"password_hash,omitempty"`
// PublicURL is the address you open Basking at, such as https://notes.example.com. The sign-in
// 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 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"`
Secret string `json:"secret,omitempty"`
}
var tokenName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$`)
// Defaults fills in what is missing.
func (c *Config) Defaults() {
if c.Listen == "" {
@@ -57,27 +52,75 @@ func (c *Config) Defaults() {
rand.Read(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 {
seen := map[string]bool{}
for _, t := range c.Tokens {
if !tokenName.MatchString(t.Name) {
return fmt.Errorf("token name %q must be 1 to 40 letters, numbers, spaces, dots, dashes or underscores", t.Name)
}
if seen[strings.ToLower(t.Name)] {
return fmt.Errorf("there is already a token called %q", t.Name)
}
seen[strings.ToLower(t.Name)] = true
// RedirectURL is the address to enter in Authentik as the redirect URI.
func (c Config) RedirectURL() string { return c.PublicURL + "/auth/callback" }
// Secure says whether Basking is reached over HTTPS, which decides whether cookies are Secure.
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")
}
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)
}
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
}
// 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 {
path string
mu sync.RWMutex
cfg Config
file Config
}
// Load reads the file. A missing file gives the defaults (and is created by the first Update).
@@ -89,14 +132,11 @@ func Load(path string) (*Holder, error) {
case err != nil:
return nil, err
default:
if err := json.Unmarshal(b, &h.cfg); err != nil {
if err := json.Unmarshal(b, &h.file); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
}
h.cfg.Defaults()
if err := h.cfg.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
h.file.Defaults()
return h, nil
}
@@ -105,108 +145,49 @@ func (h *Holder) Path() string { return h.path }
// Exists says whether the file is on disk yet.
func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil }
// Get returns a private copy; change it freely.
// Get returns the settings with the environment applied.
func (h *Holder) Get() Config {
h.mu.RLock()
defer h.mu.RUnlock()
return clone(h.cfg)
}
func clone(c Config) Config {
b, _ := json.Marshal(c)
var out Config
json.Unmarshal(b, &out)
return out
}
// Update applies fn to a copy, validates it, saves it and only then makes it current.
func (h *Holder) Update(fn func(*Config) error) error {
h.mu.Lock()
defer h.mu.Unlock()
c := clone(h.cfg)
if err := fn(&c); err != nil {
return err
c := h.file
c.Allowed = append([]string{}, c.Allowed...)
h.mu.RUnlock()
env := func(name string, to *string) {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
*to = v
}
}
env("BASKING_LISTEN", &c.Listen)
env("BASKING_DATA", &c.DataDir)
env("BASKING_PUBLIC_URL", &c.PublicURL)
env("BASKING_OIDC_ISSUER", &c.OIDC.Issuer)
env("BASKING_OIDC_CLIENT_ID", &c.OIDC.ClientID)
env("BASKING_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
if v := os.Getenv("BASKING_ALLOWED"); strings.TrimSpace(v) != "" {
c.Allowed = nil
for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) {
if a = strings.TrimSpace(a); a != "" {
c.Allowed = append(c.Allowed, a)
}
}
}
c.Defaults()
if err := c.Validate(); err != nil {
return err
}
if err := save(h.path, c); err != nil {
return err
}
h.cfg = c
return nil
return c
}
func save(path string, c Config) error {
b, err := json.MarshalIndent(c, "", " ")
// Save writes the file as it is now, which is what makes the generated secret stick.
func (h *Holder) Save() error {
h.mu.Lock()
defer h.mu.Unlock()
b, err := json.MarshalIndent(h.file, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(path); dir != "." {
if dir := filepath.Dir(h.path); dir != "." {
os.MkdirAll(dir, 0o755)
}
tmp := path + ".tmp"
tmp := h.path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ---- passwords and tokens ----
const pbkdf2Rounds = 600_000
// HashPassword returns "pbkdf2$rounds$salt$key".
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32)
if err != nil {
return "", err
}
return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Rounds, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
// CheckPassword compares in constant time. An unparseable hash never matches.
func CheckPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 4 || parts[0] != "pbkdf2" {
return false
}
rounds, err := strconv.Atoi(parts[1])
if err != nil || rounds < 1 || rounds > 10_000_000 {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[2])
want, err2 := base64.RawStdEncoding.DecodeString(parts[3])
if err1 != nil || err2 != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
}
// NewToken makes an API token and the hash to store for it. The token itself is shown once.
func NewToken() (token, hash string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
token = "bsk_" + base64.RawURLEncoding.EncodeToString(b)
return token, HashToken(token), nil
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// RandomPassword makes a password for a first run.
func RandomPassword() string {
b := make([]byte, 12)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
return os.Rename(tmp, h.path)
}
+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
}
+586 -73
View File
@@ -1,34 +1,44 @@
// Package store keeps notes as plain markdown files, one per note, so they stay readable and
// portable with or without Basking. An in-memory index makes listing and searching quick.
// Package store keeps pages as plain markdown files, one per page, so they stay readable and
// portable with or without Basking. An in-memory index makes listing, searching and linking quick.
package store
import (
"archive/zip"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
)
// MaxBody is the longest note, in bytes.
const MaxBody = 256 << 10
// MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters.
const (
MaxBody = 256 << 10
MaxTitle = 120
)
var (
ErrNotFound = errors.New("note not found")
ErrBadID = errors.New("bad note id")
ErrEmpty = errors.New("a note needs some text")
ErrTooLong = errors.New("that note is too long")
ErrConflict = errors.New("this note changed somewhere else")
ErrBadDate = errors.New("the date must look like 2026-10-31")
ErrNotTrash = errors.New("only a note in the trash can be deleted for good")
ErrNotFound = errors.New("page not found")
ErrBadID = errors.New("bad page id")
ErrEmpty = errors.New("a page needs a title or some text")
ErrTooLong = errors.New("that page is too long")
ErrConflict = errors.New("this page changed somewhere else")
ErrBadDate = errors.New("the date must look like 2026-10-31")
ErrNotTrash = errors.New("only a page in the trash can be deleted for good")
ErrTitleTaken = errors.New("another page already has that title")
ErrDailyTitle = errors.New("a journal day is named by its date")
ErrBadParent = errors.New("a page can only go inside another page, and not inside itself")
)
type Note struct {
@@ -36,12 +46,16 @@ type Note struct {
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Pinned bool `json:"pinned"`
Daily string `json:"daily,omitempty"` // the date, for a daily note
Daily string `json:"daily,omitempty"` // the date, for a journal day
Trashed bool `json:"trashed,omitempty"`
Title string `json:"title"`
Name string `json:"name"` // what to call it: the title, else the first line
Parent string `json:"parent,omitempty"`
Body string `json:"body"`
Tags []string `json:"tags"`
lower string // lower-cased body, for searching
lower string // lower-cased title and body, for searching
links []string // lower-cased titles this page links to with [[...]]
}
type Store struct {
@@ -50,12 +64,15 @@ type Store struct {
by map[string]*Note
}
var idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
var (
idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
)
// ValidID says whether s can be used as a note id (and so as a file name).
// ValidID says whether s can be used as a page id (and so as a file name).
func ValidID(s string) bool { return idRe.MatchString(s) }
// Open reads every note under dir, creating the folders if needed.
// Open reads every page under dir, creating the folders if needed.
func Open(dir string) (*Store, error) {
s := &Store{dir: dir, by: map[string]*Note{}}
for _, sub := range []string{"notes", "trash"} {
@@ -85,9 +102,32 @@ func Open(dir string) (*Store, error) {
s.by[id] = n
}
}
s.mend()
return s, nil
}
// mend drops parents that point nowhere, or in a circle, so a hand-edited file cannot break the tree.
func (s *Store) mend() {
for _, n := range s.by {
if n.Parent == "" {
continue
}
p, ok := s.by[n.Parent]
if !ok || (p.Trashed && !n.Trashed) || n.Daily != "" {
n.Parent = ""
continue
}
seen := map[string]bool{n.ID: true}
for cur := p; cur != nil; cur = s.by[cur.Parent] {
if seen[cur.ID] {
n.Parent = ""
break
}
seen[cur.ID] = true
}
}
}
func (s *Store) path(n *Note) string {
sub := "notes"
if n.Trashed {
@@ -99,17 +139,26 @@ func (s *Store) path(n *Note) string {
// ---- file format ----
//
// ---
// title: "Trip to Lisbon"
// parent: 20261031-143205-a1b2c3
// created: 2026-10-31T14:32:05.123Z
// updated: 2026-10-31T14:35:00.456Z
// pinned: true
// daily: 2026-10-31
// ---
//
// the note, in markdown
// the page, in markdown
func format(n *Note) []byte {
var b strings.Builder
b.WriteString("---\n")
if n.Title != "" && n.Title != n.Daily {
t, _ := json.Marshal(n.Title)
fmt.Fprintf(&b, "title: %s\n", t)
}
if n.Parent != "" {
fmt.Fprintf(&b, "parent: %s\n", n.Parent)
}
fmt.Fprintf(&b, "created: %s\n", n.Created.UTC().Format(time.RFC3339Nano))
fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339Nano))
if n.Pinned {
@@ -124,7 +173,7 @@ func format(n *Note) []byte {
return []byte(b.String())
}
// parse reads a note file. A file with no header is still a note: it takes its time from the file.
// parse reads a page file. A file with no header is still a page: it takes its time from the file.
func parse(id, text string, mod time.Time) *Note {
text = strings.ReplaceAll(text, "\r\n", "\n")
n := &Note{ID: id, Created: mod.UTC(), Updated: mod.UTC()}
@@ -137,6 +186,17 @@ func parse(id, text string, mod time.Time) *Note {
k, v, _ := strings.Cut(line, ":")
v = strings.TrimSpace(v)
switch strings.TrimSpace(k) {
case "title":
var t string
if strings.HasPrefix(v, `"`) && json.Unmarshal([]byte(v), &t) == nil {
n.Title = CleanTitle(t)
} else {
n.Title = CleanTitle(v)
}
case "parent":
if ValidID(v) {
n.Parent = v
}
case "created":
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
n.Created = t.UTC()
@@ -156,25 +216,74 @@ func parse(id, text string, mod time.Time) *Note {
text = strings.TrimPrefix(body, "\n")
}
}
if n.Daily != "" {
n.Title = n.Daily
n.Parent = ""
}
n.set(text)
return n
}
// set stores the body (without trailing blank space) and works out the tags.
// CleanTitle makes a title safe to link to: one line, no brackets or bars, not too long.
func CleanTitle(s string) string {
s = strings.Map(func(r rune) rune {
switch {
case r == '[' || r == ']' || r == '|':
return -1
case unicode.IsControl(r):
return ' '
}
return r
}, s)
s = strings.Join(strings.Fields(s), " ")
if utf8.RuneCountInString(s) > MaxTitle {
s = strings.TrimSpace(string([]rune(s)[:MaxTitle]))
}
return s
}
// set stores the body (without trailing blank space) and works out what is derived from it.
func (n *Note) set(body string) {
n.Body = strings.TrimRight(strings.ReplaceAll(body, "\r\n", "\n"), " \t\n")
n.lower = strings.ToLower(n.Body)
n.refresh()
}
func (n *Note) refresh() {
n.lower = strings.ToLower(n.Title + "\n" + n.Body)
n.Tags = Tags(n.Body)
n.links = Links(n.Body)
n.Name = nameOf(n)
}
var leadRe = regexp.MustCompile(`^(?:#{1,6}\s+|>\s*|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)+`)
func nameOf(n *Note) string {
if n.Title != "" {
return n.Title
}
for _, l := range strings.Split(n.Body, "\n") {
l = strings.TrimSpace(leadRe.ReplaceAllString(strings.TrimSpace(l), ""))
if l == "" || strings.HasPrefix(l, "```") {
continue
}
l = strings.NewReplacer("[[", "", "]]", "", "**", "", "`", "").Replace(l)
if r := []rune(l); len(r) > 60 {
l = strings.TrimSpace(string(r[:60])) + "..."
}
return l
}
return "Untitled"
}
var (
tagRe = regexp.MustCompile(`(?:^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)`)
inlineCodeRe = regexp.MustCompile("`[^`\n]*`")
linkRe = regexp.MustCompile(`\[\[([^\[\]|\n]+?)(?:\|[^\[\]\n]*)?\]\]`)
)
// Tags returns the #tags in a note, lower-cased and without repeats. Code is skipped, and so are
// numbers such as #12.
func Tags(body string) []string {
// withoutCode is the body with fenced and inline code taken out, so tags and links inside code
// are not counted.
func withoutCode(body string) string {
var lines []string
fence := false
for _, l := range strings.Split(body, "\n") {
@@ -186,9 +295,15 @@ func Tags(body string) []string {
lines = append(lines, inlineCodeRe.ReplaceAllString(l, ""))
}
}
return strings.Join(lines, "\n")
}
// Tags returns the #tags in a page, lower-cased and without repeats. Code is skipped, and so are
// numbers such as #12.
func Tags(body string) []string {
seen := map[string]bool{}
out := []string{}
for _, m := range tagRe.FindAllStringSubmatch(strings.Join(lines, "\n"), -1) {
for _, m := range tagRe.FindAllStringSubmatch(withoutCode(body), -1) {
t := strings.ToLower(strings.TrimRight(m[1], "/-"))
if t == "" || len(t) > 40 || isNumber(t) || seen[t] {
continue
@@ -199,6 +314,19 @@ func Tags(body string) []string {
return out
}
// Links returns the titles a page links to with [[Title]] or [[Title|text]], lower-cased and
// without repeats.
func Links(body string) []string {
var out []string
for _, m := range linkRe.FindAllStringSubmatch(withoutCode(body), -1) {
t := strings.ToLower(strings.TrimSpace(m[1]))
if t != "" && !slices.Contains(out, t) {
out = append(out, t)
}
}
return out
}
func isNumber(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
@@ -236,21 +364,81 @@ func checkBody(body string) error {
return nil
}
// moved keeps updated moving forward, so a stale tab is always noticed.
func moved(prev time.Time) time.Time {
now := time.Now().UTC()
if !now.After(prev) {
return prev.Add(time.Microsecond)
}
return now
}
// ---- changes ----
// Create saves a new note.
func (s *Store) Create(body string) (Note, error) {
if err := checkBody(body); err != nil {
// titleTaken says whether another live page has this title. Dates belong to the journal.
func (s *Store) titleTaken(title, except string) bool {
l := strings.ToLower(title)
if dateRe.MatchString(title) && !strings.HasPrefix(except, "daily-") {
return true
}
for id, n := range s.by {
if id != except && !n.Trashed && n.Title != "" && strings.ToLower(n.Title) == l {
return true
}
}
return false
}
// underneath says whether id is parent or lies somewhere below it.
func (s *Store) underneath(id, parent string) bool {
for cur, hops := parent, 0; cur != "" && hops < 200; hops++ {
if cur == id {
return true
}
n, ok := s.by[cur]
if !ok {
return false
}
cur = n.Parent
}
return false
}
func (s *Store) checkParent(id, parent string) error {
if parent == "" {
return nil
}
p, ok := s.by[parent]
if !ok || p.Trashed || p.Daily != "" || s.underneath(id, parent) {
return ErrBadParent
}
return nil
}
type NewNote struct {
Title, Body, Parent string
}
// Create saves a new page.
func (s *Store) Create(in NewNote) (Note, error) {
if err := checkBody(in.Body); err != nil {
return Note{}, err
}
if strings.TrimSpace(body) == "" {
title := CleanTitle(in.Title)
if title == "" && strings.TrimSpace(in.Body) == "" {
return Note{}, ErrEmpty
}
now := time.Now().UTC()
n := &Note{ID: newID(now), Created: now, Updated: now}
n.set(body)
n := &Note{ID: newID(now), Created: now, Updated: now, Title: title, Parent: in.Parent}
n.set(in.Body)
s.mu.Lock()
defer s.mu.Unlock()
if title != "" && s.titleTaken(title, n.ID) {
return Note{}, ErrTitleTaken
}
if err := s.checkParent(n.ID, n.Parent); err != nil {
return Note{}, err
}
if err := s.write(n); err != nil {
return Note{}, err
}
@@ -258,7 +446,7 @@ func (s *Store) Create(body string) (Note, error) {
return n.copy(), nil
}
// Daily returns the note for a date (like 2026-10-31), making it if it is not there yet.
// Daily returns the page for a date (like 2026-10-31), making it if it is not there yet.
func (s *Store) Daily(date string) (Note, error) {
if _, err := time.Parse("2006-01-02", date); err != nil {
return Note{}, ErrBadDate
@@ -270,15 +458,17 @@ func (s *Store) Daily(date string) (Note, error) {
if n.Trashed { // asking for the day brings it back
old := s.path(n)
n.Trashed = false
if err := os.Rename(old, s.path(n)); err != nil {
if err := s.write(n); err != nil {
n.Trashed = true
return Note{}, err
}
os.Remove(old)
}
return n.copy(), nil
}
now := time.Now().UTC()
n := &Note{ID: id, Created: now, Updated: now, Daily: date}
n := &Note{ID: id, Created: now, Updated: now, Daily: date, Title: date}
n.refresh()
if err := s.write(n); err != nil {
return Note{}, err
}
@@ -296,41 +486,143 @@ func (s *Store) Get(id string) (Note, error) {
return n.copy(), nil
}
// Update changes the text and/or the pin. When base is given, the text is only replaced if the
// note has not been changed since then; otherwise the current note comes back with ErrConflict.
func (s *Store) Update(id string, body *string, pinned *bool, base time.Time) (Note, error) {
// Change is an update; fields that are nil stay as they are.
type Change struct {
Title, Body, Parent *string
Pinned *bool
}
// Update changes a page. When base is given, the title and text are only replaced if the page has
// not been changed since then; otherwise the current page comes back with ErrConflict. Everything is
// checked before anything is written.
func (s *Store) Update(id string, ch Change, base time.Time) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.by[id]
if !ok {
return Note{}, ErrNotFound
}
if body != nil {
if err := checkBody(*body); err != nil {
next := n.copy()
content := false
if ch.Body != nil {
if err := checkBody(*ch.Body); err != nil {
return Note{}, err
}
if !base.IsZero() && !n.Updated.Equal(base) {
return n.copy(), ErrConflict
content = true
}
if ch.Title != nil {
t := CleanTitle(*ch.Title)
if t != n.Title {
if n.Daily != "" {
return Note{}, ErrDailyTitle
}
if t != "" && s.titleTaken(t, id) {
return Note{}, ErrTitleTaken
}
next.Title = t
content = true
}
}
next := n.copy()
if body != nil {
next.set(*body)
next.Updated = time.Now().UTC()
if !next.Updated.After(n.Updated) { // keep updated moving forward, so a stale tab is always noticed
next.Updated = n.Updated.Add(time.Microsecond)
}
if content && !base.IsZero() && !n.Updated.Equal(base) {
return n.copy(), ErrConflict
}
if pinned != nil {
next.Pinned = *pinned
if ch.Parent != nil {
if n.Daily != "" && *ch.Parent != "" {
return Note{}, ErrBadParent
}
if err := s.checkParent(id, *ch.Parent); err != nil {
return Note{}, err
}
next.Parent = *ch.Parent
}
if ch.Pinned != nil {
next.Pinned = *ch.Pinned
}
if ch.Body != nil {
next.set(*ch.Body)
} else {
next.refresh()
}
if content {
next.Updated = moved(n.Updated)
}
if err := s.write(&next); err != nil {
return Note{}, err
}
old := n.Title
*n = next
if old != "" && n.Title != old {
if err := s.relink(old, n.Title, id); err != nil {
return n.copy(), err
}
}
return n.copy(), nil
}
// relink points the [[old]] links of other pages at a renamed page. It rewrites nothing inside code.
func (s *Store) relink(oldTitle, newTitle, except string) error {
if newTitle == "" {
return nil
}
old := strings.ToLower(oldTitle)
for _, m := range s.by {
if m.ID == except || m.Trashed || !slices.Contains(m.links, old) {
continue
}
body := rewriteLinks(m.Body, old, newTitle)
if body == m.Body {
continue
}
next := m.copy()
next.set(body)
next.Updated = moved(m.Updated)
if err := s.write(&next); err != nil {
return err
}
*m = next
}
return nil
}
func rewriteLinks(body, old, newTitle string) string {
lines := strings.Split(body, "\n")
fence := false
for i, l := range lines {
if strings.HasPrefix(strings.TrimSpace(l), "```") {
fence = !fence
continue
}
if fence {
continue
}
lines[i] = linkRe.ReplaceAllStringFunc(l, func(x string) string {
sub := linkRe.FindStringSubmatch(x)
if strings.ToLower(strings.TrimSpace(sub[1])) != old {
return x
}
alias := ""
if k := strings.Index(x, "|"); k >= 0 {
alias = x[k : len(x)-2]
}
return "[[" + newTitle + alias + "]]"
})
}
return strings.Join(lines, "\n")
}
// free finds a title that no live page has, by adding a number if it must.
func (s *Store) free(title, except string) string {
if title == "" || !s.titleTaken(title, except) {
return title
}
for i := 2; ; i++ {
t := fmt.Sprintf("%s %d", title, i)
if !s.titleTaken(t, except) {
return t
}
}
}
func (s *Store) move(id string, toTrash bool) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -342,19 +634,44 @@ func (s *Store) move(id string, toTrash bool) (Note, error) {
return n.copy(), nil
}
old := s.path(n)
prev := n.copy()
n.Trashed = toTrash
if err := os.Rename(old, s.path(n)); err != nil {
n.Trashed = !toTrash
if toTrash {
// Pages inside it move up a level, so nothing is lost with it.
for _, c := range s.by {
if c.Parent == id && !c.Trashed {
next := c.copy()
next.Parent = n.Parent
if err := s.write(&next); err != nil {
*n = prev
return Note{}, err
}
*c = next
}
}
} else {
if p, ok := s.by[n.Parent]; n.Parent != "" && (!ok || p.Trashed) {
n.Parent = ""
}
n.Title = s.free(n.Title, id)
if n.Daily != "" {
n.Title = n.Daily
}
n.refresh()
}
if err := s.write(n); err != nil {
*n = prev
return Note{}, err
}
os.Remove(old)
return n.copy(), nil
}
// Trash moves a note to the trash. Restore brings it back.
// Trash moves a page to the trash. Restore brings it back.
func (s *Store) Trash(id string) (Note, error) { return s.move(id, true) }
func (s *Store) Restore(id string) (Note, error) { return s.move(id, false) }
// Purge deletes a note that is already in the trash, for good.
// Purge deletes a page that is already in the trash, for good.
func (s *Store) Purge(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -372,7 +689,7 @@ func (s *Store) Purge(id string) error {
return nil
}
// EmptyTrash deletes everything in the trash for good and says how many notes that was.
// EmptyTrash deletes everything in the trash for good and says how many pages that was.
func (s *Store) EmptyTrash() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -400,16 +717,9 @@ type Query struct {
Limit int
}
// List returns one page of matching notes, newest first, and how many matched in all.
// List returns one page of matching pages, newest first, and how many matched in all.
func (s *Store) List(q Query) ([]Note, int) {
var words, tags []string
for _, w := range strings.Fields(strings.ToLower(q.Text)) {
if t := strings.TrimPrefix(w, "#"); t != w && t != "" {
tags = append(tags, t)
} else {
words = append(words, w)
}
}
words, tags := splitQuery(q.Text)
if t := strings.ToLower(strings.TrimPrefix(q.Tag, "#")); t != "" {
tags = append(tags, t)
}
@@ -449,6 +759,17 @@ func (s *Store) List(q Query) ([]Note, int) {
return out, total
}
func splitQuery(text string) (words, tags []string) {
for _, w := range strings.Fields(strings.ToLower(text)) {
if t := strings.TrimPrefix(w, "#"); t != w && t != "" {
tags = append(tags, t)
} else {
words = append(words, w)
}
}
return
}
func matches(n *Note, words, tags []string) bool {
for _, w := range words {
if !strings.Contains(n.lower, w) {
@@ -470,6 +791,162 @@ func matches(n *Note, words, tags []string) bool {
return true
}
// Ref is a page as the sidebar and link pickers see it: no text.
type Ref struct {
ID string `json:"id"`
Title string `json:"title"`
Name string `json:"name"`
Parent string `json:"parent,omitempty"`
Daily string `json:"daily,omitempty"`
Pinned bool `json:"pinned,omitempty"`
}
func ref(n *Note) Ref {
return Ref{ID: n.ID, Title: n.Title, Name: n.Name, Parent: n.Parent, Daily: n.Daily, Pinned: n.Pinned}
}
// Pages lists the pages worth showing in navigation: those with a title, those inside another page or
// holding one, the pinned ones, and journal days with something written. Untitled quick notes are left
// out, so jotting things down never clutters the sidebar.
func (s *Store) Pages() []Ref {
s.mu.RLock()
defer s.mu.RUnlock()
holders := map[string]bool{}
for _, n := range s.by {
if !n.Trashed && n.Parent != "" {
holders[n.Parent] = true
}
}
out := []Ref{}
for _, n := range s.by {
switch {
case n.Trashed:
case n.Daily != "":
if n.Body != "" {
out = append(out, ref(n))
}
case n.Title != "" || n.Parent != "" || n.Pinned || holders[n.ID]:
out = append(out, ref(n))
}
}
sort.Slice(out, func(i, j int) bool {
a, b := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name)
if a != b {
return a < b
}
return out[i].ID < out[j].ID
})
return out
}
// Backlinks lists the pages that link to this one with [[its title]].
func (s *Store) Backlinks(id string) []Ref {
s.mu.RLock()
defer s.mu.RUnlock()
n, ok := s.by[id]
out := []Ref{}
if !ok || n.Title == "" {
return out
}
want := strings.ToLower(n.Title)
for _, m := range s.by {
if m.ID != id && !m.Trashed && slices.Contains(m.links, want) {
out = append(out, ref(m))
}
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
return out
}
// Hit is a search result for the quick switcher.
type Hit struct {
Ref
Snippet string `json:"snippet,omitempty"`
}
// Find searches titles and text for the quick switcher and the [[ picker. Title matches come first.
// With no query it returns the pages touched most recently.
func (s *Store) Find(q string, limit int) []Hit {
words, _ := splitQuery(q)
whole := strings.ToLower(strings.TrimSpace(q))
type scored struct {
n *Note
score int
}
s.mu.RLock()
defer s.mu.RUnlock()
var hits []scored
for _, n := range s.by {
if n.Trashed {
continue
}
if len(words) == 0 {
hits = append(hits, scored{n, 0})
continue
}
if !matches(n, words, nil) {
continue
}
t, score := strings.ToLower(n.Name), 3
switch {
case t == whole:
score = 0
case strings.HasPrefix(t, whole):
score = 1
case strings.Contains(t, whole) || allIn(t, words):
score = 2
}
hits = append(hits, scored{n, score})
}
sort.Slice(hits, func(i, j int) bool {
a, b := hits[i], hits[j]
if a.score != b.score {
return a.score < b.score
}
if !a.n.Updated.Equal(b.n.Updated) {
return a.n.Updated.After(b.n.Updated)
}
return a.n.ID < b.n.ID
})
if limit > 0 && len(hits) > limit {
hits = hits[:limit]
}
out := make([]Hit, len(hits))
for i, h := range hits {
out[i] = Hit{Ref: ref(h.n)}
if h.score == 3 {
out[i].Snippet = snippet(h.n.Body, words)
}
}
return out
}
func allIn(s string, words []string) bool {
for _, w := range words {
if !strings.Contains(s, w) {
return false
}
}
return true
}
// snippet is the first line that has one of the words in it, cut to a sensible length.
func snippet(body string, words []string) string {
for _, l := range strings.Split(body, "\n") {
low := strings.ToLower(l)
for _, w := range words {
if strings.Contains(low, w) {
l = strings.TrimSpace(l)
if r := []rune(l); len(r) > 100 {
l = string(r[:100]) + "..."
}
return l
}
}
}
return ""
}
type TagCount struct {
Tag string `json:"tag"`
Count int `json:"count"`
@@ -482,7 +959,7 @@ type Counts struct {
Trash int `json:"trash"`
}
// Summary counts the notes and the tags in use (trashed notes do not count towards tags).
// Summary counts the pages and the tags in use (trashed pages do not count towards tags).
func (s *Store) Summary() (Counts, []TagCount) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -517,27 +994,63 @@ func (s *Store) Summary() (Counts, []TagCount) {
return c, tags
}
// WriteZip writes every note that is not in the trash as a .md file in a zip archive.
var badFile = strings.NewReplacer("/", "-", `\`, "-", ":", "-", "*", "-", "?", "-", `"`, "-", "<", "-", ">", "-", "|", "-", "#", "-", "^", "-")
func fileName(n *Note) string {
name := strings.Trim(badFile.Replace(n.Name), ". ")
if name == "" {
name = "Untitled"
}
if n.Daily != "" {
return "Journal/" + n.Daily
}
return name
}
// WriteZip writes every page that is not in the trash as a .md file in a zip archive that opens as a
// vault in Obsidian: a page's file name is its title, so [[links]] keep working, and journal days sit
// in a Journal folder.
func (s *Store) WriteZip(w io.Writer) error {
s.mu.RLock()
defer s.mu.RUnlock()
ids := make([]string, 0, len(s.by))
for id, n := range s.by {
all := make([]*Note, 0, len(s.by))
for _, n := range s.by {
if !n.Trashed {
ids = append(ids, id)
all = append(all, n)
}
}
sort.Strings(ids)
sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID })
used := map[string]bool{}
zw := zip.NewWriter(w)
for _, id := range ids {
n := s.by[id]
f, err := zw.CreateHeader(&zip.FileHeader{Name: "basking/" + id + ".md", Method: zip.Deflate, Modified: n.Updated})
for _, n := range all {
base := fileName(n)
name := base
for i := 2; used[strings.ToLower(name)]; i++ {
name = fmt.Sprintf("%s %d", base, i)
}
used[strings.ToLower(name)] = true
f, err := zw.CreateHeader(&zip.FileHeader{Name: "basking/" + name + ".md", Method: zip.Deflate, Modified: n.Updated})
if err != nil {
return err
}
if _, err := f.Write(format(n)); err != nil {
if _, err := f.Write(exportFormat(n)); err != nil {
return err
}
}
return zw.Close()
}
// exportFormat is the page with a header Obsidian understands.
func exportFormat(n *Note) []byte {
var b strings.Builder
b.WriteString("---\n")
fmt.Fprintf(&b, "created: %s\n", n.Created.UTC().Format(time.RFC3339))
fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339))
if n.Pinned {
b.WriteString("pinned: true\n")
}
b.WriteString("---\n\n")
b.WriteString(n.Body)
b.WriteString("\n")
return []byte(b.String())
}
+295 -64
View File
@@ -22,6 +22,17 @@ func open(t *testing.T) (*Store, string) {
return s, dir
}
func mk(t *testing.T, s *Store, title, body string) Note {
t.Helper()
n, err := s.Create(NewNote{Title: title, Body: body})
if err != nil {
t.Fatal(err)
}
return n
}
func str(s string) *string { return &s }
func TestTags(t *testing.T) {
cases := []struct {
in string
@@ -41,14 +52,47 @@ func TestTags(t *testing.T) {
}
}
func TestLinks(t *testing.T) {
got := Links("see [[Trip Plan]] and [[trip plan|the plan]] and [[ Ideas ]], not `[[code]]` or\n```\n[[fenced]]\n```\n[[]] [[a|b]]")
want := []string{"trip plan", "ideas", "a"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Links = %v, want %v", got, want)
}
}
func TestNameComesFromTitleOrFirstLine(t *testing.T) {
s, _ := open(t)
cases := []struct{ title, body, want string }{
{"Trip", "ignored", "Trip"},
{"", "# Heading line\nmore", "Heading line"},
{"", "- [ ] buy **milk** [[Shop]]", "buy milk Shop"},
{"", "\n\n plain \n", "plain"},
{"", strings.Repeat("word ", 30), strings.TrimSpace(strings.Repeat("word ", 30)[:60]) + "..."},
}
for _, c := range cases {
n := mk(t, s, c.title, c.body)
if n.Name != c.want {
t.Errorf("name of %q/%q = %q, want %q", c.title, c.body, n.Name, c.want)
}
}
if _, err := s.Create(NewNote{}); !errors.Is(err, ErrEmpty) {
t.Fatalf("empty page: %v", err)
}
only := mk(t, s, "Just a title", "")
if only.Name != "Just a title" {
t.Fatal("a page can be only a title")
}
}
func TestCreateRoundTripsThroughDisk(t *testing.T) {
s, dir := open(t)
n, err := s.Create("first thought #idea\n\nwith a second paragraph \n\n")
parent := mk(t, s, "Projects", "")
n, err := s.Create(NewNote{Title: " Basking: a \"notebook\" [x]|y ", Body: "first thought #idea\n\nwith a second paragraph \n\n", Parent: parent.ID})
if err != nil {
t.Fatal(err)
}
if n.Body != "first thought #idea\n\nwith a second paragraph" {
t.Fatalf("body %q", n.Body)
if n.Body != "first thought #idea\n\nwith a second paragraph" || n.Title != `Basking: a "notebook" xy` {
t.Fatalf("clean-up: %q %q", n.Body, n.Title)
}
if !ValidID(n.ID) {
t.Fatalf("id %q", n.ID)
@@ -56,7 +100,6 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) {
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil {
t.Fatal(err)
}
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
@@ -65,23 +108,20 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.Body != n.Body || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) {
if got.Body != n.Body || got.Title != n.Title || got.Parent != parent.ID || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) {
t.Fatalf("reloaded %+v, want %+v", got, n)
}
}
func TestCreateRejectsEmptyAndHuge(t *testing.T) {
func TestCreateRejectsHuge(t *testing.T) {
s, _ := open(t)
if _, err := s.Create(" \n\t"); !errors.Is(err, ErrEmpty) {
t.Fatalf("empty: %v", err)
}
if _, err := s.Create(strings.Repeat("x", MaxBody+1)); !errors.Is(err, ErrTooLong) {
if _, err := s.Create(NewNote{Body: strings.Repeat("x", MaxBody+1)}); !errors.Is(err, ErrTooLong) {
t.Fatalf("huge: %v", err)
}
}
func TestPlainMarkdownFileIsANote(t *testing.T) {
s, dir := open(t)
func TestPlainMarkdownFileIsAPage(t *testing.T) {
_, dir := open(t)
path := filepath.Join(dir, "notes", "from-elsewhere.md")
os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644)
mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
@@ -94,44 +134,151 @@ func TestPlainMarkdownFileIsANote(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) {
if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) || n.Name != "Dropped in" {
t.Fatalf("%+v", n)
}
_ = s
}
func TestUpdateConflictAndPin(t *testing.T) {
s, _ := open(t)
n, _ := s.Create("one")
body := "two"
n2, err := s.Update(n.ID, &body, nil, n.Updated)
n := mk(t, s, "", "one")
n2, err := s.Update(n.ID, Change{Body: str("two")}, n.Updated)
if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) {
t.Fatalf("update: %v %+v", err, n2)
}
stale := "three"
cur, err := s.Update(n.ID, &stale, nil, n.Updated)
cur, err := s.Update(n.ID, Change{Body: str("three")}, n.Updated)
if !errors.Is(err, ErrConflict) || cur.Body != "two" {
t.Fatalf("stale write: %v %+v", err, cur)
}
if _, err := s.Update(n.ID, Change{Title: str("Late title")}, n.Updated); !errors.Is(err, ErrConflict) {
t.Fatalf("a stale title should conflict too: %v", err)
}
yes := true
n3, err := s.Update(n.ID, nil, &yes, time.Time{})
n3, err := s.Update(n.ID, Change{Pinned: &yes}, time.Time{})
if err != nil || !n3.Pinned || !n3.Updated.Equal(n2.Updated) {
t.Fatalf("pin should not move updated: %v %+v", err, n3)
}
// a write without a base always goes through (scripts appending)
if _, err := s.Update(n.ID, &stale, nil, time.Time{}); err != nil {
if _, err := s.Update(n.ID, Change{Body: str("four")}, time.Time{}); err != nil {
t.Fatal(err) // a write without a base always goes through
}
if _, err := s.Update("nope", Change{Body: str("x")}, time.Time{}); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing: %v", err)
}
}
func TestTitlesAreUnique(t *testing.T) {
s, _ := open(t)
a := mk(t, s, "Trip Plan", "")
if _, err := s.Create(NewNote{Title: "trip plan"}); !errors.Is(err, ErrTitleTaken) {
t.Fatalf("same title, other case: %v", err)
}
b := mk(t, s, "Other", "")
if _, err := s.Update(b.ID, Change{Title: str("TRIP PLAN")}, time.Time{}); !errors.Is(err, ErrTitleTaken) {
t.Fatalf("rename onto a taken title: %v", err)
}
if got, _ := s.Get(b.ID); got.Title != "Other" {
t.Fatal("a refused rename must change nothing")
}
if _, err := s.Update(a.ID, Change{Title: str("trip plan")}, time.Time{}); err != nil {
t.Fatalf("a page may change the case of its own title: %v", err)
}
if _, err := s.Create(NewNote{Title: "2026-10-31"}); !errors.Is(err, ErrTitleTaken) {
t.Fatalf("dates belong to the journal: %v", err)
}
if _, err := s.Update(b.ID, Change{Title: str("")}, time.Time{}); err != nil {
t.Fatalf("clearing a title: %v", err)
}
}
func TestRenameRewritesLinks(t *testing.T) {
s, _ := open(t)
target := mk(t, s, "Trip Plan", "details")
a := mk(t, s, "", "see [[Trip Plan]] and [[trip plan|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Trip Plan]]`")
b := mk(t, s, "", "nothing here, [[Elsewhere]]")
if _, err := s.Update(target.ID, Change{Title: str("Lisbon Trip")}, time.Time{}); err != nil {
t.Fatal(err)
}
if _, err := s.Update("nope", &stale, nil, time.Time{}); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing: %v", err)
got, _ := s.Get(a.ID)
want := "see [[Lisbon Trip]] and [[Lisbon Trip|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Lisbon Trip]]`"
if got.Body != want {
t.Fatalf("body:\n%s\nwant:\n%s", got.Body, want)
}
if !got.Updated.After(a.Updated) {
t.Fatal("a rewritten page should move forward so open editors notice")
}
if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) {
t.Fatal("unrelated pages must not be touched")
}
s2, _ := Open(s.dir)
if again, _ := s2.Get(a.ID); again.Body != want {
t.Fatal("rewrite was not saved")
}
}
func TestBacklinks(t *testing.T) {
s, _ := open(t)
target := mk(t, s, "Ideas", "")
mk(t, s, "Zeta", "mentions [[ideas]]")
mk(t, s, "Alpha", "also [[Ideas|my ideas]]")
mk(t, s, "Nope", "[[Something else]]")
gone := mk(t, s, "Deleted", "[[Ideas]]")
s.Trash(gone.ID)
got := s.Backlinks(target.ID)
if len(got) != 2 || got[0].Name != "Alpha" || got[1].Name != "Zeta" {
t.Fatalf("backlinks %+v", got)
}
if b := s.Backlinks(mk(t, s, "", "no title").ID); len(b) != 0 {
t.Fatal("an untitled page cannot be linked to")
}
}
func TestTreeParentsAndCycles(t *testing.T) {
s, dir := open(t)
a := mk(t, s, "A", "")
b, _ := s.Create(NewNote{Title: "B", Parent: a.ID})
c, _ := s.Create(NewNote{Title: "C", Parent: b.ID})
if _, err := s.Update(a.ID, Change{Parent: &c.ID}, time.Time{}); !errors.Is(err, ErrBadParent) {
t.Fatalf("a page inside its own descendant: %v", err)
}
if _, err := s.Update(a.ID, Change{Parent: &a.ID}, time.Time{}); !errors.Is(err, ErrBadParent) {
t.Fatalf("a page inside itself: %v", err)
}
if _, err := s.Create(NewNote{Title: "D", Parent: "nope"}); !errors.Is(err, ErrBadParent) {
t.Fatalf("a missing parent: %v", err)
}
root := ""
if got, err := s.Update(c.ID, Change{Parent: &root}, time.Time{}); err != nil || got.Parent != "" {
t.Fatalf("moving to the top level: %v %+v", err, got)
}
// a hand-edited file with a circle of parents is mended on load
os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644)
os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644)
os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644)
s2, err := Open(dir)
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"x1", "x2", "x3"} {
n, _ := s2.Get(id)
if id == "x3" && n.Parent != "" {
t.Errorf("%s keeps a parent that does not exist", id)
}
}
x1, _ := s2.Get("x1")
x2, _ := s2.Get("x2")
if x1.Parent != "" && x2.Parent != "" {
t.Error("a circle of parents must be broken")
}
}
func TestTrashRestorePurge(t *testing.T) {
s, dir := open(t)
n, _ := s.Create("to be thrown away")
parent := mk(t, s, "Parent", "")
n, _ := s.Create(NewNote{Title: "Child", Body: "to be thrown away", Parent: parent.ID})
kid, _ := s.Create(NewNote{Title: "Grandchild", Parent: n.ID})
if err := s.Purge(n.ID); !errors.Is(err, ErrNotTrash) {
t.Fatalf("purge of a live note: %v", err)
t.Fatalf("purge of a live page: %v", err)
}
if _, err := s.Trash(n.ID); err != nil {
t.Fatal(err)
@@ -139,35 +286,38 @@ func TestTrashRestorePurge(t *testing.T) {
if _, err := os.Stat(filepath.Join(dir, "trash", n.ID+".md")); err != nil {
t.Fatal("not moved to trash:", err)
}
if list, _ := s.List(Query{}); len(list) != 0 {
t.Fatal("trashed note still listed")
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err == nil {
t.Fatal("the live copy should be gone")
}
if got, _ := s.Get(kid.ID); got.Parent != parent.ID {
t.Fatalf("pages inside a trashed page move up a level, got parent %q", got.Parent)
}
if list, total := s.List(Query{View: "trash"}); len(list) != 1 || total != 1 {
t.Fatal("trash view empty")
}
// survives a restart in the trash
s2, _ := Open(dir)
if list, _ := s2.List(Query{View: "trash"}); len(list) != 1 {
t.Fatal("trash lost on reload")
}
if _, err := s2.Restore(n.ID); err != nil {
// a new page takes the title while it is away; restoring must not make two
if _, err := s.Create(NewNote{Title: "Child"}); err != nil {
t.Fatal(err)
}
if list, _ := s2.List(Query{}); len(list) != 1 {
t.Fatal("restore failed")
s2, _ := Open(dir)
back, err := s2.Restore(n.ID)
if err != nil || back.Title != "Child 2" || back.Parent != parent.ID {
t.Fatalf("restore: %v %+v", err, back)
}
s2.Trash(parent.ID)
s2.Trash(n.ID)
again, _ := s2.Restore(n.ID)
if again.Parent != "" {
t.Fatal("restoring under a parent that is gone should go to the top level")
}
s2.Trash(n.ID)
if err := s2.Purge(n.ID); err != nil {
t.Fatal(err)
}
if _, err := s2.Get(n.ID); !errors.Is(err, ErrNotFound) {
t.Fatal("purged note is still there")
t.Fatal("purged page is still there")
}
a, _ := s2.Create("a")
b, _ := s2.Create("b")
s2.Trash(a.ID)
s2.Trash(b.ID)
if k, err := s2.EmptyTrash(); err != nil || k != 2 {
if k, err := s2.EmptyTrash(); err != nil || k != 1 {
t.Fatalf("empty trash: %d %v", k, err)
}
}
@@ -175,18 +325,21 @@ func TestTrashRestorePurge(t *testing.T) {
func TestDaily(t *testing.T) {
s, _ := open(t)
d1, err := s.Daily("2026-10-31")
if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" {
if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" || d1.Title != "2026-10-31" {
t.Fatalf("%v %+v", err, d1)
}
d2, _ := s.Daily("2026-10-31")
if d1.ID != d2.ID || !d1.Created.Equal(d2.Created) {
t.Fatal("asking twice should give the same note")
t.Fatal("asking twice should give the same page")
}
for _, bad := range []string{"", "2026-13-01", "../etc/passwd", "today"} {
if _, err := s.Daily(bad); !errors.Is(err, ErrBadDate) {
t.Errorf("%q: %v", bad, err)
}
}
if _, err := s.Update(d1.ID, Change{Title: str("Renamed")}, time.Time{}); !errors.Is(err, ErrDailyTitle) {
t.Fatalf("renaming a day: %v", err)
}
s.Trash(d1.ID)
d3, _ := s.Daily("2026-10-31")
if d3.Trashed {
@@ -197,24 +350,38 @@ func TestDaily(t *testing.T) {
if len(list) != 2 || list[0].Daily != "2026-10-31" {
t.Fatalf("daily view order: %+v", list)
}
linker := mk(t, s, "", "on [[2026-10-31]] I wrote")
if got := s.Backlinks(d1.ID); len(got) != 1 || got[0].ID != linker.ID {
t.Fatalf("a day can be linked by its date: %+v", got)
}
// reload keeps the title from the date, and an empty day stays out of navigation
s2, _ := Open(s.dir)
if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" {
t.Fatalf("title after reload: %q", n.Title)
}
for _, r := range s2.Pages() {
if r.Daily != "" {
t.Fatalf("an empty day should not be listed: %+v", r)
}
}
}
func TestSearchAndTags(t *testing.T) {
s, _ := open(t)
s.Create("Buy oat MILK #errands")
s.Create(NewNote{Body: "Buy oat MILK #errands"})
time.Sleep(2 * time.Millisecond)
s.Create("Call the dentist #errands #health")
s.Create(NewNote{Body: "Call the dentist #errands #health"})
time.Sleep(2 * time.Millisecond)
s.Create("Idea: a #projects/basking dashboard")
s.Create(NewNote{Title: "Roadmap", Body: "Idea: a #projects/basking dashboard"})
if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 {
t.Fatalf("case-insensitive search: %d", len(list))
}
if list, _ := s.List(Query{Text: "the dentist"}); len(list) != 1 {
t.Fatalf("all words must match: %d", len(list))
if list, _ := s.List(Query{Text: "roadmap"}); len(list) != 1 {
t.Fatalf("titles are searched: %d", len(list))
}
if list, _ := s.List(Query{Text: "milk dentist"}); len(list) != 0 {
t.Fatalf("words in different notes: %d", len(list))
t.Fatalf("words in different pages: %d", len(list))
}
if list, _ := s.List(Query{Tag: "errands"}); len(list) != 2 {
t.Fatalf("tag filter: %d", len(list))
@@ -226,39 +393,93 @@ func TestSearchAndTags(t *testing.T) {
t.Fatalf("a parent tag finds its children: %d", len(list))
}
all, total := s.List(Query{Limit: 2})
if len(all) != 2 || total != 3 || !strings.HasPrefix(all[0].Body, "Idea") {
t.Fatalf("newest first with a page size: %d of %d, first %q", len(all), total, all[0].Body)
if len(all) != 2 || total != 3 || all[0].Title != "Roadmap" {
t.Fatalf("newest first with a page size: %d of %d", len(all), total)
}
if page2, _ := s.List(Query{Limit: 2, Offset: 2}); len(page2) != 1 {
t.Fatalf("second page: %d", len(page2))
}
counts, tags := s.Summary()
if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 {
t.Fatalf("summary %+v %+v", counts, tags)
}
}
func TestFind(t *testing.T) {
s, _ := open(t)
mk(t, s, "Trip to Lisbon", "flights and hotels")
time.Sleep(2 * time.Millisecond)
mk(t, s, "Packing list", "passport, charger\nlisbon weather is warm")
time.Sleep(2 * time.Millisecond)
mk(t, s, "Lis", "")
time.Sleep(2 * time.Millisecond)
mk(t, s, "Unrelated", "nothing")
hits := s.Find("lis", 10)
var names []string
for _, h := range hits {
names = append(names, h.Name)
}
if !reflect.DeepEqual(names, []string{"Lis", "Packing list", "Trip to Lisbon"}) {
t.Fatalf("exact title first, then newer titles ahead of older: %v", names)
}
body := s.Find("warm", 10)
if len(body) != 1 || body[0].Name != "Packing list" || body[0].Snippet != "lisbon weather is warm" {
t.Fatalf("body match: %+v", body)
}
if recent := s.Find("", 2); len(recent) != 2 || recent[0].Name != "Unrelated" {
t.Fatalf("no query lists recent pages: %+v", recent)
}
}
func TestPagesListsWhatBelongsInNavigation(t *testing.T) {
s, _ := open(t)
titled := mk(t, s, "Titled", "")
quick := mk(t, s, "", "a quick thought")
holder := mk(t, s, "", "untitled but holds a page")
child, _ := s.Create(NewNote{Body: "child", Parent: holder.ID})
pinned := mk(t, s, "", "pinned thought")
yes := true
s.Update(pinned.ID, Change{Pinned: &yes}, time.Time{})
gone := mk(t, s, "Gone", "")
s.Trash(gone.ID)
ids := map[string]bool{}
for _, r := range s.Pages() {
ids[r.ID] = true
}
for _, id := range []string{titled.ID, holder.ID, child.ID, pinned.ID} {
if !ids[id] {
t.Errorf("%s should be listed", id)
}
}
for _, id := range []string{quick.ID, gone.ID} {
if ids[id] {
t.Errorf("%s should not be listed", id)
}
}
}
func TestIDsCannotEscape(t *testing.T) {
for _, id := range []string{"", "..", "../x", "a/b", `a\b`, "A", ".hidden", strings.Repeat("a", 65)} {
if ValidID(id) {
t.Errorf("%q should not be a valid id", id)
}
}
s, dir := open(t)
_, dir := open(t)
os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644)
s2, _ := Open(dir)
if list, _ := s2.List(Query{}); len(list) != 0 {
t.Fatal("a file whose name is not a valid id should be ignored")
}
_ = s
}
func TestZip(t *testing.T) {
func TestZipIsAnObsidianVault(t *testing.T) {
s, _ := open(t)
a, _ := s.Create("keep me")
b, _ := s.Create("bin me")
s.Trash(b.ID)
mk(t, s, "Trip: Lisbon/Porto", "see [[Packing]]")
mk(t, s, "Packing", "")
mk(t, s, "", "# A quick one")
mk(t, s, "", "# A quick one")
bin := mk(t, s, "Bin me", "")
s.Trash(bin.ID)
s.Daily("2026-10-31")
var buf bytes.Buffer
if err := s.WriteZip(&buf); err != nil {
t.Fatal(err)
@@ -267,7 +488,17 @@ func TestZip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(zr.File) != 1 || zr.File[0].Name != "basking/"+a.ID+".md" {
t.Fatalf("zip has %d files", len(zr.File))
var names []string
for _, f := range zr.File {
names = append(names, f.Name)
}
want := map[string]bool{"basking/Trip- Lisbon-Porto.md": true, "basking/Packing.md": true, "basking/A quick one.md": true, "basking/A quick one 2.md": true, "basking/Journal/2026-10-31.md": true}
if len(names) != len(want) {
t.Fatalf("zip has %v", names)
}
for _, n := range names {
if !want[n] {
t.Errorf("unexpected %q in %v", n, names)
}
}
}
+38 -138
View File
@@ -3,24 +3,16 @@ package web
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"strconv"
"strings"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
)
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, map[string]any{"version": s.version, "api": p.API})
}
func (s *Server) summary(w http.ResponseWriter, r *http.Request, p principal) {
counts, tags := s.st.Summary()
writeJSON(w, map[string]any{"counts": counts, "tags": tags})
func (s *Server) pages(w http.ResponseWriter, r *http.Request, p principal) {
counts, tags := p.st.Summary()
writeJSON(w, map[string]any{"pages": p.st.Pages(), "counts": counts, "tags": tags})
}
func (s *Server) listNotes(w http.ResponseWriter, r *http.Request, p principal) {
@@ -34,29 +26,24 @@ func (s *Server) listNotes(w http.ResponseWriter, r *http.Request, p principal)
if len(text) > 200 {
text = text[:200]
}
notes, total := s.st.List(store.Query{Text: text, Tag: q.Get("tag"), View: q.Get("view"), Offset: offset, Limit: limit})
notes, total := p.st.List(store.Query{Text: text, Tag: q.Get("tag"), View: q.Get("view"), Offset: offset, Limit: limit})
writeJSON(w, map[string]any{"notes": notes, "total": total})
}
// createNote takes {"body": "..."} as JSON, or the note itself as plain text, so a script can
// just post what it has: curl -d 'buy milk' -H 'Content-Type: text/plain' ...
func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) {
var body string
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt == "application/json" {
var in struct{ Body, Text string }
if !decode(w, r, &in) {
return
}
body = in.Body + in.Text
} else {
b, err := io.ReadAll(r.Body)
if err != nil {
fail(w, http.StatusRequestEntityTooLarge, "request too large")
return
}
body = string(b)
func (s *Server) find(w http.ResponseWriter, r *http.Request, p principal) {
q := r.URL.Query().Get("q")
if len(q) > 200 {
q = q[:200]
}
n, err := s.st.Create(body)
writeJSON(w, map[string]any{"hits": p.st.Find(q, 12)})
}
func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Title, Body, Parent string }
if !decode(w, r, &in) {
return
}
n, err := p.st.Create(store.NewNote{Title: in.Title, Body: in.Body, Parent: in.Parent})
if err != nil {
storeFail(w, err)
return
@@ -64,19 +51,27 @@ func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal)
writeJSONStatus(w, http.StatusCreated, n)
}
// page is a note with the pages that link to it.
type page struct {
store.Note
Backlinks []store.Ref `json:"backlinks"`
}
func (s *Server) getNote(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.Get(r.PathValue("id"))
n, err := p.st.Get(r.PathValue("id"))
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
writeJSON(w, page{n, p.st.Backlinks(n.ID)})
}
func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Title *string `json:"title"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
Parent *string `json:"parent"`
Base string `json:"base"`
}
if !decode(w, r, &in) {
@@ -86,30 +81,30 @@ func (s *Server) updateNote(w http.ResponseWriter, r *http.Request, p principal)
if in.Base != "" {
t, err := time.Parse(time.RFC3339Nano, in.Base)
if err != nil {
fail(w, http.StatusBadRequest, "base must be the note's updated time")
fail(w, http.StatusBadRequest, "base must be the page's updated time")
return
}
base = t
}
n, err := s.st.Update(r.PathValue("id"), in.Body, in.Pinned, base)
n, err := p.st.Update(r.PathValue("id"), store.Change{Title: in.Title, Body: in.Body, Pinned: in.Pinned, Parent: in.Parent}, base)
if errors.Is(err, store.ErrConflict) {
writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This note changed somewhere else", "note": n})
writeJSONStatus(w, http.StatusConflict, map[string]any{"error": "This page changed somewhere else", "note": n})
return
}
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
writeJSON(w, page{n, p.st.Backlinks(n.ID)})
}
func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal) {
id := r.PathValue("id")
var err error
if r.URL.Query().Get("purge") == "1" {
err = s.st.Purge(id)
err = p.st.Purge(id)
} else {
_, err = s.st.Trash(id)
_, err = p.st.Trash(id)
}
if err != nil {
storeFail(w, err)
@@ -119,7 +114,7 @@ func (s *Server) deleteNote(w http.ResponseWriter, r *http.Request, p principal)
}
func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.Restore(r.PathValue("id"))
n, err := p.st.Restore(r.PathValue("id"))
if err != nil {
storeFail(w, err)
return
@@ -128,7 +123,7 @@ func (s *Server) restoreNote(w http.ResponseWriter, r *http.Request, p principal
}
func (s *Server) emptyTrash(w http.ResponseWriter, r *http.Request, p principal) {
n, err := s.st.EmptyTrash()
n, err := p.st.EmptyTrash()
if err != nil {
storeFail(w, err)
return
@@ -141,114 +136,19 @@ func (s *Server) daily(w http.ResponseWriter, r *http.Request, p principal) {
if !decode(w, r, &in) {
return
}
n, err := s.st.Daily(in.Date)
n, err := p.st.Daily(in.Date)
if err != nil {
storeFail(w, err)
return
}
writeJSON(w, n)
writeJSON(w, page{n, p.st.Backlinks(n.ID)})
}
func (s *Server) export(w http.ResponseWriter, r *http.Request, p principal) {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="basking-%s.zip"`, time.Now().Format("2006-01-02")))
if err := s.st.WriteZip(w); err != nil {
if err := p.st.WriteZip(w); err != nil {
// The download has started, so all that can be done is to cut it short.
panic(http.ErrAbortHandler)
}
}
// ---- password and tokens ----
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Current, New string }
if !decode(w, r, &in) {
return
}
if !config.CheckPassword(s.cfg.Get().PasswordHash, in.Current) {
fail(w, http.StatusForbidden, "the current password is wrong")
return
}
if len(in.New) < 8 {
fail(w, http.StatusBadRequest, "the new password must be at least 8 characters")
return
}
hash, err := config.HashPassword(in.New)
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.cfg.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
s.startSession(w, r) // the old cookie no longer matches, so give this browser a new one
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, p principal) {
type row struct {
Name string `json:"name"`
Created time.Time `json:"created"`
}
out := []row{}
for _, t := range s.cfg.Get().Tokens {
out = append(out, row{t.Name, t.Created})
}
writeJSON(w, out)
}
func (s *Server) newToken(w http.ResponseWriter, r *http.Request, p principal) {
if p.API {
fail(w, http.StatusForbidden, "tokens are managed from the dashboard")
return
}
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
token, hash, err := config.NewToken()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
name := strings.TrimSpace(in.Name)
err = s.cfg.Update(func(c *config.Config) error {
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now().UTC()})
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, map[string]string{"name": name, "token": token})
}
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request, p principal) {
if p.API {
fail(w, http.StatusForbidden, "tokens are managed from the dashboard")
return
}
name := r.PathValue("name")
found := false
err := s.cfg.Update(func(c *config.Config) error {
keep := c.Tokens[:0]
for _, t := range c.Tokens {
if t.Name == name {
found = true
continue
}
keep = append(keep, t)
}
c.Tokens = keep
return nil
})
switch {
case err != nil:
fail(w, http.StatusInternalServerError, err.Error())
case !found:
fail(w, http.StatusNotFound, "no such token")
default:
writeJSON(w, map[string]bool{"ok": true})
}
}
+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
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"strconv"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/basking/internal/store"
)
//go:embed static
var staticFiles embed.FS
const (
cookieName = "basking_session"
sessionTTL = 30 * 24 * time.Hour
maxLoginFails = 6
loginFailSpan = 5 * time.Minute
maxRequestBody = store.MaxBody + 4096
)
const maxRequestBody = store.MaxBody + 4096
type Server struct {
cfg *config.Holder
st *store.Store
oidc *oidc.Client
version string
mu sync.Mutex
fails map[string]failure
dummyOnce sync.Once
dummyHash string
books map[string]*store.Store
}
type failure struct {
n int
until time.Time
}
// principal is who is making a request.
// principal is who is making a request, with the notebook that is theirs alone.
type principal struct {
Name string
API bool // came with an API token rather than a browser session
Sub, Name, Email string
st *store.Store
}
func New(h *config.Holder, st *store.Store, version string) *Server {
return &Server{cfg: h, st: st, version: version, fails: map[string]failure{}}
// New checks that Authentik is set up and returns the server.
func New(h *config.Holder, version string) (*Server, error) {
c := h.Get()
if err := c.Check(); err != nil {
return nil, err
}
return &Server{cfg: h, version: version, books: map[string]*store.Store{},
oidc: oidc.New(c.OIDC.Issuer, c.OIDC.ClientID, c.OIDC.ClientSecret, c.RedirectURL())}, nil
}
// OIDC is the sign-in client, for the check command.
func (s *Server) OIDC() *oidc.Client { return s.oidc }
// notebook opens the notes that belong to one person. Their folder is named from a hash of who they
// are, so nothing a person controls ever becomes part of a path.
func (s *Server) notebook(c config.Config, sub, name, email string) (*store.Store, error) {
sum := sha256.Sum256([]byte(c.OIDC.Issuer + "|" + sub))
key := hex.EncodeToString(sum[:16])
s.mu.Lock()
defer s.mu.Unlock()
if st, ok := s.books[key]; ok {
return st, nil
}
dir := filepath.Join(c.DataDir, "users", key)
st, err := store.Open(dir)
if err != nil {
return nil, err
}
// A note for whoever looks after the server, so a folder can be matched to a person.
who := fmt.Sprintf("%s <%s>\n", name, email)
if b, _ := os.ReadFile(filepath.Join(dir, "owner.txt")); string(b) != who {
os.WriteFile(filepath.Join(dir, "owner.txt"), []byte(who), 0o644)
}
s.books[key] = st
return st, nil
}
// Handler returns the whole site.
@@ -65,10 +82,11 @@ func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
mux.HandleFunc("POST /api/login", s.login)
mux.HandleFunc("GET /auth/login", s.authLogin)
mux.HandleFunc("GET /auth/callback", s.authCallback)
mux.HandleFunc("POST /api/logout", s.api(s.logout))
mux.HandleFunc("GET /api/me", s.api(s.me))
mux.HandleFunc("GET /api/summary", s.api(s.summary))
mux.HandleFunc("GET /api/pages", s.api(s.pages))
mux.HandleFunc("GET /api/notes", s.api(s.listNotes))
mux.HandleFunc("POST /api/notes", s.api(s.createNote))
@@ -78,168 +96,60 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote))
mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash))
mux.HandleFunc("POST /api/daily", s.api(s.daily))
mux.HandleFunc("GET /api/find", s.api(s.find))
mux.HandleFunc("GET /api/export", s.api(s.export))
mux.HandleFunc("POST /api/password", s.api(s.changePassword))
mux.HandleFunc("GET /api/tokens", s.api(s.listTokens))
mux.HandleFunc("POST /api/tokens", s.api(s.newToken))
mux.HandleFunc("DELETE /api/tokens/{name}", s.api(s.deleteToken))
static, _ := fs.Sub(staticFiles, "static")
mux.Handle("GET /", http.FileServerFS(static))
return secure(mux)
return s.secure(mux)
}
// secure adds headers that keep the page from being framed, sniffed or fed outside scripts.
func secure(next http.Handler) http.Handler {
func (s *Server) secure(next http.Handler) http.Handler {
hsts := s.cfg.Get().Secure()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
if strings.HasPrefix(r.URL.Path, "/api/") {
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if hsts {
h.Set("Strict-Transport-Security", "max-age=31536000")
}
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/auth/") {
h.Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
// ---- authentication ----
type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal)
// api wraps a handler with sign-in and CSRF protection.
func (s *Server) api(fn handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p, ok := s.authenticate(r)
c := s.cfg.Get()
ses, ok := s.session(c, r)
if !ok {
fail(w, http.StatusUnauthorized, "sign in required")
return
}
// Browser sessions must send a header a web page on another site cannot add.
if !p.API && r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" {
// A browser must send a header a web page on another site cannot add.
if r.Method != http.MethodGet && r.Header.Get("X-Basking") != "1" {
fail(w, http.StatusForbidden, "missing X-Basking header")
return
}
st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email)
if err != nil {
fail(w, http.StatusInternalServerError, "could not open your notes: "+err.Error())
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
fn(w, r, p)
fn(w, r, principal{Sub: ses.Sub, Name: ses.Name, Email: ses.Email, st: st})
}
}
// A session cookie is "expiry.signature". The signature covers the password hash, so changing
// the password signs every browser out, and nothing has to be remembered on the server.
func sign(c config.Config, exp int64) string {
m := hmac.New(sha256.New, []byte(c.Secret))
fmt.Fprintf(m, "session|%d|%s", exp, c.PasswordHash)
return hex.EncodeToString(m.Sum(nil))
}
func (s *Server) authenticate(r *http.Request) (principal, bool) {
c := s.cfg.Get()
if c.PasswordHash == "" {
return principal{}, false
}
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
sum := config.HashToken(strings.TrimPrefix(h, "Bearer "))
for _, t := range c.Tokens {
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(sum)) == 1 {
return principal{Name: "token:" + t.Name, API: true}, true
}
}
return principal{}, false
}
ck, err := r.Cookie(cookieName)
if err != nil {
return principal{}, false
}
expStr, sig, ok := strings.Cut(ck.Value, ".")
exp, err := strconv.ParseInt(expStr, 10, 64)
if !ok || err != nil || time.Now().Unix() > exp {
return principal{}, false
}
if !hmac.Equal([]byte(sig), []byte(sign(c, exp))) {
return principal{}, false
}
return principal{Name: "you"}, true
}
func (s *Server) clientIP(r *http.Request) string {
if s.cfg.Get().TrustProxy {
if f := r.Header.Get("X-Forwarded-For"); f != "" {
return strings.TrimSpace(strings.Split(f, ",")[0])
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (s *Server) secureCookie(r *http.Request) bool {
c := s.cfg.Get()
return c.CookieSecure || r.TLS != nil || (c.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https")
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
exp := time.Now().Add(sessionTTL).Unix()
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: fmt.Sprintf("%d.%s", exp, sign(c, exp)), Path: "/", HttpOnly: true,
SameSite: http.SameSiteStrictMode, Secure: s.secureCookie(r), MaxAge: int(sessionTTL.Seconds())})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if r.Header.Get("X-Basking") != "1" {
fail(w, http.StatusForbidden, "missing X-Basking header")
return
}
var in struct{ Password string }
if json.NewDecoder(r.Body).Decode(&in) != nil {
fail(w, http.StatusBadRequest, "bad request")
return
}
ip := s.clientIP(r)
s.mu.Lock()
f := s.fails[ip]
if f.n >= maxLoginFails && time.Now().Before(f.until) {
s.mu.Unlock()
fail(w, http.StatusTooManyRequests, "too many failed attempts; wait a few minutes")
return
}
s.mu.Unlock()
hash := s.cfg.Get().PasswordHash
if hash == "" {
// Spend the same time as for a real check.
s.dummyOnce.Do(func() { s.dummyHash, _ = config.HashPassword("dummy") })
hash = s.dummyHash
}
ok := config.CheckPassword(hash, in.Password) && s.cfg.Get().PasswordHash != ""
s.mu.Lock()
if !ok {
if time.Now().After(f.until) {
f = failure{}
}
f.n++
f.until = time.Now().Add(loginFailSpan)
s.fails[ip] = f
s.mu.Unlock()
fail(w, http.StatusUnauthorized, "wrong password")
return
}
delete(s.fails, ip)
s.mu.Unlock()
s.startSession(w, r)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
writeJSON(w, map[string]bool{"ok": true})
}
// ---- helpers ----
func writeJSON(w http.ResponseWriter, v any) { writeJSONStatus(w, http.StatusOK, v) }
@@ -252,12 +162,14 @@ func writeJSONStatus(w http.ResponseWriter, code int, v any) {
// fail answers with an error. Messages are capitalized here, once, so they read as sentences.
func fail(w http.ResponseWriter, code int, msg string) {
failCode(w, code, msg, "")
}
func failCode(w http.ResponseWriter, code int, msg, kind string) {
if r := []rune(msg); len(r) > 0 {
msg = strings.ToUpper(string(r[:1])) + string(r[1:])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
writeJSONStatus(w, code, map[string]string{"error": msg, "code": kind})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
@@ -280,7 +192,10 @@ func storeFail(w http.ResponseWriter, err error) {
fail(w, http.StatusNotFound, err.Error())
case errors.Is(err, store.ErrConflict):
fail(w, http.StatusConflict, err.Error())
case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID):
case errors.Is(err, store.ErrTitleTaken):
failCode(w, http.StatusUnprocessableEntity, err.Error(), "title_taken")
case errors.Is(err, store.ErrEmpty), errors.Is(err, store.ErrBadDate), errors.Is(err, store.ErrNotTrash), errors.Is(err, store.ErrBadID),
errors.Is(err, store.ErrDailyTitle), errors.Is(err, store.ErrBadParent):
fail(w, http.StatusBadRequest, err.Error())
case errors.Is(err, store.ErrTooLong):
fail(w, http.StatusRequestEntityTooLarge, err.Error())
+384 -516
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>
<body>
<div id="app"></div>
<script src="toolkit.js"></script>
<script src="markdown.js"></script>
<script src="editor.js"></script>
<script src="app.js"></script>
</body>
</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-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--sidebar-w:272px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@@ -219,8 +219,6 @@
.toast-act { background:transparent; border:0; color:inherit; font:inherit; font-weight:600; text-decoration:underline; cursor:pointer; padding:0; }
.toolbar .push { margin-left:auto; }
.toolbar input[type=search] { max-width:360px; }
.snippet { margin:0 0 var(--s4); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; }
.snippet code { white-space:pre; }
/* rendered markdown */
.md > *:first-child { margin-top:0; } .md > *:last-child { margin-bottom:0; }
@@ -237,3 +235,93 @@
.md pre { margin:0 0 var(--s3); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; }
.md code { background:var(--surface-2); padding:1px 4px; border-radius:4px; } .md pre code { background:none; padding:0; }
.md hr { border:0; border-top:var(--border-w) solid var(--border); margin:var(--s4) 0; }
/* sign in */
.btn-link { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; text-decoration:none; white-space:nowrap; background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
.btn-link:hover { background:var(--surface-2); text-decoration:none; }
.btn-link.primary { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
.btn-link.primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
.btn-link.wide { display:flex; width:100%; min-height:var(--touch); margin-top:var(--s4); }
.login-h { font-size:var(--text-xl); text-align:center; margin-bottom:var(--s2); }
.center-text { text-align:center; }
kbd { font:inherit; font-family:var(--font-mono); font-size:var(--text-xs); padding:1px 6px; border:var(--border-w) solid var(--border-strong); border-radius:4px; background:var(--surface-2); color:var(--text-muted); white-space:nowrap; }
/* sidebar: search, pages tree */
.navitem.find { border:var(--border-w) solid var(--border); margin-bottom:var(--s2); }
.navitem.find kbd { margin-left:auto; }
.navsec-row { display:flex; align-items:center; justify-content:space-between; padding-right:var(--s1); }
button.tiny { width:26px; min-height:26px; height:26px; }
.treerow { display:flex; align-items:center; gap:2px; }
.treerow.d1 { padding-left:var(--s4); } .treerow.d2 { padding-left:var(--s5); } .treerow.d3 { padding-left:calc(var(--s5) + var(--s3)); }
.treerow.d4 { padding-left:var(--s6); } .treerow.d5 { padding-left:calc(var(--s6) + var(--s3)); } .treerow.d6 { padding-left:calc(var(--s6) + var(--s4)); }
.tree-link { flex:1 1 auto; min-width:0; min-height:32px; padding:var(--s1) var(--s2); }
.twist, .twist-gap { width:24px; height:24px; flex:none; }
.twist { display:inline-flex; align-items:center; justify-content:center; border:0; background:none; color:var(--text-faint); cursor:pointer; border-radius:var(--radius); padding:0; }
.twist:hover { background:var(--surface-2); color:var(--text); }
.twist .icon { transition:transform var(--ease); }
.twist.open .icon { transform:rotate(90deg); }
.sub-add { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; flex:none; border:0; background:none; border-radius:var(--radius); color:var(--text-faint); cursor:pointer; opacity:0; padding:0; }
.treerow:hover .sub-add, .sub-add:focus-visible { opacity:1; }
.sub-add:hover { background:var(--surface-2); color:var(--text); }
.navhint { margin:0; padding:var(--s1) var(--s3); font-size:var(--text-sm); color:var(--text-muted); }
@media (pointer: coarse) { .sub-add { opacity:1; } .navitem.find kbd { display:none; } }
/* a page */
.page { max-width:780px; margin:0 auto; }
.page-top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; margin-bottom:var(--s5); }
.crumbs { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-sm); flex-wrap:wrap; min-width:0; }
.crumbs a { color:var(--text-muted); }
.crumbs .sep { color:var(--text-faint); }
.page-actions { display:flex; align-items:center; gap:var(--s2); flex-wrap:wrap; flex:1 1 auto; justify-content:flex-end; }
.page-actions .sub { min-width:4.5em; }
.daynav { margin-bottom:var(--s4); }
.seg { display:inline-flex; border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; }
.seg-b { min-height:30px; padding:0 var(--s3); font:inherit; font-size:var(--text-sm); font-weight:500; background:var(--surface); color:var(--text-muted); border:0; cursor:pointer; }
.seg-b + .seg-b { border-left:var(--border-w) solid var(--border-strong); }
.seg-b.on { background:var(--accent-soft); color:var(--accent-text); }
.title-wrap { margin-bottom:var(--s2); }
.title-input { font-size:var(--text-2xl); font-weight:700; letter-spacing:-.02em; line-height:1.25; border:0; padding:0; background:transparent; min-height:0; width:100%; }
.title-input:focus { outline:none; border:0; }
.title-input::placeholder { color:var(--text-faint); }
.day-title { font-size:var(--text-2xl); letter-spacing:-.02em; margin-bottom:var(--s2); }
.editor-wrap { position:relative; margin-top:var(--s3); }
.page textarea.write.big { min-height:45vh; line-height:1.7; font-family:var(--font); font-size:var(--text-base); resize:none; overflow:hidden; }
.view { min-height:45vh; cursor:text; overflow-wrap:anywhere; }
.md-big { font-size:var(--text-base); line-height:1.7; }
.md-big h3.md-h { font-size:var(--text-xl); margin-top:var(--s5); } .md-big h4.md-h { font-size:var(--text-lg); margin-top:var(--s4); }
.tagrow { display:flex; flex-wrap:wrap; gap:var(--s2); margin-top:var(--s4); }
.tagrow:empty { display:none; }
.aside { margin-top:var(--s6); display:flex; flex-direction:column; gap:var(--s5); }
.aside:empty { display:none; }
.aside-sec h2 { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); margin-bottom:var(--s2); }
.linklist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px; }
.linklist a { display:inline-flex; align-items:center; gap:var(--s2); padding:var(--s1) 0; }
.page-meta { margin-top:var(--s6); }
.wikilink { text-decoration:none; border-bottom:var(--border-w) solid var(--accent-text); }
.wikilink:hover { background:var(--accent-soft); }
.wikilink.missing { color:var(--text-muted); border-bottom-style:dashed; border-bottom-color:var(--text-faint); }
.popup { position:absolute; z-index:var(--z-sticky); min-width:240px; max-width:320px; max-height:260px; overflow-y:auto; padding:var(--s1); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius-lg); box-shadow:var(--shadow); }
.popup-row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); padding:var(--s2) var(--s3); border-radius:var(--radius); cursor:pointer; }
.popup-row.on, .popup-row:hover { background:var(--accent-soft); }
.popup-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.popup-hint { color:var(--text-faint); font-size:var(--text-xs); font-family:var(--font-mono); white-space:nowrap; }
.mirror { position:absolute; left:-9999px; top:0; visibility:hidden; white-space:pre-wrap; overflow-wrap:break-word; box-sizing:content-box; }
.notice .btn-link { min-height:30px; }
/* quick find and the move picker */
.shade-top { align-items:start; padding-top:12vh; }
.finder-input { font-size:var(--text-lg); min-height:44px; margin-bottom:var(--s2); }
.finder-list { display:flex; flex-direction:column; gap:2px; max-height:50vh; overflow-y:auto; }
.finder-row { display:flex; align-items:center; gap:var(--s3); width:100%; padding:var(--s2) var(--s3); border:0; border-radius:var(--radius); background:none; color:var(--text); font:inherit; text-align:left; cursor:pointer; }
.finder-row.on, .finder-row:hover { background:var(--accent-soft); }
.finder-name { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.finder-sub { flex:1 1 0; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; }
.pad { padding:var(--s3); margin:0; }
/* note cards and settings */
.note.openable { cursor:pointer; }
.note-title { font-size:var(--text-lg); margin-bottom:var(--s2); }
.note-title a { color:inherit; text-decoration:none; }
.note-title a:hover { text-decoration:underline; }
.keys { list-style:none; margin:0; padding:0; display:grid; gap:var(--s2); }
@media (max-width:640px) { .page-actions { justify-content:flex-start; } .shade-top { padding-top:var(--s4); } }
+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) });
});
}
+528 -253
View File
@@ -3,337 +3,612 @@ package web
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/json"
"io"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
"git.veillax.com/Veillax/basking/internal/oidc"
)
const password = "correct horse battery"
type rig struct {
t *testing.T
srv *httptest.Server
cfg *config.Holder
cookie *http.Cookie
// idp is a stand-in for Authentik: it publishes its metadata and trades the code "good" for an ID
// token carrying whatever claims the test set.
type idp struct {
srv *httptest.Server
mu sync.Mutex
nonce string
challenge string
claims map[string]any
down bool
}
func newRig(t *testing.T) *rig {
func newIdP(t *testing.T) *idp {
p := &idp{}
mux := http.NewServeMux()
mux.HandleFunc("/app/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"issuer": p.srv.URL + "/app/", "authorization_endpoint": p.srv.URL + "/authorize", "token_endpoint": p.srv.URL + "/token"})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
p.mu.Lock()
defer p.mu.Unlock()
r.ParseForm()
if p.down {
http.Error(w, "boom", http.StatusBadGateway)
return
}
if r.Form.Get("code") != "good" || r.Form.Get("client_secret") != "shh" || r.Form.Get("grant_type") != "authorization_code" ||
oidc.Challenge(r.Form.Get("code_verifier")) != p.challenge || r.Form.Get("redirect_uri") != "http://127.0.0.1:8490/auth/callback" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
return
}
c := map[string]any{"iss": p.srv.URL + "/app/", "aud": "basking-client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": p.nonce}
for k, v := range p.claims {
c[k] = v
}
raw, _ := json.Marshal(c)
enc := base64.RawURLEncoding.EncodeToString
json.NewEncoder(w).Encode(map[string]string{"id_token": enc([]byte(`{"alg":"none"}`)) + "." + enc(raw) + ".sig", "access_token": "x"})
})
p.srv = httptest.NewServer(mux)
t.Cleanup(p.srv.Close)
return p
}
type env struct {
t *testing.T
s *Server
h http.Handler
idp *idp
dir string
cfg config.Config
}
func setup(t *testing.T, tweak func(map[string]any)) *env {
t.Helper()
p := newIdP(t)
dir := t.TempDir()
h, err := config.Load(filepath.Join(dir, "basking.json"))
conf := map[string]any{
"data_dir": filepath.Join(dir, "data"), "public_url": "http://127.0.0.1:8490", "secret": "test-secret",
"oidc": map[string]string{"issuer": p.srv.URL + "/app/", "client_id": "basking-client", "client_secret": "shh"},
}
if tweak != nil {
tweak(conf)
}
b, _ := json.Marshal(conf)
path := filepath.Join(dir, "basking.json")
os.WriteFile(path, b, 0o600)
h, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
hash, _ := config.HashPassword(password)
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(dir, "data"))
s, err := New(h, "test")
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(New(h, st, "test").Handler())
t.Cleanup(srv.Close)
return &rig{t: t, srv: srv, cfg: h}
return &env{t: t, s: s, h: s.Handler(), idp: p, dir: dir, cfg: h.Get()}
}
func (r *rig) do(method, path, ctype, body string, headers ...string) (*http.Response, []byte) {
r.t.Helper()
req, _ := http.NewRequest(method, r.srv.URL+path, strings.NewReader(body))
if ctype != "" {
req.Header.Set("Content-Type", ctype)
func (e *env) do(method, path string, body any, cookies []*http.Cookie, hdr ...string) *httptest.ResponseRecorder {
e.t.Helper()
var rd *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
r := httptest.NewRequest(method, path, rd)
if body != nil {
r.Header.Set("Content-Type", "application/json")
}
if r.cookie != nil {
req.AddCookie(r.cookie)
if method != http.MethodGet {
r.Header.Set("X-Basking", "1")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
r.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, b
}
// js sends JSON as a browser would, with the CSRF header.
func (r *rig) js(method, path string, v any) (*http.Response, map[string]any) {
r.t.Helper()
var body string
if v != nil {
b, _ := json.Marshal(v)
body = string(b)
}
resp, b := r.do(method, path, "application/json", body, "X-Basking", "1")
var out map[string]any
json.Unmarshal(b, &out)
return resp, out
}
func (r *rig) signIn() {
r.t.Helper()
resp, _ := r.js("POST", "/api/login", map[string]string{"password": password})
if resp.StatusCode != 200 {
r.t.Fatalf("login: %d", resp.StatusCode)
}
for _, c := range resp.Cookies() {
if c.Name == cookieName {
r.cookie = c
for i := 0; i+1 < len(hdr); i += 2 {
if hdr[i+1] == "" {
r.Header.Del(hdr[i])
} else {
r.Header.Set(hdr[i], hdr[i+1])
}
}
if r.cookie == nil {
r.t.Fatal("no session cookie")
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
e.h.ServeHTTP(w, r)
return w
}
func TestRequiresSignIn(t *testing.T) {
r := newRig(t)
for _, p := range []string{"/api/notes", "/api/summary", "/api/export", "/api/tokens", "/api/me"} {
if resp, _ := r.do("GET", p, "", ""); resp.StatusCode != 401 {
t.Errorf("GET %s without signing in: %d", p, resp.StatusCode)
func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie {
for _, c := range w.Result().Cookies() {
if c.Name == name {
return c
}
}
if resp, _ := r.do("GET", "/healthz", "", ""); resp.StatusCode != 200 {
t.Error("healthz should be open")
}
if resp, _ := r.do("GET", "/", "", ""); resp.StatusCode != 200 {
t.Error("the page itself should load")
return nil
}
func jsonOf(t *testing.T, w *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.Unmarshal(w.Body.Bytes(), v); err != nil {
t.Fatalf("bad json %q: %v", w.Body.String(), err)
}
}
func TestLoginAndHeaders(t *testing.T) {
r := newRig(t)
if resp, _ := r.do("POST", "/api/login", "application/json", `{"password":"`+password+`"}`); resp.StatusCode != 403 {
t.Errorf("login without the CSRF header: %d", resp.StatusCode)
// begin starts a sign-in and returns the redirect the browser would follow and the flow cookie.
func (e *env) begin(next string) (*url.URL, *http.Cookie) {
e.t.Helper()
w := e.do("GET", "/auth/login?next="+url.QueryEscape(next), nil, nil)
if w.Code != http.StatusSeeOther {
e.t.Fatalf("login: %d %s", w.Code, w.Body)
}
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 {
t.Errorf("wrong password: %d", resp.StatusCode)
to, _ := url.Parse(w.Header().Get("Location"))
return to, cookieNamed(w, flowCookie)
}
// signIn runs the whole flow for a person and returns their session cookie.
func (e *env) signIn(claims map[string]any) []*http.Cookie {
e.t.Helper()
to, flow := e.begin("")
e.idp.mu.Lock()
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), claims
e.idp.mu.Unlock()
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
ck := cookieNamed(w, sessionCookie)
if w.Code != http.StatusSeeOther || ck == nil {
e.t.Fatalf("callback: %d %s -> %s", w.Code, w.Body, w.Header().Get("Location"))
}
r.signIn()
resp, _ := r.do("GET", "/api/me", "", "")
if resp.StatusCode != 200 {
t.Fatalf("me: %d", resp.StatusCode)
return []*http.Cookie{ck}
}
var alice = map[string]any{"sub": "alice-sub", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice"}
var bob = map[string]any{"sub": "bob-sub", "email": "bob@example.com", "name": "Bob", "preferred_username": "bob"}
func TestNothingWorksWithoutSigningIn(t *testing.T) {
e := setup(t, nil)
for _, path := range []string{"/api/me", "/api/pages", "/api/notes", "/api/notes/abc", "/api/find?q=x", "/api/export"} {
if w := e.do("GET", path, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("GET %s without a session: %d", path, w.Code)
}
}
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") {
t.Errorf("CSP %q", csp)
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, nil); w.Code != http.StatusUnauthorized {
t.Errorf("POST without a session: %d", w.Code)
}
if !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie flags %+v", r.cookie)
if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Basking") {
t.Errorf("the sign-in page itself must load: %d", w.Code)
}
// a browser write needs the header
if resp, _ := r.do("POST", "/api/notes", "application/json", `{"body":"x"}`); resp.StatusCode != 403 {
t.Errorf("write without the header: %d", resp.StatusCode)
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
t.Errorf("healthz: %d", w.Code)
}
// a forged cookie does nothing
r.cookie = &http.Cookie{Name: cookieName, Value: "9999999999.deadbeef"}
if resp, _ := r.do("GET", "/api/me", "", ""); resp.StatusCode != 401 {
t.Errorf("forged cookie: %d", resp.StatusCode)
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: "garbage"}}); w.Code != http.StatusUnauthorized {
t.Errorf("garbage cookie: %d", w.Code)
}
}
func TestLoginIsRateLimited(t *testing.T) {
r := newRig(t)
var last int
for i := 0; i < maxLoginFails+2; i++ {
resp, _ := r.js("POST", "/api/login", map[string]string{"password": "nope"})
last = resp.StatusCode
func TestSignInFlow(t *testing.T) {
e := setup(t, nil)
to, flow := e.begin("#/p/abc-123")
q := to.Query()
if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") {
t.Fatalf("goes to Authentik: %s", to)
}
if last != 429 {
t.Fatalf("after many failures got %d", last)
for k, want := range map[string]string{"response_type": "code", "client_id": "basking-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback",
"scope": "openid profile email", "code_challenge_method": "S256"} {
if q.Get(k) != want {
t.Errorf("%s = %q, want %q", k, q.Get(k), want)
}
}
if resp, _ := r.js("POST", "/api/login", map[string]string{"password": password}); resp.StatusCode != 429 {
t.Fatalf("even the right password waits: %d", resp.StatusCode)
for _, k := range []string{"state", "nonce", "code_challenge"} {
if len(q.Get(k)) < 16 {
t.Errorf("%s is too short: %q", k, q.Get(k))
}
}
if !flow.HttpOnly || flow.SameSite != http.SameSiteLaxMode || flow.Path != "/auth/" {
t.Errorf("flow cookie: %+v", flow)
}
e.idp.nonce, e.idp.challenge, e.idp.claims = q.Get("nonce"), q.Get("code_challenge"), alice
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(q.Get("state")), nil, []*http.Cookie{flow})
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/#/p/abc-123" {
t.Fatalf("callback: %d -> %s", w.Code, w.Header().Get("Location"))
}
ses := cookieNamed(w, sessionCookie)
if ses == nil || !ses.HttpOnly || ses.SameSite != http.SameSiteLaxMode || ses.Secure {
t.Fatalf("session cookie: %+v", ses)
}
if c := cookieNamed(w, flowCookie); c == nil || c.MaxAge >= 0 {
t.Error("the flow cookie should be cleared after use")
}
var me struct{ Name, Email string }
jsonOf(t, e.do("GET", "/api/me", nil, []*http.Cookie{ses}), &me)
if me.Name != "Alice" || me.Email != "alice@example.com" {
t.Fatalf("me: %+v", me)
}
// the same flow cookie cannot be used twice by a browser that kept it, and a stale state is refused
if w := e.do("GET", "/auth/callback?code=good&state=wrong", nil, []*http.Cookie{flow}); w.Header().Get("Location") != "/?signin=failed" {
t.Errorf("wrong state: %s", w.Header().Get("Location"))
}
}
func TestNoteLifecycle(t *testing.T) {
r := newRig(t)
r.signIn()
resp, n := r.js("POST", "/api/notes", map[string]string{"body": "remember the milk #errands"})
if resp.StatusCode != 201 {
t.Fatalf("create: %d %v", resp.StatusCode, n)
func TestSignInIsRefusedWhenAnythingIsOff(t *testing.T) {
e := setup(t, nil)
try := func(name string, mutate func(to *url.URL, flow **http.Cookie) string, want string) {
t.Helper()
to, flow := e.begin("")
e.idp.mu.Lock()
e.idp.nonce, e.idp.challenge, e.idp.claims, e.idp.down = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice, false
e.idp.mu.Unlock()
target := mutate(to, &flow)
var cks []*http.Cookie
if flow != nil {
cks = append(cks, flow)
}
w := e.do("GET", target, nil, cks)
if got := w.Header().Get("Location"); got != "/?signin="+want || cookieNamed(w, sessionCookie) != nil {
t.Errorf("%s: went to %q (%d), session %v; want %s", name, got, w.Code, cookieNamed(w, sessionCookie), want)
}
}
id, _ := n["id"].(string)
if id == "" || n["body"] != "remember the milk #errands" {
t.Fatalf("created %v", n)
}
if resp, _ := r.js("POST", "/api/notes", map[string]string{"body": " "}); resp.StatusCode != 400 {
t.Errorf("empty note: %d", resp.StatusCode)
good := func(to *url.URL) string {
return "/auth/callback?code=good&state=" + url.QueryEscape(to.Query().Get("state"))
}
_, list := r.js("GET", "/api/notes?q=milk", nil)
if list["total"] != float64(1) {
t.Fatalf("search: %v", list)
}
_, list = r.js("GET", "/api/notes?tag=errands", nil)
if list["total"] != float64(1) {
t.Fatalf("tag: %v", list)
}
try("no flow cookie", func(to *url.URL, f **http.Cookie) string { *f = nil; return good(to) }, "expired")
try("tampered flow cookie", func(to *url.URL, f **http.Cookie) string { c := *f; c.Value = "x" + c.Value; return good(to) }, "expired")
try("wrong state", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?code=good&state=nope" }, "failed")
try("no code", func(to *url.URL, f **http.Cookie) string { return "/auth/callback?state=" + to.Query().Get("state") }, "failed")
try("denied at Authentik", func(to *url.URL, f **http.Cookie) string {
return "/auth/callback?error=access_denied&state=" + to.Query().Get("state")
}, "denied")
try("bad code", func(to *url.URL, f **http.Cookie) string {
return "/auth/callback?code=stolen&state=" + url.QueryEscape(to.Query().Get("state"))
}, "failed")
try("wrong nonce", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.nonce = "other"
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("wrong audience", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "aud": "someone-else"}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("expired token", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "exp": time.Now().Add(-time.Hour).Unix()}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("wrong issuer", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": "x", "iss": "https://evil.example.com/"}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("no subject", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.claims = map[string]any{"sub": ""}
e.idp.mu.Unlock()
return good(to)
}, "failed")
try("Authentik is down", func(to *url.URL, f **http.Cookie) string {
e.idp.mu.Lock()
e.idp.down = true
e.idp.mu.Unlock()
return good(to)
}, "unavailable")
// edit with the version we have, then again with a stale one
resp, n2 := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "remember the oat milk", "base": n["updated"]})
if resp.StatusCode != 200 || n2["body"] != "remember the oat milk" {
t.Fatalf("update: %d %v", resp.StatusCode, n2)
}
resp, c := r.js("PUT", "/api/notes/"+id, map[string]any{"body": "stale", "base": n["updated"]})
if resp.StatusCode != 409 {
t.Fatalf("stale update: %d", resp.StatusCode)
}
if cur, _ := c["note"].(map[string]any); cur["body"] != "remember the oat milk" {
t.Errorf("conflict should return the current note: %v", c)
}
if resp, _ := r.js("PUT", "/api/notes/"+id, map[string]any{"pinned": true}); resp.StatusCode != 200 {
t.Errorf("pin: %d", resp.StatusCode)
}
_, list = r.js("GET", "/api/notes?view=pinned", nil)
if list["total"] != float64(1) {
t.Errorf("pinned view: %v", list)
}
if resp, _ := r.js("DELETE", "/api/notes/"+id+"?purge=1", nil); resp.StatusCode != 400 {
t.Errorf("purge of a live note: %d", resp.StatusCode)
}
if resp, _ := r.js("DELETE", "/api/notes/"+id, nil); resp.StatusCode != 200 {
t.Errorf("trash: %d", resp.StatusCode)
}
_, sum := r.js("GET", "/api/summary", nil)
if counts := sum["counts"].(map[string]any); counts["trash"] != float64(1) || counts["all"] != float64(0) {
t.Errorf("summary after trashing: %v", sum)
}
if resp, _ := r.js("POST", "/api/notes/"+id+"/restore", nil); resp.StatusCode != 200 {
t.Errorf("restore: %d", resp.StatusCode)
}
r.js("DELETE", "/api/notes/"+id, nil)
if resp, _ := r.js("DELETE", "/api/trash", nil); resp.StatusCode != 200 {
t.Errorf("empty trash: %d", resp.StatusCode)
}
if resp, _ := r.js("GET", "/api/notes/"+id, nil); resp.StatusCode != 404 {
t.Errorf("gone: %d", resp.StatusCode)
}
for _, bad := range []string{"..%2f..%2fbasking.json", "UPPER", "a.b"} {
if resp, _ := r.js("GET", "/api/notes/"+bad, nil); resp.StatusCode != 404 {
t.Errorf("odd id %q: %d", bad, resp.StatusCode)
// and a next address that points anywhere but inside Basking is dropped
for _, bad := range []string{"https://evil.example.com", "//evil.example.com", "/api/export", "#/<script>", "javascript:alert(1)"} {
to, flow := e.begin(bad)
e.idp.mu.Lock()
e.idp.down = false
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), alice
e.idp.mu.Unlock()
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
if w.Header().Get("Location") != "/" {
t.Errorf("next %q led to %q", bad, w.Header().Get("Location"))
}
}
}
func TestDailyEndpoint(t *testing.T) {
r := newRig(t)
r.signIn()
resp, n := r.js("POST", "/api/daily", map[string]string{"date": "2026-10-31"})
if resp.StatusCode != 200 || n["id"] != "daily-2026-10-31" {
t.Fatalf("%d %v", resp.StatusCode, n)
}
if resp, _ := r.js("POST", "/api/daily", map[string]string{"date": "yesterday"}); resp.StatusCode != 400 {
t.Errorf("bad date: %d", resp.StatusCode)
func TestSignInWhenAuthentikIsUnreachable(t *testing.T) {
e := setup(t, nil)
e.idp.srv.Close()
w := e.do("GET", "/auth/login", nil, nil)
if w.Header().Get("Location") != "/?signin=unavailable" {
t.Fatalf("went to %q", w.Header().Get("Location"))
}
}
func TestTokensAndPlainTextCapture(t *testing.T) {
r := newRig(t)
r.signIn()
resp, tok := r.js("POST", "/api/tokens", map[string]string{"name": "phone"})
if resp.StatusCode != 200 {
t.Fatalf("new token: %d %v", resp.StatusCode, tok)
func TestAllowedList(t *testing.T) {
e := setup(t, func(c map[string]any) { c["allowed"] = []string{"ALICE@example.com", "carol"} })
to, flow := e.begin("")
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), bob
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
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)
if !strings.HasPrefix(token, "bsk_") {
t.Fatalf("token %q", token)
ses := e.signIn(alice) // matched by email, whatever the case
if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
t.Fatalf("Alice: %d", w.Code)
}
if resp, _ := r.js("POST", "/api/tokens", map[string]string{"name": "PHONE"}); resp.StatusCode != 400 {
t.Errorf("duplicate name: %d", resp.StatusCode)
if c := e.signIn(map[string]any{"sub": "carol-sub", "preferred_username": "Carol"}); c == nil { // matched by username
t.Fatal("Carol should get in")
}
if b, _ := json.Marshal(r.cfg.Get()); strings.Contains(string(b), token) {
t.Error("the token itself must not be stored")
}
// a script with only the token: no cookie, no CSRF header, plain text body
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)
// taking someone off the list ends their session at once
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 {
t.Fatalf("a valid cookie for someone no longer allowed: %d", w.Code)
}
}
func TestChangePassword(t *testing.T) {
r := newRig(t)
r.signIn()
old := r.cookie
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": "wrong", "new": "a brand new password"}); resp.StatusCode != 403 {
t.Errorf("wrong current: %d", resp.StatusCode)
func TestSessionsAreSignedAndExpire(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
if w := e.do("GET", "/api/me", nil, ses); w.Code != 200 {
t.Fatal("valid session refused")
}
if resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "short"}); resp.StatusCode != 400 {
t.Errorf("short: %d", resp.StatusCode)
}
resp, _ := r.js("POST", "/api/password", map[string]string{"current": password, "new": "a brand new password"})
if resp.StatusCode != 200 {
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
payload, sig, _ := strings.Cut(ses[0].Value, ".")
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": ""} {
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)
}
}
if r.cookie == old {
t.Fatal("no new cookie")
expired := seal(e.cfg, "session", sessionData{Sub: "alice-sub", Exp: time.Now().Add(-time.Minute).Unix()})
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 {
t.Errorf("new cookie: %d", resp2.StatusCode)
// a cookie for another purpose, or signed with another secret, is not a session
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) {
r := newRig(t)
r.signIn()
r.js("POST", "/api/notes", map[string]string{"body": "one"})
r.js("POST", "/api/notes", map[string]string{"body": "two"})
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)
func TestWritesNeedTheHeader(t *testing.T) {
e := setup(t, nil)
ses := e.signIn(alice)
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
t.Fatalf("a write without the header: %d", w.Code)
}
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil || len(zr.File) != 2 {
t.Fatalf("zip: %v, %d files", err, len(zr.File))
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
t.Fatalf("a delete without the header: %d", w.Code)
}
}
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) {
w := httptest.NewRecorder()
fail(w, 400, "écrit mal")
if !strings.Contains(w.Body.String(), "Écrit mal") {
t.Errorf("%s", w.Body.String())
fail(w, 400, "éclair is ready")
var out map[string]string
json.Unmarshal(w.Body.Bytes(), &out)
if out["error"] != "Éclair is ready" {
t.Fatalf("%q", out["error"])
}
}