Rename Basking to Nurse
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cec2702260
commit
6a4be5b2f4
24 files changed
+208
-208
No files matched your search
@@ -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,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
@@ -2,5 +2,5 @@ bin/
|
||||
dist/
|
||||
*.exe
|
||||
data/
|
||||
basking.json
|
||||
nurse.json
|
||||
.env
|
||||
+7
-7
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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."
|
||||
@@ -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
@@ -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,3 +1,3 @@
|
||||
module git.veillax.com/Veillax/basking
|
||||
module git.veillax.com/Veillax/nurse
|
||||
|
||||
go 1.27.3
|
||||
+17
-17
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'))),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user