Rename Nurse to Mako
CI / test (push) Successful in 2m9s
Release / release (push) Successful in 2m16s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-11-01 00:07:43 -06:00
1 parent 6a4be5b2f4
commit e3147597e8
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/nurse
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/mako
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 "nurse v0.1.0" && git push origin v0.1.0
# git tag -a v0.1.0 -m "mako 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/
nurse.json
mako.json
.env
+7 -7
View File
@@ -1,16 +1,16 @@
# Build: docker build -t nurse .
# Build: docker build -t mako .
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /nurse ./cmd/nurse
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /mako ./cmd/mako
FROM alpine:3.20
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
RUN apk add --no-cache tzdata ca-certificates && adduser -D -u 10001 mako && mkdir /config && chown mako /config
COPY --from=build /mako /usr/local/bin/mako
USER mako
# The config file and the notes both live under /config.
ENV NURSE_CONFIG=/config/nurse.json
ENV MAKO_CONFIG=/config/mako.json
WORKDIR /config
EXPOSE 8490
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8490/healthz || exit 1
ENTRYPOINT ["nurse", "serve", "-listen", "0.0.0.0:8490"]
ENTRYPOINT ["mako", "serve", "-listen", "0.0.0.0:8490"]
+42 -42
View File
@@ -1,11 +1,11 @@
# Nurse
# Mako
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 Nurse.
dependencies. You sign in with **Authentik** and nothing else: there are no passwords in Mako.
Named for the nurse shark, which rests on the sea floor and stays close to home.
Named for the mako shark, the fastest in the sea, because a notebook should keep up with you.
## What it does
@@ -27,9 +27,9 @@ Named for the nurse shark, which rests on the sea floor and stays close to home.
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/nurse` anywhere. Numbers such as `#12` and
- **Tags by typing.** Write `#errands` or `#projects/mako` 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, Nurse
- **Autosave that does not lose things.** Changes save as you type. If the connection drops, Mako
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 nurse shark, which rests on the sea floor and stays close to home.
## Signing in with Authentik
Nurse never sees a password. Who may open it is decided in Authentik, and each person gets a
Mako 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 Nurse at), and keep the
`https://notes.example.com/auth/callback` (use the address you will open Mako at), and keep the
default scopes (openid, profile, email).
2. **Applications → Create**: name it Nurse and pick that provider. To limit who can open it, bind a
2. **Applications → Create**: name it Mako 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/nurse/`.
4. Give Nurse those four things (see below), then run `nurse check`. It tells you whether
Issuer**, which looks like `https://auth.example.com/application/o/mako/`.
4. Give Mako those four things (see below), then run `mako check`. It tells you whether
Authentik answers and what redirect URI to allow.
Nurse asks for the `openid profile email` scopes and uses the authorization code flow with PKCE, a
Mako 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 Nurse turns anyone else away even if Authentik
people under `allowed` (emails or usernames), and Mako turns anyone else away even if Authentik
lets them through.
## Installing on Linux (systemd)
```sh
curl -fsSL https://git.veillax.com/Veillax/nurse/raw/branch/main/deploy/install.sh | sudo sh
curl -fsSL https://git.veillax.com/Veillax/mako/raw/branch/main/deploy/install.sh | sudo sh
```
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:
This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/mako`, creates a
`mako` user, `/etc/mako/` (settings) and `/var/lib/mako/` (your notes), and writes the
systemd service and `/etc/mako/mako.env`. Then:
```sh
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
sudoedit /etc/mako/mako.env # fill in the four Authentik lines at the top
sudo systemctl start mako
journalctl -u mako -f # it says at once if a setting is missing or wrong
```
Nurse listens on `127.0.0.1:8490`. Put an HTTPS reverse proxy (or
Mako 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 `NURSE_PUBLIC_URL`. Authentik will only send people back to an address you allowed, and cookies
as `MAKO_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 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
export MAKO_PUBLIC_URL=https://notes.example.com
export MAKO_OIDC_ISSUER=https://auth.example.com/application/o/mako/
export MAKO_OIDC_CLIENT_ID=...
export MAKO_OIDC_CLIENT_SECRET=...
./mako check # optional: test the settings
./mako 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
```
nurse serve [-config nurse.json] [-listen addr] [-data dir]
nurse check [-config nurse.json] test the Authentik settings without starting
nurse version
mako serve [-config mako.json] [-listen addr] [-data dir]
mako check [-config mako.json] test the Authentik settings without starting
mako version
```
## Settings
@@ -111,15 +111,15 @@ written to it:
| Setting | Variable | |
| --- | --- | --- |
| `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` |
| `public_url` | `MAKO_PUBLIC_URL` | where you open Mako, no path |
| `oidc.issuer` | `MAKO_OIDC_ISSUER` | the provider's issuer |
| `oidc.client_id` | `MAKO_OIDC_CLIENT_ID` | |
| `oidc.client_secret` | `MAKO_OIDC_CLIENT_SECRET` | |
| `allowed` | `MAKO_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated |
| `listen` | `MAKO_LISTEN` | default `127.0.0.1:8490` |
| `data_dir` | `MAKO_DATA` | default `data` |
`nurse.json` also holds a `secret` that signs session cookies. It is made on first run and written
`mako.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 Nurse to see files added by hand.
lower-case letters, numbers and dashes). Restart Mako 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 Nurse must be reachable over https unless both are on the same machine.
are checked. Authentik and Mako 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 Nurse to see files added by han
- 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 Nurse starts.
- Pages are only reloaded from disk when Mako 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/nurse serve # with the NURSE_* variables set
go run ./cmd/mako serve # with the MAKO_* variables set
```
Releases are built by the Gitea workflow when a `v*` tag is pushed.
+21 -21
View File
@@ -1,4 +1,4 @@
// Command nurse is a private notebook: pages you can link, nest and search, kept as plain
// Command mako 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/nurse/internal/config"
"git.veillax.com/Veillax/nurse/internal/oidc"
"git.veillax.com/Veillax/nurse/internal/web"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc"
"git.veillax.com/Veillax/mako/internal/web"
)
var version = "dev"
const usage = `nurse is a private notebook that you sign in to with Authentik.
const usage = `mako is a private notebook that you sign in to with Authentik.
nurse serve [-config nurse.json] [-listen addr] [-data dir]
mako serve [-config mako.json] [-listen addr] [-data dir]
run the notebook
nurse check [-config nurse.json] test the Authentik settings without starting
nurse version
mako check [-config mako.json] test the Authentik settings without starting
mako version
Settings come from the settings file and can be overridden by environment variables:
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
MAKO_PUBLIC_URL where you open Mako, such as https://notes.example.com
MAKO_OIDC_ISSUER the Authentik provider's OpenID configuration issuer
MAKO_OIDC_CLIENT_ID the provider's Client ID
MAKO_OIDC_CLIENT_SECRET the provider's Client Secret
MAKO_ALLOWED optional: only these emails or usernames may sign in (comma separated)
MAKO_LISTEN, MAKO_DATA, MAKO_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("nurse", version)
fmt.Println("mako", 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, "nurse:", err)
fmt.Fprintln(os.Stderr, "mako:", 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("NURSE_CONFIG")
def := os.Getenv("MAKO_CONFIG")
if def == "" {
def = "nurse.json"
def = "mako.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("NURSE_LISTEN", *listen)
os.Setenv("MAKO_LISTEN", *listen)
}
if *data != "" {
os.Setenv("NURSE_DATA", *data)
os.Setenv("MAKO_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("nurse %s: notes are kept in %s; open %s", version, cfg.DataDir, cfg.PublicURL)
log.Printf("mako %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 nurse for every release platform and packs the archives into dist/.
# Builds mako for every release platform and packs the archives into dist/.
#
# deploy/build.sh [version] version defaults to `git describe`
#
# Output:
# dist/nurse_<version>_<os>_<arch>.tar.gz or .zip
# dist/mako_<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=nurse
CMDS="nurse"
NAME=mako
CMDS="mako"
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 @@
# Nurse, a private notebook you sign in to with Authentik. Reach it through an HTTPS reverse proxy
# Mako, 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:
nurse:
image: git.veillax.com/veillax/nurse:latest # or: build: ..
mako:
image: git.veillax.com/veillax/mako:latest # or: build: ..
restart: unless-stopped
ports:
- "127.0.0.1:8490:8490"
environment:
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
MAKO_PUBLIC_URL: https://notes.example.com
MAKO_OIDC_ISSUER: https://auth.example.com/application/o/mako/
MAKO_OIDC_CLIENT_ID: change-me
MAKO_OIDC_CLIENT_SECRET: change-me
# MAKO_ALLOWED: you@example.com # optional: limit who may sign in
volumes:
- ./config:/config # nurse.json (the signing secret) and data/ (your pages) live here
- ./config:/config # mako.json (the signing secret) and data/ (your pages) live here
+50 -50
View File
@@ -1,31 +1,31 @@
#!/bin/sh
# Installs (or upgrades) Nurse as a systemd service.
# Installs (or upgrades) Mako as a systemd service.
#
# curl -fsSL https://git.veillax.com/Veillax/nurse/raw/branch/main/deploy/install.sh | sudo sh
# curl -fsSL https://git.veillax.com/Veillax/mako/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/nurse,
# 3. creates a "nurse" user, /etc/nurse (settings) and /var/lib/nurse (data),
# 4. writes /etc/nurse/nurse.env and the systemd service,
# 2. installs the binary to /usr/local/bin/mako,
# 3. creates a "mako" user, /etc/mako (settings) and /var/lib/mako (data),
# 4. writes /etc/mako/mako.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=${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
REPO_URL=${MAKO_REPO_URL:-https://git.veillax.com/Veillax/mako}
API_URL=${MAKO_API_URL:-https://git.veillax.com/api/v1/repos/Veillax/mako}
BIN=/usr/local/bin/mako
CONF_DIR=/etc/mako
DATA_DIR=/var/lib/mako
ENV_FILE=$CONF_DIR/mako.env
CONF_FILE=$CONF_DIR/mako.json
UNIT=/etc/systemd/system/mako.service
USER_NAME=mako
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 nurse 2>/dev/null || true
systemctl disable --now mako 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 "Nurse removed, including its settings and data."
say "Mako removed, including its settings and data."
else
say "Nurse removed. Settings ($CONF_DIR) and data ($DATA_DIR) were kept; add --purge to delete them."
say "Mako 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/nurse" ./cmd/nurse)
(cd "$TMP/src" && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION:-source}" -o "$TMP/mako" ./cmd/mako)
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="nurse_${VERSION}_linux_${ARCH}.tar.gz"
say "Downloading Nurse $VERSION ($ARCH) ..."
ARCHIVE="mako_${VERSION}_linux_${ARCH}.tar.gz"
say "Downloading Mako $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/nurse_${VERSION}_linux_${ARCH}/nurse" "$TMP/nurse"
cp "$TMP/mako_${VERSION}_linux_${ARCH}/mako" "$TMP/mako"
fi
# ---- 2. install it ----
UPGRADE=0
[ -x "$BIN" ] && UPGRADE=1
if [ "$UPGRADE" -eq 1 ] && [ "$HAVE_SYSTEMD" -eq 1 ]; then systemctl stop nurse 2>/dev/null || true; fi
install -m 0755 "$TMP/nurse" "$BIN"
if [ "$UPGRADE" -eq 1 ] && [ "$HAVE_SYSTEMD" -eq 1 ]; then systemctl stop mako 2>/dev/null || true; fi
install -m 0755 "$TMP/mako" "$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 nurse service. Restart it after editing: systemctl restart nurse
# Environment for the mako service. Restart it after editing: systemctl restart mako
#
# Nurse has no passwords. You sign in with Authentik, so fill in the four lines below.
# Mako 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
# <NURSE_PUBLIC_URL>/auth/callback
# then create an Application that uses it. Check the result with: nurse check
# <MAKO_PUBLIC_URL>/auth/callback
# then create an Application that uses it. Check the result with: mako check
# 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=
# Where you open Mako in a browser, such as https://notes.example.com (no path).
MAKO_PUBLIC_URL=
# The provider's issuer, such as https://auth.example.com/application/o/mako/
MAKO_OIDC_ISSUER=
MAKO_OIDC_CLIENT_ID=
MAKO_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.
#NURSE_ALLOWED=you@example.com
#MAKO_ALLOWED=you@example.com
# Where the settings file is (it holds the signing secret).
NURSE_CONFIG=$CONF_FILE
MAKO_CONFIG=$CONF_FILE
# Optional. These override what the settings file says.
#NURSE_LISTEN=$LISTEN
#NURSE_DATA=$DATA_DIR
#MAKO_LISTEN=$LISTEN
#MAKO_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=Nurse, a private notebook
Description=Mako, 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
# Nurse only needs the network and its own folders.
# Mako 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 nurse >/dev/null 2>&1
systemctl enable mako >/dev/null 2>&1
if [ "$START" -eq 1 ] || systemctl is-active --quiet nurse; then
systemctl restart nurse
if [ "$START" -eq 1 ] || systemctl is-active --quiet mako; then
systemctl restart mako
sleep 3
if systemctl is-active --quiet nurse; then
say "Nurse is running."
if systemctl is-active --quiet mako; then
say "Mako is running."
else
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"
say "Mako did not start. If you have not filled in the Authentik settings in $ENV_FILE yet, that is why."
say "Look at: journalctl -u mako -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 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 " 2. systemctl start mako (or run this again with --start)"
say " 3. journalctl -u mako -f to watch it; it says at once if a setting is missing or wrong"
say ""
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."
say "Mako listens on $LISTEN. Put an HTTPS reverse proxy (or Hammerhead) in front of it and open the"
say "address you set as MAKO_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/nurse}
REPO=${GITHUB_REPOSITORY:-Veillax/mako}
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
+3 -3
View File
@@ -1,6 +1,6 @@
# Nurse design language
# Mako design language
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.
Mako 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 @@ Nurse follows the Lightwell design language, the same one Hammerhead and Megalod
* **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 Nurse differs
## Where Mako 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/nurse
module git.veillax.com/Veillax/mako
go 1.27.3
+17 -17
View File
@@ -1,4 +1,4 @@
// Package config holds Nurse's settings: where to listen, where notes live and how to reach
// Package config holds Mako'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 Nurse.
// OIDC is the OAuth client Authentik gives you for Mako.
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 Nurse at, such as https://notes.example.com. The sign-in
// PublicURL is the address you open Mako 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 Nurse is reached over HTTPS, which decides whether cookies are Secure.
// Secure says whether Mako 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 Nurse can sign anyone in, in words a person can act on.
// Check says what is still missing before Mako 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 (NURSE_PUBLIC_URL) where you open Nurse, such as https://notes.example.com")
missing = append(missing, " public_url (MAKO_PUBLIC_URL) where you open Mako, such as https://notes.example.com")
}
if c.OIDC.Issuer == "" {
missing = append(missing, " oidc.issuer (NURSE_OIDC_ISSUER) from Authentik, such as https://auth.example.com/application/o/nurse/")
missing = append(missing, " oidc.issuer (MAKO_OIDC_ISSUER) from Authentik, such as https://auth.example.com/application/o/mako/")
}
if c.OIDC.ClientID == "" {
missing = append(missing, " oidc.client_id (NURSE_OIDC_CLIENT_ID) the Client ID of the Authentik provider")
missing = append(missing, " oidc.client_id (MAKO_OIDC_CLIENT_ID) the Client ID of the Authentik provider")
}
if c.OIDC.ClientSecret == "" {
missing = append(missing, " oidc.client_secret (NURSE_OIDC_CLIENT_SECRET) the Client Secret of the Authentik provider")
missing = append(missing, " oidc.client_secret (MAKO_OIDC_CLIENT_SECRET) the Client Secret of the Authentik provider")
}
if len(missing) > 0 {
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))
return fmt.Errorf("Mako 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("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) != "" {
env("MAKO_LISTEN", &c.Listen)
env("MAKO_DATA", &c.DataDir)
env("MAKO_PUBLIC_URL", &c.PublicURL)
env("MAKO_OIDC_ISSUER", &c.OIDC.Issuer)
env("MAKO_OIDC_CLIENT_ID", &c.OIDC.ClientID)
env("MAKO_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
if v := os.Getenv("MAKO_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 Nurse needs: the authorization code flow
// Package oidc is the small slice of OpenID Connect Mako 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/nurse/internal/config"
"git.veillax.com/Veillax/mako/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 Nurse. An in-memory index makes listing, searching and linking quick.
// portable with or without Mako. 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: "nurse/" + name + ".md", Method: zip.Deflate, Modified: n.Updated})
f, err := zw.CreateHeader(&zip.FileHeader{Name: "mako/" + 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: " Nurse: a \"notebook\" [x]|y ", Body: "first thought #idea\n\nwith a second paragraph \n\n", Parent: parent.ID})
n, err := s.Create(NewNote{Title: " Mako: 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 != `Nurse: a "notebook" xy` {
if n.Body != "first thought #idea\n\nwith a second paragraph" || n.Title != `Mako: 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/nurse dashboard"})
s.Create(NewNote{Title: "Roadmap", Body: "Idea: a #projects/mako 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{"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}
want := map[string]bool{"mako/Trip- Lisbon-Porto.md": true, "mako/Packing.md": true, "mako/A quick one.md": true, "mako/A quick one 2.md": true, "mako/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/nurse/internal/store"
"git.veillax.com/Veillax/mako/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="nurse-%s.zip"`, time.Now().Format("2006-01-02")))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mako-%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/nurse/internal/config"
"git.veillax.com/Veillax/nurse/internal/oidc"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc"
)
const (
sessionCookie = "nurse_session"
flowCookie = "nurse_signin"
sessionCookie = "mako_session"
flowCookie = "mako_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 Nurse, such as #/p/abc.
// Where to go after signing in: only a place inside Mako, 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 Nurse's dashboard and its JSON API.
// Package web serves Mako's dashboard and its JSON API.
package web
import (
@@ -15,9 +15,9 @@ import (
"strings"
"sync"
"git.veillax.com/Veillax/nurse/internal/config"
"git.veillax.com/Veillax/nurse/internal/oidc"
"git.veillax.com/Veillax/nurse/internal/store"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc"
"git.veillax.com/Veillax/mako/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-Nurse") != "1" {
fail(w, http.StatusForbidden, "missing X-Nurse header")
if r.Method != http.MethodGet && r.Header.Get("X-Mako") != "1" {
fail(w, http.StatusForbidden, "missing X-Mako 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 Nurse to add you.',
denied: 'This account is not allowed to open this notebook. Ask whoever runs Mako to add you.',
failed: 'Signing in did not finish. Please try again.',
expired: 'That sign-in took too long. Please try again.',
unavailable: 'Nurse could not reach Authentik. Try again in a moment.',
unavailable: 'Mako 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 · Nurse';
S.editors.clear(); S.view = null; document.title = 'Sign in · Mako';
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 + ' · ' : '') + 'Nurse';
document.title = (page.title ? page.title + ' · ' : '') + 'Mako';
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') + ' · Nurse'; drawActions(); drawCrumbs(); refreshPages(); },
onCreated(c) { note = c; history.replaceState(null, '', '#/p/' + c.id); document.title = (c.name || 'New page') + ' · Mako'; drawActions(); drawCrumbs(); refreshPages(); },
onSaved(c, { renamed }) {
note = c; if (c.backlinks) backlinks = c.backlinks;
drawTags(); drawAside(); drawMeta(); drawActions();
document.title = (c.name || 'New page') + ' · Nurse';
document.title = (c.name || 'New page') + ' · Mako';
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 + ' · Nurse';
document.title = view.title + ' · Mako';
// 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' }, 'Nurse 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' }, 'Mako 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 Nurse, 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 Mako, 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>Nurse</title>
<title>Mako</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-Nurse': '1' }, credentials: 'same-origin', keepalive: !!opt.keepalive };
const init = { method, headers: { 'X-Mako': '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 Nurse. Check your connection.'), { offline: true }); }
catch { throw Object.assign(new Error('Could not reach Mako. 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), 'Nurse');
const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Mako');
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/nurse/internal/config"
"git.veillax.com/Veillax/nurse/internal/oidc"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/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": "nurse-client", "exp": time.Now().Add(time.Hour).Unix(), "nonce": p.nonce}
c := map[string]any{"iss": p.srv.URL + "/app/", "aud": "mako-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": "nurse-client", "client_secret": "shh"},
"oidc": map[string]string{"issuer": p.srv.URL + "/app/", "client_id": "mako-client", "client_secret": "shh"},
}
if tweak != nil {
tweak(conf)
}
b, _ := json.Marshal(conf)
path := filepath.Join(dir, "nurse.json")
path := filepath.Join(dir, "mako.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-Nurse", "1")
r.Header.Set("X-Mako", "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(), "Nurse") {
if w := e.do("GET", "/", nil, nil); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Mako") {
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": "nurse-client", "redirect_uri": "http://127.0.0.1:8490/auth/callback",
for k, want := range map[string]string{"response_type": "code", "client_id": "mako-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 Nurse is dropped
// and a next address that points anywhere but inside Mako 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-Nurse", ""); w.Code != http.StatusForbidden {
if w := e.do("POST", "/api/notes", map[string]string{"body": "x"}, ses, "X-Mako", ""); w.Code != http.StatusForbidden {
t.Fatalf("a write without the header: %d", w.Code)
}
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Nurse", ""); w.Code != http.StatusForbidden {
if w := e.do("DELETE", "/api/trash", nil, ses, "X-Mako", ""); 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) != "[nurse/Journal/2026-10-31.md nurse/Plans 2027.md nurse/Trip.md]" && len(names) != 3 {
if fmt.Sprint(names) != "[mako/Journal/2026-10-31.md mako/Plans 2027.md mako/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, "nurse.json")
path := filepath.Join(dir, "mako.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", "NURSE_OIDC_CLIENT_ID", "client_secret", "/auth/callback"} {
for _, want := range []string{"oidc.issuer", "MAKO_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("NURSE_PUBLIC_URL", "https://notes.example.com")
t.Setenv("MAKO_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 === "nurse.json") continue;
if (rel === "scripts/lint-design.mjs" || rel === "mako.json") continue;
const isPage = rel.startsWith("internal/web/static/");
let inTokens = false;
readFileSync(file, "utf8").split("\n").forEach((line, i) => {