diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5205784 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 https://git.librete.ch/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/` for new features +- `fix/` for bug fixes +- `docs/` for documentation +- `chore/` for tooling, CI, refactors +- `phase-N/` 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= 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. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..3e1fc75 --- /dev/null +++ b/docs/api.md @@ -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 `. + +### `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=` and `POST /auth/verify?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 `. 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=`: 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`. diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..434b682 --- /dev/null +++ b/docs/self-hosting.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`. diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..c00dbdb --- /dev/null +++ b/docs/user-guide.md @@ -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).