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
CONTRIBUTING.md
Normal file
115
CONTRIBUTING.md
Normal 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
161
docs/api.md
Normal 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
117
docs/self-hosting.md
Normal 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
83
docs/user-guide.md
Normal 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).
|
||||
Reference in New Issue
Block a user