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:
+115
@@ -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
@@ -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`.
|
||||||
@@ -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`.
|
||||||
@@ -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).
|
||||||
Reference in New Issue
Block a user