Add user guide, self-hosting, API, and contributing docs

docs/user-guide.md — magic-link sign-in, note basics, wikilink
syntax, keyboard shortcuts, offline behaviour, and privacy
notes (sessionStorage for tokens, tenant-scoped localStorage).

docs/self-hosting.md — system requirements, Docker Compose
quick-start, the full LIBRENOTES_* env-var matrix (which are
required, which conditional), reverse proxy snippets for Caddy
and nginx, volume layout, the in-binary healthcheck, and
update/rollback procedure.

docs/api.md — every public endpoint: auth (login/verify),
notes CRUD, /api/whoami, /healthz. Status codes per endpoint,
the optimistic-locking ?base=<unix> contract for PUT/DELETE,
note-ID regex, and the rate-limit policy.

CONTRIBUTING.md — dev setup (Nix flake .#dev, plain Go, Docker),
package layout overview, coding standards (one-way dep flow,
tenant FS gateway requirement), branch naming, commit format,
and the PR process. Also points security reports at
security@librete.ch rather than public issues.

Closes #29.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:52:09 +02:00
parent bcccba92f7
commit 61edef9483
4 changed files with 476 additions and 0 deletions

115
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,115 @@
# Contributing
Thank you for your interest in librenotes. This document covers
the developer workflow: setup, conventions, commit messages, and
how to land a pull request.
## Code of conduct
Participation in this project is governed by the
[Contributor Covenant 2.1](./CODE_OF_CONDUCT.md). Please read it
before opening issues or PRs.
## Development setup
The fastest path is the Nix flake. The `dev` shell drops you
into a working Go toolchain without any sandbox wrapping:
```sh
git clone ssh://tengo@git.librete.ch:41240/public/librenotes.git
cd librenotes
nix develop .#dev
make build && make test
```
If you don't use Nix:
- Install Go 1.22 or later.
- Install `make`, `git`.
- Optional: `staticcheck` (Go), `sqlite3` (for backup tooling).
A Docker-based dev environment also exists:
```sh
docker build -f Dockerfile.dev -t librenotes-dev .
docker run --rm -it -v "$PWD:/workspace" librenotes-dev
```
See the [README](./README.md) for the full layout. In short:
```
cmd/librenotes/ Binary entry + serve cmd + frontend assets
internal/auth/ Magic-link auth + JWT
internal/storage/ SQLite + user model
internal/tenant/ Per-user filesystem sandbox
internal/httpapi/ Routes, middleware, notes CRUD
internal/notesium/ Forked Notesium core (notes engine)
docs/ User-facing docs (this guide, ops, api, ...)
scripts/ Backup, prune, restore tools
```
## Coding standards
- Match the existing style. We don't have a custom linter beyond
`go vet`; `gofmt` and `goimports` are enforced via the
toolchain. Run `make lint` before pushing.
- Keep packages focused. Cross-package dependencies should flow
one way: `httpapi -> auth/storage/tenant`, never the reverse.
- Tests live next to the code they exercise (`*_test.go`). Aim
for table-driven tests for input parsing and behavioural tests
with `httptest.NewRecorder` for handlers.
- New runtime configuration goes through `LIBRENOTES_*` env vars
with a corresponding `--flag` on `librenotes serve`.
- All filesystem operations on tenant data must go through
`internal/tenant`. Never `os.Open` a path containing a user ID.
## Branch naming
- `feat/<short-slug>` for new features
- `fix/<short-slug>` for bug fixes
- `docs/<short-slug>` for documentation
- `chore/<short-slug>` for tooling, CI, refactors
- `phase-N/<topic>` for issues belonging to a roadmap phase
## Commit messages
Imperative mood, present tense. First line ≤72 characters. Body
explains the *why*, references the issue with `Closes #N` or
`Refs #N` so Gitea can auto-link. Example:
```
Add tenant-scoped notes REST API
Backed by tenant.FS so all reads/writes go through the per-user
sandbox. ?base=<unix> drives optimistic-locking conflict
detection for PUT/DELETE.
Closes #11.
```
## Pull request process
1. Open an issue first for anything non-trivial. Reference it
from the PR.
2. Branch off `main`, push, open a PR against `main`.
3. CI must pass (`make lint && make test`). Failed CI blocks
review.
4. Fill out the PR template — at minimum: what changed, why, how
it was tested.
5. Squash on merge unless the history is genuinely useful.
## Reporting issues
Use the bug-report or feature-request templates from the *New
issue* page on Gitea. The templates ask for the right things;
filling them out gets faster responses.
For security issues please **do not** open a public issue. Email
`security@librete.ch` with details.
## Releases
Tagged releases follow [SemVer](https://semver.org). The
maintainer creates the tag and the deploy workflow handles the
build and registry push automatically. Individual contributors
are not expected to tag releases.

161
docs/api.md Normal file
View File

@@ -0,0 +1,161 @@
# API reference
This is the public HTTP API of librenotes. All endpoints return
JSON unless otherwise noted.
Base URL: whatever you configured as `LIBRENOTES_BASE_URL` (for
the cloud: `https://librenot.es`).
## Authentication
librenotes uses passwordless email magic links plus short-lived
JWTs. The flow is:
1. `POST /auth/login` with an email — server emails a one-time
token.
2. The recipient clicks the link, which is `GET /auth/verify?token=...`.
3. The verify endpoint returns a session JWT (24-hour lifetime).
4. Subsequent calls send `Authorization: Bearer <jwt>`.
### `POST /auth/login`
Request:
```http
POST /auth/login
Content-Type: application/json
{ "email": "you@example.com" }
```
Response:
| Status | Meaning |
| ------ | ------- |
| `202 Accepted` | Email accepted; magic link sent (or rate-limited silently). |
| `400 Bad Request` | Body is malformed. |
| `429 Too Many Requests` | Rate limit hit (5 per 15 min per email). |
The response body is `{"status":"sent"}` on 202. The endpoint
deliberately returns 202 even for unknown addresses to avoid
account enumeration.
### `GET /auth/verify?token=<token>` and `POST /auth/verify?token=<token>`
Consumes a magic-link token. Single-use. 15-minute expiry.
```json
{
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6...",
"user_id": "f7b2e2c3-4a51-4d7c-bdc0-...",
"email": "you@example.com",
"expires_at": 1777411035
}
```
| Status | Meaning |
| ------ | ------- |
| `200 OK` | Verified; JWT issued. |
| `400 Bad Request` | `token` parameter missing. |
| `401 Unauthorized` | Token unknown, expired, or already used. |
## Notes
All `/api/*` endpoints require `Authorization: Bearer <jwt>`. A
401 means the token is missing/invalid; the client should
redirect to the login page.
### `GET /api/whoami`
Returns the verified tenant identity.
```json
{ "user_id": "f7b2e2c3-...", "email": "you@example.com" }
```
### `GET /api/notes`
List all notes for the current tenant.
```json
[
{ "id": "ideas", "title": "Ideas", "updated_at": 1777400000 },
{ "id": "shopping", "title": "Shopping", "updated_at": 1777300000 }
]
```
### `GET /api/notes/{id}`
Read a single note.
```json
{
"id": "ideas",
"title": "Ideas",
"content": "A few directions...\n",
"updated_at": 1777400000
}
```
| Status | Meaning |
| ------ | ------- |
| `200 OK` | Found. |
| `400 Bad Request` | Invalid ID (must match `^[a-z0-9][a-z0-9_-]{0,127}$`). |
| `404 Not Found` | No such note for this tenant. |
### `PUT /api/notes/{id}`
Create or update a note. Supports optimistic locking via
`?base=<unix-seconds>`: the server checks the current `updated_at`
against `base` and returns 409 if the note has been modified
since the client last saw it.
Request:
```http
PUT /api/notes/ideas?base=1777400000
Content-Type: application/json
{ "title": "Ideas", "content": "A few directions...\n" }
```
| Status | Meaning |
| ------ | ------- |
| `200 OK` | Saved; body returns the new `updated_at`. |
| `400 Bad Request` | Bad ID, missing fields, or invalid `base`. |
| `409 Conflict` | Note has been modified after `base`; body returns the current note so the client can resolve. |
### `DELETE /api/notes/{id}`
Delete a note. Same `?base=` semantics as PUT.
| Status | Meaning |
| ------ | ------- |
| `204 No Content` | Deleted (or already gone). |
| `409 Conflict` | Modified after `base`. |
## Health
### `GET /healthz`
Unauthenticated. Returns `{"status":"ok"}` when the binary is
serving requests. Used by load balancers and the Docker
HEALTHCHECK.
## Errors
Beyond the per-endpoint codes above, all endpoints may return
`500` for unhandled internal errors. Bodies are plain text
(`internal server error`), not JSON, to avoid leaking internals.
## Rate limits
`POST /auth/login` is the only currently rate-limited endpoint:
5 requests per 15-minute window per email address. The window
resets on the oldest attempt expiring.
## Versioning
The current public API surface is unversioned. Breaking changes
will be signalled via a major version tag in the project as a
whole, with a deprecation window announced in `CHANGELOG.md`.

117
docs/self-hosting.md Normal file
View File

@@ -0,0 +1,117 @@
# Self-hosting
This guide explains how to run your own librenotes instance with
Docker Compose. For day-2 ops (deploys, backups) see
[operations.md](./operations.md).
## System requirements
- Linux server with Docker Engine 24+ and the Compose plugin.
- At least 256 MB of RAM and 1 GB of disk for the application
plus whatever space you expect notes to consume (typically
measured in MB even for heavy users).
- A public DNS name pointing to the host. Magic-link sign-in will
not work over plain IP because some email clients refuse to
follow links to bare-IP URLs.
- An SMTP relay (or a service like Postmark/SES) that the host
can reach on port 587 or 465.
- A reverse proxy (nginx, Caddy, or Traefik) terminating TLS in
front of librenotes.
## Quick start
```sh
git clone https://git.librete.ch/public/librenotes
cd librenotes
cp .env.example .env # edit values, see below
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
Visit `https://your-domain/` and sign in.
## Environment configuration
The runtime reads its configuration from environment variables.
Place them in an `.env` file next to the Compose stack.
| Variable | Required | Description |
| -------- | -------- | ----------- |
| `LIBRENOTES_BASE_URL` | yes | Public origin used in magic links, e.g. `https://notes.example.com`. Must match what users will type. |
| `LIBRENOTES_JWT_SECRET` | yes | At least 32 bytes of random data. Sessions are invalidated when this changes. |
| `LIBRENOTES_SMTP_HOST` | yes (prod) | SMTP server hostname. Without it, magic links are logged to stdout (useful for dev only). |
| `LIBRENOTES_SMTP_PORT` | no | Default `587`. |
| `LIBRENOTES_SMTP_USER` | conditional | SMTP credential. |
| `LIBRENOTES_SMTP_PASS` | conditional | SMTP credential. |
| `LIBRENOTES_SMTP_FROM` | yes | Envelope sender, e.g. `no-reply@notes.example.com`. |
| `LIBRENOTES_DATA_DIR` | no | Default `/data` inside the container. |
| `LIBRENOTES_DB` | no | Default `/var/lib/librenotes/librenotes.db`. |
| `LIBRENOTES_IMAGE` | yes (prod) | Image tag to pull, e.g. `registry.librete.ch/librenotes:v0.1.0`. |
Generate a JWT secret:
```sh
openssl rand -hex 32
```
## Reverse proxy
### Caddy
```caddy
notes.example.com {
encode zstd gzip
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
server {
listen 443 ssl http2;
server_name notes.example.com;
ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
```
The application speaks plain HTTP and trusts the proxy for the
public URL via `LIBRENOTES_BASE_URL`. Always terminate TLS at the
proxy.
## Volumes
The Compose stack declares two named volumes:
- `notes` mounted at `/data` — per-tenant note files.
- `state` mounted at `/var/lib/librenotes` — the SQLite database.
Both must be on persistent storage. Backing them up is covered
in [operations.md](./operations.md#backups).
## Health check
`GET /healthz` returns `{"status":"ok"}` when the binary is
running. The Compose stacks invoke `librenotes healthcheck` from
inside the container (since the runtime image has no shell tools
to use `curl`/`wget`).
## Updating
Pull the new image and restart:
```sh
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
To pin a specific version, set `LIBRENOTES_IMAGE` to that tag in
`.env`. Rolling back is the same command after editing `.env`.

83
docs/user-guide.md Normal file
View File

@@ -0,0 +1,83 @@
# User guide
Welcome to librenotes — an open-source, multi-tenant notes app
with bi-directional links and Markdown.
## Getting started
1. Visit [https://librenot.es](https://librenot.es).
2. Click **Sign in with email** and enter your address.
3. Check your inbox; the magic link is valid for 15 minutes and
can be used once.
4. Clicking the link signs you in and drops you into the app.
There is no password to remember. Each link is a one-time
credential; the session it creates lasts 24 hours.
## Notes are Markdown files
Each note is a single Markdown (`.md`) file scoped to your
tenant directory on the server. The first H1 line is the title;
the rest is content. You can edit notes in the web UI today; in
a self-hosted setup you can also drop files directly into the
data directory and they will appear in your notebook on next
sync.
```markdown
# Project ideas
A few directions worth chasing this quarter.
- Try [[graph-view]] for the dashboard
- Read [[zettelkasten-overview]]
```
### Bi-directional links (wikilinks)
Use `[[note-id]]` to link to another note. The target's backlinks
list will include the source automatically. If the target does
not exist yet, the link will create it on click. Note IDs are
short slugs: lowercase letters, digits, hyphens, underscores. A
note titled "Project Ideas" might have the ID `project-ideas`.
### Markdown syntax
Standard CommonMark is supported: headings, lists, links, code
blocks, fenced code with language hints, blockquotes, tables,
images. Inline HTML is sanitised on render.
## Keyboard shortcuts
| Action | Shortcut |
| ------ | -------- |
| New note | `Ctrl+N` / `Cmd+N` |
| Save current note | `Ctrl+S` / `Cmd+S` |
| Open note finder | `Ctrl+P` / `Cmd+P` |
| Toggle preview | `Ctrl+\\` / `Cmd+\\` |
| Toggle theme | `Ctrl+Shift+T` |
| Sign out | (header menu) |
## Offline use
librenotes works offline. The Progressive Web App caches the
shell so you can open the page without a network, and your notes
are mirrored into IndexedDB so they are readable and editable
offline. Edits made offline are queued and pushed when you come
back online; conflicts (when both you and another session edited
the same note) prompt you to keep one version or the other.
## Privacy
Your notes are stored on disk per-tenant, isolated by the
filesystem sandbox; no other user can access your directory. The
session token is held in `sessionStorage` so closing the browser
tab signs you out on shared devices. Per-user UI preferences live
in `localStorage` under a tenant-scoped prefix and are wiped on
logout.
For the full privacy policy see [/privacy.html](/privacy.html).
## Self-hosting
Want to run your own instance instead? See
[self-hosting.md](./self-hosting.md).