Rename Nurse to Mako
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a4be5b2f4
commit
e3147597e8
24 files changed
+208
-208
No files matched your search
@@ -38,5 +38,5 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
for t in linux/amd64 linux/arm64 windows/amd64; do
|
for t in linux/amd64 linux/arm64 windows/amd64; do
|
||||||
echo "building for $t"
|
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
|
done
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Push a tag such as v0.1.0 to build and publish a 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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
|
|||||||
+1
-1
@@ -2,5 +2,5 @@ bin/
|
|||||||
dist/
|
dist/
|
||||||
*.exe
|
*.exe
|
||||||
data/
|
data/
|
||||||
nurse.json
|
mako.json
|
||||||
.env
|
.env
|
||||||
+7
-7
@@ -1,16 +1,16 @@
|
|||||||
# Build: docker build -t nurse .
|
# Build: docker build -t mako .
|
||||||
FROM golang:1.27-alpine AS build
|
FROM golang:1.27-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
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
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache tzdata ca-certificates && adduser -D -u 10001 nurse && mkdir /config && chown nurse /config
|
RUN apk add --no-cache tzdata ca-certificates && adduser -D -u 10001 mako && mkdir /config && chown mako /config
|
||||||
COPY --from=build /nurse /usr/local/bin/nurse
|
COPY --from=build /mako /usr/local/bin/mako
|
||||||
USER nurse
|
USER mako
|
||||||
# The config file and the notes both live under /config.
|
# The config file and the notes both live under /config.
|
||||||
ENV NURSE_CONFIG=/config/nurse.json
|
ENV MAKO_CONFIG=/config/mako.json
|
||||||
WORKDIR /config
|
WORKDIR /config
|
||||||
EXPOSE 8490
|
EXPOSE 8490
|
||||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8490/healthz || exit 1
|
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"]
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# Nurse
|
# Mako
|
||||||
|
|
||||||
A private notebook in the spirit of Obsidian and Notion, for people who would rather run their own.
|
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
|
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
|
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
|
## 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.
|
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
|
- **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.
|
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.
|
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
|
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,
|
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.
|
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
|
## 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.
|
notebook that only they can read.
|
||||||
|
|
||||||
1. In Authentik, go to **Applications → Providers → Create** and choose **OAuth2/OpenID Provider**.
|
1. In Authentik, go to **Applications → Providers → Create** and choose **OAuth2/OpenID Provider**.
|
||||||
Set the client type to **Confidential**, add the redirect URI (strict)
|
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).
|
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.
|
user or group to the application.
|
||||||
3. Copy the provider's **Client ID** and **Client Secret**, and note its **OpenID Configuration
|
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/`.
|
Issuer**, which looks like `https://auth.example.com/application/o/mako/`.
|
||||||
4. Give Nurse those four things (see below), then run `nurse check`. It tells you whether
|
4. Give Mako those four things (see below), then run `mako check`. It tells you whether
|
||||||
Authentik answers and what redirect URI to allow.
|
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
|
`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.
|
lets them through.
|
||||||
|
|
||||||
## Installing on Linux (systemd)
|
## Installing on Linux (systemd)
|
||||||
|
|
||||||
```sh
|
```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
|
This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/mako`, creates a
|
||||||
`nurse` user, `/etc/nurse/` (settings) and `/var/lib/nurse/` (your notes), and writes the
|
`mako` user, `/etc/mako/` (settings) and `/var/lib/mako/` (your notes), and writes the
|
||||||
systemd service and `/etc/nurse/nurse.env`. Then:
|
systemd service and `/etc/mako/mako.env`. Then:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudoedit /etc/nurse/nurse.env # fill in the four Authentik lines at the top
|
sudoedit /etc/mako/mako.env # fill in the four Authentik lines at the top
|
||||||
sudo systemctl start nurse
|
sudo systemctl start mako
|
||||||
journalctl -u nurse -f # it says at once if a setting is missing or wrong
|
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
|
[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.
|
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
|
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
|
## Running it by hand or in Docker
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export NURSE_PUBLIC_URL=https://notes.example.com
|
export MAKO_PUBLIC_URL=https://notes.example.com
|
||||||
export NURSE_OIDC_ISSUER=https://auth.example.com/application/o/nurse/
|
export MAKO_OIDC_ISSUER=https://auth.example.com/application/o/mako/
|
||||||
export NURSE_OIDC_CLIENT_ID=...
|
export MAKO_OIDC_CLIENT_ID=...
|
||||||
export NURSE_OIDC_CLIENT_SECRET=...
|
export MAKO_OIDC_CLIENT_SECRET=...
|
||||||
./nurse check # optional: test the settings
|
./mako check # optional: test the settings
|
||||||
./nurse serve
|
./mako serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker: see [`Dockerfile`](Dockerfile) and [`deploy/docker-compose.yml`](deploy/docker-compose.yml).
|
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
|
### Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
nurse serve [-config nurse.json] [-listen addr] [-data dir]
|
mako serve [-config mako.json] [-listen addr] [-data dir]
|
||||||
nurse check [-config nurse.json] test the Authentik settings without starting
|
mako check [-config mako.json] test the Authentik settings without starting
|
||||||
nurse version
|
mako version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
@@ -111,15 +111,15 @@ written to it:
|
|||||||
|
|
||||||
| Setting | Variable | |
|
| Setting | Variable | |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `public_url` | `NURSE_PUBLIC_URL` | where you open Nurse, no path |
|
| `public_url` | `MAKO_PUBLIC_URL` | where you open Mako, no path |
|
||||||
| `oidc.issuer` | `NURSE_OIDC_ISSUER` | the provider's issuer |
|
| `oidc.issuer` | `MAKO_OIDC_ISSUER` | the provider's issuer |
|
||||||
| `oidc.client_id` | `NURSE_OIDC_CLIENT_ID` | |
|
| `oidc.client_id` | `MAKO_OIDC_CLIENT_ID` | |
|
||||||
| `oidc.client_secret` | `NURSE_OIDC_CLIENT_SECRET` | |
|
| `oidc.client_secret` | `MAKO_OIDC_CLIENT_SECRET` | |
|
||||||
| `allowed` | `NURSE_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated |
|
| `allowed` | `MAKO_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated |
|
||||||
| `listen` | `NURSE_LISTEN` | default `127.0.0.1:8490` |
|
| `listen` | `MAKO_LISTEN` | default `127.0.0.1:8490` |
|
||||||
| `data_dir` | `NURSE_DATA` | default `data` |
|
| `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.
|
with mode `0600`. Changing it signs everyone out.
|
||||||
|
|
||||||
## Where your notes are
|
## 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
|
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
|
## Security notes
|
||||||
|
|
||||||
- Sign-in is Authentik only: the authorization code flow with PKCE, `state` and `nonce`, over https. The
|
- 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
|
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
|
- 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`
|
settings file. Nothing is kept on the server, so a restart does not sign anyone out. The `allowed`
|
||||||
list is checked on every request.
|
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.
|
- 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
|
- Searching holds every page in memory, which is comfortable for tens of thousands of pages and not
|
||||||
built for millions.
|
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
|
- 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.
|
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
|
```sh
|
||||||
go test ./...
|
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.
|
Releases are built by the Gitea workflow when a `v*` tag is pushed.
|
||||||
|
|||||||
@@ -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.
|
// markdown files. You sign in with Authentik; there is no other way in.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -15,27 +15,27 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/nurse/internal/config"
|
"git.veillax.com/Veillax/mako/internal/config"
|
||||||
"git.veillax.com/Veillax/nurse/internal/oidc"
|
"git.veillax.com/Veillax/mako/internal/oidc"
|
||||||
"git.veillax.com/Veillax/nurse/internal/web"
|
"git.veillax.com/Veillax/mako/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
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
|
run the notebook
|
||||||
nurse check [-config nurse.json] test the Authentik settings without starting
|
mako check [-config mako.json] test the Authentik settings without starting
|
||||||
nurse version
|
mako version
|
||||||
|
|
||||||
Settings come from the settings file and can be overridden by environment variables:
|
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
|
MAKO_PUBLIC_URL where you open Mako, such as https://notes.example.com
|
||||||
NURSE_OIDC_ISSUER the Authentik provider's OpenID configuration issuer
|
MAKO_OIDC_ISSUER the Authentik provider's OpenID configuration issuer
|
||||||
NURSE_OIDC_CLIENT_ID the provider's Client ID
|
MAKO_OIDC_CLIENT_ID the provider's Client ID
|
||||||
NURSE_OIDC_CLIENT_SECRET the provider's Client Secret
|
MAKO_OIDC_CLIENT_SECRET the provider's Client Secret
|
||||||
NURSE_ALLOWED optional: only these emails or usernames may sign in (comma separated)
|
MAKO_ALLOWED optional: only these emails or usernames may sign in (comma separated)
|
||||||
NURSE_LISTEN, NURSE_DATA, NURSE_CONFIG
|
MAKO_LISTEN, MAKO_DATA, MAKO_CONFIG
|
||||||
`
|
`
|
||||||
|
|
||||||
// exitConfig is the exit status for settings that need fixing by hand, so a service manager does
|
// 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":
|
case "check":
|
||||||
err = check(os.Args[2:])
|
err = check(os.Args[2:])
|
||||||
case "version", "-v", "--version":
|
case "version", "-v", "--version":
|
||||||
fmt.Println("nurse", version)
|
fmt.Println("mako", version)
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
fmt.Print(usage)
|
fmt.Print(usage)
|
||||||
default:
|
default:
|
||||||
@@ -63,7 +63,7 @@ func main() {
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "nurse:", err)
|
fmt.Fprintln(os.Stderr, "mako:", err)
|
||||||
var ce configError
|
var ce configError
|
||||||
if errors.As(err, &ce) {
|
if errors.As(err, &ce) {
|
||||||
os.Exit(exitConfig)
|
os.Exit(exitConfig)
|
||||||
@@ -75,9 +75,9 @@ func main() {
|
|||||||
type configError struct{ error }
|
type configError struct{ error }
|
||||||
|
|
||||||
func configFlag(fs *flag.FlagSet) *string {
|
func configFlag(fs *flag.FlagSet) *string {
|
||||||
def := os.Getenv("NURSE_CONFIG")
|
def := os.Getenv("MAKO_CONFIG")
|
||||||
if def == "" {
|
if def == "" {
|
||||||
def = "nurse.json"
|
def = "mako.json"
|
||||||
}
|
}
|
||||||
return fs.String("config", def, "path of the settings file")
|
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")
|
data := fs.String("data", "", "override the folder the notes are kept in")
|
||||||
fs.Parse(args)
|
fs.Parse(args)
|
||||||
if *listen != "" {
|
if *listen != "" {
|
||||||
os.Setenv("NURSE_LISTEN", *listen)
|
os.Setenv("MAKO_LISTEN", *listen)
|
||||||
}
|
}
|
||||||
if *data != "" {
|
if *data != "" {
|
||||||
os.Setenv("NURSE_DATA", *data)
|
os.Setenv("MAKO_DATA", *data)
|
||||||
}
|
}
|
||||||
|
|
||||||
h, err := config.Load(*path)
|
h, err := config.Load(*path)
|
||||||
@@ -122,7 +122,7 @@ func serve(args []string) error {
|
|||||||
defer stop()
|
defer stop()
|
||||||
errc := make(chan error, 1)
|
errc := make(chan error, 1)
|
||||||
go func() { errc <- srv.ListenAndServe() }()
|
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())
|
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 {
|
if len(cfg.Allowed) == 0 {
|
||||||
log.Print("anyone Authentik lets into this application gets a notebook of their own; set \"allowed\" to limit it")
|
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
|
#!/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`
|
# deploy/build.sh [version] version defaults to `git describe`
|
||||||
#
|
#
|
||||||
# Output:
|
# Output:
|
||||||
# dist/nurse_<version>_<os>_<arch>.tar.gz or .zip
|
# dist/mako_<version>_<os>_<arch>.tar.gz or .zip
|
||||||
# dist/SHA256SUMS
|
# dist/SHA256SUMS
|
||||||
#
|
#
|
||||||
# Releases are published for linux/amd64, linux/arm64 and windows/amd64. Set TARGETS="linux/arm64 darwin/arm64"
|
# 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.
|
# (any GOOS/GOARCH pair Go supports) to build something else by hand.
|
||||||
set -eu
|
set -eu
|
||||||
NAME=nurse
|
NAME=mako
|
||||||
CMDS="nurse"
|
CMDS="mako"
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
|
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
|
||||||
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 windows/amd64"}
|
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 windows/amd64"}
|
||||||
|
|||||||
@@ -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
|
# (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).
|
# https://notes.example.com/auth/callback (use your own address).
|
||||||
services:
|
services:
|
||||||
nurse:
|
mako:
|
||||||
image: git.veillax.com/veillax/nurse:latest # or: build: ..
|
image: git.veillax.com/veillax/mako:latest # or: build: ..
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8490:8490"
|
- "127.0.0.1:8490:8490"
|
||||||
environment:
|
environment:
|
||||||
NURSE_PUBLIC_URL: https://notes.example.com
|
MAKO_PUBLIC_URL: https://notes.example.com
|
||||||
NURSE_OIDC_ISSUER: https://auth.example.com/application/o/nurse/
|
MAKO_OIDC_ISSUER: https://auth.example.com/application/o/mako/
|
||||||
NURSE_OIDC_CLIENT_ID: change-me
|
MAKO_OIDC_CLIENT_ID: change-me
|
||||||
NURSE_OIDC_CLIENT_SECRET: change-me
|
MAKO_OIDC_CLIENT_SECRET: change-me
|
||||||
# NURSE_ALLOWED: you@example.com # optional: limit who may sign in
|
# MAKO_ALLOWED: you@example.com # optional: limit who may sign in
|
||||||
volumes:
|
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
@@ -1,31 +1,31 @@
|
|||||||
#!/bin/sh
|
#!/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 [--start] [--version v0.1.0] [--listen 127.0.0.1:8490] [--source]
|
||||||
# sudo sh install.sh --uninstall [--purge]
|
# sudo sh install.sh --uninstall [--purge]
|
||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. downloads the release archive for this machine and checks its SHA-256 (or, with --source,
|
# 1. downloads the release archive for this machine and checks its SHA-256 (or, with --source,
|
||||||
# clones the repository and builds it with Go),
|
# clones the repository and builds it with Go),
|
||||||
# 2. installs the binary to /usr/local/bin/nurse,
|
# 2. installs the binary to /usr/local/bin/mako,
|
||||||
# 3. creates a "nurse" user, /etc/nurse (settings) and /var/lib/nurse (data),
|
# 3. creates a "mako" user, /etc/mako (settings) and /var/lib/mako (data),
|
||||||
# 4. writes /etc/nurse/nurse.env and the systemd service,
|
# 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.
|
# 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
|
# 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.
|
# is restarted. Add --start to start it straight away instead of editing the env file first.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
REPO_URL=${NURSE_REPO_URL:-https://git.veillax.com/Veillax/nurse}
|
REPO_URL=${MAKO_REPO_URL:-https://git.veillax.com/Veillax/mako}
|
||||||
API_URL=${NURSE_API_URL:-https://git.veillax.com/api/v1/repos/Veillax/nurse}
|
API_URL=${MAKO_API_URL:-https://git.veillax.com/api/v1/repos/Veillax/mako}
|
||||||
BIN=/usr/local/bin/nurse
|
BIN=/usr/local/bin/mako
|
||||||
CONF_DIR=/etc/nurse
|
CONF_DIR=/etc/mako
|
||||||
DATA_DIR=/var/lib/nurse
|
DATA_DIR=/var/lib/mako
|
||||||
ENV_FILE=$CONF_DIR/nurse.env
|
ENV_FILE=$CONF_DIR/mako.env
|
||||||
CONF_FILE=$CONF_DIR/nurse.json
|
CONF_FILE=$CONF_DIR/mako.json
|
||||||
UNIT=/etc/systemd/system/nurse.service
|
UNIT=/etc/systemd/system/mako.service
|
||||||
USER_NAME=nurse
|
USER_NAME=mako
|
||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
LISTEN="127.0.0.1:8490"
|
LISTEN="127.0.0.1:8490"
|
||||||
@@ -58,7 +58,7 @@ HAVE_SYSTEMD=${HAVE_SYSTEMD:-1}
|
|||||||
|
|
||||||
if [ "$UNINSTALL" -eq 1 ]; then
|
if [ "$UNINSTALL" -eq 1 ]; then
|
||||||
if [ "$HAVE_SYSTEMD" -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"
|
rm -f "$UNIT"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
fi
|
fi
|
||||||
@@ -66,9 +66,9 @@ if [ "$UNINSTALL" -eq 1 ]; then
|
|||||||
if [ "$PURGE" -eq 1 ]; then
|
if [ "$PURGE" -eq 1 ]; then
|
||||||
rm -rf "$CONF_DIR" "$DATA_DIR"
|
rm -rf "$CONF_DIR" "$DATA_DIR"
|
||||||
userdel "$USER_NAME" 2>/dev/null || true
|
userdel "$USER_NAME" 2>/dev/null || true
|
||||||
say "Nurse removed, including its settings and data."
|
say "Mako removed, including its settings and data."
|
||||||
else
|
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
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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/)"
|
command -v go >/dev/null 2>&1 || die "--source needs Go (https://go.dev/dl/)"
|
||||||
say "Cloning $REPO_URL ..."
|
say "Cloning $REPO_URL ..."
|
||||||
git clone --quiet ${VERSION:+--branch "$VERSION"} --depth 1 "$REPO_URL" "$TMP/src"
|
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
|
else
|
||||||
case $(uname -m) in
|
case $(uname -m) in
|
||||||
x86_64|amd64) ARCH=amd64 ;;
|
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)
|
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"
|
[ -n "$VERSION" ] || die "could not find the latest release; use --version or --source"
|
||||||
fi
|
fi
|
||||||
ARCHIVE="nurse_${VERSION}_linux_${ARCH}.tar.gz"
|
ARCHIVE="mako_${VERSION}_linux_${ARCH}.tar.gz"
|
||||||
say "Downloading Nurse $VERSION ($ARCH) ..."
|
say "Downloading Mako $VERSION ($ARCH) ..."
|
||||||
fetch "$REPO_URL/releases/download/$VERSION/$ARCHIVE" "$TMP/$ARCHIVE"
|
fetch "$REPO_URL/releases/download/$VERSION/$ARCHIVE" "$TMP/$ARCHIVE"
|
||||||
fetch "$REPO_URL/releases/download/$VERSION/SHA256SUMS" "$TMP/SHA256SUMS"
|
fetch "$REPO_URL/releases/download/$VERSION/SHA256SUMS" "$TMP/SHA256SUMS"
|
||||||
want=$(grep " $ARCHIVE\$" "$TMP/SHA256SUMS" | cut -d' ' -f1)
|
want=$(grep " $ARCHIVE\$" "$TMP/SHA256SUMS" | cut -d' ' -f1)
|
||||||
@@ -109,14 +109,14 @@ else
|
|||||||
got=$(sha256sum "$TMP/$ARCHIVE" | cut -d' ' -f1)
|
got=$(sha256sum "$TMP/$ARCHIVE" | cut -d' ' -f1)
|
||||||
[ "$want" = "$got" ] || die "checksum mismatch for $ARCHIVE; refusing to install"
|
[ "$want" = "$got" ] || die "checksum mismatch for $ARCHIVE; refusing to install"
|
||||||
tar -xzf "$TMP/$ARCHIVE" -C "$TMP"
|
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
|
fi
|
||||||
|
|
||||||
# ---- 2. install it ----
|
# ---- 2. install it ----
|
||||||
UPGRADE=0
|
UPGRADE=0
|
||||||
[ -x "$BIN" ] && UPGRADE=1
|
[ -x "$BIN" ] && UPGRADE=1
|
||||||
if [ "$UPGRADE" -eq 1 ] && [ "$HAVE_SYSTEMD" -eq 1 ]; then systemctl stop nurse 2>/dev/null || true; fi
|
if [ "$UPGRADE" -eq 1 ] && [ "$HAVE_SYSTEMD" -eq 1 ]; then systemctl stop mako 2>/dev/null || true; fi
|
||||||
install -m 0755 "$TMP/nurse" "$BIN"
|
install -m 0755 "$TMP/mako" "$BIN"
|
||||||
say "Installed $("$BIN" version) to $BIN"
|
say "Installed $("$BIN" version) to $BIN"
|
||||||
|
|
||||||
# ---- 3. user and folders ----
|
# ---- 3. user and folders ----
|
||||||
@@ -136,31 +136,31 @@ if [ ! -f "$CONF_FILE" ]; then
|
|||||||
fi
|
fi
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
cat > "$ENV_FILE" <<EOF
|
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
|
# In Authentik: Applications > Providers > Create > OAuth2/OpenID Provider (client type
|
||||||
# Confidential, scopes openid, profile and email), allow the redirect URI
|
# Confidential, scopes openid, profile and email), allow the redirect URI
|
||||||
# <NURSE_PUBLIC_URL>/auth/callback
|
# <MAKO_PUBLIC_URL>/auth/callback
|
||||||
# then create an Application that uses it. Check the result with: nurse check
|
# 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).
|
# Where you open Mako in a browser, such as https://notes.example.com (no path).
|
||||||
NURSE_PUBLIC_URL=
|
MAKO_PUBLIC_URL=
|
||||||
# The provider's issuer, such as https://auth.example.com/application/o/nurse/
|
# The provider's issuer, such as https://auth.example.com/application/o/mako/
|
||||||
NURSE_OIDC_ISSUER=
|
MAKO_OIDC_ISSUER=
|
||||||
NURSE_OIDC_CLIENT_ID=
|
MAKO_OIDC_CLIENT_ID=
|
||||||
NURSE_OIDC_CLIENT_SECRET=
|
MAKO_OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
# Optional. Only these people may sign in (emails or usernames, separated by commas). Left empty,
|
# 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.
|
# 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).
|
# 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.
|
# Optional. These override what the settings file says.
|
||||||
#NURSE_LISTEN=$LISTEN
|
#MAKO_LISTEN=$LISTEN
|
||||||
#NURSE_DATA=$DATA_DIR
|
#MAKO_DATA=$DATA_DIR
|
||||||
EOF
|
EOF
|
||||||
chmod 0600 "$ENV_FILE" # systemd reads it as root, so the service user does not need access
|
chmod 0600 "$ENV_FILE" # systemd reads it as root, so the service user does not need access
|
||||||
say "Created $ENV_FILE."
|
say "Created $ENV_FILE."
|
||||||
@@ -175,7 +175,7 @@ fi
|
|||||||
|
|
||||||
cat > "$UNIT" <<EOF
|
cat > "$UNIT" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Nurse, a private notebook
|
Description=Mako, a private notebook
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=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.
|
# Settings that need fixing by hand exit with status 2; do not loop on those.
|
||||||
RestartPreventExitStatus=2
|
RestartPreventExitStatus=2
|
||||||
|
|
||||||
# Nurse only needs the network and its own folders.
|
# Mako only needs the network and its own folders.
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths=$CONF_DIR $DATA_DIR
|
ReadWritePaths=$CONF_DIR $DATA_DIR
|
||||||
@@ -205,16 +205,16 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
systemctl daemon-reload
|
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
|
if [ "$START" -eq 1 ] || systemctl is-active --quiet mako; then
|
||||||
systemctl restart nurse
|
systemctl restart mako
|
||||||
sleep 3
|
sleep 3
|
||||||
if systemctl is-active --quiet nurse; then
|
if systemctl is-active --quiet mako; then
|
||||||
say "Nurse is running."
|
say "Mako is running."
|
||||||
else
|
else
|
||||||
say "Nurse did not start. If you have not filled in the Authentik settings in $ENV_FILE yet, that is why."
|
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 nurse -n 50"
|
say "Look at: journalctl -u mako -n 50"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -222,8 +222,8 @@ fi
|
|||||||
say "Installed $("$BIN" version)."
|
say "Installed $("$BIN" version)."
|
||||||
say "Next:"
|
say "Next:"
|
||||||
say " 1. sudoedit $ENV_FILE (fill in the Authentik settings at the top)"
|
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 " 2. systemctl start mako (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 " 3. journalctl -u mako -f to watch it; it says at once if a setting is missing or wrong"
|
||||||
say ""
|
say ""
|
||||||
say "Nurse listens on $LISTEN. Put an HTTPS reverse proxy (or Hammerhead) in front of it and open the"
|
say "Mako 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 "address you set as MAKO_PUBLIC_URL; Authentik will not send people back to plain http."
|
||||||
@@ -9,7 +9,7 @@ set -eu
|
|||||||
TAG=${1:?usage: publish-release.sh <tag>}
|
TAG=${1:?usage: publish-release.sh <tag>}
|
||||||
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
|
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
|
||||||
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
|
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"
|
API="$SERVER/api/v1/repos/$REPO"
|
||||||
AUTH="Authorization: token $TOKEN"
|
AUTH="Authorization: token $TOKEN"
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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
|
## 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.
|
* **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.
|
* **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.
|
* **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.
|
* **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/nurse
|
module git.veillax.com/Veillax/mako
|
||||||
|
|
||||||
go 1.27.3
|
go 1.27.3
|
||||||
+17
-17
@@ -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.
|
// Authentik. The notes themselves are not in here.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OIDC is the OAuth client Authentik gives you for Nurse.
|
// OIDC is the OAuth client Authentik gives you for Mako.
|
||||||
type OIDC struct {
|
type OIDC struct {
|
||||||
Issuer string `json:"issuer"`
|
Issuer string `json:"issuer"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
@@ -26,7 +26,7 @@ type Config struct {
|
|||||||
Listen string `json:"listen"`
|
Listen string `json:"listen"`
|
||||||
DataDir string `json:"data_dir"`
|
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.
|
// redirect is PublicURL + /auth/callback.
|
||||||
PublicURL string `json:"public_url"`
|
PublicURL string `json:"public_url"`
|
||||||
OIDC OIDC `json:"oidc"`
|
OIDC OIDC `json:"oidc"`
|
||||||
@@ -61,7 +61,7 @@ func (c *Config) Defaults() {
|
|||||||
// RedirectURL is the address to enter in Authentik as the redirect URI.
|
// RedirectURL is the address to enter in Authentik as the redirect URI.
|
||||||
func (c Config) RedirectURL() string { return c.PublicURL + "/auth/callback" }
|
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://") }
|
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.
|
// 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
|
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 {
|
func (c Config) Check() error {
|
||||||
var missing []string
|
var missing []string
|
||||||
if c.PublicURL == "" {
|
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 == "" {
|
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 == "" {
|
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 == "" {
|
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 {
|
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 {
|
if err := checkURL("public_url", c.PublicURL); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -156,13 +156,13 @@ func (h *Holder) Get() Config {
|
|||||||
*to = v
|
*to = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
env("NURSE_LISTEN", &c.Listen)
|
env("MAKO_LISTEN", &c.Listen)
|
||||||
env("NURSE_DATA", &c.DataDir)
|
env("MAKO_DATA", &c.DataDir)
|
||||||
env("NURSE_PUBLIC_URL", &c.PublicURL)
|
env("MAKO_PUBLIC_URL", &c.PublicURL)
|
||||||
env("NURSE_OIDC_ISSUER", &c.OIDC.Issuer)
|
env("MAKO_OIDC_ISSUER", &c.OIDC.Issuer)
|
||||||
env("NURSE_OIDC_CLIENT_ID", &c.OIDC.ClientID)
|
env("MAKO_OIDC_CLIENT_ID", &c.OIDC.ClientID)
|
||||||
env("NURSE_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
|
env("MAKO_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
|
||||||
if v := os.Getenv("NURSE_ALLOWED"); strings.TrimSpace(v) != "" {
|
if v := os.Getenv("MAKO_ALLOWED"); strings.TrimSpace(v) != "" {
|
||||||
c.Allowed = nil
|
c.Allowed = nil
|
||||||
for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) {
|
for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) {
|
||||||
if a = strings.TrimSpace(a); a != "" {
|
if a = strings.TrimSpace(a); a != "" {
|
||||||
|
|||||||
@@ -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.
|
// 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
|
// The ID token is taken straight from the provider's token endpoint over TLS, which is the case
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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
|
// 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
|
// 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
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -1029,7 +1029,7 @@ func (s *Store) WriteZip(w io.Writer) error {
|
|||||||
name = fmt.Sprintf("%s %d", base, i)
|
name = fmt.Sprintf("%s %d", base, i)
|
||||||
}
|
}
|
||||||
used[strings.ToLower(name)] = true
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,11 +87,11 @@ func TestNameComesFromTitleOrFirstLine(t *testing.T) {
|
|||||||
func TestCreateRoundTripsThroughDisk(t *testing.T) {
|
func TestCreateRoundTripsThroughDisk(t *testing.T) {
|
||||||
s, dir := open(t)
|
s, dir := open(t)
|
||||||
parent := mk(t, s, "Projects", "")
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("clean-up: %q %q", n.Body, n.Title)
|
||||||
}
|
}
|
||||||
if !ValidID(n.ID) {
|
if !ValidID(n.ID) {
|
||||||
@@ -372,7 +372,7 @@ func TestSearchAndTags(t *testing.T) {
|
|||||||
time.Sleep(2 * time.Millisecond)
|
time.Sleep(2 * time.Millisecond)
|
||||||
s.Create(NewNote{Body: "Call the dentist #errands #health"})
|
s.Create(NewNote{Body: "Call the dentist #errands #health"})
|
||||||
time.Sleep(2 * time.Millisecond)
|
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 {
|
if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 {
|
||||||
t.Fatalf("case-insensitive search: %d", len(list))
|
t.Fatalf("case-insensitive search: %d", len(list))
|
||||||
@@ -492,7 +492,7 @@ func TestZipIsAnObsidianVault(t *testing.T) {
|
|||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
names = append(names, f.Name)
|
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) {
|
if len(names) != len(want) {
|
||||||
t.Fatalf("zip has %v", names)
|
t.Fatalf("zip has %v", names)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"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) {
|
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) {
|
func (s *Server) export(w http.ResponseWriter, r *http.Request, p principal) {
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
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 {
|
if err := p.st.WriteZip(w); err != nil {
|
||||||
// The download has started, so all that can be done is to cut it short.
|
// The download has started, so all that can be done is to cut it short.
|
||||||
panic(http.ErrAbortHandler)
|
panic(http.ErrAbortHandler)
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/nurse/internal/config"
|
"git.veillax.com/Veillax/mako/internal/config"
|
||||||
"git.veillax.com/Veillax/nurse/internal/oidc"
|
"git.veillax.com/Veillax/mako/internal/oidc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sessionCookie = "nurse_session"
|
sessionCookie = "mako_session"
|
||||||
flowCookie = "nurse_signin"
|
flowCookie = "mako_signin"
|
||||||
sessionTTL = 14 * 24 * time.Hour
|
sessionTTL = 14 * 24 * time.Hour
|
||||||
flowTTL = 10 * time.Minute
|
flowTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
@@ -99,7 +99,7 @@ func setCookie(w http.ResponseWriter, c config.Config, name, value, path string,
|
|||||||
http.SetCookie(w, ck)
|
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}$`)
|
var nextRe = regexp.MustCompile(`^#/[A-Za-z0-9/_.~%-]{0,300}$`)
|
||||||
|
|
||||||
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,9 +15,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/nurse/internal/config"
|
"git.veillax.com/Veillax/mako/internal/config"
|
||||||
"git.veillax.com/Veillax/nurse/internal/oidc"
|
"git.veillax.com/Veillax/mako/internal/oidc"
|
||||||
"git.veillax.com/Veillax/nurse/internal/store"
|
"git.veillax.com/Veillax/mako/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static
|
//go:embed static
|
||||||
@@ -136,8 +136,8 @@ func (s *Server) api(fn handlerFunc) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A browser must send a header a web page on another site cannot add.
|
// 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" {
|
if r.Method != http.MethodGet && r.Header.Get("X-Mako") != "1" {
|
||||||
fail(w, http.StatusForbidden, "missing X-Nurse header")
|
fail(w, http.StatusForbidden, "missing X-Mako header")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email)
|
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 ----------
|
// ---------- signing in ----------
|
||||||
const SIGNIN_MESSAGES = {
|
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.',
|
failed: 'Signing in did not finish. Please try again.',
|
||||||
expired: 'That sign-in took too long. 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() {
|
async function boot() {
|
||||||
@@ -54,7 +54,7 @@ async function boot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showLogin(why) {
|
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 : '';
|
const next = location.hash.length > 2 ? location.hash : '';
|
||||||
$app.replaceChildren(h('main', { class: 'center' }, card('', brand(),
|
$app.replaceChildren(h('main', { class: 'center' }, card('', brand(),
|
||||||
h('h1', { class: 'login-h' }, 'Your private notebook'),
|
h('h1', { class: 'login-h' }, 'Your private notebook'),
|
||||||
@@ -180,7 +180,7 @@ async function show() {
|
|||||||
}
|
}
|
||||||
S.view = page;
|
S.view = page;
|
||||||
main.replaceChildren(page.el);
|
main.replaceChildren(page.el);
|
||||||
document.title = (page.title ? page.title + ' · ' : '') + 'Nurse';
|
document.title = (page.title ? page.title + ' · ' : '') + 'Mako';
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
page.start?.();
|
page.start?.();
|
||||||
}
|
}
|
||||||
@@ -250,11 +250,11 @@ function pageView({ id, date, parent = '' }) {
|
|||||||
view.title = date ? dayLabel(date) : n?.name || 'New page';
|
view.title = date ? dayLabel(date) : n?.name || 'New page';
|
||||||
ed = pageEditor({
|
ed = pageEditor({
|
||||||
note: n, parent, fixedTitle: date ? parseDay(date).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : undefined,
|
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 }) {
|
onSaved(c, { renamed }) {
|
||||||
note = c; if (c.backlinks) backlinks = c.backlinks;
|
note = c; if (c.backlinks) backlinks = c.backlinks;
|
||||||
drawTags(); drawAside(); drawMeta(); drawActions();
|
drawTags(); drawAside(); drawMeta(); drawActions();
|
||||||
document.title = (c.name || 'New page') + ' · Nurse';
|
document.title = (c.name || 'New page') + ' · Mako';
|
||||||
renamed ? refreshPages().catch(() => { }) : refreshSoon();
|
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);
|
fill(body, ed.notice, title, date ? h('h1', { class: 'day-title' }, ed.titleEl.value) : null, ed.wrap, tagsRow, asideBox, meta);
|
||||||
ed.setMode(start);
|
ed.setMode(start);
|
||||||
drawCrumbs(); drawTags(); drawAside(); drawMeta(); drawActions();
|
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
|
// 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); };
|
const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); };
|
||||||
if (!n) setTimeout(() => ed.titleEl.focus());
|
if (!n) setTimeout(() => ed.titleEl.focus());
|
||||||
@@ -547,7 +547,7 @@ function settingsPage() {
|
|||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
el: h('div', {}, pageHeader('Settings', 'Who you are signed in as, and a copy of everything.'),
|
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.'),
|
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(); }))),
|
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.'),
|
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'; })),
|
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 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('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'))),
|
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.'))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light dark">
|
<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="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">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</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
|
// onSignedOut is set by the app; it decides what a 401 means while someone is writing
|
||||||
let onSignedOut = () => { };
|
let onSignedOut = () => { };
|
||||||
async function api(method, url, body, opt = {}) {
|
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); }
|
if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
|
||||||
let r;
|
let r;
|
||||||
try { r = await fetch(url, init); }
|
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(() => ({}));
|
const j = await r.json().catch(() => ({}));
|
||||||
if (r.status === 401) { onSignedOut(); throw Object.assign(new Error(SIGNED_OUT), { status: 401 }); }
|
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 });
|
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>`;
|
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;
|
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);
|
const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label);
|
||||||
|
|
||||||
// ---------- dialogs ----------
|
// ---------- dialogs ----------
|
||||||
|
|||||||
+15
-15
@@ -16,8 +16,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.veillax.com/Veillax/nurse/internal/config"
|
"git.veillax.com/Veillax/mako/internal/config"
|
||||||
"git.veillax.com/Veillax/nurse/internal/oidc"
|
"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
|
// 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"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
|
||||||
return
|
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 {
|
for k, v := range p.claims {
|
||||||
c[k] = v
|
c[k] = v
|
||||||
}
|
}
|
||||||
@@ -79,13 +79,13 @@ func setup(t *testing.T, tweak func(map[string]any)) *env {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
conf := map[string]any{
|
conf := map[string]any{
|
||||||
"data_dir": filepath.Join(dir, "data"), "public_url": "http://127.0.0.1:8490", "secret": "test-secret",
|
"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 {
|
if tweak != nil {
|
||||||
tweak(conf)
|
tweak(conf)
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(conf)
|
b, _ := json.Marshal(conf)
|
||||||
path := filepath.Join(dir, "nurse.json")
|
path := filepath.Join(dir, "mako.json")
|
||||||
os.WriteFile(path, b, 0o600)
|
os.WriteFile(path, b, 0o600)
|
||||||
h, err := config.Load(path)
|
h, err := config.Load(path)
|
||||||
if err != nil {
|
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")
|
r.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
if method != http.MethodGet {
|
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 {
|
for i := 0; i+1 < len(hdr); i += 2 {
|
||||||
if hdr[i+1] == "" {
|
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 {
|
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)
|
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)
|
t.Errorf("the sign-in page itself must load: %d", w.Code)
|
||||||
}
|
}
|
||||||
if w := e.do("GET", "/healthz", nil, nil); w.Code != http.StatusOK {
|
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?") {
|
if !strings.HasPrefix(to.String(), e.idp.srv.URL+"/authorize?") {
|
||||||
t.Fatalf("goes to Authentik: %s", to)
|
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"} {
|
"scope": "openid profile email", "code_challenge_method": "S256"} {
|
||||||
if q.Get(k) != want {
|
if q.Get(k) != want {
|
||||||
t.Errorf("%s = %q, want %q", k, 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)
|
return good(to)
|
||||||
}, "unavailable")
|
}, "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)"} {
|
for _, bad := range []string{"https://evil.example.com", "//evil.example.com", "/api/export", "#/<script>", "javascript:alert(1)"} {
|
||||||
to, flow := e.begin(bad)
|
to, flow := e.begin(bad)
|
||||||
e.idp.mu.Lock()
|
e.idp.mu.Lock()
|
||||||
@@ -393,10 +393,10 @@ func TestSessionsAreSignedAndExpire(t *testing.T) {
|
|||||||
func TestWritesNeedTheHeader(t *testing.T) {
|
func TestWritesNeedTheHeader(t *testing.T) {
|
||||||
e := setup(t, nil)
|
e := setup(t, nil)
|
||||||
ses := e.signIn(alice)
|
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)
|
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)
|
t.Fatalf("a delete without the header: %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,7 +542,7 @@ func TestPageLifecycle(t *testing.T) {
|
|||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
names = append(names, f.Name)
|
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)
|
t.Fatalf("export has %v", names)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,14 +576,14 @@ func TestSecurityHeaders(t *testing.T) {
|
|||||||
|
|
||||||
func TestServerWillNotStartHalfConfigured(t *testing.T) {
|
func TestServerWillNotStartHalfConfigured(t *testing.T) {
|
||||||
dir := t.TempDir()
|
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)
|
os.WriteFile(path, []byte(`{"public_url":"https://notes.example.com"}`), 0o600)
|
||||||
h, _ := config.Load(path)
|
h, _ := config.Load(path)
|
||||||
_, err := New(h, "t")
|
_, err := New(h, "t")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("started without Authentik settings")
|
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) {
|
if !strings.Contains(err.Error(), want) {
|
||||||
t.Errorf("the message should mention %q:\n%v", want, err)
|
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") {
|
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.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)
|
h, _ = config.Load(path)
|
||||||
if _, err := New(h, "t"); err != nil {
|
if _, err := New(h, "t"); err != nil {
|
||||||
t.Errorf("environment settings should override the file: %v", err)
|
t.Errorf("environment settings should override the file: %v", err)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const problems = [];
|
|||||||
for (const file of walk(root)) {
|
for (const file of walk(root)) {
|
||||||
if (!exts.has(extname(file))) continue;
|
if (!exts.has(extname(file))) continue;
|
||||||
const rel = relative(root, file).split(sep).join("/");
|
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/");
|
const isPage = rel.startsWith("internal/web/static/");
|
||||||
let inTokens = false;
|
let inTokens = false;
|
||||||
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
||||||
|
|||||||
Reference in new issue
Block a user