Dockerfile is multi-stage:
- build: golang:1.25-bookworm, CGO_ENABLED=0 (modernc.org/sqlite
is pure-Go) + -trimpath + -ldflags "-s -w" so the resulting
binary is small and reproducible-ish.
- runtime: gcr.io/distroless/static:nonroot, ~2 MB. Runs as uid
65532. /data and /var/lib/librenotes are declared volumes so
per-tenant notes and the SQLite database survive container
restarts.
healthcheck subcommand: distroless static has no shell or
wget/curl, so /healthz is reachable but no client to call it. A
new "librenotes healthcheck" subcommand uses net/http to GET
$LIBRENOTES_HEALTHCHECK_URL (default 127.0.0.1:8080/healthz) and
exits non-zero on failure. Both compose files invoke it from the
HEALTHCHECK directive.
httpapi adds a tiny GET /healthz that returns {"status":"ok"}
(no DB ping yet — added when readiness probes need it).
docker-compose.yml: dev stack on :8080 with named volumes and a
LogMailer; everything via env vars, JWT secret defaulted to a
dev value.
docker-compose.prod.yml: layered overrides — pulls a registry
image, expects LIBRENOTES_* env, sets memory limits and JSON-
file log rotation.
Closes#25.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/httpapi/notes.go exposes:
- GET /api/notes list summaries {id, title, updated_at}
- GET /api/notes/{id} full {id, title, content, updated_at}
- PUT /api/notes/{id} create/update; ?base=<unix> for
optimistic-locking conflict detection
- DELETE /api/notes/{id} remove; ?base=<unix> guards against
deleting a row modified after the
client last saw it
Backed by tenant.FS so all reads/writes go through the per-user
sandbox — path traversal is rejected at parse time (regex slug)
and again by os.Root inside the FS layer.
On-disk format is plain Markdown: first line `# Title`, rest is
content. grep / cat / vim still produce a usable view of raw
files. Title round-trips through composeNote/splitTitle.
Conflict semantics: when the client supplies ?base=<unix>, the
server compares against the file's mtime. If the file is newer,
respond 409 with the current note body so the client can present
a merge UI. Same logic on DELETE returns 409 alone.
cmd/librenotes/serve.go grows a tenantPool that memoises FS
handles per user id; defer-closes them on shutdown.
Tests cover: full CRUD round-trip, cross-tenant isolation,
unauthenticated 401s, invalid IDs (regex rejection), and the
conflict path with a real mtime advance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/notesium/web/app/pane.js drove the sidebar/pane resize
via mousedown + window-level mousemove/mouseup, which doesn't
fire on touch (browsers only emulate mouse for taps, not drags).
Replaced with pointer events:
- @pointerdown on the handle (covers mouse, touch, pen).
- setPointerCapture so we keep receiving pointermove/pointerup
events when the pointer drifts off the handle. This eliminates
the need for document-level listeners and avoids stuck-drag
states when the user releases outside the window.
- pointermove + pointerup + pointercancel listeners on the
captured target only — when the capture ends they're removed
regardless of whether the user is still on top of the handle.
- Filter on event.pointerId so a second simultaneous touch
(e.g., a multi-finger gesture) cannot hijack the in-progress
resize.
- event.button !== 0 guard rejects right-click / middle-click.
- touch-action: none on the handle so the browser doesn't try
to interpret a horizontal drag as a page scroll.
CodeMirror's internal mousedown handlers in note.js / preview.js
are left alone — those are link-click guards, not drags, and
CodeMirror's own pointer support handles touch internally.
Closes#20.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/httpapi/ provides:
- Tenant{UserID, Email} carried on context.Context, with
WithTenant / TenantFrom helpers and ErrNoTenant for the
programming-error case (route reached without middleware).
- AuthMiddleware verifies an Authorization: Bearer <jwt> on every
request via auth.Signer.Verify (which already enforces HS256
and rejects alg=none). On failure: 401, with the underlying
reason logged server-side but not exposed to the client.
- RequireTenantOwnership(ownerID) compares the request's tenant
against the resource owner; returns 403 on mismatch. Handlers
that touch tenant-owned resources call this guard.
- Server.Routes() mounts /auth/* unauthenticated and wraps
/api/* with the middleware. /api/whoami is included as the
canonical example of a tenant-scoped endpoint.
Tests cover: valid JWT pass-through, missing/empty Authorization,
wrong scheme, malformed JWT, tampered signature, JWT signed with
a different secret (cross-tenant key confusion), and the 200/403
matrix for RequireTenantOwnership.
Closes#11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/tenant/ provides FS, a sandboxed handle for a single
tenant's notes directory. Implementation strategy:
- Defence in depth: every relative path is validated up front
(rejects "..", absolute paths, NUL bytes, empty), then handed
to os.Root (Go 1.24+) which enforces the boundary at the
syscall layer using openat(2)+RESOLVE_BENEATH on Linux. This
closes TOCTOU races and symlink-target swapping.
- WriteFile is atomic (write to .tmp, rename in-root). Mode 0o600
on files, 0o700 on directories. Tenant root is created with
0o700 by Open().
- Errors are normalised: fs.ErrNotExist -> ErrNotFound, anything
os.Root rejects as "outside" the root -> ErrInvalidPath. The
HTTP layer can map cleanly to 404 / 400.
Tests cover the full traversal attack surface — "../", absolute
paths, mixed separators, NUL bytes, "." and "" — plus symlink
escapes and cross-tenant isolation. All vectors return errors;
none escape the root.
Closes#10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/auth/ provides:
- TokenStore: 32-byte cryptographically random one-time tokens.
Only the SHA-256 hash is persisted (so a DB leak doesn't grant
active sessions). Comparison uses subtle.ConstantTimeCompare.
Single-use is enforced via UPDATE ... WHERE used_at IS NULL.
- Signer: HS256 JWTs with 24h lifetime, jwt.WithValidMethods to
reject alg=none and other downgrade attacks.
- LogMailer (dev) and SMTPMailer (prod via net/smtp) behind a
Mailer interface.
- RateLimiter: DB-backed fixed window per email; default 5 per
15 min for the magic-link flow.
- Service: orchestrates RequestLogin (auto-creates user on first
login, generates token, emails magic link) and Verify (consumes
token, updates last_login, issues JWT).
- Handlers: POST /auth/login and GET/POST /auth/verify.
HandleLogin returns 202 even on validation failure to avoid
account enumeration; rate-limit hits surface as 429.
Schema additions: magic_tokens (with FK + cascade) and
login_attempts. UserStore.SetStoragePath added for completeness.
Tests cover: token issue/consume, single-use, expiry, rate limit,
JWT round-trip, alg=none rejection, signature tampering, purge,
HTTP handlers (login + verify, missing/invalid token paths).
Closes#9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/storage/ provides:
- Open(path) to create or open the SQLite database with WAL journal,
busy timeout, and foreign keys enabled
- Embedded migrations that create the users table on first run
- UserStore with Create, GetByID, GetByEmail, UpdateLastLogin, Delete
- Email normalisation (trim+lowercase) and uniqueness enforcement
with ErrEmailTaken
- ErrNotFound on lookups and deletes
- UUIDv4 IDs auto-generated when caller leaves ID empty
Uses modernc.org/sqlite (pure-Go) so the binary stays CGO-free and
matches Dockerfile.dev's CGO_ENABLED=0.
Tests cover all CRUD operations, email uniqueness (case-insensitive),
WAL mode verification, and ErrNotFound paths.
Closes#8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Initial fork of github.com/alonswartz/notesium into librenotes:
- Source moved to internal/notesium/ (package notesium)
- Thin entry point at cmd/librenotes/main.go
- Module renamed to git.librete.ch/public/librenotes
- main() exposed as notesium.Run()
- LICENSE preserved (MIT), NOTICE added with attribution
- Web assets and completion.bash co-located with embedding code
to satisfy go:embed path constraints
Closes#3, #34, #35.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>