Rename Basking to Nurse

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-11-01 00:06:51 -06:00
1 parent cec2702260
commit 6a4be5b2f4
24 files changed
+208 -208

No files matched your search

+1 -1
View File
@@ -38,5 +38,5 @@ jobs:
run: |
for t in linux/amd64 linux/arm64 windows/amd64; do
echo "building for $t"
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/basking
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/nurse
done
+1 -1
View File
@@ -1,7 +1,7 @@
name: Release
# Push a tag such as v0.1.0 to build and publish a release:
# git tag -a v0.1.0 -m "basking v0.1.0" && git push origin v0.1.0
# git tag -a v0.1.0 -m "nurse v0.1.0" && git push origin v0.1.0
on:
push:
tags: ['v*']
+1 -1
View File
@@ -2,5 +2,5 @@ bin/
dist/
*.exe
data/
basking.json
nurse.json
.env
+7 -7
View File
@@ -1,16 +1,16 @@
# Build: docker build -t basking .
# Build: docker build -t nurse .
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /basking ./cmd/basking
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /nurse ./cmd/nurse
FROM alpine:3.20
RUN apk add --no-cache tzdata ca-certificates && adduser -D -u 10001 basking && mkdir /config && chown basking /config
COPY --from=build /basking /usr/local/bin/basking
USER basking
RUN apk add --no-cache tzdata ca-certificates && adduser -D -u 10001 nurse && mkdir /config && chown nurse /config
COPY --from=build /nurse /usr/local/bin/nurse
USER nurse
# The config file and the notes both live under /config.
ENV BASKING_CONFIG=/config/basking.json
ENV NURSE_CONFIG=/config/nurse.json
WORKDIR /config
EXPOSE 8490
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8490/healthz || exit 1
ENTRYPOINT ["basking", "serve", "-listen", "0.0.0.0:8490"]
ENTRYPOINT ["nurse", "serve", "-listen", "0.0.0.0:8490"]
+42 -42
View File
@@ -1,11 +1,11 @@
# Basking
# Nurse
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.
dependencies. You sign in with **Authentik** and nothing else: there are no passwords in Nurse.
Named for the basking shark, which swims along with its mouth open and takes in whatever drifts by.
Named for the nurse shark, which rests on the sea floor and stays close to home.
## What it does
@@ -27,9 +27,9 @@ Named for the basking shark, which swims along with its mouth open and takes in
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
- **Tags by typing.** Write `#errands` or `#projects/nurse` 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
- **Autosave that does not lose things.** Changes save as you type. If the connection drops, Nurse
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.
@@ -38,44 +38,44 @@ Named for the basking shark, which swims along with its mouth open and takes in
## Signing in with Authentik
Basking never sees a password. Who may open it is decided in Authentik, and each person gets a
Nurse 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
`https://notes.example.com/auth/callback` (use the address you will open Nurse 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
2. **Applications → Create**: name it Nurse 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
Issuer**, which looks like `https://auth.example.com/application/o/nurse/`.
4. Give Nurse those four things (see below), then run `nurse 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
Nurse 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
people under `allowed` (emails or usernames), and Nurse turns anyone else away even if Authentik
lets them through.
## Installing on Linux (systemd)
```sh
curl -fsSL https://git.veillax.com/Veillax/basking/raw/branch/main/deploy/install.sh | sudo sh
curl -fsSL https://git.veillax.com/Veillax/nurse/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`. Then:
This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/nurse`, creates a
`nurse` user, `/etc/nurse/` (settings) and `/var/lib/nurse/` (your notes), and writes the
systemd service and `/etc/nurse/nurse.env`. Then:
```sh
sudoedit /etc/basking/basking.env # fill in the four Authentik lines at the top
sudo systemctl start basking
journalctl -u basking -f # it says at once if a setting is missing or wrong
sudoedit /etc/nurse/nurse.env # fill in the four Authentik lines at the top
sudo systemctl start nurse
journalctl -u nurse -f # it says at once if a setting is missing or wrong
```
Basking listens on `127.0.0.1:8490`. Put an HTTPS reverse proxy (or
Nurse 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
as `NURSE_PUBLIC_URL`. Authentik will only send people back to an address you allowed, and cookies
are marked Secure when that address is https.
Run the installer again to upgrade; your settings and notes are kept and a running service is
@@ -86,12 +86,12 @@ instead) and `--uninstall` (add `--purge` to delete settings and notes). Read it
## Running it by hand or in Docker
```sh
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
export NURSE_PUBLIC_URL=https://notes.example.com
export NURSE_OIDC_ISSUER=https://auth.example.com/application/o/nurse/
export NURSE_OIDC_CLIENT_ID=...
export NURSE_OIDC_CLIENT_SECRET=...
./nurse check # optional: test the settings
./nurse serve
```
Docker: see [`Dockerfile`](Dockerfile) and [`deploy/docker-compose.yml`](deploy/docker-compose.yml).
@@ -99,9 +99,9 @@ Docker: see [`Dockerfile`](Dockerfile) and [`deploy/docker-compose.yml`](deploy/
### Commands
```
basking serve [-config basking.json] [-listen addr] [-data dir]
basking check [-config basking.json] test the Authentik settings without starting
basking version
nurse serve [-config nurse.json] [-listen addr] [-data dir]
nurse check [-config nurse.json] test the Authentik settings without starting
nurse version
```
## Settings
@@ -111,15 +111,15 @@ 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` |
| `public_url` | `NURSE_PUBLIC_URL` | where you open Nurse, no path |
| `oidc.issuer` | `NURSE_OIDC_ISSUER` | the provider's issuer |
| `oidc.client_id` | `NURSE_OIDC_CLIENT_ID` | |
| `oidc.client_secret` | `NURSE_OIDC_CLIENT_SECRET` | |
| `allowed` | `NURSE_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated |
| `listen` | `NURSE_LISTEN` | default `127.0.0.1:8490` |
| `data_dir` | `NURSE_DATA` | default `data` |
`basking.json` also holds a `secret` that signs session cookies. It is made on first run and written
`nurse.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
@@ -147,13 +147,13 @@ Flights and hotels, see [[Packing list]] #travel
```
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.
lower-case letters, numbers and dashes). Restart Nurse to see files added by hand.
## Security 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.
are checked. Authentik and Nurse 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.
@@ -170,7 +170,7 @@ lower-case letters, numbers and dashes). Restart Basking to see files added by h
- 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.
- Pages are only reloaded from disk when Basking starts.
- Pages are only reloaded from disk when Nurse starts.
- Live preview works a line at a time (a code block counts as one), so a table or a quote spread over
several lines shows rendered line by line. View shows the whole page as one.
@@ -183,7 +183,7 @@ The interface follows the Lightwell design language, the same one Hammerhead and
```sh
go test ./...
go run ./cmd/basking serve # with the BASKING_* variables set
go run ./cmd/nurse serve # with the NURSE_* variables set
```
Releases are built by the Gitea workflow when a `v*` tag is pushed.
+21 -21
View File
@@ -1,4 +1,4 @@
// Command basking is a private notebook: pages you can link, nest and search, kept as plain
// Command nurse 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
@@ -15,27 +15,27 @@ import (
"syscall"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/basking/internal/web"
"git.veillax.com/Veillax/nurse/internal/config"
"git.veillax.com/Veillax/nurse/internal/oidc"
"git.veillax.com/Veillax/nurse/internal/web"
)
var version = "dev"
const usage = `basking is a private notebook that you sign in to with Authentik.
const usage = `nurse is a private notebook that you sign in to with Authentik.
basking serve [-config basking.json] [-listen addr] [-data dir]
nurse serve [-config nurse.json] [-listen addr] [-data dir]
run the notebook
basking check [-config basking.json] test the Authentik settings without starting
basking version
nurse check [-config nurse.json] test the Authentik settings without starting
nurse version
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
NURSE_PUBLIC_URL where you open Nurse, such as https://notes.example.com
NURSE_OIDC_ISSUER the Authentik provider's OpenID configuration issuer
NURSE_OIDC_CLIENT_ID the provider's Client ID
NURSE_OIDC_CLIENT_SECRET the provider's Client Secret
NURSE_ALLOWED optional: only these emails or usernames may sign in (comma separated)
NURSE_LISTEN, NURSE_DATA, NURSE_CONFIG
`
// exitConfig is the exit status for settings that need fixing by hand, so a service manager does
@@ -55,7 +55,7 @@ func main() {
case "check":
err = check(os.Args[2:])
case "version", "-v", "--version":
fmt.Println("basking", version)
fmt.Println("nurse", version)
case "help", "-h", "--help":
fmt.Print(usage)
default:
@@ -63,7 +63,7 @@ func main() {
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "basking:", err)
fmt.Fprintln(os.Stderr, "nurse:", err)
var ce configError
if errors.As(err, &ce) {
os.Exit(exitConfig)
@@ -75,9 +75,9 @@ func main() {
type configError struct{ error }
func configFlag(fs *flag.FlagSet) *string {
def := os.Getenv("BASKING_CONFIG")
def := os.Getenv("NURSE_CONFIG")
if def == "" {
def = "basking.json"
def = "nurse.json"
}
return fs.String("config", def, "path of the settings file")
}
@@ -89,10 +89,10 @@ func serve(args []string) error {
data := fs.String("data", "", "override the folder the notes are kept in")
fs.Parse(args)
if *listen != "" {
os.Setenv("BASKING_LISTEN", *listen)
os.Setenv("NURSE_LISTEN", *listen)
}
if *data != "" {
os.Setenv("BASKING_DATA", *data)
os.Setenv("NURSE_DATA", *data)
}
h, err := config.Load(*path)
@@ -122,7 +122,7 @@ func serve(args []string) error {
defer stop()
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
log.Printf("basking %s: notes are kept in %s; open %s", version, cfg.DataDir, cfg.PublicURL)
log.Printf("nurse %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")
+4 -4
View File
@@ -1,17 +1,17 @@
#!/bin/sh
# Builds basking for every release platform and packs the archives into dist/.
# Builds nurse for every release platform and packs the archives into dist/.
#
# deploy/build.sh [version] version defaults to `git describe`
#
# Output:
# dist/basking_<version>_<os>_<arch>.tar.gz or .zip
# dist/nurse_<version>_<os>_<arch>.tar.gz or .zip
# dist/SHA256SUMS
#
# Releases are published for linux/amd64, linux/arm64 and windows/amd64. Set TARGETS="linux/arm64 darwin/arm64"
# (any GOOS/GOARCH pair Go supports) to build something else by hand.
set -eu
NAME=basking
CMDS="basking"
NAME=nurse
CMDS="nurse"
cd "$(dirname "$0")/.."
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 windows/amd64"}
+9 -9
View File
@@ -1,17 +1,17 @@
# Basking, a private notebook you sign in to with Authentik. Reach it through an HTTPS reverse proxy
# Nurse, 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: ..
nurse:
image: git.veillax.com/veillax/nurse: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
NURSE_PUBLIC_URL: https://notes.example.com
NURSE_OIDC_ISSUER: https://auth.example.com/application/o/nurse/
NURSE_OIDC_CLIENT_ID: change-me
NURSE_OIDC_CLIENT_SECRET: change-me
# NURSE_ALLOWED: you@example.com # optional: limit who may sign in
volumes:
- ./config:/config # basking.json (the signing secret) and data/ (your pages) live here
- ./config:/config # nurse.json (the signing secret) and data/ (your pages) live here
+50 -50
View File
@@ -1,31 +1,31 @@
#!/bin/sh
# Installs (or upgrades) Basking as a systemd service.
# Installs (or upgrades) Nurse as a systemd service.
#
# curl -fsSL https://git.veillax.com/Veillax/basking/raw/branch/main/deploy/install.sh | sudo sh
# curl -fsSL https://git.veillax.com/Veillax/nurse/raw/branch/main/deploy/install.sh | sudo sh
# sudo sh install.sh [--start] [--version v0.1.0] [--listen 127.0.0.1:8490] [--source]
# sudo sh install.sh --uninstall [--purge]
#
# What it does:
# 1. downloads the release archive for this machine and checks its SHA-256 (or, with --source,
# clones the repository and builds it with Go),
# 2. installs the binary to /usr/local/bin/basking,
# 3. creates a "basking" user, /etc/basking (settings) and /var/lib/basking (data),
# 4. writes /etc/basking/basking.env and the systemd service,
# 2. installs the binary to /usr/local/bin/nurse,
# 3. creates a "nurse" user, /etc/nurse (settings) and /var/lib/nurse (data),
# 4. writes /etc/nurse/nurse.env and the systemd service,
# 5. tells you what to do next: edit the env file, start the service, watch the log.
#
# Running it again upgrades the binary and keeps your settings; a service that is already running
# is restarted. Add --start to start it straight away instead of editing the env file first.
set -eu
REPO_URL=${BASKING_REPO_URL:-https://git.veillax.com/Veillax/basking}
API_URL=${BASKING_API_URL:-https://git.veillax.com/api/v1/repos/Veillax/basking}
BIN=/usr/local/bin/basking
CONF_DIR=/etc/basking
DATA_DIR=/var/lib/basking
ENV_FILE=$CONF_DIR/basking.env
CONF_FILE=$CONF_DIR/basking.json
UNIT=/etc/systemd/system/basking.service
USER_NAME=basking
REPO_URL=${NURSE_REPO_URL:-https://git.veillax.com/Veillax/nurse}
API_URL=${NURSE_API_URL:-https://git.veillax.com/api/v1/repos/Veillax/nurse}
BIN=/usr/local/bin/nurse
CONF_DIR=/etc/nurse
DATA_DIR=/var/lib/nurse
ENV_FILE=$CONF_DIR/nurse.env
CONF_FILE=$CONF_DIR/nurse.json
UNIT=/etc/systemd/system/nurse.service
USER_NAME=nurse
VERSION=""
LISTEN="127.0.0.1:8490"
@@ -58,7 +58,7 @@ HAVE_SYSTEMD=${HAVE_SYSTEMD:-1}
if [ "$UNINSTALL" -eq 1 ]; then
if [ "$HAVE_SYSTEMD" -eq 1 ]; then
systemctl disable --now basking 2>/dev/null || true
systemctl disable --now nurse 2>/dev/null || true
rm -f "$UNIT"
systemctl daemon-reload
fi
@@ -66,9 +66,9 @@ if [ "$UNINSTALL" -eq 1 ]; then
if [ "$PURGE" -eq 1 ]; then
rm -rf "$CONF_DIR" "$DATA_DIR"
userdel "$USER_NAME" 2>/dev/null || true
say "Basking removed, including its settings and data."
say "Nurse removed, including its settings and data."
else
say "Basking removed. Settings ($CONF_DIR) and data ($DATA_DIR) were kept; add --purge to delete them."
say "Nurse removed. Settings ($CONF_DIR) and data ($DATA_DIR) were kept; add --purge to delete them."
fi
exit 0
fi
@@ -88,7 +88,7 @@ if [ "$SOURCE" -eq 1 ]; then
command -v go >/dev/null 2>&1 || die "--source needs Go (https://go.dev/dl/)"
say "Cloning $REPO_URL ..."
git clone --quiet ${VERSION:+--branch "$VERSION"} --depth 1 "$REPO_URL" "$TMP/src"
(cd "$TMP/src" && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION:-source}" -o "$TMP/basking" ./cmd/basking)
(cd "$TMP/src" && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION:-source}" -o "$TMP/nurse" ./cmd/nurse)
else
case $(uname -m) in
x86_64|amd64) ARCH=amd64 ;;
@@ -100,8 +100,8 @@ else
VERSION=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMP/latest.json" | head -n 1)
[ -n "$VERSION" ] || die "could not find the latest release; use --version or --source"
fi
ARCHIVE="basking_${VERSION}_linux_${ARCH}.tar.gz"
say "Downloading Basking $VERSION ($ARCH) ..."
ARCHIVE="nurse_${VERSION}_linux_${ARCH}.tar.gz"
say "Downloading Nurse $VERSION ($ARCH) ..."
fetch "$REPO_URL/releases/download/$VERSION/$ARCHIVE" "$TMP/$ARCHIVE"
fetch "$REPO_URL/releases/download/$VERSION/SHA256SUMS" "$TMP/SHA256SUMS"
want=$(grep " $ARCHIVE\$" "$TMP/SHA256SUMS" | cut -d' ' -f1)
@@ -109,14 +109,14 @@ else
got=$(sha256sum "$TMP/$ARCHIVE" | cut -d' ' -f1)
[ "$want" = "$got" ] || die "checksum mismatch for $ARCHIVE; refusing to install"
tar -xzf "$TMP/$ARCHIVE" -C "$TMP"
cp "$TMP/basking_${VERSION}_linux_${ARCH}/basking" "$TMP/basking"
cp "$TMP/nurse_${VERSION}_linux_${ARCH}/nurse" "$TMP/nurse"
fi
# ---- 2. install it ----
UPGRADE=0
[ -x "$BIN" ] && UPGRADE=1
if [ "$UPGRADE" -eq 1 ] && [ "$HAVE_SYSTEMD" -eq 1 ]; then systemctl stop basking 2>/dev/null || true; fi
install -m 0755 "$TMP/basking" "$BIN"
if [ "$UPGRADE" -eq 1 ] && [ "$HAVE_SYSTEMD" -eq 1 ]; then systemctl stop nurse 2>/dev/null || true; fi
install -m 0755 "$TMP/nurse" "$BIN"
say "Installed $("$BIN" version) to $BIN"
# ---- 3. user and folders ----
@@ -136,31 +136,31 @@ if [ ! -f "$CONF_FILE" ]; then
fi
if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
# Environment for the basking service. Restart it after editing: systemctl restart basking
# Environment for the nurse service. Restart it after editing: systemctl restart nurse
#
# Basking has no passwords. You sign in with Authentik, so fill in the four lines below.
# Nurse 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
# <NURSE_PUBLIC_URL>/auth/callback
# then create an Application that uses it. Check the result with: nurse check
# 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=
# Where you open Nurse in a browser, such as https://notes.example.com (no path).
NURSE_PUBLIC_URL=
# The provider's issuer, such as https://auth.example.com/application/o/nurse/
NURSE_OIDC_ISSUER=
NURSE_OIDC_CLIENT_ID=
NURSE_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
#NURSE_ALLOWED=you@example.com
# Where the settings file is (it holds the signing secret).
BASKING_CONFIG=$CONF_FILE
NURSE_CONFIG=$CONF_FILE
# Optional. These override what the settings file says.
#BASKING_LISTEN=$LISTEN
#BASKING_DATA=$DATA_DIR
#NURSE_LISTEN=$LISTEN
#NURSE_DATA=$DATA_DIR
EOF
chmod 0600 "$ENV_FILE" # systemd reads it as root, so the service user does not need access
say "Created $ENV_FILE."
@@ -175,7 +175,7 @@ fi
cat > "$UNIT" <<EOF
[Unit]
Description=Basking, a private notebook
Description=Nurse, a private notebook
After=network-online.target
Wants=network-online.target
@@ -189,7 +189,7 @@ 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.
# Nurse only needs the network and its own folders.
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=$CONF_DIR $DATA_DIR
@@ -205,16 +205,16 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable basking >/dev/null 2>&1
systemctl enable nurse >/dev/null 2>&1
if [ "$START" -eq 1 ] || systemctl is-active --quiet basking; then
systemctl restart basking
if [ "$START" -eq 1 ] || systemctl is-active --quiet nurse; then
systemctl restart nurse
sleep 3
if systemctl is-active --quiet basking; then
say "Basking is running."
if systemctl is-active --quiet nurse; then
say "Nurse is running."
else
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"
say "Nurse did not start. If you have not filled in the Authentik settings in $ENV_FILE yet, that is why."
say "Look at: journalctl -u nurse -n 50"
exit 1
fi
fi
@@ -222,8 +222,8 @@ fi
say "Installed $("$BIN" version)."
say "Next:"
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; it says at once if a setting is missing or wrong"
say " 2. systemctl start nurse (or run this again with --start)"
say " 3. journalctl -u nurse -f to watch it; it says at once if a setting is missing or wrong"
say ""
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."
say "Nurse listens on $LISTEN. Put an HTTPS reverse proxy (or Hammerhead) in front of it and open the"
say "address you set as NURSE_PUBLIC_URL; Authentik will not send people back to plain http."
+1 -1
View File
@@ -9,7 +9,7 @@ set -eu
TAG=${1:?usage: publish-release.sh <tag>}
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
REPO=${GITHUB_REPOSITORY:-Veillax/basking}
REPO=${GITHUB_REPOSITORY:-Veillax/nurse}
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
+3 -3
View File
@@ -1,6 +1,6 @@
# Basking design language
# Nurse design language
Basking follows the Lightwell design language, the same one Hammerhead and Megalodon use. The reference is `docs/design.md` in the Lightwell repository. When this page and Lightwell disagree, Lightwell wins.
Nurse follows the Lightwell design language, the same one Hammerhead and Megalodon use. The reference is `docs/design.md` in the Lightwell repository. When this page and Lightwell disagree, Lightwell wins.
## Shared with Lightwell
@@ -9,7 +9,7 @@ Basking follows the Lightwell design language, the same one Hammerhead and Megal
* **Status carries text.** "Pinned", "Saving", "Saved" and "Not saved" are words, never a colored dot.
* **Structure.** Sidebar and drawer shell, cards, dialogs with a focus trap, `confirmDialog` for anything that cannot be undone, skeletons for loading, empty states that say why a list is empty.
## Where Basking differs
## Where Nurse differs
* **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.
+1 -1
View File
@@ -1,3 +1,3 @@
module git.veillax.com/Veillax/basking
module git.veillax.com/Veillax/nurse
go 1.27.3
+17 -17
View File
@@ -1,4 +1,4 @@
// Package config holds Basking's settings: where to listen, where notes live and how to reach
// Package config holds Nurse's settings: where to listen, where notes live and how to reach
// Authentik. The notes themselves are not in here.
package config
@@ -15,7 +15,7 @@ import (
"sync"
)
// OIDC is the OAuth client Authentik gives you for Basking.
// OIDC is the OAuth client Authentik gives you for Nurse.
type OIDC struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
@@ -26,7 +26,7 @@ type Config struct {
Listen string `json:"listen"`
DataDir string `json:"data_dir"`
// PublicURL is the address you open Basking at, such as https://notes.example.com. The sign-in
// PublicURL is the address you open Nurse at, such as https://notes.example.com. The sign-in
// redirect is PublicURL + /auth/callback.
PublicURL string `json:"public_url"`
OIDC OIDC `json:"oidc"`
@@ -61,7 +61,7 @@ func (c *Config) Defaults() {
// 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.
// Secure says whether Nurse 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.
@@ -81,23 +81,23 @@ func checkURL(name, raw string) error {
return nil
}
// Check says what is still missing before Basking can sign anyone in, in words a person can act on.
// Check says what is still missing before Nurse 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")
missing = append(missing, " public_url (NURSE_PUBLIC_URL) where you open Nurse, 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/")
missing = append(missing, " oidc.issuer (NURSE_OIDC_ISSUER) from Authentik, such as https://auth.example.com/application/o/nurse/")
}
if c.OIDC.ClientID == "" {
missing = append(missing, " oidc.client_id (BASKING_OIDC_CLIENT_ID) the Client ID of the Authentik provider")
missing = append(missing, " oidc.client_id (NURSE_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")
missing = append(missing, " oidc.client_secret (NURSE_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))
return fmt.Errorf("Nurse 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
@@ -156,13 +156,13 @@ func (h *Holder) Get() Config {
*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) != "" {
env("NURSE_LISTEN", &c.Listen)
env("NURSE_DATA", &c.DataDir)
env("NURSE_PUBLIC_URL", &c.PublicURL)
env("NURSE_OIDC_ISSUER", &c.OIDC.Issuer)
env("NURSE_OIDC_CLIENT_ID", &c.OIDC.ClientID)
env("NURSE_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
if v := os.Getenv("NURSE_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 != "" {
+2 -2
View File
@@ -1,4 +1,4 @@
// Package oidc is the small slice of OpenID Connect Basking needs: the authorization code flow
// Package oidc is the small slice of OpenID Connect Nurse 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
@@ -22,7 +22,7 @@ import (
"sync"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/nurse/internal/config"
)
// ErrUnavailable means the provider could not be reached or answered nonsense, as opposed to
+2 -2
View File
@@ -1,5 +1,5 @@
// 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.
// portable with or without Nurse. An in-memory index makes listing, searching and linking quick.
package store
import (
@@ -1029,7 +1029,7 @@ func (s *Store) WriteZip(w io.Writer) error {
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})
f, err := zw.CreateHeader(&zip.FileHeader{Name: "nurse/" + name + ".md", Method: zip.Deflate, Modified: n.Updated})
if err != nil {
return err
}
+4 -4
View File
@@ -87,11 +87,11 @@ func TestNameComesFromTitleOrFirstLine(t *testing.T) {
func TestCreateRoundTripsThroughDisk(t *testing.T) {
s, dir := open(t)
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})
n, err := s.Create(NewNote{Title: " Nurse: 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" || n.Title != `Basking: a "notebook" xy` {
if n.Body != "first thought #idea\n\nwith a second paragraph" || n.Title != `Nurse: a "notebook" xy` {
t.Fatalf("clean-up: %q %q", n.Body, n.Title)
}
if !ValidID(n.ID) {
@@ -372,7 +372,7 @@ func TestSearchAndTags(t *testing.T) {
time.Sleep(2 * time.Millisecond)
s.Create(NewNote{Body: "Call the dentist #errands #health"})
time.Sleep(2 * time.Millisecond)
s.Create(NewNote{Title: "Roadmap", Body: "Idea: a #projects/basking dashboard"})
s.Create(NewNote{Title: "Roadmap", Body: "Idea: a #projects/nurse dashboard"})
if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 {
t.Fatalf("case-insensitive search: %d", len(list))
@@ -492,7 +492,7 @@ func TestZipIsAnObsidianVault(t *testing.T) {
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}
want := map[string]bool{"nurse/Trip- Lisbon-Porto.md": true, "nurse/Packing.md": true, "nurse/A quick one.md": true, "nurse/A quick one 2.md": true, "nurse/Journal/2026-10-31.md": true}
if len(names) != len(want) {
t.Fatalf("zip has %v", names)
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"strconv"
"time"
"git.veillax.com/Veillax/basking/internal/store"
"git.veillax.com/Veillax/nurse/internal/store"
)
func (s *Server) pages(w http.ResponseWriter, r *http.Request, p principal) {
@@ -146,7 +146,7 @@ func (s *Server) daily(w http.ResponseWriter, r *http.Request, p principal) {
func (s *Server) export(w http.ResponseWriter, r *http.Request, p principal) {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="basking-%s.zip"`, time.Now().Format("2006-01-02")))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="nurse-%s.zip"`, time.Now().Format("2006-01-02")))
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)
+5 -5
View File
@@ -14,13 +14,13 @@ import (
"strings"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/nurse/internal/config"
"git.veillax.com/Veillax/nurse/internal/oidc"
)
const (
sessionCookie = "basking_session"
flowCookie = "basking_signin"
sessionCookie = "nurse_session"
flowCookie = "nurse_signin"
sessionTTL = 14 * 24 * time.Hour
flowTTL = 10 * time.Minute
)
@@ -99,7 +99,7 @@ func setCookie(w http.ResponseWriter, c config.Config, name, value, path string,
http.SetCookie(w, ck)
}
// Where to go after signing in: only a place inside Basking, such as #/p/abc.
// Where to go after signing in: only a place inside Nurse, such as #/p/abc.
var nextRe = regexp.MustCompile(`^#/[A-Za-z0-9/_.~%-]{0,300}$`)
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
+6 -6
View File
@@ -1,4 +1,4 @@
// Package web serves Basking's dashboard and its JSON API.
// Package web serves Nurse's dashboard and its JSON API.
package web
import (
@@ -15,9 +15,9 @@ import (
"strings"
"sync"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/basking/internal/store"
"git.veillax.com/Veillax/nurse/internal/config"
"git.veillax.com/Veillax/nurse/internal/oidc"
"git.veillax.com/Veillax/nurse/internal/store"
)
//go:embed static
@@ -136,8 +136,8 @@ func (s *Server) api(fn handlerFunc) http.HandlerFunc {
return
}
// 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")
if r.Method != http.MethodGet && r.Header.Get("X-Nurse") != "1" {
fail(w, http.StatusForbidden, "missing X-Nurse header")
return
}
st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email)
+9 -9
View File
@@ -33,10 +33,10 @@ onSignedOut = () => { if (S.ready && ![...S.editors].some(e => e.dirty())) { S.r
// ---------- signing in ----------
const SIGNIN_MESSAGES = {
denied: 'This account is not allowed to open this notebook. Ask whoever runs Basking to add you.',
denied: 'This account is not allowed to open this notebook. Ask whoever runs Nurse to add you.',
failed: 'Signing in did not finish. Please try again.',
expired: 'That sign-in took too long. Please try again.',
unavailable: 'Basking could not reach Authentik. Try again in a moment.',
unavailable: 'Nurse could not reach Authentik. Try again in a moment.',
};
async function boot() {
@@ -54,7 +54,7 @@ async function boot() {
}
function showLogin(why) {
S.editors.clear(); S.view = null; document.title = 'Sign in · Basking';
S.editors.clear(); S.view = null; document.title = 'Sign in · Nurse';
const next = location.hash.length > 2 ? location.hash : '';
$app.replaceChildren(h('main', { class: 'center' }, card('', brand(),
h('h1', { class: 'login-h' }, 'Your private notebook'),
@@ -180,7 +180,7 @@ async function show() {
}
S.view = page;
main.replaceChildren(page.el);
document.title = (page.title ? page.title + ' · ' : '') + 'Basking';
document.title = (page.title ? page.title + ' · ' : '') + 'Nurse';
window.scrollTo(0, 0);
page.start?.();
}
@@ -250,11 +250,11 @@ function pageView({ id, date, parent = '' }) {
view.title = date ? dayLabel(date) : n?.name || 'New page';
ed = pageEditor({
note: n, parent, fixedTitle: date ? parseDay(date).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : undefined,
onCreated(c) { note = c; history.replaceState(null, '', '#/p/' + c.id); document.title = (c.name || 'New page') + ' · Basking'; drawActions(); drawCrumbs(); refreshPages(); },
onCreated(c) { note = c; history.replaceState(null, '', '#/p/' + c.id); document.title = (c.name || 'New page') + ' · Nurse'; drawActions(); drawCrumbs(); refreshPages(); },
onSaved(c, { renamed }) {
note = c; if (c.backlinks) backlinks = c.backlinks;
drawTags(); drawAside(); drawMeta(); drawActions();
document.title = (c.name || 'New page') + ' · Basking';
document.title = (c.name || 'New page') + ' · Nurse';
renamed ? refreshPages().catch(() => { }) : refreshSoon();
},
});
@@ -267,7 +267,7 @@ function pageView({ id, date, parent = '' }) {
fill(body, ed.notice, title, date ? h('h1', { class: 'day-title' }, ed.titleEl.value) : null, ed.wrap, tagsRow, asideBox, meta);
ed.setMode(start);
drawCrumbs(); drawTags(); drawAside(); drawMeta(); drawActions();
document.title = view.title + ' · Basking';
document.title = view.title + ' · Nurse';
// keep the person's choice of Write or Read once they have made one
const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); };
if (!n) setTimeout(() => ed.titleEl.focus());
@@ -547,7 +547,7 @@ function settingsPage() {
title: 'Settings',
el: h('div', {}, pageHeader('Settings', 'Who you are signed in as, and a copy of everything.'),
card('Account', h('p', {}, 'You are signed in as ', h('strong', {}, S.me.name), S.me.email ? ' (' + S.me.email + ')' : '', ' through Authentik.'),
h('p', { class: 'dim' }, 'Basking has no password of its own. Who can sign in is decided in Authentik, and your pages are visible to you alone.'),
h('p', { class: 'dim' }, 'Nurse has no password of its own. Who can sign in is decided in Authentik, and your pages are visible to you alone.'),
btn('Sign out', 'out', act(async () => { await flushAll(); await api('POST', '/api/logout'); S.ready = false; S.editors.clear(); showLogin(); }))),
card('Your notes', h('p', { class: 'dim' }, 'Download every page as a folder of markdown files. It opens as a vault in Obsidian, with your [[links]] and journal intact.'),
btn('Download all pages', 'download', () => { location.href = '/api/export'; })),
@@ -555,7 +555,7 @@ function settingsPage() {
row('Ctrl K', 'Search pages, or make a new one'), row('c', 'New page'), row('n', 'Jot a quick note'),
row('Ctrl E', 'Switch between editing and View'), row('/', 'In a page: commands. Anywhere else: search'),
row('[[', 'In a page: link to another page'), row('Ctrl B, Ctrl I', 'Bold, italic'), row('Tab', 'In a list: indent'))),
card('Privacy', h('p', { class: 'dim' }, 'Pages are stored as plain markdown files on the machine running Basking, under a folder only you map to. They are not encrypted on disk, so anyone who can read that machine can read them.'))),
card('Privacy', h('p', { class: 'dim' }, 'Pages are stored as plain markdown files on the machine running Nurse, under a folder only you map to. They are not encrypted on disk, so anyone who can read that machine can read them.'))),
};
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Basking</title>
<title>Nurse</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231f5c5c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 20c4-1 7-5 8-14 3 5 8 7 10 14z'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
</head>
+3 -3
View File
@@ -47,11 +47,11 @@ 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 };
const init = { method, headers: { 'X-Nurse': '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 }); }
catch { throw Object.assign(new Error('Could not reach Nurse. 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 });
@@ -123,7 +123,7 @@ function icon(name, size = 18) {
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 brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Nurse');
const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label);
// ---------- dialogs ----------
+15 -15
View File
@@ -16,8 +16,8 @@ import (
"testing"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/oidc"
"git.veillax.com/Veillax/nurse/internal/config"
"git.veillax.com/Veillax/nurse/internal/oidc"
)
// idp is a stand-in for Authentik: it publishes its metadata and trades the code "good" for an ID
@@ -51,7 +51,7 @@ func newIdP(t *testing.T) *idp {
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}
c := map[string]any{"iss": p.srv.URL + "/app/", "aud": "nurse-client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": p.nonce}
for k, v := range p.claims {
c[k] = v
}
@@ -79,13 +79,13 @@ func setup(t *testing.T, tweak func(map[string]any)) *env {
dir := t.TempDir()
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"},
"oidc": map[string]string{"issuer": p.srv.URL + "/app/", "client_id": "nurse-client", "client_secret": "shh"},
}
if tweak != nil {
tweak(conf)
}
b, _ := json.Marshal(conf)
path := filepath.Join(dir, "basking.json")
path := filepath.Join(dir, "nurse.json")
os.WriteFile(path, b, 0o600)
h, err := config.Load(path)
if err != nil {
@@ -112,7 +112,7 @@ func (e *env) do(method, path string, body any, cookies []*http.Cookie, hdr ...s
r.Header.Set("Content-Type", "application/json")
}
if method != http.MethodGet {
r.Header.Set("X-Basking", "1")
r.Header.Set("X-Nurse", "1")
}
for i := 0; i+1 < len(hdr); i += 2 {
if hdr[i+1] == "" {
@@ -184,7 +184,7 @@ func TestNothingWorksWithoutSigningIn(t *testing.T) {
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 w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Basking") {
if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Nurse") {
t.Errorf("the sign-in page itself must load: %d", w.Code)
}
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
@@ -202,7 +202,7 @@ func TestSignInFlow(t *testing.T) {
if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") {
t.Fatalf("goes to Authentik: %s", to)
}
for k, want := range map[string]string{"response_type": "code", "client_id": "basking-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback",
for k, want := range map[string]string{"response_type": "code", "client_id": "nurse-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)
@@ -310,7 +310,7 @@ func TestSignInIsRefusedWhenAnythingIsOff(t *testing.T) {
return good(to)
}, "unavailable")
// and a next address that points anywhere but inside Basking is dropped
// and a next address that points anywhere but inside Nurse 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()
@@ -393,10 +393,10 @@ func TestSessionsAreSignedAndExpire(t *testing.T) {
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 {
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, ses, "X-Nurse", ""); w.Code != http.StatusForbidden {
t.Fatalf("a write without the header: %d", w.Code)
}
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Basking", ""); w.Code != http.StatusForbidden {
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Nurse", ""); w.Code != http.StatusForbidden {
t.Fatalf("a delete without the header: %d", w.Code)
}
}
@@ -542,7 +542,7 @@ func TestPageLifecycle(t *testing.T) {
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 {
if fmt.Sprint(names) != "[nurse/Journal/2026-10-31.md nurse/Plans 2027.md nurse/Trip.md]" && len(names) != 3 {
t.Fatalf("export has %v", names)
}
}
@@ -576,14 +576,14 @@ func TestSecurityHeaders(t *testing.T) {
func TestServerWillNotStartHalfConfigured(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "basking.json")
path := filepath.Join(dir, "nurse.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"} {
for _, want := range []string{"oidc.issuer", "NURSE_OIDC_CLIENT_ID", "client_secret", "/auth/callback"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the message should mention %q:\n%v", want, err)
}
@@ -593,7 +593,7 @@ func TestServerWillNotStartHalfConfigured(t *testing.T) {
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")
t.Setenv("NURSE_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)
+1 -1
View File
@@ -23,7 +23,7 @@ const problems = [];
for (const file of walk(root)) {
if (!exts.has(extname(file))) continue;
const rel = relative(root, file).split(sep).join("/");
if (rel === "scripts/lint-design.mjs" || rel === "basking.json") continue;
if (rel === "scripts/lint-design.mjs" || rel === "nurse.json") continue;
const isPage = rel.startsWith("internal/web/static/");
let inTokens = false;
readFileSync(file, "utf8").split("\n").forEach((line, i) => {