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:
1 parent
b548faa258
commit
98f7730913
19 files changed
+3269
-1556
No files matched your search
@@ -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 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.
|
||||
|
||||
+75
-140
@@ -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
|
||||
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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
+109
-128
@@ -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"`
|
||||
}
|
||||
|
||||
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)
|
||||
// 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 seen[strings.ToLower(t.Name)] {
|
||||
return fmt.Errorf("there is already a token called %q", t.Name)
|
||||
}
|
||||
seen[strings.ToLower(t.Name)] = true
|
||||
if u.Scheme == "http" && !IsLocal(u) {
|
||||
return fmt.Errorf("%s must use https unless it points at this machine (got %q)", name, raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Package oidc is the small slice of OpenID Connect Basking needs: the authorization code flow
|
||||
// with PKCE against one provider (Authentik), using only the standard library.
|
||||
//
|
||||
// The ID token is taken straight from the provider's token endpoint over TLS, which is the case
|
||||
// OpenID Connect Core section 3.1.3.7 allows to skip checking its signature. Its issuer, audience,
|
||||
// expiry and nonce are still checked.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/basking/internal/config"
|
||||
)
|
||||
|
||||
// ErrUnavailable means the provider could not be reached or answered nonsense, as opposed to
|
||||
// rejecting the sign-in.
|
||||
var ErrUnavailable = errors.New("the sign-in provider is not reachable")
|
||||
|
||||
type Client struct {
|
||||
issuer, id, secret, redirect string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
disco *Discovery
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
// Discovery is the part of the provider's metadata that is used.
|
||||
type Discovery struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
Sub string
|
||||
Email string
|
||||
Name string
|
||||
Username string
|
||||
}
|
||||
|
||||
func New(issuer, clientID, clientSecret, redirect string) *Client {
|
||||
return &Client{
|
||||
issuer: strings.TrimRight(issuer, "/"), id: clientID, secret: clientSecret, redirect: redirect,
|
||||
http: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Random returns n random bytes as URL-safe text.
|
||||
func Random(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Challenge is the PKCE S256 challenge for a verifier.
|
||||
func Challenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func secureEnough(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "https" || (u.Scheme == "http" && config.IsLocal(u))
|
||||
}
|
||||
|
||||
// Discover fetches (and for an hour remembers) the provider's metadata.
|
||||
func (c *Client) Discover(ctx context.Context) (*Discovery, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.disco != nil && time.Since(c.fetched) < time.Hour {
|
||||
return c.disco, nil
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", c.issuer+"/.well-known/openid-configuration", nil)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %s answered %s (is the issuer address right?)", ErrUnavailable, req.URL, resp.Status)
|
||||
}
|
||||
var d Discovery
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&d); err != nil {
|
||||
return nil, fmt.Errorf("%w: unreadable metadata: %v", ErrUnavailable, err)
|
||||
}
|
||||
if strings.TrimRight(d.Issuer, "/") != c.issuer {
|
||||
return nil, fmt.Errorf("%w: the provider says its issuer is %q, not %q", ErrUnavailable, d.Issuer, c.issuer)
|
||||
}
|
||||
if !secureEnough(d.AuthorizationEndpoint) || !secureEnough(d.TokenEndpoint) {
|
||||
return nil, fmt.Errorf("%w: the provider's endpoints are missing or not https", ErrUnavailable)
|
||||
}
|
||||
c.disco, c.fetched = &d, time.Now()
|
||||
return c.disco, nil
|
||||
}
|
||||
|
||||
// AuthURL is where to send the browser to sign in.
|
||||
func (c *Client) AuthURL(ctx context.Context, state, nonce, challenge string) (string, error) {
|
||||
d, err := c.Discover(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u, _ := url.Parse(d.AuthorizationEndpoint)
|
||||
q := u.Query()
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", c.id)
|
||||
q.Set("redirect_uri", c.redirect)
|
||||
q.Set("scope", "openid profile email")
|
||||
q.Set("state", state)
|
||||
q.Set("nonce", nonce)
|
||||
q.Set("code_challenge", challenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
type audience []string
|
||||
|
||||
func (a *audience) UnmarshalJSON(b []byte) error {
|
||||
var one string
|
||||
if json.Unmarshal(b, &one) == nil {
|
||||
*a = audience{one}
|
||||
return nil
|
||||
}
|
||||
var many []string
|
||||
if err := json.Unmarshal(b, &many); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = many
|
||||
return nil
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
Aud audience `json:"aud"`
|
||||
Azp string `json:"azp"`
|
||||
Exp float64 `json:"exp"`
|
||||
Nonce string `json:"nonce"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"preferred_username"`
|
||||
}
|
||||
|
||||
// Exchange trades the code from the redirect for who signed in.
|
||||
func (c *Client) Exchange(ctx context.Context, code, verifier, nonce string) (Identity, error) {
|
||||
d, err := c.Discover(ctx)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {c.redirect},
|
||||
"client_id": {c.id}, "client_secret": {c.secret}, "code_verifier": {verifier},
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", d.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var tok struct {
|
||||
IDToken string `json:"id_token"`
|
||||
Error string `json:"error"`
|
||||
Desc string `json:"error_description"`
|
||||
}
|
||||
json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok)
|
||||
if resp.StatusCode != http.StatusOK || tok.Error != "" {
|
||||
if resp.StatusCode >= 500 {
|
||||
return Identity{}, fmt.Errorf("%w: token endpoint answered %s", ErrUnavailable, resp.Status)
|
||||
}
|
||||
return Identity{}, fmt.Errorf("the provider refused the code: %s %s (%s)", tok.Error, tok.Desc, resp.Status)
|
||||
}
|
||||
return c.verify(tok.IDToken, nonce, time.Now())
|
||||
}
|
||||
|
||||
func (c *Client) verify(idToken, nonce string, now time.Time) (Identity, error) {
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return Identity{}, errors.New("the provider sent no ID token (is the openid scope enabled?)")
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
||||
if err != nil {
|
||||
return Identity{}, errors.New("the ID token is not readable")
|
||||
}
|
||||
var cl claims
|
||||
if err := json.Unmarshal(raw, &cl); err != nil {
|
||||
return Identity{}, errors.New("the ID token is not readable")
|
||||
}
|
||||
switch {
|
||||
case strings.TrimRight(cl.Iss, "/") != c.issuer:
|
||||
return Identity{}, fmt.Errorf("the ID token is from %q, not %q", cl.Iss, c.issuer)
|
||||
case !contains(cl.Aud, c.id):
|
||||
return Identity{}, errors.New("the ID token is for a different application")
|
||||
case len(cl.Aud) > 1 && cl.Azp != c.id:
|
||||
return Identity{}, errors.New("the ID token was issued to a different application")
|
||||
case cl.Exp == 0 || now.Unix() > int64(cl.Exp)+60:
|
||||
return Identity{}, errors.New("the ID token has expired")
|
||||
case nonce == "" || subtle.ConstantTimeCompare([]byte(cl.Nonce), []byte(nonce)) != 1:
|
||||
return Identity{}, errors.New("the ID token does not match this sign-in attempt")
|
||||
case cl.Sub == "":
|
||||
return Identity{}, errors.New("the ID token has no subject")
|
||||
}
|
||||
return Identity{Sub: cl.Sub, Email: cl.Email, Name: cl.Name, Username: cl.Username}, nil
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+583
-70
@@ -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")
|
||||
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 note in the trash can be deleted for good")
|
||||
ErrNotTrash = errors.New("only a page in the trash can be deleted for good")
|
||||
ErrTitleTaken = errors.New("another page already has that title")
|
||||
ErrDailyTitle = errors.New("a journal day is named by its date")
|
||||
ErrBadParent = errors.New("a page can only go inside another page, and not inside itself")
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
@@ -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) {
|
||||
content = true
|
||||
}
|
||||
if ch.Title != nil {
|
||||
t := CleanTitle(*ch.Title)
|
||||
if t != n.Title {
|
||||
if n.Daily != "" {
|
||||
return Note{}, ErrDailyTitle
|
||||
}
|
||||
if t != "" && s.titleTaken(t, id) {
|
||||
return Note{}, ErrTitleTaken
|
||||
}
|
||||
next.Title = t
|
||||
content = true
|
||||
}
|
||||
}
|
||||
if content && !base.IsZero() && !n.Updated.Equal(base) {
|
||||
return n.copy(), ErrConflict
|
||||
}
|
||||
if ch.Parent != nil {
|
||||
if n.Daily != "" && *ch.Parent != "" {
|
||||
return Note{}, ErrBadParent
|
||||
}
|
||||
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 err := s.checkParent(id, *ch.Parent); err != nil {
|
||||
return Note{}, err
|
||||
}
|
||||
next.Parent = *ch.Parent
|
||||
}
|
||||
if pinned != nil {
|
||||
next.Pinned = *pinned
|
||||
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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-135
@@ -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) find(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if len(q) > 200 {
|
||||
q = q[:200]
|
||||
}
|
||||
writeJSON(w, map[string]any{"hits": p.st.Find(q, 12)})
|
||||
}
|
||||
|
||||
func (s *Server) createNote(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
var body string
|
||||
if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt == "application/json" {
|
||||
var in struct{ Body, Text string }
|
||||
var in struct{ Title, Body, Parent 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)
|
||||
}
|
||||
n, err := s.st.Create(body)
|
||||
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})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
|
||||
+375
-507
File diff suppressed because it is too large.
Load diff
@@ -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); },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); } }
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
// ---------- tiny toolkit ----------
|
||||
// h builds an element. Strings become text nodes, never HTML, so page text can never inject markup.
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
let value;
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'class') el.className = v;
|
||||
else if (k === 'value') value = v;
|
||||
else if (k === 'checked') el.checked = true;
|
||||
else el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
|
||||
if (value !== undefined) el.value = value;
|
||||
return el;
|
||||
}
|
||||
const $app = document.getElementById('app');
|
||||
|
||||
// theme: follows the system unless the person picked one
|
||||
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* storage is off */ }
|
||||
function toggleTheme() {
|
||||
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const next = dark ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
try { localStorage.setItem('theme', next); } catch (e) { /* storage is off */ }
|
||||
}
|
||||
const store = {
|
||||
get(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } },
|
||||
set(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) { /* storage is off */ } },
|
||||
};
|
||||
|
||||
// short confirmations at the bottom right; errors stay longer and are announced as alerts
|
||||
let toastBox;
|
||||
function toast(msg, bad, action) {
|
||||
if (!toastBox) { toastBox = h('div', { class: 'toasts', 'aria-live': 'polite' }); document.body.append(toastBox); }
|
||||
const t = h('div', { class: 'toast' + (bad ? ' toast-error' : ''), role: bad ? 'alert' : 'status' }, h('span', {}, msg),
|
||||
action ? h('button', { class: 'toast-act', onclick: () => { t.remove(); action.fn(); } }, action.label) : null);
|
||||
toastBox.append(t);
|
||||
setTimeout(() => t.remove(), bad ? 7000 : action ? 6000 : 3000);
|
||||
}
|
||||
const cap = s => s ? s[0].toUpperCase() + s.slice(1) : s;
|
||||
const SIGNED_OUT = 'Your session ended. Sign in again to continue.';
|
||||
|
||||
// onSignedOut is set by the app; it decides what a 401 means while someone is writing
|
||||
let onSignedOut = () => { };
|
||||
async function api(method, url, body, opt = {}) {
|
||||
const init = { method, headers: { 'X-Basking': '1' }, credentials: 'same-origin', keepalive: !!opt.keepalive };
|
||||
if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
|
||||
let r;
|
||||
try { r = await fetch(url, init); }
|
||||
catch { throw Object.assign(new Error('Could not reach Basking. Check your connection.'), { offline: true }); }
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.status === 401) { onSignedOut(); throw Object.assign(new Error(SIGNED_OUT), { status: 401 }); }
|
||||
if (!r.ok) throw Object.assign(new Error(cap(j.error || r.statusText)), { status: r.status, data: j });
|
||||
return j;
|
||||
}
|
||||
// run an action; show errors as a toast instead of throwing into the void
|
||||
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.status !== 401) toast(e.message, true); } };
|
||||
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const hhmm = d => { const t = new Date(d); return `${pad(t.getHours())}:${pad(t.getMinutes())}`; };
|
||||
const fmtTime = d => new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const parseDay = s => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||
const addDays = (s, n) => { const d = parseDay(s); d.setDate(d.getDate() + n); return isoDay(d); };
|
||||
function dayLabel(d) {
|
||||
const day = isoDay(d instanceof Date ? d : parseDay(d)), now = new Date();
|
||||
if (day === isoDay(now)) return 'Today';
|
||||
if (day === addDays(isoDay(now), -1)) return 'Yesterday';
|
||||
const dt = parseDay(day);
|
||||
return dt.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: dt.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
|
||||
}
|
||||
const isDay = s => /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||
// status pill: color always travels with a text label
|
||||
const badge = (text, tone) => h('span', { class: 'badge' + (tone ? ' badge-' + tone : '') }, text);
|
||||
const emptyState = (name, title, text, action) => h('div', { class: 'empty' }, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
|
||||
// placeholder rows while a page loads, so the layout does not jump
|
||||
const skeleton = () => h('div', { class: 'card', role: 'status', 'aria-busy': 'true' }, h('span', { class: 'sr-only' }, 'Loading'),
|
||||
['w60', 'w100', 'w90', 'w100', 'w75'].map(w => h('div', { class: 'skeleton ' + w })));
|
||||
// replaceChildren stringifies null and arrays, so everything goes through here
|
||||
const fill = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter(k => k != null && k !== false));
|
||||
const card = (title, ...kids) => h('div', { class: 'card' }, title ? h('h2', {}, title) : null, ...kids);
|
||||
const inSelection = () => { const s = getSelection(); return s && !s.isCollapsed; };
|
||||
|
||||
// ---------- icons (lucide-style, static strings; see docs/design.md for the approved names) ----------
|
||||
const ICONS = {
|
||||
fin: '<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
|
||||
notes: '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>',
|
||||
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/>',
|
||||
today: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
journal: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
pin: '<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
||||
tag: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
|
||||
trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>',
|
||||
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
||||
restore: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
|
||||
view: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
prev: '<path d="m15 18-6-6 6-6"/>',
|
||||
next: '<path d="m9 18 6-6-6-6"/>',
|
||||
chev: '<path d="m9 18 6-6-6-6"/>',
|
||||
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
move: '<path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
|
||||
out: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
|
||||
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||
close: '<path d="M18 6 6 18M6 6l12 12"/>',
|
||||
add: '<path d="M5 12h14M12 5v14"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
|
||||
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
};
|
||||
function icon(name, size = 18) {
|
||||
const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' });
|
||||
s.innerHTML = `<svg class="icon icon-${size}" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
|
||||
return s;
|
||||
}
|
||||
const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Basking');
|
||||
const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label);
|
||||
|
||||
// ---------- dialogs ----------
|
||||
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
|
||||
let dialogSeq = 0;
|
||||
function modal(title, body, opts = {}) {
|
||||
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
|
||||
const shade = h('div', { class: 'shade' + (opts.top ? ' shade-top' : '') });
|
||||
const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' },
|
||||
h('h2', { id, class: opts.hideTitle ? 'sr-only' : '' }, title), body);
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return; closed = true;
|
||||
document.removeEventListener('keydown', onKey, true); shade.remove();
|
||||
if (opener && opener.isConnected && opener.focus) opener.focus();
|
||||
if (opts.onClose) opts.onClose();
|
||||
};
|
||||
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
|
||||
function onKey(e) {
|
||||
if ([...document.querySelectorAll('.shade')].pop() !== shade) return;
|
||||
if (e.key === 'Escape') { e.stopPropagation(); close(); }
|
||||
else if (e.key === 'Tab') {
|
||||
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
|
||||
const first = f[0], last = f[f.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}
|
||||
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
|
||||
shade.append(box);
|
||||
document.body.append(shade);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
|
||||
return close;
|
||||
}
|
||||
const modalOpen = () => !!document.querySelector('.shade');
|
||||
|
||||
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
|
||||
function confirmDialog({ title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true }) {
|
||||
return new Promise(resolve => {
|
||||
let close;
|
||||
const done = v => { resolve(v); close(); };
|
||||
close = modal(title, h('div', { class: 'stack' }, h('p', { class: 'dim' }, body),
|
||||
h('div', { class: 'actions form-actions' }, h('button', { class: 'ghost', onclick: () => done(false) }, cancel),
|
||||
h('button', { class: danger ? 'ghost danger' : 'btn', onclick: () => done(true) }, confirm))), { onClose: () => resolve(false) });
|
||||
});
|
||||
}
|
||||
+525
-250
@@ -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
|
||||
// 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
|
||||
cfg *config.Holder
|
||||
cookie *http.Cookie
|
||||
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)
|
||||
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])
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// js sends JSON as a browser would, with the CSRF header.
|
||||
func (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)
|
||||
func cookieNamed(w *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
resp, b := r.do(method, path, "application/json", body, "X-Basking", "1")
|
||||
var out map[string]any
|
||||
json.Unmarshal(b, &out)
|
||||
return resp, out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
if r.cookie == nil {
|
||||
r.t.Fatal("no session cookie")
|
||||
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 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)
|
||||
// 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)
|
||||
}
|
||||
to, _ := url.Parse(w.Header().Get("Location"))
|
||||
return to, cookieNamed(w, flowCookie)
|
||||
}
|
||||
|
||||
// signIn runs the whole flow for a person and returns their session cookie.
|
||||
func (e *env) signIn(claims map[string]any) []*http.Cookie {
|
||||
e.t.Helper()
|
||||
to, flow := e.begin("")
|
||||
e.idp.mu.Lock()
|
||||
e.idp.nonce, e.idp.challenge, e.idp.claims = to.Query().Get("nonce"), to.Query().Get("code_challenge"), claims
|
||||
e.idp.mu.Unlock()
|
||||
w := e.do("GET", "/auth/callback?code=good&state="+url.QueryEscape(to.Query().Get("state")), nil, []*http.Cookie{flow})
|
||||
ck := cookieNamed(w, sessionCookie)
|
||||
if w.Code != http.StatusSeeOther || ck == nil {
|
||||
e.t.Fatalf("callback: %d %s -> %s", w.Code, w.Body, w.Header().Get("Location"))
|
||||
}
|
||||
return []*http.Cookie{ck}
|
||||
}
|
||||
|
||||
var alice = map[string]any{"sub": "alice-sub", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice"}
|
||||
var bob = map[string]any{"sub": "bob-sub", "email": "bob@example.com", "name": "Bob", "preferred_username": "bob"}
|
||||
|
||||
func TestNothingWorksWithoutSigningIn(t *testing.T) {
|
||||
e := setup(t, nil)
|
||||
for _, path := range []string{"/api/me", "/api/pages", "/api/notes", "/api/notes/abc", "/api/find?q=x", "/api/export"} {
|
||||
if w := e.do("GET", path, nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET %s without a session: %d", path, w.Code)
|
||||
}
|
||||
}
|
||||
if resp, _ := r.do("GET", "/healthz", "", ""); resp.StatusCode != 200 {
|
||||
t.Error("healthz should be open")
|
||||
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 resp, _ := r.do("GET", "/", "", ""); resp.StatusCode != 200 {
|
||||
t.Error("the page itself should load")
|
||||
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)
|
||||
}
|
||||
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
|
||||
t.Errorf("healthz: %d", w.Code)
|
||||
}
|
||||
if w := e.do("GET", "/api/me", nil, []*http.Cookie{{Name: sessionCookie, Value: "garbage"}}); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("garbage cookie: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndHeaders(t *testing.T) {
|
||||
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)
|
||||
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 resp, _ := r.js("POST", "/api/login", map[string]string{"password": "wrong"}); resp.StatusCode != 401 {
|
||||
t.Errorf("wrong password: %d", resp.StatusCode)
|
||||
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)
|
||||
}
|
||||
r.signIn()
|
||||
resp, _ := r.do("GET", "/api/me", "", "")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("me: %d", resp.StatusCode)
|
||||
}
|
||||
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") {
|
||||
t.Errorf("CSP %q", csp)
|
||||
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 !r.cookie.HttpOnly || r.cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Errorf("cookie flags %+v", r.cookie)
|
||||
}
|
||||
// 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 !flow.HttpOnly || flow.SameSite != http.SameSiteLaxMode || flow.Path != "/auth/" {
|
||||
t.Errorf("flow cookie: %+v", flow)
|
||||
}
|
||||
// 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)
|
||||
|
||||
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 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 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)
|
||||
}
|
||||
if last != 429 {
|
||||
t.Fatalf("after many failures got %d", last)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user