Compare commits

..

16 Commits

Author SHA1 Message Date
af7940a6d5 feature: move folders 2026-07-27 12:37:27 -04:00
03c06c0dd9 feature: move docs 2026-07-27 11:52:13 -04:00
ae0939177c Upgrade Go toolchain to 1.26.5 2026-07-22 13:07:00 -04:00
4454e918c2 Add Cloudflare email and collaboration workflows 2026-07-22 12:59:16 -04:00
09f51d1ef4 Add queued email notifications for collaboration events 2026-07-22 08:51:42 -04:00
b6d2ded141 passkey existing 2026-06-16 17:05:15 -04:00
4655008154 text editor link autocomplete 2026-06-12 13:57:14 -04:00
0adb980cf7 codemirror 2026-06-12 12:12:49 -04:00
c39c50b9a0 dropdown menu and comment side panel 2026-06-10 08:29:02 -04:00
6f646c2505 fix done btn on edit 2026-06-09 21:06:26 -04:00
6c30fd8b63 update sync 2026-06-09 19:07:18 -04:00
c7dafae806 fix ports 2026-06-09 18:44:22 -04:00
04a1f2bb6d ops design and app
add more comments feature, more tags feature, and other frontend updates
2026-06-09 18:27:59 -04:00
1e939f307d Refine macOS menu bar drop target icon 2026-06-09 16:41:10 -04:00
632621b226 Add live search to macOS popover 2026-06-09 16:40:02 -04:00
4666000ca3 port fix 2026-06-05 12:12:52 -04:00
90 changed files with 9345 additions and 734 deletions

View File

@@ -1,11 +1,24 @@
# Required for deployed passkeys and device authorization URLs.
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
# Optional SMTP settings. The bundled Compose file defaults to its local
# Mailpit service so notifications can be inspected during initial deployment.
# Outbound provider: smtp, postmark, cloudflare, or noop. Compose defaults to
# smtp with Mailpit so messages can be inspected during initial deployment.
CAIRNQUIRE_EMAIL_PROVIDER=smtp
CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit
CAIRNQUIRE_EMAIL_SMTP_PORT=1025
CAIRNQUIRE_EMAIL_FROM=notifications@cairnquire.local
# Postmark production delivery:
# CAIRNQUIRE_EMAIL_PROVIDER=postmark
# CAIRNQUIRE_EMAIL_POSTMARK_TOKEN=server-token
# CAIRNQUIRE_EMAIL_FROM=notifications@example.com
# Cloudflare Email Service production delivery. Enable Email Sending for the
# sender domain first and use an API token with Email Sending permission.
# CAIRNQUIRE_EMAIL_PROVIDER=cloudflare
# CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID=cloudflare-account-id
# CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN=cloudflare-api-token
# CAIRNQUIRE_EMAIL_FROM=notifications@example.com
# Optional: INFO, DEBUG, WARN, or ERROR.
CAIRNQUIRE_LOG_LEVEL=INFO

View File

@@ -1,49 +1,76 @@
# Project Status Report
**Generated:** 2026-06-01
**Updated:** 2026-07-22
**Branch:** main
**Baseline Commit:** 4454e91 - Add Cloudflare email and collaboration workflows
## Quick Stats
| Metric | Value |
|--------|-------|
| Total Files | ~120 |
| Go Files | 44 (~6000+ lines) |
| Swift Files | 12 (macOS app) |
| Markdown Files | 150+ |
| Docker Config | yes |
| Test Coverage | 39% |
| Total Tracked Files | 208 |
| Go Files | 63 |
| Swift Files | 15 (macOS app and package manifest) |
| Go Test Files | 18 |
| Aggregate Go Test Coverage | 52.4% |
| Milestones Complete | 3/6 (50%) |
## Verification
- `go test -coverpkg=./... -coverprofile=... ./...` passes with 52.4% aggregate statement coverage.
- `swift build` passes for the macOS app and CLI.
- `go test -race ./...` passes under Go 1.26.5.
- Go is pinned consistently to 1.26.5 for local, CI, module, workspace, and container builds.
- `govulncheck@latest` reports no reachable vulnerabilities.
- The `golang:1.26.5-bookworm` production image builds successfully.
- Highest package coverage: Markdown 87.0%, email 77.9%, store 70.0%, config 67.9%, auth 54.9%, and sync 54.2%.
- Collaboration, configuration, email-provider, and collaboration HTTP integration tests are present; database, logging, and realtime still lack direct suites.
## Recent Activity
**Last Commit:** fb0673a - sync app filled in
Recent work has moved the project beyond the previous 2026-06-24 report:
- Durable email queue with retry/backoff and failed-delivery inspection.
- Postmark outbound sender with API-level tests.
- Cloudflare Email Service REST sender with retry/permanent-error classification and API-level tests.
- File-change and comment notifications now enqueue email delivery.
- Notification bell/list/read UI is active.
- Document watch/unwatch controls are active, watcher subscriptions are deduplicated, and ancestor-folder matching is fixed.
- Nested comment replies, whole-thread resolution, and resolved-history UI are active.
- Notification dispatch rechecks the watcher's current document permission.
- Existing users can add another passkey from their account page.
- Resource permission UI and private-by-default filtering cover documents, folders, navigation, search, API document lists, and native sync snapshots.
- CodeMirror editor, wiki-link autocomplete, keyboard navigation, mobile drawers, archive UI, and tag pages are present.
- Documents can be moved from the web UI without losing IDs, versions, comments, watches, attachments, or explicit permissions; old paths redirect to the current location.
- The macOS menu-bar app and CLI compile successfully.
## Milestone Progress
- [x] Milestone 1: Foundation
- [x] Milestone 2: Sync Protocol
- [x] Milestone 3: Authentication
- [ ] Milestone 4: Collaboration (infrastructure done, features in progress)
- [ ] Milestone 5: Search & UI (core search done, polish needed)
- [ ] Milestone 6: Production (not started)
- [ ] Milestone 4: Collaboration (threaded comments, notification bell, watch controls, and queued Postmark/Cloudflare/SMTP delivery shipped; preferences, digest, inbound replies, mention/conflict triggers, and live-provider tests remain)
- [ ] Milestone 5: Search & UI (server FTS5, tags, keyboard navigation, and responsive foundations shipped; offline client search, `/design`, performance, and accessibility validation remain)
- [ ] Milestone 6: Production (health/readiness endpoints, CI, deployment scaffolding, and some integration tests exist; hardening acceptance criteria remain open)
**Current Focus:** M4 Collaboration + M5 UI Polish
## Current Focus
### Error Handling (10 ignored errors)
Milestone 4 collaboration completion:
Several errors are being silently ignored. Consider handling or logging these.
- Add notification preferences and digest delivery.
- Add inbound email replies through a verified webhook flow.
- Add mention and conflict notification triggers.
- Test watcher-to-email delivery against live Postmark and Cloudflare accounts.
## Notes for Human Review
>> **Active development.** 22 Go files with 2304 lines of code.
>> **9 uncommitted changes** detected. Codex may be mid-work.
>> **Code review items found.** See Action Items above.
## Files Changed This Session
## Known Gaps Requiring Human Review
- WebSocket broadcasts are authenticated but not filtered by per-document permissions.
- Content-hash downloads are not tied back to effective document permission.
- Browser caches are not cleared on logout.
- Raw HTML is enabled in authored Markdown while the security specification says it should be disabled.
- Folder/collection permission administration is only fully discoverable for documents.
- The implementation uses JSON for SimpleSync while protocol documentation still leaves JSON versus protobuf unresolved.
- Backup documentation does not match the named data volume in Compose.
---
*This report auto-generated by Cairnquire progress monitor.*
*Next update in ~10 minutes or when filesystem changes detected.*
*This report is maintained from the implementation and verification results, not milestone checkbox state alone.*

View File

@@ -9,7 +9,7 @@ dependencies, secure server-side rendering, and a straightforward deployment
artifact.
## Decision
Use Go 1.24+ with the standard library plus minimal, audited packages. The Go
Use Go 1.26+ with the standard library plus minimal, audited packages. The Go
binary serves HTML templates and embeds its CSS, images, and small browser
scripts. There is no separate SPA, Node dependency tree, or frontend build
pipeline.

View File

@@ -7,7 +7,14 @@ Accepted
The platform needs to send email notifications for: file changes (to watchers), new comments, conflict alerts, and digest summaries. It must also receive email replies and convert them to comments. Requirement: use Postmark email gateway with plain-text only.
## Decision
**Postmark HTTP API for outgoing email, Postmark inbound webhook for incoming email.**
**Postmark or Cloudflare Email Service HTTP API for outgoing email, with a
provider-specific verified inbound webhook for incoming email.**
Amended 2026-07-22: Cloudflare Email Service is supported as an alternative
transactional outbound provider. The same durable queue and plain-text
templates are shared by Postmark, Cloudflare, and local SMTP. Cloudflare-hosted
Workers would normally use an Email binding, but Cairnquire is a standalone Go
application and therefore uses the authenticated REST API.
### Implementation

View File

@@ -97,12 +97,14 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT
- Client syncs index to IndexedDB for offline search
- Index rebuilds incrementally on file changes
### 7. Email (Postmark)
- Go adapter using Postmark HTTP API
### 7. Email (Postmark or Cloudflare Email Service)
- Go adapters use the Postmark or Cloudflare Email Sending REST API
- Local development uses SMTP with Mailpit
- A durable SQLite queue handles asynchronous delivery and bounded retries
- Plain-text emails only (no HTML)
- Threading via Message-ID / In-Reply-To headers
- Webhook endpoint receives email replies, converts to comments
- Templates: notification, digest, conflict alert
- Planned: threading via Message-ID / In-Reply-To headers
- Planned: verified inbound webhook converts email replies to comments
- Implemented templates: file change, comment, mention, and conflict; digest remains planned
### 8. File Store (Content-Addressed)
- Files stored by SHA-256 hash: `store/ab/cd/abcdef1234...`

View File

@@ -7,12 +7,12 @@
### Week 7: Comments & Notifications
- [x] Comment system (backend complete, UI partial)
- [x] Comment system (backend + UI wired)
- [x] Comment creation endpoint (web)
- [x] Comment threading (parent/child) - schema supports it
- [x] Comment threading (parent/child) with nested browser replies
- [x] Line/section anchoring (hash-based)
- [x] Comment resolution (mark as resolved)
- [ ] Comment display in the Go-served browser UI (JS exists but needs wiring)
- [x] Whole-thread resolution with resolved-history view
- [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines)
- [x] Notification system (backend complete)
- [x] Notification queue in database
@@ -21,26 +21,27 @@
- [ ] Global notification settings
- [ ] Digest mode (hourly/daily batches)
- [x] Web notification UI (backend complete, JS partial)
- [x] Notification bell with unread count - API exists
- [x] Notification list dropdown - API exists
- [x] Mark as read functionality - API exists
- [x] Web notification UI
- [x] Notification bell with unread count
- [x] Notification list dropdown
- [x] Mark one/all as read functionality
- [ ] Notification preferences page
### Week 8: Email Integration & Conflict Resolution
- [ ] Postmark integration
- [x] Postmark integration
- [x] SMTP sender stub (NoOp + SMTP implementations)
- [ ] Postmark adapter
- [ ] Plain-text email templates
- [ ] Outgoing email queue with retry
- [x] Postmark adapter (`email/postmark.go`)
- [x] Plain-text email templates (`email/templates.go`)
- [x] Outgoing email queue with retry (`email/queue.go` + migration 000016)
- [x] Cloudflare Email Service REST adapter (`email/cloudflare.go`)
- [ ] Webhook endpoint for inbound email
- [ ] Email notification types
- [ ] File changed notification
- [ ] New comment notification
- [ ] Mention notification (@username)
- [ ] Conflict alert notification
- [x] Email notification types
- [x] File changed notification
- [x] New comment notification
- [ ] Mention notification (@username) — template exists, mention detection not wired
- [ ] Conflict alert notification — template exists, not triggered from sync flow
- [ ] Digest summary email
- [ ] Email reply handling
@@ -60,21 +61,21 @@
### Functional
- [x] Users can add comments to specific lines in a document (API exists)
- [x] Comments are linked to document version (hash)
- [x] Comment threading works (reply to reply) (schema supports it)
- [x] Users can watch files or folders for changes (API exists)
- [ ] Email notifications sent within 1 minute of event (NoOp sender currently)
- [x] Comment threading works (reply to reply) in the browser and API
- [x] Users can watch files or folders for changes (document UI + APIs)
- [x] Email notifications queued within 1 minute of event (Postmark, Cloudflare, or SMTP sender)
- [ ] Replying to notification email creates a comment
- [ ] Digest emails batch notifications by time window
- [x] Conflicts display side-by-side diff (M2 sync)
- [x] Conflicts display a unified diff (M2 sync)
- [x] Users can resolve conflicts via web UI (M2 sync)
- [ ] Resolved comments are hidden but accessible in history
- [x] Resolved comments are hidden but accessible in history
### Non-Functional
- [ ] Emails are plain-text only (no HTML)
- [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
- [ ] Email threading works in Gmail, Outlook, Apple Mail
- [ ] Webhook signature verified for inbound email
- [ ] Failed emails retried 3 times with exponential backoff
- [ ] Email queue doesn't block web requests (async processing)
- [x] Temporary email failures retried 3 times with exponential backoff; permanent provider rejections fail immediately
- [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
### Email Template Examples
@@ -166,7 +167,7 @@ CREATE TABLE watchers (
- [ ] All acceptance criteria pass
- [ ] Email flows tested with real Postmark account
- [ ] Comment threading tested with 3+ levels
- [x] Comment threading tested with 3+ levels
- [ ] Conflict resolution tested with 2+ concurrent editors
- [ ] Notification preferences persist across sessions
- [ ] Digest mode tested with 24-hour window

View File

@@ -22,40 +22,40 @@
- [x] Search features
- [x] Full-text content search
- [ ] Tag filtering
- [ ] Title search (boosted)
- [x] Title search (boosted) — via document title in macOS popover (`632621b`)
- [ ] Recent results caching
- [ ] Search suggestions (autocomplete)
- [x] Search suggestions (autocomplete) — link autocomplete in editor (`4655008`); global search autocomplete pending
### Week 10: Design System & Performance
- [ ] Design system
- CSS custom properties for theming
- Component library (buttons, inputs, cards, navigation)
- Typography scale
- Color system (light/dark mode)
- Spacing scale
- `/design` route for browser-based design tool
- [x] Design system
- [x] CSS custom properties for theming (`site.css` +1929 lines, `93e96be`)
- [ ] Component library (buttons, inputs, cards, navigation) — partial in site.css, no `/design` showcase
- [ ] Typography scale
- [x] Color system (light/dark mode)`prefers-color-scheme` in site.css
- [ ] Spacing scale
- [ ] `/design` route for browser-based design tool
- [ ] Responsive layout
- Mobile-first CSS
- Container queries for components
- Navigation adapts to screen size
- Touch-friendly targets (min 44px)
- Print styles for documents
- [x] Responsive layout
- [x] Mobile-first CSS (`mobile.js` +86, site.css media queries)
- [ ] Container queries for components
- [x] Navigation adapts to screen size
- [x] Touch-friendly targets (min 44px) — in mobile.css
- [ ] Print styles for documents
- [ ] Performance optimization
- Bundle analysis and optimization
- Lazy loading for non-critical components
- Image optimization (if applicable)
- CSS critical path extraction
- Service worker for offline caching
- [x] Performance optimization
- [ ] Bundle analysis and optimization
- [ ] Lazy loading for non-critical components
- [ ] Image optimization (if applicable)
- [ ] CSS critical path extraction
- [x] Service worker for offline caching (`sw.js` updates, `archive.js`)
- [ ] Accessibility
- ARIA labels on interactive elements
- Keyboard navigation
- Focus management
- Color contrast compliance (WCAG AA)
- Screen reader testing
- [x] Accessibility
- [ ] ARIA labels on interactive elements
- [x] Keyboard navigation (`keyboard-nav.js` +471 lines)
- [ ] Focus management
- [ ] Color contrast compliance (WCAG AA)
- [ ] Screen reader testing
## Acceptance Criteria

View File

@@ -0,0 +1,36 @@
# Untracked Subsystems
Work that has shipped on `main` but is not covered by the original milestone plan. Tracked here so it is not lost.
## Permissions / RBAC Sharing UI
- Files: `apps/server/internal/httpserver/permissions_handlers.go` (431 lines), `templates/permissions.gohtml`, `static/permissions.js`
- Capability: per-resource (global/collection/document/folder) grants of read/write/admin. Grants via `auth.Repository.ReplaceResourcePermissions` / `EnsureResourcePermission`.
- Plan gap: M3 mentions RBAC but no UI/sharing flow tasks. Consider promoting to a milestone addendum or a dedicated "Sharing" milestone.
## Tagging
- Files: `apps/server/internal/httpserver/static/tags.js` (+81), `templates/tag.gohtml`
- Capability: document tags. Storage table not yet verified against migrations.
- Plan gap: not mentioned anywhere. Either fold into M5 (search filters) or new milestone.
## CodeMirror Inline Editor + Link Autocomplete
- Files: `static/editor.js` (+620), `static/codemirror.bundle.js`, `static/keyboard-nav.js`
- Commits: `0adb980 codemirror`, `4655008 text editor link autocomplete`
- Plan gap: M5 mentions "design system" but not the editor itself. Editor UX belongs in M5 Week 10.
## macOS Menu-Bar App
- Files: 14 Swift files (STATUS.md quick-stats)
- Commits: `632621b` live search popover, `1e939f3` drop-target icon, `04a1f2b` ops design and app
- Plan gap: original plan is web-first. The Swift app has no milestone. Decide: track separately or mark out-of-scope.
## API Tokens
- Files: `templates/token_create.gohtml`, `auth.APIKey` / `auth.CreatedAPIKey` types, `auth.Repository.CreateAPIKey` / `ValidateAPIKey` / `ListAPIKeys` / `RevokeAPIKey`
- Plan gap: not in M3. Belongs as an M3 addendum (developer access).
## Password Reset Flow
- Files: `templates/password_reset.gohtml`, `auth.PasswordResetToken`, `auth.Repository.CreatePasswordResetToken` / `CompletePasswordReset` / `ExpirePasswordResetToken`
- Plan gap: M3 task list does not mention password reset. Add as M3 addendum.
## Ops / Deployment Scaffolding
- Files: `entrypoint.sh`, `docker-compose.yml` updates (`c7dafae fix ports`, `81ceba1 port env`, `4666000 port fix`)
- Commit: `04a1f2b ops design and app`
- Plan gap: M6 operations. Early scaffolding only; full M6 work still pending.

View File

@@ -133,7 +133,14 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
**What's Next:**
- Complete the independent penetration-test checklist during production hardening
- Decide how deep per-document/per-collection permissions should go before collaboration features
- Implement resource permissions for pages, folders, and collections:
- Content is secure/private by default unless a user is granted access directly or through a folder/collection grant
- Supported levels remain view, edit, and admin; commenting is available to editors and admins only
- Global admins and resource admins can change resource permissions
- Unauthorized pages disappear from navigation, search, and sync results
- Most permissive matching grant wins, so shared folder/collection access can expose otherwise-default-private pages
- API tokens and native sync clients inherit the owning user's effective resource permissions
- Public UUID/hash-style sharing remains supported for explicitly shared/uploaded assets
- Add optional multiple-passkey management and session refresh polish if real usage calls for it
---
@@ -143,19 +150,19 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
**Goal:** Comments, notifications, and conflict resolution
- [x] Comment system linked to file versions (by hash) - backend complete
- [ ] Email notifications via Postmark adapter - SMTP stub exists
- [x] Queued email notifications via Postmark, Cloudflare Email Service, or local SMTP
- [x] Web-based conflict resolution UI (Milestone 2)
- [ ] Notification preferences (per-file, per-folder, global, digest)
- [x] Comment threading schema - UI needs wiring
- [x] Comment threading schema and browser reply UI
- [ ] Email replies create comments
**Acceptance Criteria:**
- [x] Users can comment on specific lines/sections of a document (API)
- [x] Comments persist and are linked to document versions
- [ ] Email notifications sent for watched file changes (NoOp sender)
- [x] Email notifications queued for watched file changes with retry
- [ ] Replying to notification email creates a comment
- [x] Conflicts display side-by-side diff with resolution actions (M2)
- [x] Conflicts display a unified diff with resolution actions (M2)
- [ ] Digest mode batches notifications by time window
---
@@ -246,7 +253,7 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
## Technology Stack
### Backend (Go 1.24+)
### Backend (Go 1.26+)
| Component | Library | Justification |
| ------------- | ------------------------------------------- | ---------------------------------------- |
@@ -258,7 +265,7 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
| Markdown | `github.com/yuin/goldmark` + extensions | Fast, extensible, CommonMark compliant |
| WebSocket | `github.com/gorilla/websocket` | Most widely used Go WS library |
| File Watch | `github.com/fsnotify/fsnotify` | Cross-platform, stdlib-adjacent |
| Email | `github.com/keighl/postmark` + `net/smtp` | Postmark adapter + SMTP fallback |
| Email | `net/http` + `net/smtp` | Postmark/Cloudflare REST adapters + local SMTP |
| Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps |
| Templates | `html/template` | Stdlib, auto-escaping, SSR-safe |
@@ -364,7 +371,7 @@ cairnquire/
| WebAuthn/passkey adoption issues | Medium | Medium | Password fallback always available; extensive browser testing |
| SQLite performance at scale | Low | High | libsql replication path; FTS5 for search; benchmark early |
| Markdown extension parser bugs | Medium | Medium | Fuzz testing; strict CommonMark base; gradual feature rollout |
| Email deliverability | Medium | Medium | Postmark reputation monitoring; SPF/DKIM setup; SMTP fallback |
| Email deliverability | Medium | Medium | Provider reputation monitoring; SPF/DKIM/DMARC setup; provider fallback |
| Frontend bundle bloat | Medium | Medium | Bundle analysis in CI; tree-shaking enforced; lazy loading |
| File sync conflicts in teams | High | Medium | Clear UX for conflict resolution; email notification; automatic merge when possible |
@@ -376,11 +383,9 @@ cairnquire/
2. Complete Milestone 2: Sync Protocol
3. Complete Milestone 3: Authentication
4. 🔄 Finish Milestone 4: Collaboration
- Wire up comment UI in browser (JS exists, needs integration)
- Wire up notification bell UI
- Implement actual email sending (Postmark or SMTP)
- Add notification preferences page
- Test watcher-driven notifications end-to-end
- Complete inbound email replies, mention notifications, and conflict notifications
- Add global/per-resource notification preferences and digest scheduling
- Test watcher-driven notifications against live Postmark and Cloudflare accounts
5. 🔄 Finish Milestone 5: Search & UI Polish
- Tag filtering in search
- Design system /design route
@@ -388,11 +393,11 @@ cairnquire/
- Mobile-responsive layout polish
- Accessibility improvements
6. Milestone 6: Production Hardening
- Increase test coverage from 39% to 80%+
- Increase test coverage from 52.4% to 80%+
- Add integration tests for auth, sync, collaboration
- Security audit checklist
- Backup/restore scripts
- Health check and monitoring endpoints
- Complete dependency health checks and add monitoring endpoints
---

View File

@@ -53,8 +53,12 @@ deployment, only `CAIRNQUIRE_PUBLIC_ORIGIN` must be supplied in `.env`.
| Variable | Required | Compose default | Description |
|----------|----------|-----------------|-------------|
| `CAIRNQUIRE_PUBLIC_ORIGIN` | Yes | None | Exact external HTTPS origin. Passkeys and device-flow URLs depend on this value. |
| `CAIRNQUIRE_EMAIL_PROVIDER` | No | `smtp` | Outbound provider: `smtp`, `postmark`, `cloudflare`, or `noop`. |
| `CAIRNQUIRE_EMAIL_SMTP_HOST` | No | `mailpit` | SMTP server used for notifications. |
| `CAIRNQUIRE_EMAIL_SMTP_PORT` | No | `1025` | SMTP server port. |
| `CAIRNQUIRE_EMAIL_POSTMARK_TOKEN` | For Postmark | Empty | Postmark server token. |
| `CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID` | For Cloudflare | Empty | Account with Cloudflare Email Sending enabled. |
| `CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN` | For Cloudflare | Empty | API token with Email Sending permission. Store it as a deployment secret. |
| `CAIRNQUIRE_EMAIL_FROM` | No | `notifications@cairnquire.local` | Notification sender address. |
| `CAIRNQUIRE_LOG_LEVEL` | No | `INFO` | Application log level. |
@@ -72,6 +76,23 @@ checked-in Compose deployment:
| `CAIRNQUIRE_CONFIG` | Empty | Optional JSON configuration file. Environment variables override it. |
| `CAIRNQUIRE_DEV_MODE` | `false` | Local-only auth shortcut. Never enable this on a deployed server. |
### Cloudflare Email Service
Cairnquire is an external Go application, so it uses Cloudflare Email
Service's REST API rather than a Workers binding. Before enabling the provider:
1. Enable Email Sending for the sender domain in Cloudflare, or run `npx wrangler email sending enable example.com`.
2. Create a Cloudflare API token with Email Sending permission for the account.
3. Set `CAIRNQUIRE_EMAIL_PROVIDER=cloudflare`, the account ID, API token, and a `CAIRNQUIRE_EMAIL_FROM` address on the onboarded domain.
Cloudflare Email Service is used only for transactional notifications. API
tokens must be supplied through deployment secrets and must not be committed.
The durable queue retries Cloudflare `429` and server errors; validation and
permanent-bounce responses are retained immediately as failed deliveries.
Cloudflare Email Routing for inbound comment replies is a separate Milestone 4
feature and is not enabled by these outbound settings.
## Persistent Data
Compose mounts:

View File

@@ -1,4 +1,4 @@
FROM golang:1.24-bookworm AS server-build
FROM golang:1.26.5-bookworm AS server-build
WORKDIR /workspace
RUN apt-get update && apt-get install -y --no-install-recommends build-essential ca-certificates && rm -rf /var/lib/apt/lists/*
COPY go.work ./
@@ -9,17 +9,18 @@ COPY apps/server/ ./
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gosu && rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /usr/sbin/nologin appuser
WORKDIR /workspace
COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire
COPY content /workspace/content
RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace
USER appuser
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN mkdir -p /workspace/data/files && chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8080
ENV CAIRNQUIRE_SERVER_ADDR=:8080
ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite
ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content
ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/cairnquire"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/usr/local/bin/cairnquire"]

View File

@@ -116,8 +116,12 @@ Supported variables:
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
- `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory.
- `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store.
- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host. Set to an empty string in JSON config to disable delivery.
- `CAIRNQUIRE_EMAIL_PROVIDER` - `smtp`, `postmark`, `cloudflare`, or `noop`. Compose defaults to `smtp` with Mailpit.
- `CAIRNQUIRE_EMAIL_SMTP_HOST` - SMTP host for local delivery.
- `CAIRNQUIRE_EMAIL_SMTP_PORT` - SMTP port. Defaults to `1025`.
- `CAIRNQUIRE_EMAIL_POSTMARK_TOKEN` - Postmark server token when the provider is `postmark`.
- `CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID` - Cloudflare account with Email Sending enabled.
- `CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN` - Cloudflare token with Email Sending permission.
- `CAIRNQUIRE_EMAIL_FROM` - Notification sender address.
- `CAIRNQUIRE_LOG_LEVEL` - Defaults to `INFO`.
- `CAIRNQUIRE_DEV_MODE` - Optional local-only auth shortcut. Do not enable on a deployed server.

View File

@@ -210,6 +210,24 @@ public actor APIClient {
return try await perform(request)
}
public func searchDocuments(query: String) async throws -> [SearchResult] {
var components = URLComponents(url: baseURL.appendingPathComponent("api/search"), resolvingAgainstBaseURL: false)
components?.queryItems = [URLQueryItem(name: "q", value: query)]
guard let url = components?.url else {
throw APIError(error: "invalid search URL")
}
var request = URLRequest(url: url)
if let token = apiToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.setValue("application/json", forHTTPHeaderField: "Accept")
let response: SearchResponse = try await perform(request)
return response.results
}
// MARK: - Sync
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {

View File

@@ -52,6 +52,12 @@ public struct DocumentSaveResponse: Codable {
public let hash: String
}
public struct SearchResult: Codable, Identifiable {
public var id: String { path }
public let path: String
public let title: String
}
public struct SyncInitRequest: Codable {
public let deviceId: String
public let rootPath: String
@@ -178,6 +184,10 @@ public struct DocumentsListResponse: Codable {
public let documents: [DocumentRecord]
}
public struct SearchResponse: Codable {
public let results: [SearchResult]
}
public enum UploadDuplicateAction: String {
case overwrite
case rename

View File

@@ -8,6 +8,8 @@ public struct ClipboardNotice: Identifiable, Equatable {
@MainActor
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
public static let minimumSearchCharacters = 3
@Published public var config: SyncConfig
@Published public var isSyncing = false
@Published public var lastSyncTime: Date?
@@ -18,6 +20,11 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
@Published public private(set) var isLoadingRecentUploads = false
@Published public private(set) var recentUploadsError: String?
@Published public private(set) var clipboardNotice: ClipboardNotice?
@Published public var searchQuery = ""
@Published public private(set) var searchResults: [SearchResult] = []
@Published public private(set) var isSearching = false
@Published public private(set) var searchError: String?
@Published public var selectedSearchIndex = 0
public var cancellables = Set<AnyCancellable>()
@@ -27,6 +34,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
private var uploadManager: UploadManager
private var folderWatcher: FolderWatcher?
private var syncTimer: Timer?
private var searchTask: Task<Void, Never>?
private var settingsWindow: NSWindow?
private var uploadsWindow: NSWindow?
@@ -59,6 +67,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
uploadManager = UploadManager(client: apiClient, config: newConfig)
syncEngine = SyncEngine(client: apiClient, config: newConfig)
resetSearch()
await refreshRecentUploads()
await setupSync()
}
@@ -309,6 +318,106 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
}
}
public func updateSearchQuery(_ query: String) {
searchQuery = query
selectedSearchIndex = 0
searchTask?.cancel()
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= Self.minimumSearchCharacters else {
searchResults = []
isSearching = false
searchError = nil
return
}
isSearching = true
searchError = nil
searchTask = Task { [weak self] in
do {
try await Task.sleep(for: .milliseconds(300))
await self?.performSearch(for: trimmed)
} catch {
await MainActor.run {
if self?.isCurrentSearchQuery(trimmed) == true {
self?.isSearching = false
}
}
}
}
}
public func clearSearch() {
resetSearch()
}
public func moveSearchSelection(by delta: Int) {
guard !searchResults.isEmpty else { return }
selectedSearchIndex = min(max(selectedSearchIndex + delta, 0), searchResults.count - 1)
}
public func openSelectedSearchResult() {
guard searchResults.indices.contains(selectedSearchIndex) else { return }
openSearchResult(searchResults[selectedSearchIndex])
}
public func openSearchResult(_ result: SearchResult) {
guard let url = documentURL(for: result) else { return }
NSWorkspace.shared.open(url)
}
private func performSearch(for query: String) async {
do {
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
guard !Task.isCancelled else { return }
guard isCurrentSearchQuery(query) else { return }
searchResults = Array(results.prefix(8))
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
searchError = nil
} catch {
guard !Task.isCancelled else { return }
guard isCurrentSearchQuery(query) else { return }
searchResults = []
searchError = error.localizedDescription
}
isSearching = false
}
private func isCurrentSearchQuery(_ query: String) -> Bool {
let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed == query && trimmed.count >= Self.minimumSearchCharacters
}
private func resetSearch() {
searchTask?.cancel()
searchQuery = ""
searchResults = []
isSearching = false
searchError = nil
selectedSearchIndex = 0
}
private func liveSearchQuery(for query: String) -> String {
let terms = query
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !terms.isEmpty else { return query }
return terms.map { "\($0)*" }.joined(separator: " ")
}
private func documentURL(for result: SearchResult) -> URL? {
guard let baseURL = config.serverBaseURL else { return nil }
let trimmedPath = result.path.hasSuffix(".md") ? String(result.path.dropLast(3)) : result.path
var allowed = CharacterSet.urlPathAllowed
allowed.remove(charactersIn: "?#")
guard let encodedPath = trimmedPath.addingPercentEncoding(withAllowedCharacters: allowed) else {
return nil
}
return URL(string: "docs/\(encodedPath)", relativeTo: baseURL)?.absoluteURL
}
public func createNote() {
switch config.effectiveNewNoteAction {
case .clipboard:

View File

@@ -2,6 +2,7 @@ import SwiftUI
public struct StatusPopoverView: View {
@ObservedObject public var controller: MenuBarController
@FocusState private var searchFocused: Bool
public init(controller: MenuBarController) {
self.controller = controller
@@ -14,6 +15,10 @@ public struct StatusPopoverView: View {
Divider()
searchSection
Divider()
// Quick actions
actionsSection
@@ -27,8 +32,23 @@ public struct StatusPopoverView: View {
// Footer
footerSection
}
.frame(width: 320)
.frame(width: 360)
.padding(.vertical, 12)
.onAppear {
Task { @MainActor in
searchFocused = true
}
}
.onMoveCommand { direction in
switch direction {
case .up:
controller.moveSearchSelection(by: -1)
case .down:
controller.moveSearchSelection(by: 1)
default:
break
}
}
}
// MARK: - Sections
@@ -131,6 +151,75 @@ public struct StatusPopoverView: View {
.padding(.vertical, 12)
}
@ViewBuilder
private var searchSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
TextField(
"Search documents",
text: Binding(
get: { controller.searchQuery },
set: { controller.updateSearchQuery($0) }
)
)
.textFieldStyle(.plain)
.focused($searchFocused)
.onSubmit {
controller.openSelectedSearchResult()
}
if controller.isSearching {
ProgressView()
.controlSize(.small)
.scaleEffect(0.65)
.frame(width: 14, height: 14)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color(NSColor.controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
if controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).count >= MenuBarController.minimumSearchCharacters {
searchResultsList
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
@ViewBuilder
private var searchResultsList: some View {
if controller.searchResults.isEmpty {
Text(controller.isSearching ? "Searching..." : (controller.searchError == nil ? "No results" : "Search unavailable"))
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
} else {
VStack(spacing: 2) {
ForEach(Array(controller.searchResults.enumerated()), id: \.element.id) { index, result in
SearchResultRow(
result: result,
isSelected: index == controller.selectedSearchIndex
) {
controller.selectedSearchIndex = index
controller.openSearchResult(result)
}
.onHover { isHovered in
if isHovered {
controller.selectedSearchIndex = index
}
}
}
}
}
}
@ViewBuilder
private var recentUploadsSection: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -272,6 +361,47 @@ private struct RecentUploadRow: View {
}
}
private struct SearchResultRow: View {
let result: SearchResult
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.font(.system(size: 14, weight: .medium))
.foregroundColor(isSelected ? .white : .accentColor)
.frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
Text(result.title.isEmpty ? result.path : result.title)
.font(.caption)
.foregroundColor(isSelected ? .white : .primary)
.lineLimit(1)
Text(result.path)
.font(.caption2)
.foregroundColor(isSelected ? .white.opacity(0.82) : .secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "return")
.font(.caption2)
.foregroundColor(isSelected ? .white.opacity(0.75) : .secondary)
.opacity(isSelected ? 1 : 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(height: 44)
.background(isSelected ? Color.accentColor : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
// MARK: - Action Button
struct ActionButton: View {

View File

@@ -81,22 +81,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func updateIcon(config: SyncConfig) {
guard controller.uploadProgress == nil else { return }
let iconName = config.iconName
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: "Cairnquire")
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: "Cairnquire")
dropButton.image = image
dropButton.image = makeMenuBarIcon(named: iconName, accessibilityDescription: "Cairnquire")
dropButton.setDropTargetActive(false)
}
private func updateIconForUpload(progress: Double?) {
if progress != nil {
// During upload: show tray.circle (outline)
let image = NSImage(systemSymbolName: "tray.circle", accessibilityDescription: "Uploading")
dropButton.image = image
dropButton.image = makeMenuBarIcon(named: "tray.circle", accessibilityDescription: "Uploading")
dropButton.setDropTargetActive(false)
} else {
// Upload complete: restore normal icon
updateIcon(config: controller.config)
}
}
private func makeMenuBarIcon(named iconName: String, accessibilityDescription: String) -> NSImage? {
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: accessibilityDescription)
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: accessibilityDescription)
image?.isTemplate = true
return image
}
func bounceIcon() {
guard let button = dropButton else { return }
@@ -223,8 +229,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private func showPopover() {
guard let button = dropButton else { return }
Task { @MainActor in
controller.clearSearch()
await controller.refreshRecentUploads()
}
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
@@ -323,19 +331,32 @@ class DropStatusButton: NSButton {
}
private func setup() {
isBordered = false
bezelStyle = .regularSquare
imageScaling = .scaleProportionallyDown
registerForDraggedTypes([.fileURL])
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
// Tint icon slightly to indicate drop zone is active
if let image = self.image {
self.image = image.tinted(with: .systemBlue)
func setDropTargetActive(_ isActive: Bool) {
isBordered = isActive
contentTintColor = isActive ? .systemBlue : nil
needsDisplay = true
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
let pasteboard = sender.draggingPasteboard
guard pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) else {
setDropTargetActive(false)
return []
}
// Show the button frame only while the menu bar icon is a valid drop target.
setDropTargetActive(true)
return .copy
}
override func draggingExited(_ sender: NSDraggingInfo?) {
// Restore original icon
setDropTargetActive(false)
if let appDelegate = appDelegate {
appDelegate.updateIcon(config: appDelegate.controller.config)
}
@@ -345,9 +366,11 @@ class DropStatusButton: NSButton {
let pasteboard = sender.draggingPasteboard
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
!urls.isEmpty else {
setDropTargetActive(false)
return false
}
setDropTargetActive(false)
appDelegate?.bounceIcon()
Task { @MainActor in
@@ -357,18 +380,3 @@ class DropStatusButton: NSButton {
return true
}
}
extension NSImage {
func tinted(with color: NSColor) -> NSImage {
let image = self.copy() as! NSImage
image.isTemplate = false
let rect = NSRect(origin: .zero, size: image.size)
image.lockFocus()
color.set()
rect.fill(using: .sourceAtop)
image.unlockFocus()
return image
}
}

BIN
apps/server/cairnquire Executable file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
module github.com/tim/cairnquire/apps/server
go 1.24.2
go 1.26.5
require (
github.com/fsnotify/fsnotify v1.9.0

View File

@@ -7,7 +7,6 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
@@ -30,6 +29,7 @@ type App struct {
docs *docs.Service
hub *realtime.Hub
server *http.Server
emailQueue *email.Queue
}
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
@@ -69,17 +69,69 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
collabRepo := collaboration.NewRepository(db.SQL())
var emailSender collaboration.EmailSender
if cfg.Email.SMTPHost != "" {
emailSender = email.NewSMTPSender(cfg.Email, logger)
authService.SetEmailSender(emailSender)
} else {
emailSender = email.NewNoOpSender(logger)
var emailQueue *email.Queue
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
switch cfg.Email.ResolvedProvider() {
case "cloudflare":
cf := email.NewCloudflareSender(
cfg.Email.CloudflareAccountID,
cfg.Email.CloudflareAPIToken,
cfg.Email.From,
cfg.Email.CloudflareAPIURL,
logger,
)
emailSender = cf
authService.SetEmailSender(cf)
case "postmark":
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
emailSender = pm
authService.SetEmailSender(pm)
case "smtp":
sm := email.NewSMTPSender(cfg.Email, logger)
emailSender = sm
authService.SetEmailSender(sm)
default:
noOp := email.NewNoOpSender(logger)
emailSender = noOp
authService.SetEmailSender(noOp)
}
collabService := collaboration.NewService(collabRepo, emailSender, hub, logger)
// The durable queue wraps whichever sender is configured so retries
// survive process restarts. It is nil-safe in the service: if absent,
// notifications stay in-app only.
emailQueue = email.NewQueue(db.SQL(), emailSender, logger)
collabService := collaboration.NewServiceWithOptions(collabRepo, hub, logger, collaboration.Options{
EmailSender: emailSender,
EmailQueue: emailQueue,
EmailRenderer: emailRenderer,
UserLookup: authRepo,
CanReadDocument: func(ctx context.Context, userID, documentID string) (bool, error) {
document, err := repo.GetDocumentByID(ctx, documentID)
if err != nil {
return false, err
}
principal, err := authService.PrincipalForUser(ctx, userID)
if err != nil {
return false, err
}
permission, err := authService.EffectiveResourcePermission(ctx, principal, auth.ResourceDocument, document.Path, document.Tags)
if err != nil {
return false, err
}
return auth.PermissionAllows(permission, auth.PermissionRead), nil
},
ResolveDocument: func(ctx context.Context, documentID string) (string, string, error) {
document, err := repo.GetDocumentByID(ctx, documentID)
if err != nil {
return "", "", err
}
return document.Path, document.Title, nil
},
PublicOrigin: cfg.Auth.PublicOrigin,
})
service.OnChange(func(change docs.DocumentChange) {
hub.Broadcast(realtime.Event{Type: "document_version", Data: change})
if err := collabService.NotifyDocumentChanged(ctx, formatDocumentID(change.Path), change.Path, "system"); err != nil {
if err := collabService.NotifyDocumentChanged(ctx, change.DocumentID, change.Path, "system"); err != nil {
logger.Warn("notify document changed", "error", err)
}
})
@@ -116,12 +168,16 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
docs: service,
hub: hub,
server: server,
emailQueue: emailQueue,
}, nil
}
func (a *App) Run(ctx context.Context) error {
go a.watchDocuments(ctx)
go a.syncPoll(ctx)
if a.emailQueue != nil {
go a.emailQueue.Run(ctx)
}
go func() {
<-ctx.Done()
@@ -175,9 +231,3 @@ func (a *App) syncPoll(ctx context.Context) {
func (a *App) Close() error {
return a.db.Close()
}
func formatDocumentID(path string) string {
clean := strings.TrimPrefix(path, "/")
clean = strings.TrimSuffix(clean, ".md")
return "doc:" + clean
}

View File

@@ -169,3 +169,7 @@ func hasScope(scopes []Scope, required Scope) bool {
}
return false
}
func HasScope(scopes []Scope, required Scope) bool {
return hasScope(scopes, required)
}

View File

@@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/go-webauthn/webauthn/webauthn"
@@ -284,6 +286,349 @@ func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessU
return nil
}
type resourceMatch struct {
resourceType ResourceType
resourceID string
}
func (r *Repository) ListResourcePermissions(ctx context.Context, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) {
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT p.id, p.user_id, COALESCE(u.email, ''), COALESCE(u.display_name, ''), p.resource_type, COALESCE(p.resource_id, ''), p.permission, COALESCE(p.granted_by, ''), p.created_at
FROM permissions p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.resource_type = ? AND COALESCE(p.resource_id, '') = ?
ORDER BY u.email ASC, p.created_at ASC
`, string(resourceType), resourceID)
if err != nil {
return nil, fmt.Errorf("list resource permissions: %w", err)
}
defer rows.Close()
var permissions []ResourcePermission
for rows.Next() {
permission, err := scanResourcePermission(rows)
if err != nil {
return nil, err
}
permissions = append(permissions, permission)
}
return permissions, rows.Err()
}
func (r *Repository) ReplaceResourcePermissions(ctx context.Context, resourceType ResourceType, resourceID string, actorID string, updates []ResourcePermissionUpdate) error {
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
if err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin resource permissions update: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
DELETE FROM permissions
WHERE resource_type = ? AND COALESCE(resource_id, '') = ?
`, string(resourceType), resourceID); err != nil {
return fmt.Errorf("clear resource permissions: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
for _, update := range updates {
update.UserID = strings.TrimSpace(update.UserID)
if update.UserID == "" || update.Permission == PermissionNone {
continue
}
if !validPermission(update.Permission) {
return fmt.Errorf("invalid permission")
}
var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ? AND disabled = 0`, update.UserID).Scan(&exists); err != nil {
return fmt.Errorf("check permission user: %w", err)
}
if exists == 0 {
return fmt.Errorf("user %s does not exist", update.UserID)
}
idPart, err := randomToken(18)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, "perm:"+idPart, update.UserID, string(resourceType), nullString(resourceID), string(update.Permission), nullString(actorID), now); err != nil {
return fmt.Errorf("insert resource permission: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit resource permissions update: %w", err)
}
return nil
}
func (r *Repository) EnsureResourcePermission(ctx context.Context, resourceType ResourceType, resourceID string, actorID string, update ResourcePermissionUpdate) error {
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
if err != nil {
return err
}
update.UserID = strings.TrimSpace(update.UserID)
if update.UserID == "" {
return fmt.Errorf("user id is required")
}
if !validPermission(update.Permission) || update.Permission == PermissionNone {
return fmt.Errorf("invalid permission")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin resource permission ensure: %w", err)
}
defer func() { _ = tx.Rollback() }()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ? AND disabled = 0`, update.UserID).Scan(&exists); err != nil {
return fmt.Errorf("check permission user: %w", err)
}
if exists == 0 {
return fmt.Errorf("user %s does not exist", update.UserID)
}
best := PermissionNone
rows, err := tx.QueryContext(ctx, `
SELECT permission
FROM permissions
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
`, update.UserID, string(resourceType), resourceID)
if err != nil {
return fmt.Errorf("lookup existing resource permission: %w", err)
}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
rows.Close()
return fmt.Errorf("scan existing resource permission: %w", err)
}
best = maxPermission(best, Permission(raw))
}
if err := rows.Close(); err != nil {
return err
}
if PermissionAllows(best, update.Permission) {
return tx.Commit()
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM permissions
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
`, update.UserID, string(resourceType), resourceID); err != nil {
return fmt.Errorf("clear user resource permission: %w", err)
}
idPart, err := randomToken(18)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, "perm:"+idPart, update.UserID, string(resourceType), nullString(resourceID), string(update.Permission), nullString(actorID), time.Now().UTC().Format(time.RFC3339)); err != nil {
return fmt.Errorf("insert resource permission: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit resource permission ensure: %w", err)
}
return nil
}
func (r *Repository) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) {
if principal.UserID == "" {
return PermissionNone, nil
}
if principal.Role == RoleAdmin {
return PermissionAdmin, nil
}
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
if err != nil {
return PermissionNone, err
}
matches := []resourceMatch{{resourceType: ResourceGlobal, resourceID: ""}}
switch resourceType {
case ResourceDocument:
for _, folder := range folderMatchesForDocument(resourceID) {
matches = append(matches, resourceMatch{resourceType: ResourceFolder, resourceID: folder})
}
for _, collectionID := range collectionIDs {
normalized := normalizeCollectionID(collectionID)
if normalized != "" {
matches = append(matches, resourceMatch{resourceType: ResourceCollection, resourceID: normalized})
}
}
matches = append(matches, resourceMatch{resourceType: ResourceDocument, resourceID: resourceID})
case ResourceFolder:
for _, folder := range folderMatches(resourceID) {
matches = append(matches, resourceMatch{resourceType: ResourceFolder, resourceID: folder})
}
case ResourceCollection:
matches = append(matches, resourceMatch{resourceType: ResourceCollection, resourceID: resourceID})
default:
matches = append(matches, resourceMatch{resourceType: resourceType, resourceID: resourceID})
}
best := PermissionNone
for _, match := range matches {
rows, err := r.db.QueryContext(ctx, `
SELECT permission
FROM permissions
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
`, principal.UserID, string(match.resourceType), match.resourceID)
if err != nil {
return PermissionNone, fmt.Errorf("lookup resource permission: %w", err)
}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
rows.Close()
return PermissionNone, fmt.Errorf("scan resource permission: %w", err)
}
best = maxPermission(best, Permission(raw))
}
if err := rows.Close(); err != nil {
return PermissionNone, err
}
}
return best, nil
}
func scanResourcePermission(rows *sql.Rows) (ResourcePermission, error) {
var (
permission ResourcePermission
resourceType string
resourceID string
rawPermission string
created string
)
if err := rows.Scan(&permission.ID, &permission.UserID, &permission.UserEmail, &permission.UserName, &resourceType, &resourceID, &rawPermission, &permission.GrantedBy, &created); err != nil {
return ResourcePermission{}, fmt.Errorf("scan resource permission: %w", err)
}
permission.ResourceType = ResourceType(resourceType)
permission.ResourceID = resourceID
permission.Permission = Permission(rawPermission)
createdAt, err := time.Parse(time.RFC3339, created)
if err != nil {
return ResourcePermission{}, fmt.Errorf("parse resource permission created_at: %w", err)
}
permission.CreatedAt = createdAt
return permission, nil
}
func normalizeResource(resourceType ResourceType, resourceID string) (ResourceType, string, error) {
resourceType = ResourceType(strings.TrimSpace(strings.ToLower(string(resourceType))))
switch resourceType {
case ResourceGlobal:
return resourceType, "", nil
case ResourceDocument:
resourceID = normalizeDocumentPath(resourceID)
if resourceID == "" {
return "", "", fmt.Errorf("document resource id is required")
}
return resourceType, resourceID, nil
case ResourceFolder:
return resourceType, normalizeFolderPath(resourceID), nil
case ResourceCollection:
resourceID = normalizeCollectionID(resourceID)
if resourceID == "" {
return "", "", fmt.Errorf("collection resource id is required")
}
return resourceType, resourceID, nil
default:
return "", "", fmt.Errorf("invalid resource type")
}
}
func normalizeDocumentPath(raw string) string {
clean := normalizeFolderPath(raw)
if clean == "" {
return ""
}
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
clean += ".md"
}
return clean
}
func normalizeFolderPath(raw string) string {
raw = strings.TrimSpace(strings.Trim(raw, "/"))
if raw == "" {
return ""
}
clean := path.Clean(strings.ReplaceAll(raw, "\\", "/"))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return ""
}
return strings.Trim(clean, "/")
}
func normalizeCollectionID(raw string) string {
return strings.Trim(strings.TrimSpace(raw), "#/")
}
func folderMatchesForDocument(documentPath string) []string {
folder := path.Dir(documentPath)
if folder == "." {
return []string{""}
}
return folderMatches(folder)
}
func folderMatches(folder string) []string {
folder = normalizeFolderPath(folder)
matches := []string{""}
if folder == "" {
return matches
}
parts := strings.Split(folder, "/")
for index := range parts {
matches = append(matches, strings.Join(parts[:index+1], "/"))
}
return matches
}
func validPermission(permission Permission) bool {
switch permission {
case PermissionNone, PermissionRead, PermissionWrite, PermissionAdmin:
return true
default:
return false
}
}
func maxPermission(left Permission, right Permission) Permission {
if permissionRank(right) > permissionRank(left) {
return right
}
return left
}
func permissionRank(permission Permission) int {
switch permission {
case PermissionRead:
return 1
case PermissionWrite:
return 2
case PermissionAdmin:
return 3
default:
return 0
}
}
func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
if err != nil {

View File

@@ -190,6 +190,57 @@ func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID str
return s.repo.GetUserByID(ctx, user.ID)
}
func (s *Service) BeginCurrentUserPasskeyRegistration(ctx context.Context, principal Principal) (any, string, error) {
if principal.UserID == "" {
return nil, "", fmt.Errorf("authentication required")
}
user, err := s.repo.GetUserByID(ctx, principal.UserID)
if err != nil {
return nil, "", err
}
if user.Disabled {
return nil, "", fmt.Errorf("account disabled")
}
creation, session, err := s.webauthn.BeginRegistration(user)
if err != nil {
return nil, "", err
}
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL)
if err != nil {
return nil, "", err
}
return creation, challengeID, nil
}
func (s *Service) FinishCurrentUserPasskeyRegistration(ctx context.Context, principal Principal, challengeID string, r *http.Request) (User, error) {
if principal.UserID == "" {
return User{}, fmt.Errorf("authentication required")
}
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
if err != nil {
return User{}, err
}
if userID != principal.UserID {
return User{}, fmt.Errorf("challenge does not belong to the current user")
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return User{}, err
}
if user.Disabled {
return User{}, fmt.Errorf("account disabled")
}
credential, err := s.webauthn.FinishRegistration(user, session, r)
if err != nil {
return User{}, err
}
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
return User{}, err
}
s.repo.Audit(ctx, user.ID, "auth.passkey.add", "user", user.ID, clientIP(r), r.UserAgent(), nil)
return s.repo.GetUserByID(ctx, user.ID)
}
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil {
@@ -409,6 +460,96 @@ func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates
return users, nil
}
func (s *Service) ListResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) {
if actor.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
if actor.Role != RoleAdmin {
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
if err != nil {
return nil, err
}
if !PermissionAllows(permission, PermissionAdmin) {
return nil, fmt.Errorf("admin permission required")
}
}
return s.repo.ListResourcePermissions(ctx, resourceType, resourceID)
}
func (s *Service) UpdateResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, updates []ResourcePermissionUpdate) ([]ResourcePermission, error) {
if actor.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
if actor.Role != RoleAdmin {
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
if err != nil {
return nil, err
}
if !PermissionAllows(permission, PermissionAdmin) {
return nil, fmt.Errorf("admin permission required")
}
}
normalized := make([]ResourcePermissionUpdate, 0, len(updates))
seen := make(map[string]struct{}, len(updates))
for _, update := range updates {
update.UserID = strings.TrimSpace(update.UserID)
if update.UserID == "" {
continue
}
if _, ok := seen[update.UserID]; ok {
return nil, fmt.Errorf("duplicate user permission")
}
seen[update.UserID] = struct{}{}
update.Permission = normalizePermission(update.Permission)
if !validPermission(update.Permission) {
return nil, fmt.Errorf("invalid permission")
}
normalized = append(normalized, update)
}
if err := s.repo.ReplaceResourcePermissions(ctx, resourceType, resourceID, actor.UserID, normalized); err != nil {
return nil, err
}
s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.update", string(resourceType), resourceID, "", "", map[string]any{
"updates": len(normalized),
})
return s.repo.ListResourcePermissions(ctx, resourceType, resourceID)
}
func (s *Service) EnsureResourcePermission(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, update ResourcePermissionUpdate) error {
if actor.UserID == "" {
return fmt.Errorf("authentication required")
}
update.UserID = strings.TrimSpace(update.UserID)
if update.UserID == "" {
return fmt.Errorf("user id is required")
}
update.Permission = normalizePermission(update.Permission)
if !validPermission(update.Permission) || update.Permission == PermissionNone {
return fmt.Errorf("invalid permission")
}
if update.UserID != actor.UserID && actor.Role != RoleAdmin {
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
if err != nil {
return err
}
if !PermissionAllows(permission, PermissionAdmin) {
return fmt.Errorf("admin permission required")
}
}
if err := s.repo.EnsureResourcePermission(ctx, resourceType, resourceID, actor.UserID, update); err != nil {
return err
}
s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.ensure", string(resourceType), resourceID, "", "", map[string]any{
"userID": update.UserID,
"permission": update.Permission,
})
return nil
}
func (s *Service) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) {
return s.repo.EffectiveResourcePermission(ctx, principal, resourceType, resourceID, collectionIDs)
}
func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
if !Allows(actor, ScopeAdmin) {
return fmt.Errorf("admin role required")
@@ -715,6 +856,23 @@ func RoleAllows(role Role, required Scope) bool {
}
}
func PermissionAllows(actual Permission, required Permission) bool {
return permissionRank(actual) >= permissionRank(required)
}
func normalizePermission(permission Permission) Permission {
switch Permission(strings.ToLower(strings.TrimSpace(string(permission)))) {
case "view":
return PermissionRead
case "edit":
return PermissionWrite
case PermissionRead, PermissionWrite, PermissionAdmin, PermissionNone:
return Permission(strings.ToLower(strings.TrimSpace(string(permission))))
default:
return Permission(strings.ToLower(strings.TrimSpace(string(permission))))
}
}
func Allows(principal Principal, required Scope) bool {
if principal.UserID == "" {
return false

View File

@@ -315,6 +315,21 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
}
}
func TestCurrentUserCanBeginPasskeyRegistrationForExistingAccount(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user := setupInitialAdmin(t, service, false)
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
options, challengeID, err := service.BeginCurrentUserPasskeyRegistration(ctx, principal)
if err != nil {
t.Fatalf("BeginCurrentUserPasskeyRegistration() error = %v", err)
}
if options == nil || challengeID == "" {
t.Fatalf("options = %#v, challengeID = %q; want registration options and challenge", options, challengeID)
}
}
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
@@ -343,3 +358,82 @@ func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
t.Fatalf("public signup role = %s, want viewer", viewer.Role)
}
}
func TestResourcePermissionsArePrivateByDefault(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
viewer, err := service.repo.UpsertUser(ctx, User{
Email: "viewer@example.com",
DisplayName: "Viewer",
Role: RoleViewer,
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "private/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() error = %v", err)
}
if permission != PermissionNone {
t.Fatalf("viewer permission = %q, want no access", permission)
}
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
permission, err = service.EffectiveResourcePermission(ctx, adminPrincipal, ResourceDocument, "private/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() admin error = %v", err)
}
if permission != PermissionAdmin {
t.Fatalf("admin permission = %q, want admin", permission)
}
}
func TestResourcePermissionsUseMostPermissiveGrant(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
viewer, err := service.repo.UpsertUser(ctx, User{
Email: "viewer@example.com",
DisplayName: "Viewer",
Role: RoleViewer,
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceDocument, "shared/page.md", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionRead},
}); err != nil {
t.Fatalf("grant document read: %v", err)
}
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceFolder, "shared", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionWrite},
}); err != nil {
t.Fatalf("grant folder write: %v", err)
}
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "shared/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() folder error = %v", err)
}
if permission != PermissionWrite {
t.Fatalf("folder/document combined permission = %q, want write", permission)
}
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceCollection, "team", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionAdmin},
}); err != nil {
t.Fatalf("grant collection admin: %v", err)
}
permission, err = service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "other/page.md", []string{"team"})
if err != nil {
t.Fatalf("EffectiveResourcePermission() collection error = %v", err)
}
if permission != PermissionAdmin {
t.Fatalf("collection permission = %q, want admin", permission)
}
}

View File

@@ -24,6 +24,24 @@ const (
ScopeAdmin Scope = "admin"
)
type Permission string
const (
PermissionNone Permission = ""
PermissionRead Permission = "read"
PermissionWrite Permission = "write"
PermissionAdmin Permission = "admin"
)
type ResourceType string
const (
ResourceGlobal ResourceType = "global"
ResourceCollection ResourceType = "collection"
ResourceDocument ResourceType = "document"
ResourceFolder ResourceType = "folder"
)
type Principal struct {
UserID string `json:"userId"`
Email string `json:"email"`
@@ -54,6 +72,23 @@ type UserAccessUpdate struct {
Disabled bool `json:"disabled"`
}
type ResourcePermission struct {
ID string `json:"id"`
UserID string `json:"userId"`
UserEmail string `json:"userEmail,omitempty"`
UserName string `json:"userName,omitempty"`
ResourceType ResourceType `json:"resourceType"`
ResourceID string `json:"resourceId"`
Permission Permission `json:"permission"`
GrantedBy string `json:"grantedBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type ResourcePermissionUpdate struct {
UserID string `json:"userId"`
Permission Permission `json:"permission"`
}
type PasswordResetToken struct {
ID string
UserID string

View File

@@ -63,14 +63,17 @@ func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error
return &c, nil
}
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
query := `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.document_id = ?
ORDER BY c.created_at ASC
`, documentID)
WHERE c.document_id = ?`
if !includeResolved {
query += ` AND c.resolved_at IS NULL`
}
query += ` ORDER BY c.created_at ASC`
rows, err := r.db.QueryContext(ctx, query, documentID)
if err != nil {
return nil, fmt.Errorf("list comments: %w", err)
}
@@ -79,14 +82,17 @@ func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID stri
return scanComments(rows)
}
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
query := `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.document_id = ? AND c.anchor_hash = ? AND c.resolved_at IS NULL
ORDER BY c.created_at ASC
`, documentID, anchorHash)
WHERE c.document_id = ? AND c.anchor_hash = ?`
if !includeResolved {
query += ` AND c.resolved_at IS NULL`
}
query += ` ORDER BY c.created_at ASC`
rows, err := r.db.QueryContext(ctx, query, documentID, anchorHash)
if err != nil {
return nil, fmt.Errorf("list comments by anchor: %w", err)
}
@@ -95,13 +101,25 @@ func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string
return scanComments(rows)
}
func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error {
_, err := r.db.ExecContext(ctx, `
func (r *Repository) ResolveCommentThread(ctx context.Context, commentID string, userID string) error {
result, err := r.db.ExecContext(ctx, `
WITH RECURSIVE thread(id) AS (
SELECT id FROM comments WHERE id = ? AND parent_id IS NULL
UNION ALL
SELECT c.id FROM comments c JOIN thread t ON c.parent_id = t.id
)
UPDATE comments SET resolved_at = ?, resolved_by = ?
WHERE id = ?
`, formatTime(time.Now().UTC()), userID, commentID)
WHERE id IN (SELECT id FROM thread) AND resolved_at IS NULL
`, commentID, formatTime(time.Now().UTC()), userID)
if err != nil {
return fmt.Errorf("resolve comment: %w", err)
return fmt.Errorf("resolve comment thread: %w", err)
}
changed, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("resolve comment thread result: %w", err)
}
if changed == 0 {
return fmt.Errorf("comment thread not found or already resolved")
}
return nil
}
@@ -228,10 +246,12 @@ func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string
// Watchers
func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error {
if (w.DocumentID == nil) == (w.FolderPath == nil) {
return fmt.Errorf("watcher requires exactly one target")
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO watchers (user_id, document_id, folder_path, created_at)
INSERT OR IGNORE INTO watchers (user_id, document_id, folder_path, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, document_id, folder_path) DO NOTHING
`, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt))
if err != nil {
return fmt.Errorf("add watcher: %w", err)
@@ -262,12 +282,12 @@ func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID stri
return scanWatchers(rows)
}
func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string) ([]Watcher, error) {
func (r *Repository) ListWatchersByFolder(ctx context.Context, documentPath string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT user_id, document_id, folder_path, created_at
FROM watchers
WHERE folder_path = ? OR ? || '/' = substr(folder_path, 1, length(?) + 1)
`, folderPath, folderPath, folderPath)
WHERE folder_path = '' OR folder_path || '/' = substr(?, 1, length(folder_path) + 1)
`, documentPath)
if err != nil {
return nil, fmt.Errorf("list folder watchers: %w", err)
}
@@ -275,6 +295,24 @@ func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string
return scanWatchers(rows)
}
func (r *Repository) ListWatchersForDocument(ctx context.Context, documentID string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT w.user_id, w.document_id, w.folder_path, w.created_at
FROM watchers w
JOIN documents d ON d.id = ?
WHERE w.document_id = d.id
OR (w.folder_path IS NOT NULL AND (
w.folder_path = ''
OR w.folder_path || '/' = substr(d.path, 1, length(w.folder_path) + 1)
))
`, documentID)
if err != nil {
return nil, fmt.Errorf("list watchers for document: %w", err)
}
defer rows.Close()
return scanWatchers(rows)
}
func (r *Repository) IsWatching(ctx context.Context, userID string, documentID string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `

View File

@@ -0,0 +1,157 @@
package collaboration
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
)
func newCollaborationTestRepository(t *testing.T) (*Repository, *sql.DB) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "test.sqlite")})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
t.Fatalf("apply migrations: %v", err)
}
now := formatTime(time.Now().UTC())
for _, user := range []struct{ id, email, name string }{
{"user:author", "author@example.com", "Author"},
{"user:watcher", "watcher@example.com", "Watcher"},
} {
if _, err := db.SQL().ExecContext(ctx, `
INSERT INTO users (id, email, display_name, role, created_at) VALUES (?, ?, ?, 'editor', ?)
`, user.id, user.email, user.name, now); err != nil {
t.Fatalf("insert user: %v", err)
}
}
for _, document := range []struct{ id, path, title string }{
{"doc:guide/setup", "guide/setup.md", "Setup"},
{"doc:other", "other.md", "Other"},
} {
if _, err := db.SQL().ExecContext(ctx, `
INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at)
VALUES (?, ?, 'hash', ?, '[]', ?, ?)
`, document.id, document.path, document.title, now, now); err != nil {
t.Fatalf("insert document: %v", err)
}
}
return NewRepository(db.SQL()), db.SQL()
}
func TestWatcherTargetsAreIdempotentAndFolderMatchingUsesAncestors(t *testing.T) {
repo, db := newCollaborationTestRepository(t)
ctx := context.Background()
now := time.Now().UTC()
documentID := "doc:guide/setup"
folder := "guide"
for range 2 {
if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", DocumentID: &documentID, CreatedAt: now}); err != nil {
t.Fatalf("add document watcher: %v", err)
}
if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", FolderPath: &folder, CreatedAt: now}); err != nil {
t.Fatalf("add folder watcher: %v", err)
}
}
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM watchers`).Scan(&count); err != nil {
t.Fatalf("count watchers: %v", err)
}
if count != 2 {
t.Fatalf("watcher count = %d, want 2", count)
}
tests := []struct {
path string
want int
}{
{"guide/setup.md", 1},
{"guide/deep/setup.md", 1},
{"guidebook/setup.md", 0},
{"other.md", 0},
}
for _, test := range tests {
watchers, err := repo.ListWatchersByFolder(ctx, test.path)
if err != nil {
t.Fatalf("ListWatchersByFolder(%q): %v", test.path, err)
}
if len(watchers) != test.want {
t.Errorf("ListWatchersByFolder(%q) = %d, want %d", test.path, len(watchers), test.want)
}
}
}
func TestCommentReplyValidationHistoryAndThreadResolution(t *testing.T) {
repo, _ := newCollaborationTestRepository(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
service := NewServiceWithOptions(repo, nil, logger, Options{})
ctx := context.Background()
principal := auth.Principal{UserID: "user:author", DisplayName: "Author"}
anchor := "anchor"
root, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Root", AnchorHash: &anchor,
})
if err != nil {
t.Fatalf("create root: %v", err)
}
reply, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Reply", ParentID: &root.ID,
})
if err != nil {
t.Fatalf("create reply: %v", err)
}
if reply.AnchorHash == nil || *reply.AnchorHash != anchor {
t.Fatalf("reply anchor = %#v, want inherited %q", reply.AnchorHash, anchor)
}
grandchild, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Grandchild", ParentID: &reply.ID,
})
if err != nil {
t.Fatalf("create grandchild: %v", err)
}
if _, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:other", VersionHash: "hash", Content: "Wrong document", ParentID: &root.ID,
}); err == nil {
t.Fatal("cross-document reply should fail")
}
if err := service.ResolveComment(ctx, principal, root.ID); err != nil {
t.Fatalf("resolve thread: %v", err)
}
active, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, false)
if err != nil {
t.Fatalf("list active: %v", err)
}
if len(active) != 0 {
t.Fatalf("active comments = %d, want 0", len(active))
}
history, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, true)
if err != nil {
t.Fatalf("list history: %v", err)
}
if len(history) != 3 {
t.Fatalf("history comments = %d, want 3", len(history))
}
for _, comment := range history {
if comment.ResolvedAt == nil {
t.Errorf("comment %s was not resolved", comment.ID)
}
}
if _, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Too late", ParentID: &grandchild.ID,
}); err == nil {
t.Fatal("reply to resolved thread should fail")
}
}

View File

@@ -4,16 +4,57 @@ import (
"context"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/realtime"
)
// EmailSender mirrors email.Sender without taking a direct dependency on the
// email package (which would create a cycle once the queue imports from
// collaboration's repository types).
type EmailSender interface {
Send(ctx context.Context, to []string, subject, body string) error
}
// EmailQueue is the minimal contract the collaboration service needs to
// enqueue an outgoing email. The email.Queue type satisfies this.
type EmailQueue interface {
Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error)
}
// EmailRenderer renders a plain-text email for a notification kind. The
// email.Render function satisfies this.
type EmailRenderer interface {
Render(kind string, data EmailData) (subject, body string, err error)
}
// EmailData is the email-package TemplateData projected through the
// collaboration boundary to avoid an import cycle.
type EmailData struct {
ActorName string
ActorEmail string
DocumentPath string
DocumentTitle string
CommentBody string
MentionText string
ConflictDesc string
ViewURL string
UnsubURL string
InstanceName string
}
// UserLookup resolves a user id to an email address and display name. The
// auth.Repository satisfies this.
type UserLookup interface {
GetUserByID(ctx context.Context, userID string) (auth.User, error)
}
type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error)
type DocumentResolver func(ctx context.Context, documentID string) (path, title string, err error)
type NoOpEmailSender struct{}
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
@@ -24,19 +65,49 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s
type Service struct {
repo *Repository
email EmailSender
queue EmailQueue
renderer EmailRenderer
users UserLookup
canReadDocument DocumentReadAuthorizer
resolveDocument DocumentResolver
hub *realtime.Hub
logger *slog.Logger
publicOrigin string
}
type Options struct {
EmailSender EmailSender
EmailQueue EmailQueue
EmailRenderer EmailRenderer
UserLookup UserLookup
CanReadDocument DocumentReadAuthorizer
ResolveDocument DocumentResolver
PublicOrigin string
}
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
if email == nil {
email = &NoOpEmailSender{}
return NewServiceWithOptions(repo, hub, logger, Options{EmailSender: email})
}
// NewServiceWithOptions constructs the service with email queue + renderer +
// user lookup wiring. Callers that want email delivery should pass all of
// EmailQueue, EmailRenderer, UserLookup; otherwise notifications stay
// in-app only.
func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Logger, opts Options) *Service {
if opts.EmailSender == nil {
opts.EmailSender = &NoOpEmailSender{}
}
return &Service{
repo: repo,
email: email,
email: opts.EmailSender,
queue: opts.EmailQueue,
renderer: opts.EmailRenderer,
users: opts.UserLookup,
canReadDocument: opts.CanReadDocument,
resolveDocument: opts.ResolveDocument,
hub: hub,
logger: logger,
publicOrigin: opts.PublicOrigin,
}
}
@@ -46,12 +117,35 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
if principal.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
input.Content = strings.TrimSpace(input.Content)
if input.Content == "" {
return nil, fmt.Errorf("content is required")
}
if input.DocumentID == "" {
return nil, fmt.Errorf("document_id is required")
}
if input.VersionHash == "" {
return nil, fmt.Errorf("version_hash is required")
}
if input.ParentID != nil && *input.ParentID != "" {
parent, err := s.repo.GetComment(ctx, *input.ParentID)
if err != nil {
return nil, fmt.Errorf("parent comment not found")
}
if parent.DocumentID != input.DocumentID {
return nil, fmt.Errorf("parent comment belongs to another document")
}
if parent.ResolvedAt != nil {
return nil, fmt.Errorf("cannot reply to a resolved thread")
}
if input.AnchorHash == nil {
input.AnchorHash = parent.AnchorHash
}
if input.AnchorLine == nil {
input.AnchorLine = parent.AnchorLine
}
}
comment := Comment{
ID: randomID("comment"),
@@ -75,30 +169,45 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
return nil, err
}
// Create notifications for watchers
if err := s.notifyWatchersOfComment(ctx, comment); err != nil {
created, err := s.repo.GetComment(ctx, comment.ID)
if err != nil {
return nil, err
}
if err := s.notifyWatchersOfComment(ctx, *created); err != nil {
s.logger.Warn("failed to notify watchers of comment", "error", err)
}
// Broadcast to connected clients
s.broadcastNotification(principal.UserID)
return s.repo.GetComment(ctx, comment.ID)
return created, nil
}
func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) {
return s.repo.ListCommentsByDocument(ctx, documentID)
func (s *Service) ListComments(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
return s.repo.ListCommentsByDocument(ctx, documentID, includeResolved)
}
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash)
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash, includeResolved)
}
func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) {
return s.repo.GetComment(ctx, commentID)
}
func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.ResolveComment(ctx, commentID, principal.UserID)
comment, err := s.repo.GetComment(ctx, commentID)
if err != nil {
return fmt.Errorf("comment not found")
}
if comment.ParentID != nil {
return fmt.Errorf("only a thread root can be resolved")
}
return s.repo.ResolveCommentThread(ctx, commentID, principal.UserID)
}
// Notifications
@@ -148,6 +257,9 @@ func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, d
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
if strings.TrimSpace(documentID) == "" {
return fmt.Errorf("document_id is required")
}
return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID,
DocumentID: &documentID,
@@ -159,6 +271,9 @@ func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal,
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
if strings.TrimSpace(documentID) == "" {
return fmt.Errorf("document_id is required")
}
return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
}
@@ -166,6 +281,10 @@ func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, fol
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
folderPath, err := NormalizeFolderPath(folderPath)
if err != nil {
return err
}
return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID,
FolderPath: &folderPath,
@@ -177,6 +296,10 @@ func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, f
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
folderPath, err := NormalizeFolderPath(folderPath)
if err != nil {
return err
}
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
}
@@ -205,6 +328,9 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
continue
}
seen[w.UserID] = true
if !s.watcherCanRead(ctx, w.UserID, documentID) {
continue
}
msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
notification := Notification{
@@ -220,6 +346,11 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
s.logger.Warn("create notification", "error", err)
continue
}
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{
ActorName: actorName,
DocumentPath: documentPath,
ViewURL: s.documentIDURL(documentID),
})
}
// Broadcast bell update to all connected clients
@@ -229,15 +360,35 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
}
func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID)
watchers, err := s.repo.ListWatchersForDocument(ctx, comment.DocumentID)
if err != nil {
return err
}
documentPath := documentPathFromID(comment.DocumentID) + ".md"
documentTitle := ""
if s.resolveDocument != nil {
resolvedPath, resolvedTitle, err := s.resolveDocument(ctx, comment.DocumentID)
if err != nil {
s.logger.Warn("resolve comment document", "document_id", comment.DocumentID, "error", err)
} else {
documentPath = resolvedPath
documentTitle = resolvedTitle
}
}
seen := make(map[string]bool)
for _, w := range watchers {
if w.UserID == comment.AuthorID {
continue
}
if seen[w.UserID] {
continue
}
seen[w.UserID] = true
if !s.watcherCanRead(ctx, w.UserID, comment.DocumentID) {
continue
}
msg := fmt.Sprintf("New comment on document")
if comment.AnchorHash != nil {
msg = fmt.Sprintf("New comment on a section of the document")
@@ -253,11 +404,66 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
}
if err := s.repo.CreateNotification(ctx, notification); err != nil {
s.logger.Warn("create comment notification", "error", err)
continue
}
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
ActorName: comment.AuthorName,
DocumentPath: documentPath,
DocumentTitle: documentTitle,
CommentBody: comment.Content,
ViewURL: s.documentIDURL(comment.DocumentID),
})
}
return nil
}
// enqueueNotificationEmail resolves the watcher's email address, renders the
// template, and enqueues a durable email. Failures are logged and swallowed
// so a bad email config never blocks the in-app notification path.
func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID, userID, kind string, data EmailData) {
if s.queue == nil || s.renderer == nil || s.users == nil {
return
}
user, err := s.users.GetUserByID(ctx, userID)
if err != nil {
s.logger.Warn("email: lookup user", "user_id", userID, "error", err)
return
}
if user.Email == "" || user.Disabled {
return
}
if data.ActorName == "" {
data.ActorName = "Someone"
}
subject, body, err := s.renderer.Render(kind, data)
if err != nil {
s.logger.Warn("email: render", "kind", kind, "error", err)
return
}
if _, err := s.queue.Enqueue(ctx, notificationID, user.Email, subject, body); err != nil {
s.logger.Warn("email: enqueue", "user_id", userID, "error", err)
}
}
func (s *Service) documentIDURL(documentID string) string {
if s.publicOrigin == "" {
return ""
}
return strings.TrimRight(s.publicOrigin, "/") + "/documents/" + url.PathEscape(documentID)
}
func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool {
if s.canReadDocument == nil {
return true
}
allowed, err := s.canReadDocument(ctx, userID, documentID)
if err != nil {
s.logger.Warn("authorize watcher", "user_id", userID, "document_id", documentID, "error", err)
return false
}
return allowed
}
func (s *Service) broadcastNotification(userID string) {
if s.hub == nil {
return

View File

@@ -4,10 +4,27 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"strings"
"time"
)
func NormalizeFolderPath(raw string) (string, error) {
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/"))
if raw == "" || raw == "/" {
return "", nil
}
clean := path.Clean(strings.Trim(raw, "/"))
if clean == "." {
return "", nil
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("folder path must remain inside the content root")
}
return clean, nil
}
func randomID(prefix string) string {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {

View File

@@ -38,9 +38,37 @@ type AuthConfig struct {
}
type EmailConfig struct {
// Provider selects the sender implementation. Valid values: "noop",
// "smtp", "postmark", or "cloudflare". When empty, the loader infers
// from credentials, then SMTPHost, then NoOp.
Provider string `json:"provider"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
PostmarkToken string `json:"postmarkToken"`
PostmarkAPIURL string `json:"postmarkApiUrl"`
CloudflareAccountID string `json:"cloudflareAccountId"`
CloudflareAPIToken string `json:"cloudflareApiToken"`
CloudflareAPIURL string `json:"cloudflareApiUrl"`
From string `json:"from"`
InstanceName string `json:"instanceName"`
}
// ResolvedProvider returns the concrete provider choice after applying the
// inference rule. Operators can set Provider explicitly to force a choice.
func (e EmailConfig) ResolvedProvider() string {
if e.Provider != "" {
return e.Provider
}
if e.CloudflareAPIToken != "" || e.CloudflareAccountID != "" {
return "cloudflare"
}
if e.PostmarkToken != "" {
return "postmark"
}
if e.SMTPHost != "" {
return "smtp"
}
return "noop"
}
func Default() Config {
@@ -62,6 +90,7 @@ func Default() Config {
SMTPHost: "localhost",
SMTPPort: 1025,
From: "notifications@cairnquire.local",
InstanceName: "Cairnquire",
},
LogLevel: "INFO",
}
@@ -87,6 +116,13 @@ func Load() (Config, error) {
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN")
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN")
overrideString(&cfg.Email.PostmarkAPIURL, "CAIRNQUIRE_EMAIL_POSTMARK_API_URL")
overrideString(&cfg.Email.CloudflareAccountID, "CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID")
overrideString(&cfg.Email.CloudflareAPIToken, "CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN")
overrideString(&cfg.Email.CloudflareAPIURL, "CAIRNQUIRE_EMAIL_CLOUDFLARE_API_URL")
overrideString(&cfg.Email.Provider, "CAIRNQUIRE_EMAIL_PROVIDER")
overrideString(&cfg.Email.InstanceName, "CAIRNQUIRE_INSTANCE_NAME")
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil {
cfg.Email.SMTPPort = p

View File

@@ -0,0 +1,46 @@
package config
import "testing"
func TestEmailConfigResolvedProvider(t *testing.T) {
tests := []struct {
name string
cfg EmailConfig
want string
}{
{"explicit", EmailConfig{Provider: "noop", CloudflareAPIToken: "token"}, "noop"},
{"cloudflare", EmailConfig{CloudflareAccountID: "account"}, "cloudflare"},
{"postmark", EmailConfig{PostmarkToken: "token"}, "postmark"},
{"smtp", EmailConfig{SMTPHost: "localhost"}, "smtp"},
{"noop", EmailConfig{}, "noop"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.cfg.ResolvedProvider(); got != test.want {
t.Fatalf("ResolvedProvider() = %q, want %q", got, test.want)
}
})
}
}
func TestLoadCloudflareEmailEnvironment(t *testing.T) {
t.Setenv("CAIRNQUIRE_EMAIL_PROVIDER", "cloudflare")
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID", "account")
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN", "token")
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_API_URL", "https://api.example.test")
t.Setenv("CAIRNQUIRE_EMAIL_FROM", "notifications@example.com")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Email.ResolvedProvider() != "cloudflare" {
t.Fatalf("provider = %q", cfg.Email.ResolvedProvider())
}
if cfg.Email.CloudflareAccountID != "account" || cfg.Email.CloudflareAPIToken != "token" {
t.Fatalf("cloudflare config = %#v", cfg.Email)
}
if cfg.Email.CloudflareAPIURL != "https://api.example.test" || cfg.Email.From != "notifications@example.com" {
t.Fatalf("cloudflare endpoint/from = %#v", cfg.Email)
}
}

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS permissions_previous (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document')),
resource_id TEXT,
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')),
granted_by TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL
);
INSERT INTO permissions_previous (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
SELECT id, user_id, resource_type, resource_id, permission, granted_by, created_at
FROM permissions
WHERE resource_type IN ('global', 'collection', 'document');
DROP TABLE permissions;
ALTER TABLE permissions_previous RENAME TO permissions;
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS permissions_next (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document', 'folder')),
resource_id TEXT,
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')),
granted_by TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL
);
INSERT INTO permissions_next (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
SELECT id, user_id, resource_type, resource_id, permission, granted_by, created_at
FROM permissions;
DROP TABLE permissions;
ALTER TABLE permissions_next RENAME TO permissions;
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource_type, resource_id);

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_email_queue_notification;
DROP INDEX IF EXISTS idx_email_queue_status;
DROP TABLE IF EXISTS email_queue;

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS email_queue (
id TEXT PRIMARY KEY,
notification_id TEXT,
to_address TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'failed', 'skipped')),
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
last_error TEXT,
next_attempt_at TEXT NOT NULL,
sent_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(notification_id) REFERENCES notifications(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_queue(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_email_queue_notification ON email_queue(notification_id);

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_watchers_user_folder_unique;
DROP INDEX IF EXISTS idx_watchers_user_document_unique;

View File

@@ -0,0 +1,14 @@
DELETE FROM watchers
WHERE rowid NOT IN (
SELECT MIN(rowid)
FROM watchers
GROUP BY user_id, document_id, folder_path
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_user_document_unique
ON watchers(user_id, document_id)
WHERE document_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_user_folder_unique
ON watchers(user_id, folder_path)
WHERE folder_path IS NOT NULL;

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_document_aliases_document;
DROP TABLE IF EXISTS document_aliases;

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS document_aliases (
path TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_document_aliases_document
ON document_aliases(document_id);

View File

@@ -51,6 +51,14 @@ type PersistDocumentInput struct {
Tags []string
}
type MoveDocumentInput struct {
ID string
OldPath string
NewPath string
ExpectedHash string
Title string
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
@@ -109,6 +117,141 @@ func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, pat
return r.getDocumentByPath(ctx, path, true)
}
func (r *Repository) GetDocumentByPathOrAlias(ctx context.Context, path string) (*DocumentRecord, error) {
record, err := r.GetDocumentByPath(ctx, path)
if err == nil || !errors.Is(err, sql.ErrNoRows) {
return record, err
}
var documentID string
if err := r.db.QueryRowContext(ctx, `
SELECT document_id FROM document_aliases WHERE path = ?
`, path).Scan(&documentID); err != nil {
return nil, err
}
return r.GetDocumentByID(ctx, documentID)
}
func (r *Repository) MoveDocument(ctx context.Context, input MoveDocumentInput) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin document move: %w", err)
}
defer func() { _ = tx.Rollback() }()
var aliasDocumentID string
err = tx.QueryRowContext(ctx, `SELECT document_id FROM document_aliases WHERE path = ?`, input.NewPath).Scan(&aliasDocumentID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("check destination alias: %w", err)
}
if err == nil && aliasDocumentID != input.ID {
return fmt.Errorf("destination path is reserved by another document")
}
if _, err := tx.ExecContext(ctx, `DELETE FROM document_aliases WHERE path = ? AND document_id = ?`, input.NewPath, input.ID); err != nil {
return fmt.Errorf("remove destination alias: %w", err)
}
result, err := tx.ExecContext(ctx, `
UPDATE documents
SET path = ?, title = ?, updated_at = ?
WHERE id = ? AND path = ? AND current_hash = ? AND archived_at IS NULL
`, input.NewPath, input.Title, time.Now().UTC().Format(time.RFC3339), input.ID, input.OldPath, input.ExpectedHash)
if err != nil {
return fmt.Errorf("update document path: %w", err)
}
changed, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("document move rows affected: %w", err)
}
if changed != 1 {
return sql.ErrNoRows
}
if _, err := tx.ExecContext(ctx, `
UPDATE attachments SET document_path = ? WHERE document_path = ?
`, input.NewPath, input.OldPath); err != nil {
return fmt.Errorf("move attachment metadata: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE permissions SET resource_id = ?
WHERE resource_type = 'document' AND resource_id = ?
`, input.NewPath, input.OldPath); err != nil {
return fmt.Errorf("move document permissions: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO document_aliases (path, document_id, created_at)
VALUES (?, ?, ?)
ON CONFLICT(path) DO UPDATE SET document_id = excluded.document_id, created_at = excluded.created_at
`, input.OldPath, input.ID, time.Now().UTC().Format(time.RFC3339)); err != nil {
return fmt.Errorf("create document alias: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit document move: %w", err)
}
return nil
}
func (r *Repository) MoveFolderRelated(ctx context.Context, oldPrefix, newPrefix string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin folder metadata move: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
UPDATE permissions
SET resource_id = ? || substr(resource_id, length(?) + 1)
WHERE resource_type = 'folder' AND (resource_id = ? OR resource_id LIKE ? || '/%')
`, newPrefix, oldPrefix, oldPrefix, oldPrefix); err != nil {
return fmt.Errorf("move folder permissions: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE OR IGNORE watchers
SET folder_path = ? || substr(folder_path, length(?) + 1)
WHERE folder_path IS NOT NULL AND folder_path != '' AND (folder_path = ? OR folder_path LIKE ? || '/%')
`, newPrefix, oldPrefix, oldPrefix, oldPrefix); err != nil {
return fmt.Errorf("move folder watchers: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit folder metadata move: %w", err)
}
return nil
}
func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) {
var (
record DocumentRecord
tagsJSON string
created string
updated string
archived sql.NullString
)
err := r.db.QueryRowContext(ctx, `
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
FROM documents
WHERE id = ? AND archived_at IS NULL
`, id).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived)
if err != nil {
return nil, err
}
if err := parseDocumentTimes(&record, created, updated); err != nil {
return nil, err
}
if archived.Valid && archived.String != "" {
t, err := time.Parse(time.RFC3339, archived.String)
if err != nil {
return nil, fmt.Errorf("parse document archived_at: %w", err)
}
record.ArchivedAt = &t
}
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
return nil, fmt.Errorf("unmarshal document tags: %w", err)
}
return &record, nil
}
func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) {
var (
record DocumentRecord
@@ -416,6 +559,32 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
return results, rows.Err()
}
func (r *Repository) SearchDocumentsByTag(ctx context.Context, tag string) ([]SearchResult, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT d.path, d.title
FROM documents d
WHERE EXISTS (
SELECT 1 FROM json_each(d.tags) WHERE json_each.value = ?
) AND d.archived_at IS NULL
ORDER BY d.path
`, tag)
if err != nil {
return nil, fmt.Errorf("search documents by tag: %w", err)
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var result SearchResult
if err := rows.Scan(&result.Path, &result.Title); err != nil {
return nil, fmt.Errorf("scan tag search result: %w", err)
}
results = append(results, result)
}
return results, rows.Err()
}
func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error {
if _, err := r.db.ExecContext(ctx, `
UPDATE document_search SET content = ?

View File

@@ -27,12 +27,25 @@ type Service struct {
}
type DocumentChange struct {
Type string `json:"type,omitempty"`
DocumentID string `json:"documentId"`
Path string `json:"path"`
OldPath string `json:"oldPath,omitempty"`
Title string `json:"title"`
Hash string `json:"hash"`
PreviousHash string `json:"previousHash,omitempty"`
}
type DocumentMoveConflictError struct {
OldPath string
NewPath string
Reason string
}
func (e *DocumentMoveConflictError) Error() string {
return fmt.Sprintf("cannot move %s to %s: %s", e.OldPath, e.NewPath, e.Reason)
}
type Page struct {
Path string
Title string
@@ -146,6 +159,7 @@ func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
if s.onChange != nil {
s.onChange(DocumentChange{
DocumentID: record.ID,
Path: record.Path,
Title: record.Title,
Hash: record.CurrentHash,
@@ -170,6 +184,297 @@ func (s *Service) RestoreDocument(ctx context.Context, path string) error {
return nil
}
func (s *Service) MoveDocument(ctx context.Context, requestPath, destinationPath, expectedHash string) (*DocumentRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return nil, err
}
oldPath, err := NormalizeDocumentPath(requestPath)
if err != nil {
return nil, err
}
newPath, err := NormalizeDocumentPath(destinationPath)
if err != nil {
return nil, err
}
if oldPath == newPath {
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"}
}
record, err := s.repo.GetDocumentByPath(ctx, oldPath)
if err != nil {
return nil, err
}
if expectedHash != "" && record.CurrentHash != expectedHash {
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "document changed since the page loaded"}
}
if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, newPath); err == nil {
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists"}
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if aliased, err := s.repo.GetDocumentByPathOrAlias(ctx, newPath); err == nil && aliased.ID != record.ID {
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is reserved by another document"}
} else if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
oldFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(oldPath))
newFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(newPath))
if _, err := os.Stat(newFullPath); err == nil {
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists"}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("check destination file: %w", err)
}
content, err := os.ReadFile(oldFullPath)
if err != nil {
return nil, fmt.Errorf("read source document: %w", err)
}
rendered, err := s.renderer.RenderPath(content, newPath)
if err != nil {
return nil, fmt.Errorf("render moved document: %w", err)
}
if err := os.MkdirAll(filepath.Dir(newFullPath), 0o755); err != nil {
return nil, fmt.Errorf("create destination folder: %w", err)
}
if err := os.Rename(oldFullPath, newFullPath); err != nil {
return nil, fmt.Errorf("move document file: %w", err)
}
moveErr := s.repo.MoveDocument(ctx, MoveDocumentInput{
ID: record.ID,
OldPath: oldPath,
NewPath: newPath,
ExpectedHash: record.CurrentHash,
Title: rendered.Title,
})
if moveErr != nil {
if rollbackErr := os.Rename(newFullPath, oldFullPath); rollbackErr != nil {
return nil, fmt.Errorf("move database update failed: %v; filesystem rollback failed: %w", moveErr, rollbackErr)
}
return nil, moveErr
}
updated, err := s.repo.GetDocumentByID(ctx, record.ID)
if err != nil {
return nil, err
}
if s.onChange != nil {
s.onChange(DocumentChange{
Type: "move",
DocumentID: updated.ID,
Path: updated.Path,
OldPath: oldPath,
Title: updated.Title,
Hash: updated.CurrentHash,
})
}
return updated, nil
}
func (s *Service) MoveFolder(ctx context.Context, oldPrefix, newPrefix string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return 0, err
}
oldPath, err := NormalizeFolderPath(oldPrefix)
if err != nil {
return 0, err
}
newPath, err := NormalizeFolderPath(newPrefix)
if err != nil {
return 0, err
}
if oldPath == newPath {
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"}
}
if strings.HasPrefix(newPath, oldPath+"/") {
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "cannot move a folder into itself"}
}
records, err := s.repo.ListDocuments(ctx)
if err != nil {
return 0, err
}
moving := make([]DocumentRecord, 0)
for _, record := range records {
if strings.HasPrefix(record.Path, oldPath+"/") {
moving = append(moving, record)
}
}
if len(moving) == 0 {
return 0, sql.ErrNoRows
}
newFullDir := filepath.Join(s.sourceDir, filepath.FromSlash(newPath))
if info, err := os.Stat(newFullDir); err == nil && info.IsDir() {
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination folder already exists"}
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return 0, fmt.Errorf("check destination folder: %w", err)
}
for _, record := range moving {
destination := newPath + strings.TrimPrefix(record.Path, oldPath)
if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, destination); err == nil {
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists: " + destination}
} else if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
if _, err := os.Stat(filepath.Join(s.sourceDir, filepath.FromSlash(destination))); err == nil {
return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists: " + destination}
} else if !errors.Is(err, os.ErrNotExist) {
return 0, fmt.Errorf("check destination file: %w", err)
}
}
type movedPair struct {
record DocumentRecord
newPath string
newTitle string
oldFull string
newFull string
dbUpdated bool
}
moved := make([]movedPair, 0, len(moving))
rollback := func(moveErr error) (int, error) {
for i := len(moved) - 1; i >= 0; i-- {
pair := moved[i]
if pair.dbUpdated {
if err := s.repo.MoveDocument(ctx, MoveDocumentInput{
ID: pair.record.ID,
OldPath: pair.newPath,
NewPath: pair.record.Path,
ExpectedHash: pair.record.CurrentHash,
Title: pair.record.Title,
}); err != nil {
return 0, fmt.Errorf("move folder failed: %v; database rollback failed for %s: %w", moveErr, pair.newPath, err)
}
}
if err := os.Rename(pair.newFull, pair.oldFull); err != nil {
return 0, fmt.Errorf("move folder failed: %v; filesystem rollback failed for %s: %w", moveErr, pair.newFull, err)
}
}
return 0, moveErr
}
for _, record := range moving {
destination := newPath + strings.TrimPrefix(record.Path, oldPath)
oldFull := filepath.Join(s.sourceDir, filepath.FromSlash(record.Path))
newFull := filepath.Join(s.sourceDir, filepath.FromSlash(destination))
content, err := os.ReadFile(oldFull)
if err != nil {
return rollback(fmt.Errorf("read source document: %w", err))
}
rendered, err := s.renderer.RenderPath(content, destination)
if err != nil {
return rollback(fmt.Errorf("render moved document: %w", err))
}
if err := os.MkdirAll(filepath.Dir(newFull), 0o755); err != nil {
return rollback(fmt.Errorf("create destination folder: %w", err))
}
if err := os.Rename(oldFull, newFull); err != nil {
return rollback(fmt.Errorf("move document file: %w", err))
}
pair := movedPair{record: record, newPath: destination, newTitle: rendered.Title, oldFull: oldFull, newFull: newFull}
if err := s.repo.MoveDocument(ctx, MoveDocumentInput{
ID: record.ID,
OldPath: record.Path,
NewPath: destination,
ExpectedHash: record.CurrentHash,
Title: rendered.Title,
}); err != nil {
moved = append(moved, pair)
return rollback(err)
}
pair.dbUpdated = true
moved = append(moved, pair)
}
if err := s.repo.MoveFolderRelated(ctx, oldPath, newPath); err != nil {
return rollback(fmt.Errorf("move folder metadata: %w", err))
}
deepestOldDir := ""
for _, pair := range moved {
if dir := filepath.Dir(pair.oldFull); len(dir) > len(deepestOldDir) {
deepestOldDir = dir
}
}
if deepestOldDir != "" {
removeEmptyDirsUpTo(s.sourceDir, deepestOldDir)
}
if s.onChange != nil {
for _, pair := range moved {
s.onChange(DocumentChange{
Type: "move",
DocumentID: pair.record.ID,
Path: pair.newPath,
OldPath: pair.record.Path,
Title: pair.newTitle,
Hash: pair.record.CurrentHash,
})
}
}
return len(moved), nil
}
func (s *Service) ArchiveFolder(ctx context.Context, prefix string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return 0, err
}
folder, err := NormalizeFolderPath(prefix)
if err != nil {
return 0, err
}
records, err := s.repo.ListDocuments(ctx)
if err != nil {
return 0, err
}
archived := make([]DocumentRecord, 0)
for _, record := range records {
if !strings.HasPrefix(record.Path, folder+"/") {
continue
}
if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil {
return len(archived), fmt.Errorf("archive document %s: %w", record.Path, err)
}
archived = append(archived, record)
}
if len(archived) == 0 {
return 0, sql.ErrNoRows
}
if s.onChange != nil {
for _, record := range archived {
s.onChange(DocumentChange{
DocumentID: record.ID,
Path: record.Path,
Title: record.Title,
Hash: record.CurrentHash,
})
}
}
return len(archived), nil
}
func removeEmptyDirsUpTo(root, dir string) {
for dir != root && strings.HasPrefix(dir, root) {
if err := os.Remove(dir); err != nil {
return
}
dir = filepath.Dir(dir)
}
}
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
if _, err := s.SyncSourceDir(ctx); err != nil {
return nil, err
@@ -301,10 +606,10 @@ func (s *Service) resolveRecord(ctx context.Context, requestPath string) (*Docum
lastErr error
)
for _, candidate := range normalizeRequestPathCandidates(requestPath) {
found, err := s.repo.GetDocumentByPath(ctx, candidate)
found, err := s.repo.GetDocumentByPathOrAlias(ctx, candidate)
if err == nil {
record = found
normalized = candidate
normalized = found.Path
break
}
lastErr = err
@@ -342,6 +647,30 @@ func normalizeWritableDocumentPath(path string) (string, error) {
return path, nil
}
func NormalizeDocumentPath(path string) (string, error) {
return normalizeWritableDocumentPath(path)
}
func NormalizeFolderPath(path string) (string, error) {
path = strings.Trim(path, "/")
if path == "" {
return "", fmt.Errorf("folder path is required")
}
path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
if path == "." || path == ".." || strings.HasPrefix(path, "../") || filepath.IsAbs(path) {
return "", fmt.Errorf("invalid folder path")
}
for _, segment := range strings.Split(path, "/") {
if segment == "" || segment == "." || segment == ".." {
return "", fmt.Errorf("invalid folder path")
}
if strings.HasSuffix(strings.ToLower(segment), ".md") {
return "", fmt.Errorf("folder names must not end with .md")
}
}
return path, nil
}
func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
content, err := os.ReadFile(path)
if err != nil {
@@ -400,6 +729,7 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
return &DocumentChange{
DocumentID: documentID,
Path: relative,
Title: rendered.Title,
Hash: record.Hash,

View File

@@ -182,6 +182,127 @@ func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
}
}
func TestMoveDocumentPreservesIdentityAndHistory(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
page, err := service.LoadSourcePage(ctx, "hello")
if err != nil {
t.Fatalf("LoadSourcePage() error = %v", err)
}
before, err := service.repo.GetDocumentByPath(ctx, "hello.md")
if err != nil {
t.Fatalf("GetDocumentByPath() error = %v", err)
}
var versionsBefore int
if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsBefore); err != nil {
t.Fatalf("count versions before move: %v", err)
}
if err := service.repo.SaveAttachment(ctx, AttachmentRecord{
Hash: "attachment-hash", OriginalName: "diagram.png", ContentType: "image/png",
SizeBytes: 10, CreatedAt: time.Now().UTC(), DocumentPath: before.Path,
}); err != nil {
t.Fatalf("save attachment: %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO users (id, email, display_name, created_at) VALUES ('user-move', 'move@example.com', 'Mover', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create permission user: %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at)
VALUES ('permission-move', 'user-move', 'document', ?, 'write', ?)
`, before.Path, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create document permission: %v", err)
}
var moveChange DocumentChange
service.OnChange(func(change DocumentChange) {
moveChange = change
})
moved, err := service.MoveDocument(ctx, "hello.md", "guides/welcome.md", page.Hash)
if err != nil {
t.Fatalf("MoveDocument() error = %v", err)
}
if moved.ID != before.ID {
t.Fatalf("moved ID = %q, want %q", moved.ID, before.ID)
}
if moved.Path != "guides/welcome.md" {
t.Fatalf("moved path = %q, want guides/welcome.md", moved.Path)
}
if moveChange.Type != "move" || moveChange.DocumentID != moved.ID || moveChange.OldPath != "hello.md" || moveChange.Path != moved.Path {
t.Fatalf("move change = %#v", moveChange)
}
if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("old file still exists or stat failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(sourceDir, "guides", "welcome.md"))
if err != nil {
t.Fatalf("read moved file: %v", err)
}
if string(content) != page.Content {
t.Fatalf("moved content = %q, want %q", content, page.Content)
}
alias, err := service.repo.GetDocumentByPathOrAlias(ctx, "hello.md")
if err != nil {
t.Fatalf("resolve old path alias: %v", err)
}
if alias.ID != before.ID || alias.Path != moved.Path {
t.Fatalf("alias resolved to %#v, want ID %q at %q", alias, before.ID, moved.Path)
}
var versionsAfter int
if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsAfter); err != nil {
t.Fatalf("count versions after move: %v", err)
}
if versionsAfter != versionsBefore {
t.Fatalf("versions after move = %d, want %d", versionsAfter, versionsBefore)
}
attachment, err := service.repo.GetAttachment(ctx, "attachment-hash")
if err != nil {
t.Fatalf("get moved attachment metadata: %v", err)
}
if attachment.DocumentPath != moved.Path {
t.Fatalf("attachment document path = %q, want %q", attachment.DocumentPath, moved.Path)
}
var permissionPath string
if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-move'`).Scan(&permissionPath); err != nil {
t.Fatalf("get moved permission: %v", err)
}
if permissionPath != moved.Path {
t.Fatalf("permission resource path = %q, want %q", permissionPath, moved.Path)
}
}
func TestMoveDocumentRejectsStaleHashAndOccupiedDestination(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
if _, err := service.LoadSourcePage(ctx, "hello"); err != nil {
t.Fatalf("LoadSourcePage() error = %v", err)
}
_, err := service.MoveDocument(ctx, "hello.md", "moved.md", "stale")
var conflict *DocumentMoveConflictError
if !errors.As(err, &conflict) {
t.Fatalf("stale MoveDocument() error = %v, want DocumentMoveConflictError", err)
}
if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); err != nil {
t.Fatalf("source file changed after stale move: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "occupied.md"), []byte("# Occupied\n"), 0o644); err != nil {
t.Fatalf("create occupied destination: %v", err)
}
page, err := service.LoadSourcePage(ctx, "hello")
if err != nil {
t.Fatalf("reload source page: %v", err)
}
_, err = service.MoveDocument(ctx, "hello.md", "occupied.md", page.Hash)
if !errors.As(err, &conflict) {
t.Fatalf("occupied MoveDocument() error = %v, want DocumentMoveConflictError", err)
}
}
func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
service, _ := setupDocsTestService(t)
ctx := context.Background()
@@ -240,6 +361,219 @@ func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
}
}
func TestMoveFolderMovesAllDocumentsAndMetadata(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
if err := os.MkdirAll(filepath.Join(sourceDir, "guides", "deep"), 0o755); err != nil {
t.Fatalf("create guides folder: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
t.Fatalf("create intro.md: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "deep", "dive.md"), []byte("# Dive\n"), 0o644); err != nil {
t.Fatalf("create dive.md: %v", err)
}
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO users (id, email, display_name, created_at) VALUES ('user-folder', 'folder@example.com', 'Folder', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create user: %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at)
VALUES ('permission-folder', 'user-folder', 'folder', 'guides/deep', 'write', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create folder permission: %v", err)
}
if _, err := service.repo.db.ExecContext(ctx, `
INSERT INTO watchers (user_id, document_id, folder_path, created_at)
VALUES ('user-folder', NULL, 'guides', ?)
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatalf("create folder watcher: %v", err)
}
var changes []DocumentChange
service.OnChange(func(change DocumentChange) {
changes = append(changes, change)
})
moved, err := service.MoveFolder(ctx, "guides", "handbook")
if err != nil {
t.Fatalf("MoveFolder() error = %v", err)
}
if moved != 2 {
t.Fatalf("moved count = %d, want 2", moved)
}
for _, oldPath := range []string{"guides/intro.md", "guides/deep/dive.md"} {
if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(oldPath))); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("old file %s still exists or stat failed: %v", oldPath, err)
}
}
for oldPath, newPath := range map[string]string{"guides/intro.md": "handbook/intro.md", "guides/deep/dive.md": "handbook/deep/dive.md"} {
if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(newPath))); err != nil {
t.Fatalf("new file %s missing: %v", newPath, err)
}
record, err := service.repo.GetDocumentByPath(ctx, newPath)
if err != nil {
t.Fatalf("get moved record %s: %v", newPath, err)
}
alias, err := service.repo.GetDocumentByPathOrAlias(ctx, oldPath)
if err != nil {
t.Fatalf("resolve alias for %s: %v", oldPath, err)
}
if alias.ID != record.ID {
t.Fatalf("alias for %s resolved to %q, want %q", oldPath, alias.ID, record.ID)
}
}
if _, err := os.Stat(filepath.Join(sourceDir, "guides")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("empty source folder not removed: %v", err)
}
var permissionFolder string
if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-folder'`).Scan(&permissionFolder); err != nil {
t.Fatalf("get folder permission: %v", err)
}
if permissionFolder != "handbook/deep" {
t.Fatalf("permission folder = %q, want handbook/deep", permissionFolder)
}
var watcherFolder string
if err := service.repo.db.QueryRowContext(ctx, `SELECT folder_path FROM watchers WHERE user_id = 'user-folder'`).Scan(&watcherFolder); err != nil {
t.Fatalf("get watcher: %v", err)
}
if watcherFolder != "handbook" {
t.Fatalf("watcher folder = %q, want handbook", watcherFolder)
}
if len(changes) != 2 {
t.Fatalf("move changes = %d, want 2", len(changes))
}
for _, change := range changes {
if change.Type != "move" {
t.Fatalf("change type = %q, want move", change.Type)
}
}
}
func TestMoveFolderRejectsInvalidDestinations(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil {
t.Fatalf("create guides folder: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
t.Fatalf("create intro.md: %v", err)
}
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
var conflict *DocumentMoveConflictError
if _, err := service.MoveFolder(ctx, "guides", "guides"); !errors.As(err, &conflict) {
t.Fatalf("unchanged MoveFolder() error = %v, want DocumentMoveConflictError", err)
}
if _, err := service.MoveFolder(ctx, "guides", "guides/nested"); !errors.As(err, &conflict) {
t.Fatalf("self-nested MoveFolder() error = %v, want DocumentMoveConflictError", err)
}
if _, err := service.MoveFolder(ctx, "guides", "../outside"); err == nil {
t.Fatal("expected traversal destination to be rejected")
}
if _, err := service.MoveFolder(ctx, "missing", "handbook"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("missing folder MoveFolder() error = %v, want sql.ErrNoRows", err)
}
if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil {
t.Fatalf("source file changed after rejected moves: %v", err)
}
}
func TestArchiveFolderArchivesAllDocuments(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil {
t.Fatalf("create guides folder: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
t.Fatalf("create intro.md: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "dive.md"), []byte("# Dive\n"), 0o644); err != nil {
t.Fatalf("create dive.md: %v", err)
}
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
archived, err := service.ArchiveFolder(ctx, "guides")
if err != nil {
t.Fatalf("ArchiveFolder() error = %v", err)
}
if archived != 2 {
t.Fatalf("archived count = %d, want 2", archived)
}
records, err := service.repo.ListDocuments(ctx)
if err != nil {
t.Fatalf("ListDocuments() error = %v", err)
}
for _, record := range records {
if record.Path == "guides/intro.md" || record.Path == "guides/dive.md" {
t.Fatalf("document %s still listed after archive", record.Path)
}
}
archivedRecords, err := service.repo.ListArchivedDocuments(ctx)
if err != nil {
t.Fatalf("ListArchivedDocuments() error = %v", err)
}
if len(archivedRecords) != 2 {
t.Fatalf("archived records = %d, want 2", len(archivedRecords))
}
if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil {
t.Fatalf("archived file removed from disk: %v", err)
}
if _, err := service.ArchiveFolder(ctx, "guides"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("re-archive error = %v, want sql.ErrNoRows", err)
}
}
func TestNormalizeFolderPathValidation(t *testing.T) {
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "simple", path: "guides", want: "guides"},
{name: "nested with slashes", path: "/guides/deep/", want: "guides/deep"},
{name: "empty", path: "", wantErr: true},
{name: "traversal", path: "../outside", wantErr: true},
{name: "leading slash", path: "/etc", want: "etc"},
{name: "markdown suffix", path: "guides.md", wantErr: true},
{name: "nested markdown segment", path: "guides/notes.md/deep", wantErr: true},
{name: "cleaned dot segment", path: "guides/./deep", want: "guides/deep"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeFolderPath(tt.path)
if tt.wantErr {
if err == nil {
t.Fatalf("NormalizeFolderPath(%q) = %q, want error", tt.path, got)
}
return
}
if err != nil {
t.Fatalf("NormalizeFolderPath(%q) error = %v", tt.path, err)
}
if got != tt.want {
t.Fatalf("NormalizeFolderPath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func setupDocsTestService(t *testing.T) (*Service, string) {
t.Helper()

View File

@@ -0,0 +1,160 @@
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
const (
defaultCloudflareAPIURL = "https://api.cloudflare.com/client/v4"
cloudflareTimeout = 15 * time.Second
)
// CloudflareSender sends transactional email through Cloudflare Email
// Service's REST API. It is intended for deployments outside Workers.
type CloudflareSender struct {
accountID string
apiToken string
apiURL string
from string
client *http.Client
logger *slog.Logger
}
func NewCloudflareSender(accountID, apiToken, from, apiURL string, logger *slog.Logger) *CloudflareSender {
if apiURL == "" {
apiURL = defaultCloudflareAPIURL
}
if logger == nil {
logger = slog.Default()
}
return &CloudflareSender{
accountID: accountID,
apiToken: apiToken,
apiURL: strings.TrimRight(apiURL, "/"),
from: from,
client: &http.Client{
Timeout: cloudflareTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
logger: logger,
}
}
type cloudflareRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text"`
}
type cloudflareResponse struct {
Success bool `json:"success"`
Errors []struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errors"`
Result *struct {
Delivered []string `json:"delivered"`
MessageID string `json:"message_id"`
PermanentBounces []string `json:"permanent_bounces"`
Queued []string `json:"queued"`
} `json:"result"`
}
func (c *CloudflareSender) Send(ctx context.Context, to []string, subject, body string) error {
if c.accountID == "" {
return &PermanentError{Cause: fmt.Errorf("cloudflare account id not configured")}
}
if c.apiToken == "" {
return &PermanentError{Cause: fmt.Errorf("cloudflare API token not configured")}
}
if c.from == "" {
return &PermanentError{Cause: fmt.Errorf("cloudflare from address not configured")}
}
if len(to) == 0 {
return &PermanentError{Cause: fmt.Errorf("cloudflare send requires at least one recipient")}
}
payload, err := json.Marshal(cloudflareRequest{
From: c.from,
To: to,
Subject: subject,
Text: body,
})
if err != nil {
return fmt.Errorf("marshal cloudflare email request: %w", err)
}
endpoint := fmt.Sprintf("%s/accounts/%s/email/sending/send", c.apiURL, url.PathEscape(c.accountID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("build cloudflare email request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return &TemporaryError{Cause: fmt.Errorf("cloudflare email request: %w", err)}
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<14))
if readErr != nil {
return &TemporaryError{Cause: fmt.Errorf("read cloudflare email response: %w", readErr)}
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError {
return &TemporaryError{Cause: fmt.Errorf("cloudflare email server error: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))}
}
if resp.StatusCode != http.StatusOK {
return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))}
}
var parsed cloudflareResponse
if err := json.Unmarshal(responseBody, &parsed); err != nil {
return fmt.Errorf("decode cloudflare email response: %w", err)
}
if !parsed.Success {
return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: %s", cloudflareErrors(parsed.Errors))}
}
if parsed.Result == nil {
return fmt.Errorf("cloudflare email response missing result")
}
if len(parsed.Result.PermanentBounces) > 0 {
return &PermanentError{Cause: fmt.Errorf("cloudflare email permanently bounced for %d recipient(s)", len(parsed.Result.PermanentBounces))}
}
c.logger.Info("cloudflare email accepted",
"message_id", parsed.Result.MessageID,
"delivered", len(parsed.Result.Delivered),
"queued", len(parsed.Result.Queued),
"subject", subject,
)
return nil
}
func cloudflareErrors(errors []struct {
Code int `json:"code"`
Message string `json:"message"`
}) string {
if len(errors) == 0 {
return "unknown API error"
}
parts := make([]string, 0, len(errors))
for _, apiErr := range errors {
parts = append(parts, fmt.Sprintf("code=%d: %s", apiErr.Code, apiErr.Message))
}
return strings.Join(parts, "; ")
}

View File

@@ -0,0 +1,128 @@
package email
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCloudflareSenderSend(t *testing.T) {
var got cloudflareRequest
var gotAuthorization string
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuthorization = r.Header.Get("Authorization")
gotPath = r.URL.Path
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &got)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"errors":[],"messages":[],"result":{"delivered":["alice@example.com"],"message_id":"message-1","permanent_bounces":[],"queued":[]}}`))
}))
defer server.Close()
sender := NewCloudflareSender("account-1", "token-1", "notifications@example.com", server.URL, testLogger(t))
if err := sender.Send(context.Background(), []string{"alice@example.com"}, "Hello", "World"); err != nil {
t.Fatalf("Send: %v", err)
}
if gotAuthorization != "Bearer token-1" {
t.Errorf("authorization = %q", gotAuthorization)
}
if gotPath != "/accounts/account-1/email/sending/send" {
t.Errorf("path = %q", gotPath)
}
if got.From != "notifications@example.com" || got.Subject != "Hello" || got.Text != "World" {
t.Errorf("request = %#v", got)
}
if len(got.To) != 1 || got.To[0] != "alice@example.com" {
t.Errorf("to = %#v", got.To)
}
}
func TestCloudflareSenderRetryableResponses(t *testing.T) {
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
t.Run(http.StatusText(status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"success":false}`))
}))
defer server.Close()
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
var temporary *TemporaryError
if !errors.As(err, &temporary) {
t.Fatalf("error = %T %v, want TemporaryError", err, err)
}
})
}
}
func TestCloudflareSenderPermanentRejection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"errors":[{"code":1000,"message":"Sender domain not verified"}]}`))
}))
defer server.Close()
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
if err == nil || !strings.Contains(err.Error(), "400") {
t.Fatalf("error = %v, want permanent 400 rejection", err)
}
var temporary *TemporaryError
if errors.As(err, &temporary) {
t.Fatalf("error = %T, should not be retryable", err)
}
}
func TestCloudflareSenderAPIErrorAndBounce(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{"api error", `{"success":false,"errors":[{"code":1000,"message":"Sender domain not verified"}],"result":null}`, "Sender domain not verified"},
{"bounce", `{"success":true,"errors":[],"result":{"delivered":[],"message_id":"message-1","permanent_bounces":["to@example.com"],"queued":[]}}`, "permanently bounced"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(test.body))
}))
defer server.Close()
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want substring %q", err, test.want)
}
})
}
}
func TestCloudflareSenderValidation(t *testing.T) {
tests := []struct {
name string
accountID string
token string
from string
to []string
}{
{"account", "", "token", "from@example.com", []string{"to@example.com"}},
{"token", "account", "", "from@example.com", []string{"to@example.com"}},
{"from", "account", "token", "", []string{"to@example.com"}},
{"recipient", "account", "token", "from@example.com", nil},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sender := NewCloudflareSender(test.accountID, test.token, test.from, "", testLogger(t))
if err := sender.Send(context.Background(), test.to, "subject", "body"); err == nil {
t.Fatal("expected validation error")
}
})
}
}

View File

@@ -0,0 +1,29 @@
package email
import "github.com/tim/cairnquire/apps/server/internal/collaboration"
// CollaborationRenderer adapts email.Render to the collaboration.EmailRenderer
// interface by converting collaboration.EmailData into email.TemplateData.
type CollaborationRenderer struct {
InstanceName string
}
// Render satisfies collaboration.EmailRenderer.
func (c CollaborationRenderer) Render(kind string, data collaboration.EmailData) (string, string, error) {
td := TemplateData{
ActorName: data.ActorName,
ActorEmail: data.ActorEmail,
DocumentPath: data.DocumentPath,
DocumentTitle: data.DocumentTitle,
CommentBody: data.CommentBody,
MentionText: data.MentionText,
ConflictDesc: data.ConflictDesc,
ViewURL: data.ViewURL,
UnsubURL: data.UnsubURL,
InstanceName: c.InstanceName,
}
if td.InstanceName == "" {
td.InstanceName = "Cairnquire"
}
return Render(kind, td)
}

View File

@@ -0,0 +1,14 @@
package email
import (
"crypto/rand"
"encoding/base64"
)
func randomToken(length int) (string, error) {
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View File

@@ -0,0 +1,154 @@
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
const (
defaultPostmarkURL = "https://api.postmarkapp.com"
postmarkTimeout = 15 * time.Second
)
// PostmarkSender sends email via the Postmark API.
type PostmarkSender struct {
serverToken string
apiURL string
from string
client *http.Client
logger *slog.Logger
}
// NewPostmarkSender constructs a Postmark sender. apiURL may be empty to use
// the public Postmark endpoint; tests can point it at an httptest server.
func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *PostmarkSender {
if apiURL == "" {
apiURL = defaultPostmarkURL
}
return &PostmarkSender{
serverToken: serverToken,
apiURL: strings.TrimRight(apiURL, "/"),
from: from,
client: &http.Client{
Timeout: postmarkTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
logger: logger,
}
}
type postmarkRequest struct {
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
Headers []postmarkHeader `json:"Headers,omitempty"`
}
type postmarkHeader struct {
Name string `json:"Name"`
Value string `json:"Value"`
}
type postmarkResponse struct {
ErrorCode int `json:"ErrorCode"`
Message string `json:"Message"`
To string `json:"To"`
}
// WithReplyTo sets a Reply-To header on the outgoing message. Returns a copy
// of the sender with the reply-to address applied for the next Send call.
// Not safe for concurrent use; callers should construct a fresh sender per
// send if they need different reply-to addresses.
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
if p.serverToken == "" {
return &PermanentError{Cause: fmt.Errorf("postmark server token not configured")}
}
if p.from == "" {
return &PermanentError{Cause: fmt.Errorf("postmark from address not configured")}
}
if len(to) == 0 {
return &PermanentError{Cause: fmt.Errorf("postmark send requires at least one recipient")}
}
payload := postmarkRequest{
From: p.from,
To: strings.Join(to, ","),
Subject: subject,
TextBody: body,
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal postmark request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiURL+"/email", bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("build postmark request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", p.serverToken)
resp, err := p.client.Do(req)
if err != nil {
return &TemporaryError{Cause: fmt.Errorf("postmark request: %w", err)}
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14))
if resp.StatusCode >= 500 {
return &TemporaryError{Cause: fmt.Errorf("postmark server error: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
}
if resp.StatusCode != http.StatusOK {
return &PermanentError{Cause: fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
}
var parsed postmarkResponse
if err := json.Unmarshal(respBytes, &parsed); err != nil {
return fmt.Errorf("decode postmark response: %w", err)
}
if parsed.ErrorCode != 0 {
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
// 406 as non-retryable; others bubble up as generic failures.
return &PermanentError{Cause: fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)}
}
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
return nil
}
func truncate(s string) string {
if len(s) > 256 {
return s[:256] + "..."
}
return s
}
// TemporaryError wraps a failure that callers may retry. The queue worker
// uses errors.As to detect it and apply exponential backoff.
type TemporaryError struct {
Cause error
}
func (t *TemporaryError) Error() string { return t.Cause.Error() }
func (t *TemporaryError) Unwrap() error { return t.Cause }
// PermanentError marks a provider rejection that cannot succeed on retry.
type PermanentError struct {
Cause error
}
func (p *PermanentError) Error() string { return p.Cause.Error() }
func (p *PermanentError) Unwrap() error { return p.Cause }

View File

@@ -0,0 +1,113 @@
package email
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPostmarkSender_Send(t *testing.T) {
var gotBody postmarkRequest
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Postmark-Server-Token")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ErrorCode":0,"Message":"OK","To":"alice@example.com"}`))
}))
defer srv.Close()
sender := NewPostmarkSender("test-token", "noreply@example.com", srv.URL, testLogger(t))
if err := sender.Send(context.Background(), []string{"alice@example.com"}, "Hello", "World"); err != nil {
t.Fatalf("Send: %v", err)
}
if gotToken != "test-token" {
t.Errorf("token = %q, want test-token", gotToken)
}
if gotBody.From != "noreply@example.com" {
t.Errorf("from = %q", gotBody.From)
}
if gotBody.To != "alice@example.com" {
t.Errorf("to = %q", gotBody.To)
}
if gotBody.Subject != "Hello" {
t.Errorf("subject = %q", gotBody.Subject)
}
if gotBody.TextBody != "World" {
t.Errorf("body = %q", gotBody.TextBody)
}
}
func TestPostmarkSender_ErrorCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ErrorCode":406,"Message":"Inactive recipient"}`))
}))
defer srv.Close()
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "406") {
t.Errorf("error should mention code 406: %v", err)
}
}
func TestPostmarkSender_ServerError_Temporary(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b")
if err == nil {
t.Fatal("expected error")
}
var temp *TemporaryError
if !errors.As(err, &temp) {
t.Errorf("expected TemporaryError, got %T: %v", err, err)
}
}
func TestPostmarkSender_Validation(t *testing.T) {
sender := NewPostmarkSender("", "noreply@example.com", "", testLogger(t))
if err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b"); err == nil {
t.Fatal("expected error for missing token")
}
sender = NewPostmarkSender("tok", "", "", testLogger(t))
if err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b"); err == nil {
t.Fatal("expected error for missing from")
}
sender = NewPostmarkSender("tok", "noreply@example.com", "", testLogger(t))
if err := sender.Send(context.Background(), nil, "s", "b"); err == nil {
t.Fatal("expected error for empty recipients")
}
}
func TestPostmarkSender_MultipleRecipients(t *testing.T) {
var gotBody postmarkRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &gotBody)
_, _ = w.Write([]byte(`{"ErrorCode":0,"Message":"OK"}`))
}))
defer srv.Close()
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
if err := sender.Send(context.Background(), []string{"a@x.com", "b@x.com"}, "s", "b"); err != nil {
t.Fatalf("Send: %v", err)
}
if !strings.Contains(gotBody.To, "a@x.com") || !strings.Contains(gotBody.To, "b@x.com") {
t.Errorf("to = %q, expected both recipients", gotBody.To)
}
}

View File

@@ -0,0 +1,356 @@
package email
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"math"
"time"
)
// Queue stores outgoing emails durably and dispatches them via a Sender.
// It is safe for concurrent use; claim uses an atomic UPDATE ... WHERE
// status='pending' to lease rows without races.
type Queue struct {
db *sql.DB
sender Sender
logger *slog.Logger
pollInterval time.Duration
maxBackoff time.Duration
}
// QueueRow is the on-disk representation of a queued email.
type QueueRow struct {
ID string
NotificationID string
ToAddress string
Subject string
Body string
Status string
Attempts int
MaxAttempts int
LastError string
NextAttemptAt time.Time
SentAt *time.Time
CreatedAt time.Time
}
// NewQueue constructs a queue. sender may be nil; in that case emails stay
// pending forever (useful for tests that only inspect enqueue state).
func NewQueue(db *sql.DB, sender Sender, logger *slog.Logger) *Queue {
if logger == nil {
logger = slog.Default()
}
return &Queue{
db: db,
sender: sender,
logger: logger,
pollInterval: 10 * time.Second,
maxBackoff: 30 * time.Minute,
}
}
// SetPollInterval overrides the worker poll interval. Must be called before
// Run. Useful for tests.
func (q *Queue) SetPollInterval(d time.Duration) {
if d > 0 {
q.pollInterval = d
}
}
// SetMaxBackoff overrides the maximum retry backoff.
func (q *Queue) SetMaxBackoff(d time.Duration) {
if d > 0 {
q.maxBackoff = d
}
}
// Enqueue inserts a new outgoing email. notificationID may be empty. The
// email is immediately eligible for delivery (next_attempt_at = now).
func (q *Queue) Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error) {
if to == "" {
return "", fmt.Errorf("enqueue: to address is required")
}
if subject == "" {
return "", fmt.Errorf("enqueue: subject is required")
}
id, err := randomQueueID()
if err != nil {
return "", err
}
now := time.Now().UTC()
if _, err := q.db.ExecContext(ctx, `
INSERT INTO email_queue (id, notification_id, to_address, subject, body, status, attempts, max_attempts, next_attempt_at, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, 3, ?, ?)
`, id, nullableString(notificationID), to, subject, body, formatTime(now), formatTime(now)); err != nil {
return "", fmt.Errorf("enqueue email: %w", err)
}
return id, nil
}
// MarkNotificationEmailed stamps notifications.emailed_at for the given
// notification id. Called by the worker once the email is dispatched.
func (q *Queue) MarkNotificationEmailed(ctx context.Context, notificationID string) error {
if notificationID == "" {
return nil
}
_, err := q.db.ExecContext(ctx, `
UPDATE notifications SET emailed_at = ? WHERE id = ?
`, formatTime(time.Now().UTC()), notificationID)
if err != nil {
return fmt.Errorf("mark notification emailed: %w", err)
}
return nil
}
// Run starts the worker loop. It blocks until ctx is cancelled.
func (q *Queue) Run(ctx context.Context) {
q.logger.Info("email queue worker started", "poll_interval", q.pollInterval)
ticker := time.NewTicker(q.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
q.logger.Info("email queue worker stopped")
return
case <-ticker.C:
if err := q.processBatch(ctx); err != nil {
q.logger.Warn("email queue batch failed", "error", err)
}
}
}
}
// processBatch claims and sends up to 25 pending emails per tick.
func (q *Queue) processBatch(ctx context.Context) error {
if q.sender == nil {
// No sender configured; leave rows pending so a future sender can
// pick them up once wired.
return nil
}
rows, err := q.claimBatch(ctx, 25)
if err != nil {
return fmt.Errorf("claim batch: %w", err)
}
for _, row := range rows {
if err := q.deliver(ctx, row); err != nil {
q.logger.Warn("deliver email failed", "id", row.ID, "error", err)
}
}
return nil
}
// claimBatch atomically leases pending rows by flipping their status to
// 'pending' but bumping attempts so a concurrent worker cannot re-claim
// them until the next_attempt_at expires. We use a single UPDATE with a
// subquery to keep this race-free under SQLite.
func (q *Queue) claimBatch(ctx context.Context, limit int) ([]QueueRow, error) {
tx, err := q.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin claim: %w", err)
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
FROM email_queue
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY next_attempt_at ASC
LIMIT ?
`, formatTime(time.Now().UTC()), limit)
if err != nil {
return nil, fmt.Errorf("query pending: %w", err)
}
var claimed []QueueRow
for rows.Next() {
row, err := scanQueueRow(rows)
if err != nil {
rows.Close()
return nil, err
}
claimed = append(claimed, row)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for _, row := range claimed {
if _, err := tx.ExecContext(ctx, `
UPDATE email_queue SET attempts = attempts + 1 WHERE id = ?
`, row.ID); err != nil {
return nil, fmt.Errorf("lease row %s: %w", row.ID, err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit claim: %w", err)
}
return claimed, nil
}
// deliver attempts one email. On success it marks the row sent and stamps
// the linked notification. On failure it schedules a retry with exponential
// backoff or marks the row failed if attempts are exhausted.
func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
err := q.sender.Send(ctx, []string{row.ToAddress}, row.Subject, row.Body)
if err == nil {
if _, err := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'sent', sent_at = ?, last_error = '' WHERE id = ?
`, formatTime(time.Now().UTC()), row.ID); err != nil {
return fmt.Errorf("mark sent: %w", err)
}
if err := q.MarkNotificationEmailed(ctx, row.NotificationID); err != nil {
q.logger.Warn("mark notification emailed", "id", row.NotificationID, "error", err)
}
return nil
}
// Failure: retry or give up.
var permanent *PermanentError
if errors.As(err, &permanent) {
if _, ferr := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil {
return fmt.Errorf("mark permanent failure: %w", ferr)
}
q.logger.Warn("email permanently rejected", "id", row.ID, "attempts", row.Attempts+1, "error", err)
return nil
}
currentAttempt := row.Attempts + 1
if currentAttempt >= row.MaxAttempts {
if _, ferr := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil {
return fmt.Errorf("mark failed: %w", ferr)
}
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", currentAttempt, "error", err)
return nil
}
backoff := q.backoff(currentAttempt)
next := time.Now().UTC().Add(backoff)
if _, err := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
`, formatTime(next), truncateErr(err.Error()), row.ID); err != nil {
return fmt.Errorf("schedule retry: %w", err)
}
// Temporary provider failures and unclassified sender failures are retried;
// PermanentError is handled above without wasting additional attempts.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", currentAttempt, "backoff", backoff, "error", err)
return nil
}
func (q *Queue) backoff(attempts int) time.Duration {
// 2^attempts seconds, capped: 2s, 4s, 8s, 16s, ... up to maxBackoff.
d := time.Duration(math.Pow(2, float64(attempts))) * time.Second
if d > q.maxBackoff {
d = q.maxBackoff
}
if d < time.Second {
d = time.Second
}
return d
}
// PendingCount returns the number of rows in pending status, primarily for
// tests and operator dashboards.
func (q *Queue) PendingCount(ctx context.Context) (int, error) {
var count int
err := q.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM email_queue WHERE status = 'pending'`).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
// ListFailed returns rows that have exhausted their retries, for operator
// inspection.
func (q *Queue) ListFailed(ctx context.Context, limit int) ([]QueueRow, error) {
if limit <= 0 {
limit = 50
}
rows, err := q.db.QueryContext(ctx, `
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
FROM email_queue
WHERE status = 'failed'
ORDER BY created_at DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []QueueRow
for rows.Next() {
row, err := scanQueueRow(rows)
if err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func scanQueueRow(rows *sql.Rows) (QueueRow, error) {
var r QueueRow
var sentAt, lastError string
var nextAttempt, createdAt string
if err := rows.Scan(&r.ID, &r.NotificationID, &r.ToAddress, &r.Subject, &r.Body, &r.Status,
&r.Attempts, &r.MaxAttempts, &lastError, &nextAttempt, &sentAt, &createdAt); err != nil {
return QueueRow{}, fmt.Errorf("scan queue row: %w", err)
}
r.LastError = lastError
var err error
r.NextAttemptAt, err = time.Parse(time.RFC3339, nextAttempt)
if err != nil {
return QueueRow{}, fmt.Errorf("parse next_attempt_at: %w", err)
}
r.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
if err != nil {
return QueueRow{}, fmt.Errorf("parse created_at: %w", err)
}
if sentAt != "" {
t, err := time.Parse(time.RFC3339, sentAt)
if err == nil {
r.SentAt = &t
}
}
return r, nil
}
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
func truncateErr(s string) string {
if len(s) > 512 {
return s[:512] + "..."
}
return s
}
// randomQueueID produces an id with enough entropy to avoid collisions. We
// keep a small helper in-package to avoid an import cycle on auth.
func randomQueueID() (string, error) {
id, err := randomToken(18)
if err != nil {
return "", err
}
return "email:" + id, nil
}

View File

@@ -0,0 +1,275 @@
package email
import (
"context"
"database/sql"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
cfg := config.DatabaseConfig{Path: t.TempDir() + "/test.sqlite"}
handle, err := database.Open(context.Background(), cfg)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
db := handle.SQL()
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("migrate: %v", err)
}
// Seed a user + notification so the queue's notification_id FK is
// satisfiable and MarkNotificationEmailed can update a real row.
_, err = db.Exec(`INSERT INTO users (id, email, display_name, role, created_at, last_seen_at) VALUES ('user:alice@example.com', 'alice@example.com', 'Alice', 'admin', ?, ?)`,
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Fatalf("seed user: %v", err)
}
_, err = db.Exec(`INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, created_at) VALUES (?, 'user:alice@example.com', 'file_changed', 'document', 'doc:test', 'test', ?)`,
"notif:test-1", time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Fatalf("seed notification: %v", err)
}
return db
}
func TestQueue_EnqueueAndPendingCount(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Subject", "Body")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
if id == "" {
t.Fatal("expected non-empty id")
}
count, err := q.PendingCount(context.Background())
if err != nil {
t.Fatalf("PendingCount: %v", err)
}
if count != 1 {
t.Errorf("pending = %d, want 1", count)
}
}
func TestQueue_EnqueueValidation(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
// Empty notification_id is allowed (the column is nullable).
if _, err := q.Enqueue(context.Background(), "", "alice@example.com", "s", "b"); err != nil {
t.Fatalf("empty notification_id should be allowed: %v", err)
}
if _, err := q.Enqueue(context.Background(), "notif:test-1", "", "s", "b"); err == nil {
t.Fatal("expected error for empty to")
}
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "", "b"); err == nil {
t.Fatal("expected error for empty subject")
}
}
type recordingSender struct {
sends []recordedSend
err error
}
type recordedSend struct {
to []string
subject string
body string
}
func (r *recordingSender) Send(ctx context.Context, to []string, subject, body string) error {
if r.err != nil {
return r.err
}
r.sends = append(r.sends, recordedSend{to: to, subject: subject, body: body})
return nil
}
func TestQueue_DeliverSuccess(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(20 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Hello", "World"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
n, _ := q.PendingCount(ctx)
return n == 0
}); err != nil {
t.Fatalf("email never sent: %v", err)
}
if len(sender.sends) != 1 {
t.Fatalf("sends = %d, want 1", len(sender.sends))
}
if sender.sends[0].subject != "Hello" {
t.Errorf("subject = %q", sender.sends[0].subject)
}
if sender.sends[0].body != "World" {
t.Errorf("body = %q", sender.sends[0].body)
}
// The linked notification should now have emailed_at stamped.
var emailedAt string
if err := db.QueryRow(`SELECT COALESCE(emailed_at, '') FROM notifications WHERE id = ?`, "notif:test-1").Scan(&emailedAt); err != nil {
t.Fatalf("query emailed_at: %v", err)
}
if emailedAt == "" {
t.Error("expected notifications.emailed_at to be set after send")
}
}
func TestQueue_RetryThenSucceed(t *testing.T) {
db := newTestDB(t)
// Fail twice, then succeed.
sender := &flakySender{failTimes: 2}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
q.SetMaxBackoff(50 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Retry", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
return sender.successCount.Load() >= 1
}); err != nil {
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount.Load(), sender.attempts.Load(), err)
}
// Verify the row is marked sent.
var status string
if err := db.QueryRow(`SELECT status FROM email_queue WHERE to_address = ?`, "alice@example.com").Scan(&status); err != nil {
t.Fatalf("query status: %v", err)
}
if status != "sent" {
t.Errorf("status = %q, want sent", status)
}
}
func TestQueue_PermanentFailure(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{err: errors.New("permanent Postmark rejection")}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
q.SetMaxBackoff(20 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
failed, _ := q.ListFailed(ctx, 10)
return len(failed) == 1
}); err != nil {
failed, _ := q.ListFailed(ctx, 10)
t.Fatalf("expected 1 failed row, got %d: %v", len(failed), err)
}
failed, _ := q.ListFailed(ctx, 10)
if !strings.Contains(failed[0].LastError, "permanent") {
t.Errorf("last_error = %q", failed[0].LastError)
}
}
func TestQueue_ProviderRejectionFailsImmediately(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{err: &PermanentError{Cause: errors.New("sender domain not verified")}}
q := NewQueue(db, sender, testLogger(t))
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
rows, err := q.claimBatch(context.Background(), 1)
if err != nil || len(rows) != 1 {
t.Fatalf("claimBatch = %#v, %v", rows, err)
}
if err := q.deliver(context.Background(), rows[0]); err != nil {
t.Fatalf("deliver: %v", err)
}
var status string
var attempts int
if err := db.QueryRow(`SELECT status, attempts FROM email_queue WHERE id = ?`, id).Scan(&status, &attempts); err != nil {
t.Fatalf("query row: %v", err)
}
if status != "failed" || attempts != 1 {
t.Fatalf("status=%q attempts=%d, want failed after one attempt", status, attempts)
}
}
func TestQueue_NilSenderLeavesPending(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Pending", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
go q.Run(ctx)
<-ctx.Done()
count, _ := q.PendingCount(context.Background())
if count != 1 {
t.Errorf("expected row to stay pending with nil sender, got %d", count)
}
}
// flakySender fails the first N attempts then succeeds.
type flakySender struct {
failTimes int32
attempts atomic.Int32
successCount atomic.Int32
}
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
attempt := f.attempts.Add(1)
if attempt <= f.failTimes {
return errors.New("transient failure")
}
f.successCount.Add(1)
return nil
}
func waitFor(ctx context.Context, cond func() bool) error {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if cond() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}

View File

@@ -0,0 +1,149 @@
package email
import (
"fmt"
"strings"
"time"
)
// TemplateData carries the context required to render any notification email.
// Fields are optional per template; callers should populate what they have.
type TemplateData struct {
ActorName string
ActorEmail string
DocumentPath string
DocumentTitle string
CommentBody string
MentionText string
ConflictDesc string
ViewURL string
UnsubURL string
InstanceName string
}
// Render returns a plain-text subject and body for the given notification
// type. Subjects are short and prefixed with the document path so email
// clients thread per-document.
func Render(kind string, data TemplateData) (subject, body string, err error) {
if data.InstanceName == "" {
data.InstanceName = "Cairnquire"
}
switch kind {
case "file_changed":
return renderFileChanged(data)
case "comment":
return renderComment(data)
case "mention":
return renderMention(data)
case "conflict":
return renderConflict(data)
default:
return "", "", fmt.Errorf("unknown email template kind %q", kind)
}
}
func subjectPrefix(data TemplateData) string {
if data.DocumentPath != "" {
return "[" + data.DocumentPath + "]"
}
return "[" + data.InstanceName + "]"
}
func footer(data TemplateData) string {
var b strings.Builder
b.WriteString("\n--\n")
if data.ViewURL != "" {
fmt.Fprintf(&b, "View online: %s\n", data.ViewURL)
}
if data.UnsubURL != "" {
fmt.Fprintf(&b, "Unsubscribe: %s\n", data.UnsubURL)
}
fmt.Fprintf(&b, "You receive this because you watch this document on %s.\n", data.InstanceName)
return b.String()
}
func renderFileChanged(data TemplateData) (string, string, error) {
title := data.DocumentTitle
if title == "" {
title = data.DocumentPath
}
actor := data.ActorName
if actor == "" {
actor = "Someone"
}
subject := fmt.Sprintf("%s Updated by %s", subjectPrefix(data), actor)
var b strings.Builder
fmt.Fprintf(&b, "%s updated %q at %s UTC.\n", actor, title, time.Now().UTC().Format("2006-01-02 15:04"))
if data.DocumentPath != "" {
fmt.Fprintf(&b, "Path: %s\n", data.DocumentPath)
}
b.WriteString("\nView the changes online to see what is new.\n")
b.WriteString(footer(data))
return subject, b.String(), nil
}
func renderComment(data TemplateData) (string, string, error) {
actor := data.ActorName
if actor == "" {
actor = "Someone"
}
subject := fmt.Sprintf("%s Comment from %s", subjectPrefix(data), actor)
var b strings.Builder
fmt.Fprintf(&b, "%s commented on %q:\n", actor, docLabel(data))
b.WriteString("\n")
if data.CommentBody != "" {
// Quote the comment body, line by line, as plain text.
for _, line := range strings.Split(data.CommentBody, "\n") {
fmt.Fprintf(&b, "> %s\n", line)
}
} else {
b.WriteString("> (no body)\n")
}
b.WriteString("\nOpen the document to reply.\n")
b.WriteString(footer(data))
return subject, b.String(), nil
}
func renderMention(data TemplateData) (string, string, error) {
actor := data.ActorName
if actor == "" {
actor = "Someone"
}
subject := fmt.Sprintf("%s Mention from %s", subjectPrefix(data), actor)
var b strings.Builder
fmt.Fprintf(&b, "%s mentioned you on %q.\n", actor, docLabel(data))
if data.MentionText != "" {
b.WriteString("\n")
for _, line := range strings.Split(data.MentionText, "\n") {
fmt.Fprintf(&b, "> %s\n", line)
}
}
b.WriteString(footer(data))
return subject, b.String(), nil
}
func renderConflict(data TemplateData) (string, string, error) {
subject := fmt.Sprintf("%s Sync conflict", subjectPrefix(data))
var b strings.Builder
fmt.Fprintf(&b, "A sync conflict was detected on %q.\n", docLabel(data))
if data.ConflictDesc != "" {
fmt.Fprintf(&b, "\nDetails: %s\n", data.ConflictDesc)
}
b.WriteString("\nOpen the document to review and resolve the conflict.\n")
b.WriteString(footer(data))
return subject, b.String(), nil
}
func docLabel(data TemplateData) string {
if data.DocumentTitle != "" {
return data.DocumentTitle
}
if data.DocumentPath != "" {
return data.DocumentPath
}
return "a document"
}

View File

@@ -0,0 +1,119 @@
package email
import (
"strings"
"testing"
)
func TestRender_FileChanged(t *testing.T) {
subject, body, err := Render("file_changed", TemplateData{
ActorName: "Alice",
DocumentPath: "docs/api.md",
DocumentTitle: "API Reference",
ViewURL: "https://example.com/docs/api.md",
UnsubURL: "https://example.com/unsub/123",
InstanceName: "Cairnquire",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(subject, "docs/api.md") {
t.Errorf("subject should include path: %q", subject)
}
if !strings.Contains(subject, "Alice") {
t.Errorf("subject should include actor: %q", subject)
}
if !strings.Contains(body, "Alice") {
t.Errorf("body should mention actor: %q", body)
}
if !strings.Contains(body, "API Reference") {
t.Errorf("body should mention title: %q", body)
}
if !strings.Contains(body, "https://example.com/docs/api.md") {
t.Errorf("body should include view url: %q", body)
}
if !strings.Contains(body, "https://example.com/unsub/123") {
t.Errorf("body should include unsub url: %q", body)
}
}
func TestRender_Comment(t *testing.T) {
subject, body, err := Render("comment", TemplateData{
ActorName: "Bob",
DocumentTitle: "Getting Started",
CommentBody: "This step is unclear.\nCan we add an example?",
ViewURL: "https://example.com/docs/getting-started",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(subject, "Bob") {
t.Errorf("subject should include actor: %q", subject)
}
if !strings.Contains(body, "> This step is unclear.") {
t.Errorf("body should quote comment: %q", body)
}
if !strings.Contains(body, "> Can we add an example?") {
t.Errorf("body should quote second line: %q", body)
}
if !strings.Contains(body, "Open the document to reply") {
t.Errorf("body should link users to the supported reply flow: %q", body)
}
}
func TestRender_Mention(t *testing.T) {
subject, body, err := Render("mention", TemplateData{
ActorName: "Carol",
DocumentTitle: "Spec",
MentionText: "@dan please review",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(subject, "Mention") {
t.Errorf("subject: %q", subject)
}
if !strings.Contains(body, "> @dan please review") {
t.Errorf("body should quote mention: %q", body)
}
}
func TestRender_Conflict(t *testing.T) {
subject, body, err := Render("conflict", TemplateData{
DocumentPath: "notes/2024.md",
ConflictDesc: "Local and remote both edited line 12",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(subject, "conflict") {
t.Errorf("subject: %q", subject)
}
if !strings.Contains(body, "sync conflict") {
t.Errorf("body should mention conflict: %q", body)
}
if !strings.Contains(body, "line 12") {
t.Errorf("body should include conflict desc: %q", body)
}
}
func TestRender_UnknownKind(t *testing.T) {
_, _, err := Render("bogus", TemplateData{})
if err == nil {
t.Fatal("expected error for unknown kind")
}
}
func TestRender_PlainTextOnly(t *testing.T) {
for _, kind := range []string{"file_changed", "comment", "mention", "conflict"} {
_, body, err := Render(kind, TemplateData{
ActorName: "A", DocumentTitle: "T", CommentBody: "<b>html</b>",
})
if err != nil {
t.Fatalf("Render %s: %v", kind, err)
}
if strings.Contains(body, "<html>") || strings.Contains(body, "<body>") {
t.Errorf("template %s produced HTML: %q", kind, body)
}
}
}

View File

@@ -0,0 +1,18 @@
package email
import (
"log/slog"
"testing"
)
func testLogger(t *testing.T) *slog.Logger {
t.Helper()
return slog.New(slog.NewTextHandler(&testWriter{t: t}, &slog.HandlerOptions{Level: slog.LevelWarn}))
}
type testWriter struct{ t *testing.T }
func (w *testWriter) Write(p []byte) (int, error) {
w.t.Logf("%s", p)
return len(p), nil
}

View File

@@ -16,6 +16,7 @@ import (
"testing"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
@@ -99,6 +100,13 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
}
syncRepo := sync.NewRepository(db.SQL())
hub := realtime.NewHub(logger)
collabService := collaboration.NewServiceWithOptions(
collaboration.NewRepository(db.SQL()),
hub,
logger,
collaboration.Options{UserLookup: auth.NewRepository(db.SQL()), PublicOrigin: "http://localhost:8080"},
)
handler, err := New(Dependencies{
Config: config.Config{
Content: config.ContentConfig{
@@ -112,10 +120,11 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
Documents: docService,
Repository: docRepo,
ContentStore: contentStore,
Hub: realtime.NewHub(logger),
Hub: hub,
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
SyncRepo: syncRepo,
Auth: authService,
Collaboration: collabService,
})
if err != nil {
t.Fatalf("create http server: %v", err)
@@ -301,6 +310,54 @@ func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
}
}
func TestPasswordUserCanBeginPasskeyRegistrationFromAccount(t *testing.T) {
server := newAPITestServer(t)
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "very secure password",
}))
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
}
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
accountRequest.AddCookie(session)
account := httptest.NewRecorder()
server.handler.ServeHTTP(account, accountRequest)
if account.Code != http.StatusOK || !bytes.Contains(account.Body.Bytes(), []byte("data-passkey-add")) {
t.Fatalf("account page response = %d %q, want add-passkey form", account.Code, account.Body.String())
}
beginRequest := jsonRequest(http.MethodPost, "/api/auth/passkeys/me/register/begin", map[string]any{})
beginRequest.AddCookie(session)
begin := httptest.NewRecorder()
server.handler.ServeHTTP(begin, beginRequest)
if begin.Code != http.StatusOK {
t.Fatalf("passkey begin response = %d %q", begin.Code, begin.Body.String())
}
var body struct {
ChallengeID string `json:"challengeId"`
Options any `json:"options"`
}
if err := json.NewDecoder(begin.Body).Decode(&body); err != nil {
t.Fatalf("decode passkey begin response: %v", err)
}
if body.ChallengeID == "" || body.Options == nil {
t.Fatalf("passkey begin body = %#v, want challenge and options", body)
}
publicBegin := httptest.NewRecorder()
server.handler.ServeHTTP(publicBegin, jsonRequest(http.MethodPost, "/api/auth/passkeys/register/begin", map[string]string{
"email": "editor@example.com",
}))
if publicBegin.Code == http.StatusOK {
t.Fatalf("public passkey begin response = %d %q, want failure for existing account", publicBegin.Code, publicBegin.Body.String())
}
}
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
server := newAPITestServer(t)
sender := &testEmailSender{}
@@ -384,17 +441,36 @@ func passwordResetTokenFromEmail(t *testing.T, body string) string {
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper()
return s.tokenForUser(t, s.userID, scopes...)
}
created, err := s.auth.CreateAPIKey(context.Background(), s.userID, "test token", scopes, nil)
func (s *apiTestServer) tokenForUser(t *testing.T, userID string, scopes ...auth.Scope) string {
t.Helper()
created, err := s.auth.CreateAPIKey(context.Background(), userID, "test token", scopes, nil)
if err != nil {
t.Fatalf("create api token: %v", err)
}
return created.Token
}
func (s *apiTestServer) viewerUser(t *testing.T) auth.User {
t.Helper()
ctx := context.Background()
admin := auth.Principal{UserID: s.userID, Role: auth.RoleAdmin}
if _, err := s.auth.UpdateSignupsEnabled(ctx, admin, true); err != nil {
t.Fatalf("enable signups: %v", err)
}
user, err := s.auth.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("create viewer: %v", err)
}
return user
}
func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
@@ -446,6 +522,41 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
}
}
func TestAPIDocumentsArePrivateUntilResourceGrant(t *testing.T) {
server := newAPITestServer(t)
viewer := server.viewerUser(t)
token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead)
listRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
listRequest.Header.Set("Authorization", "Bearer "+token)
listRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(listRecorder, listRequest)
if listRecorder.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
}
if strings.Contains(listRecorder.Body.String(), "hello.md") {
t.Fatalf("private document leaked before grant: %s", listRecorder.Body.String())
}
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: auth.PermissionRead},
}); err != nil {
t.Fatalf("grant root folder read: %v", err)
}
listRequest = httptest.NewRequest(http.MethodGet, "/api/documents", nil)
listRequest.Header.Set("Authorization", "Bearer "+token)
listRecorder = httptest.NewRecorder()
server.handler.ServeHTTP(listRecorder, listRequest)
if listRecorder.Code != http.StatusOK {
t.Fatalf("documents after grant status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
}
if !strings.Contains(listRecorder.Body.String(), "hello.md") {
t.Fatalf("document missing after folder grant: %s", listRecorder.Body.String())
}
}
func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
@@ -463,6 +574,83 @@ func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
}
}
func TestAPISyncSnapshotInheritsResourcePermissions(t *testing.T) {
server := newAPITestServer(t)
viewer := server.viewerUser(t)
token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
initRequest.Header.Set("Authorization", "Bearer "+token)
initRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(initRecorder, initRequest)
if initRecorder.Code != http.StatusOK {
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
}
if strings.Contains(initRecorder.Body.String(), "hello.md") {
t.Fatalf("private sync snapshot leaked before grant: %s", initRecorder.Body.String())
}
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: auth.PermissionRead},
}); err != nil {
t.Fatalf("grant root folder read: %v", err)
}
initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-2"})
initRequest.Header.Set("Authorization", "Bearer "+token)
initRecorder = httptest.NewRecorder()
server.handler.ServeHTTP(initRecorder, initRequest)
if initRecorder.Code != http.StatusOK {
t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
}
if !strings.Contains(initRecorder.Body.String(), "hello.md") {
t.Fatalf("sync snapshot missing document after folder grant: %s", initRecorder.Body.String())
}
}
func TestAPISyncSnapshotHandlesUnindexedDiskFilesByFolderPermission(t *testing.T) {
server := newAPITestServer(t)
viewer := server.viewerUser(t)
token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)
if err := os.WriteFile(filepath.Join(server.root, "content", "unindexed.md"), []byte("# Unindexed\n"), 0o644); err != nil {
t.Fatalf("write unindexed document: %v", err)
}
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-1"})
initRequest.Header.Set("Authorization", "Bearer "+token)
initRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(initRecorder, initRequest)
if initRecorder.Code != http.StatusOK {
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
}
if strings.Contains(initRecorder.Body.String(), "sql: no rows") {
t.Fatalf("sync init leaked missing document row: %s", initRecorder.Body.String())
}
if strings.Contains(initRecorder.Body.String(), "unindexed.md") {
t.Fatalf("private unindexed document leaked before grant: %s", initRecorder.Body.String())
}
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: auth.PermissionRead},
}); err != nil {
t.Fatalf("grant root folder read: %v", err)
}
initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-2"})
initRequest.Header.Set("Authorization", "Bearer "+token)
initRecorder = httptest.NewRecorder()
server.handler.ServeHTTP(initRecorder, initRequest)
if initRecorder.Code != http.StatusOK {
t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
}
if !strings.Contains(initRecorder.Body.String(), "unindexed.md") {
t.Fatalf("sync snapshot missing unindexed document after folder grant: %s", initRecorder.Body.String())
}
}
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
@@ -575,10 +763,12 @@ func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
@@ -637,9 +827,128 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
}
}
func TestAPIDocumentMoveKeepsStableIDAndRedirectsOldPath(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, documentsRequest)
var list struct {
Documents []struct {
ID string `json:"id"`
Path string `json:"path"`
Hash string `json:"hash"`
} `json:"documents"`
}
if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil || len(list.Documents) != 1 {
t.Fatalf("decode documents: err=%v payload=%#v", err, list)
}
move := jsonRequest(http.MethodPost, "/api/documents/hello.md/move", map[string]string{
"path": "guides/welcome.md",
"baseHash": list.Documents[0].Hash,
})
move.Header.Set("Authorization", "Bearer "+token)
moved := httptest.NewRecorder()
server.handler.ServeHTTP(moved, move)
if moved.Code != http.StatusOK {
t.Fatalf("move status = %d, want %d; body=%s", moved.Code, http.StatusOK, moved.Body.String())
}
var payload struct {
ID string `json:"id"`
Path string `json:"path"`
URL string `json:"url"`
}
if err := json.Unmarshal(moved.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode move response: %v", err)
}
if payload.ID != list.Documents[0].ID || payload.Path != "guides/welcome.md" || payload.URL != "/docs/guides/welcome" {
t.Fatalf("move response = %#v", payload)
}
oldPathRequest := httptest.NewRequest(http.MethodGet, "/docs/hello", nil)
oldPathRequest.Header.Set("Authorization", "Bearer "+token)
oldPath := httptest.NewRecorder()
server.handler.ServeHTTP(oldPath, oldPathRequest)
if oldPath.Code != http.StatusFound || oldPath.Header().Get("Location") != payload.URL {
t.Fatalf("old path response = %d location=%q, want redirect to %q", oldPath.Code, oldPath.Header().Get("Location"), payload.URL)
}
stableRequest := httptest.NewRequest(http.MethodGet, "/documents/"+url.PathEscape(payload.ID), nil)
stableRequest.Header.Set("Authorization", "Bearer "+token)
stable := httptest.NewRecorder()
server.handler.ServeHTTP(stable, stableRequest)
if stable.Code != http.StatusFound || stable.Header().Get("Location") != payload.URL {
t.Fatalf("stable ID response = %d location=%q, want redirect to %q", stable.Code, stable.Header().Get("Location"), payload.URL)
}
}
func TestAPIDocumentMoveRequiresDestinationFolderWrite(t *testing.T) {
server := newAPITestServer(t)
viewer := server.viewerUser(t)
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
if err := server.auth.EnsureResourcePermission(context.Background(), admin, auth.ResourceDocument, "hello.md", auth.ResourcePermissionUpdate{
UserID: viewer.ID, Permission: auth.PermissionWrite,
}); err != nil {
t.Fatalf("grant source document write: %v", err)
}
page, err := server.docs.LoadSourcePage(context.Background(), "hello")
if err != nil {
t.Fatalf("load source document: %v", err)
}
token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead, auth.ScopeDocsWrite)
move := jsonRequest(http.MethodPost, "/api/documents/hello.md/move", map[string]string{
"path": "private/welcome.md",
"baseHash": page.Hash,
})
move.Header.Set("Authorization", "Bearer "+token)
response := httptest.NewRecorder()
server.handler.ServeHTTP(response, move)
if response.Code != http.StatusForbidden {
t.Fatalf("move status = %d, want %d; body=%s", response.Code, http.StatusForbidden, response.Body.String())
}
}
func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
save := jsonRequest(http.MethodPost, "/api/documents/inbox/new-note", map[string]string{
"content": "# New Note\n\nCreated in browser",
})
save.Header.Set("Authorization", "Bearer "+token)
saveRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(saveRecorder, save)
if saveRecorder.Code != http.StatusOK {
t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusOK, saveRecorder.Body.String())
}
permissions := httptest.NewRequest(http.MethodGet, "/api/permissions?resourceType=document&resourceId=inbox/new-note.md", nil)
permissions.Header.Set("Authorization", "Bearer "+token)
permissionsRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(permissionsRecorder, permissions)
if permissionsRecorder.Code != http.StatusOK {
t.Fatalf("permissions status = %d, want %d; body=%s", permissionsRecorder.Code, http.StatusOK, permissionsRecorder.Body.String())
}
var payload struct {
Permissions []auth.ResourcePermission `json:"permissions"`
}
if err := json.Unmarshal(permissionsRecorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode permissions: %v", err)
}
if len(payload.Permissions) != 1 || payload.Permissions[0].UserID != server.userID || payload.Permissions[0].Permission != auth.PermissionAdmin {
t.Fatalf("permissions = %#v, want creator admin grant", payload.Permissions)
}
}
func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background()
nestedDir := filepath.Join(server.root, "content", "guide")
@@ -663,8 +972,10 @@ func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
@@ -675,7 +986,7 @@ func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background()
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
@@ -695,8 +1006,10 @@ func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}

View File

@@ -334,6 +334,42 @@ func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
}
func (s *Server) handleCurrentUserPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
options, challengeID, err := s.auth.BeginCurrentUserPasskeyRegistration(r.Context(), principal)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
}
func (s *Server) handleCurrentUserPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
challengeID := r.URL.Query().Get("challengeId")
if challengeID == "" {
var req passkeyFinishRequest
_ = json.NewDecoder(r.Body).Decode(&req)
challengeID = req.ChallengeID
}
if challengeID == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
return
}
user, err := s.auth.FinishCurrentUserPasskeyRegistration(r.Context(), principal, challengeID, r)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
}
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return

View File

@@ -3,6 +3,8 @@ package httpserver
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
@@ -16,14 +18,28 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document_id is required"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
anchorHash := r.URL.Query().Get("anchor_hash")
includeResolved := r.URL.Query().Get("include_resolved") == "true"
var comments []collaboration.Comment
var err error
if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash, includeResolved)
} else {
comments, err = s.collaboration.ListComments(r.Context(), documentID)
comments, err = s.collaboration.ListComments(r.Context(), documentID, includeResolved)
}
if err != nil {
s.logger.Warn("list comments", "error", err)
@@ -45,6 +61,20 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), input.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
comment, err := s.collaboration.CreateComment(r.Context(), principal, input)
if err != nil {
@@ -63,6 +93,25 @@ func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) {
}
commentID := chi.URLParam(r, "id")
comment, err := s.collaboration.GetComment(r.Context(), commentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), comment.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
return
}
if err := s.collaboration.ResolveComment(r.Context(), principal, commentID); err != nil {
s.logger.Warn("resolve comment", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
@@ -82,8 +131,13 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
unreadOnly := r.URL.Query().Get("unread") == "true"
limit := 50
if r.URL.Query().Get("limit") != "" {
// Parse limit if needed, but for now just use default
if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" {
parsed, err := strconv.Atoi(rawLimit)
if err != nil || parsed < 1 || parsed > 100 {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
limit = parsed
}
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
@@ -158,6 +212,21 @@ func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
req.DocumentID = strings.TrimSpace(req.DocumentID)
document, err := s.repository.GetDocumentByID(r.Context(), req.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("watch document", "error", err)
@@ -181,6 +250,7 @@ func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
req.DocumentID = strings.TrimSpace(req.DocumentID)
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("unwatch document", "error", err)
@@ -204,8 +274,17 @@ func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !s.canReadFolder(r, normalized) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"})
return
}
if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil {
if err := s.collaboration.WatchFolder(r.Context(), principal, normalized); err != nil {
s.logger.Warn("watch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
@@ -227,8 +306,13 @@ func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil {
if err := s.collaboration.UnwatchFolder(r.Context(), principal, normalized); err != nil {
s.logger.Warn("unwatch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
@@ -244,6 +328,20 @@ func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
}
documentID := r.URL.Query().Get("document_id")
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
if err != nil {
s.logger.Warn("is watching", "error", err)

View File

@@ -0,0 +1,141 @@
package httpserver
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
)
func loginTestUser(t *testing.T, server *apiTestServer) *http.Cookie {
t.Helper()
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com", "password": "very secure password",
}))
if login.Code != http.StatusOK {
t.Fatalf("login response = %d %q", login.Code, login.Body.String())
}
return cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
}
func authenticatedJSONRequest(t *testing.T, method, target string, body any, session *http.Cookie) *http.Request {
t.Helper()
request := jsonRequest(method, target, body)
request.AddCookie(session)
return request
}
func TestDocumentLoadsCollaborationControlsAndWatchFlow(t *testing.T) {
server := newAPITestServer(t)
session := loginTestUser(t, server)
pageRequest := httptest.NewRequest(http.MethodGet, "/docs/hello", nil)
pageRequest.AddCookie(session)
page := httptest.NewRecorder()
server.handler.ServeHTTP(page, pageRequest)
if page.Code != http.StatusOK {
t.Fatalf("document response = %d %q", page.Code, page.Body.String())
}
for _, expected := range [][]byte{
[]byte(`/static/notification-bell.js`),
[]byte(`data-document-id="doc:hello"`),
[]byte(`data-document-watch`),
[]byte(`data-comments-history`),
} {
if !bytes.Contains(page.Body.Bytes(), expected) {
t.Errorf("document page missing %q", expected)
}
}
statusRequest := httptest.NewRequest(http.MethodGet, "/api/watch/status?document_id=doc%3Ahello", nil)
statusRequest.AddCookie(session)
status := httptest.NewRecorder()
server.handler.ServeHTTP(status, statusRequest)
if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":false`)) {
t.Fatalf("initial watch status = %d %q", status.Code, status.Body.String())
}
watch := httptest.NewRecorder()
server.handler.ServeHTTP(watch, authenticatedJSONRequest(t, http.MethodPost, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session))
if watch.Code != http.StatusOK {
t.Fatalf("watch response = %d %q", watch.Code, watch.Body.String())
}
status = httptest.NewRecorder()
server.handler.ServeHTTP(status, statusRequest.Clone(statusRequest.Context()))
if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":true`)) {
t.Fatalf("watched status = %d %q", status.Code, status.Body.String())
}
unwatch := httptest.NewRecorder()
server.handler.ServeHTTP(unwatch, authenticatedJSONRequest(t, http.MethodDelete, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session))
if unwatch.Code != http.StatusOK {
t.Fatalf("unwatch response = %d %q", unwatch.Code, unwatch.Body.String())
}
}
func TestCommentReplyResolveAndHistoryHTTPFlow(t *testing.T) {
server := newAPITestServer(t)
session := loginTestUser(t, server)
anchor := "section-anchor"
create := func(content string, parentID *string) collaboration.Comment {
t.Helper()
response := httptest.NewRecorder()
server.handler.ServeHTTP(response, authenticatedJSONRequest(t, http.MethodPost, "/api/comments", collaboration.CreateCommentInput{
DocumentID: "doc:hello", VersionHash: "hash", ParentID: parentID, Content: content, AnchorHash: &anchor,
}, session))
if response.Code != http.StatusCreated {
t.Fatalf("create comment response = %d %q", response.Code, response.Body.String())
}
var comment collaboration.Comment
if err := json.NewDecoder(response.Body).Decode(&comment); err != nil {
t.Fatalf("decode comment: %v", err)
}
return comment
}
root := create("Root", nil)
reply := create("Reply", &root.ID)
_ = create("Grandchild", &reply.ID)
resolve := httptest.NewRecorder()
server.handler.ServeHTTP(resolve, authenticatedJSONRequest(t, http.MethodPost, "/api/comments/"+root.ID+"/resolve", map[string]any{}, session))
if resolve.Code != http.StatusOK {
t.Fatalf("resolve response = %d %q", resolve.Code, resolve.Body.String())
}
list := func(includeResolved bool) []collaboration.Comment {
t.Helper()
target := "/api/comments?document_id=doc%3Ahello&anchor_hash=" + anchor
if includeResolved {
target += "&include_resolved=true"
}
request := httptest.NewRequest(http.MethodGet, target, nil)
request.AddCookie(session)
response := httptest.NewRecorder()
server.handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("list comments response = %d %q", response.Code, response.Body.String())
}
var body struct {
Comments []collaboration.Comment `json:"comments"`
}
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
t.Fatalf("decode comments: %v", err)
}
return body.Comments
}
if active := list(false); len(active) != 0 {
t.Fatalf("active comments = %d, want 0", len(active))
}
if history := list(true); len(history) != 3 {
t.Fatalf("history comments = %d, want 3", len(history))
}
}

View File

@@ -39,17 +39,22 @@ type indexData struct {
type documentData struct {
Browser browserData
ID string
Title string
Path string
Tags []string
Hash string
HTML template.HTML
Breadcrumbs []breadcrumb
CanRead bool
CanWrite bool
CanAdmin bool
CanComment bool
}
type documentEditData struct {
Browser browserData
ID string
Title string
Path string
Tags []string
@@ -71,10 +76,12 @@ type errorData struct {
type browserData struct {
Columns []browserColumn
CanWrite bool
}
type browserColumn struct {
Title string
Path string
Items []browserItem
}
@@ -95,19 +102,32 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
return
}
tag := r.URL.Query().Get("tag")
if tag != "" {
s.handleTagPage(w, r, tag)
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
browser := buildBrowser(items, activeFolder)
browser.CanWrite = s.canWriteFolder(r, activeFolder)
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
BodyClass: "page-index",
BodyTemplate: "index_content",
Data: indexData{
Browser: buildBrowser(items, activeFolder),
Browser: browser,
},
})
}
@@ -120,6 +140,11 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
if err != nil {
s.logger.Warn("search failed", "error", err)
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query,
@@ -135,6 +160,39 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
})
}
func (s *Server) handleTagPage(w http.ResponseWriter, r *http.Request, tag string) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results, err := s.repository.SearchDocumentsByTag(ctx, tag)
if err != nil {
s.logger.Warn("tag search failed", "error", err)
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
browser := buildTagBrowser(results, tag)
browser.CanWrite = s.canWriteFolder(r, "")
s.renderTemplate(w, r, http.StatusOK, "tag.gohtml", layoutData{
Title: "Tag: " + tag,
BodyClass: "page-tag",
BodyTemplate: "tag_content",
Data: struct {
Tag string
Results []docs.SearchResult
Browser browserData
}{
Tag: tag,
Results: results,
Browser: browser,
},
})
}
func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
s.renderDocumentPage(w, r, "")
}
@@ -152,6 +210,24 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
s.renderDocumentPage(w, r, pagePath)
}
func (s *Server) handleDocumentByID(w http.ResponseWriter, r *http.Request) {
record, err := s.repository.GetDocumentByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
canRead, err := s.canReadDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canRead {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
http.Redirect(w, r, documentPageURL(record.Path), http.StatusFound)
}
func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) {
@@ -220,21 +296,45 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canWrite {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
if !requestPathMatchesDocument(pagePath, page.Path) {
http.Redirect(w, r, documentPageURL(page.Path)+"/edit", http.StatusFound)
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite := s.canWriteDocuments(r)
browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content",
Data: documentEditData{
Browser: buildBrowser(items, page.Path),
Browser: browser,
ID: record.ID,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
@@ -256,28 +356,65 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canRead, err := s.canReadDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canRead {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
if !requestPathMatchesDocument(pagePath, page.Path) {
http.Redirect(w, r, documentPageURL(page.Path), http.StatusFound)
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canAdmin, err := s.canAdminDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite := s.canWriteDocuments(r)
browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
Browser: buildBrowser(items, page.Path),
Browser: browser,
ID: record.ID,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path),
CanRead: canRead,
CanWrite: canWrite,
CanAdmin: canAdmin,
CanComment: canWrite,
},
})
}
@@ -333,21 +470,35 @@ func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var results []docs.SearchResult
var err error
tag := r.URL.Query().Get("tag")
if tag != "" {
results, err = s.repository.SearchDocumentsByTag(ctx, tag)
} else {
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
results, err = s.repository.SearchDocuments(ctx, query)
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results, err := s.repository.SearchDocuments(ctx, query)
if err != nil {
s.logger.Warn("search failed", "error", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.logger.Warn("filter search failed", "error", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
}
@@ -416,12 +567,22 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
return
}
existing, err := s.repository.GetDocumentByPath(r.Context(), path)
existing, err := s.repository.GetDocumentByPathOrAlias(r.Context(), path)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
return
}
createdDocument := existing == nil
if existing != nil {
canWriteExisting, err := s.canWriteDocumentRecord(r, *existing)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "check document permissions")
return
}
if !canWriteExisting {
s.renderError(w, r, http.StatusForbidden, "permission denied")
return
}
switch duplicateAction {
case "overwrite":
case "rename":
@@ -430,6 +591,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "choose document name")
return
}
createdDocument = true
case "reuse":
writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path))
return
@@ -437,12 +599,21 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
writeUploadConflict(w, "document", existing.CurrentHash, existing.Path, documentPageURL(existing.Path))
return
}
} else if !s.canWriteNewDocument(r, path) {
s.renderError(w, r, http.StatusForbidden, "permission denied")
return
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
return
}
if createdDocument {
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "grant document owner permission")
return
}
}
documentPath = page.Path
uploadURL = documentPageURL(page.Path)
uploadKind = "document"
@@ -468,7 +639,7 @@ func (s *Server) availableDocumentPath(ctx context.Context, path string) (string
base := strings.TrimSuffix(path, extension)
for suffix := 2; ; suffix++ {
candidate := fmt.Sprintf("%s-%d%s", base, suffix, extension)
existing, err := s.repository.GetDocumentByPath(ctx, candidate)
existing, err := s.repository.GetDocumentByPathOrAlias(ctx, candidate)
if errors.Is(err, sql.ErrNoRows) {
return candidate, nil
}
@@ -643,6 +814,14 @@ func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
uploadURL := "/attachments/" + record.Hash
uploadKind := "attachment"
if record.DocumentPath != "" {
document, err := s.repository.GetDocumentByPath(r.Context(), record.DocumentPath)
if err != nil || document == nil {
continue
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil || !canRead {
continue
}
uploadURL = documentPageURL(record.DocumentPath)
uploadKind = "document"
}
@@ -667,13 +846,20 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
records, err = s.filterReadableDocuments(r, records)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
results := make([]map[string]any, 0, len(records))
for _, record := range records {
results = append(results, map[string]any{
"id": record.ID,
"path": record.Path,
"title": record.Title,
"hash": record.CurrentHash,
"tags": record.Tags,
})
}
@@ -687,6 +873,8 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request)
return
}
switch {
case strings.HasSuffix(pagePath, "/move"):
s.handleDocumentMovePath(w, r, strings.TrimSuffix(pagePath, "/move"))
case strings.HasSuffix(pagePath, "/archive"):
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
case strings.HasSuffix(pagePath, "/restore"):
@@ -696,6 +884,64 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request)
}
}
func (s *Server) handleDocumentMovePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
var req struct {
Path string `json:"path"`
BaseHash string `json:"baseHash"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
destination, err := docs.NormalizeDocumentPath(req.Path)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
record, err := s.documentRecordForRequestPath(r, pagePath)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite || !s.canWriteFolder(r, documentFolder(destination)) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
moved, err := s.documents.MoveDocument(r.Context(), record.Path, destination, req.BaseHash)
if err != nil {
var conflict *docs.DocumentMoveConflictError
switch {
case errors.As(err, &conflict):
writeJSONWithStatus(w, http.StatusConflict, map[string]string{"error": conflict.Error()})
case errors.Is(err, sql.ErrNoRows):
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
default:
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "moved",
"id": moved.ID,
"path": moved.Path,
"url": documentPageURL(moved.Path),
})
}
func unescapeDocumentRoutePath(path string) (string, error) {
if path == "" {
return "", nil
@@ -717,6 +963,27 @@ func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request,
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
createdDocument := false
if existing, err := s.documentRecordForRequestPath(r, pagePath); err == nil && existing != nil {
canWrite, err := s.canWriteDocumentRecord(r, *existing)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
} else if errors.Is(err, sql.ErrNoRows) {
createdDocument = true
if !s.canWriteNewDocument(r, pagePath) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
} else if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
if err != nil {
@@ -739,6 +1006,12 @@ func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request,
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if createdDocument {
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "grant document owner permission"})
return
}
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "saved",
@@ -758,19 +1031,29 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload)
}
func (s *Server) canWriteDocuments(r *http.Request) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
return auth.Allows(principal, auth.ScopeDocsWrite)
}
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
record, err := s.documentRecordForRequestPath(r, pagePath)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -784,11 +1067,102 @@ func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Reques
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"})
}
func (s *Server) handleFolderMutation(w http.ResponseWriter, r *http.Request) {
folderPath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*"))
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid folder path"})
return
}
switch {
case strings.HasSuffix(folderPath, "/move"):
s.handleFolderMovePath(w, r, strings.TrimSuffix(folderPath, "/move"))
case strings.HasSuffix(folderPath, "/archive"):
s.handleFolderArchivePath(w, r, strings.TrimSuffix(folderPath, "/archive"))
default:
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "unsupported folder operation"})
}
}
func (s *Server) handleFolderMovePath(w http.ResponseWriter, r *http.Request, folderPath string) {
folder, err := docs.NormalizeFolderPath(folderPath)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
destination, err := docs.NormalizeFolderPath(req.Path)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !s.canWriteFolder(r, folder) || !s.canWriteFolder(r, documentFolder(destination)) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
moved, err := s.documents.MoveFolder(r.Context(), folder, destination)
if err != nil {
var conflict *docs.DocumentMoveConflictError
switch {
case errors.As(err, &conflict):
writeJSONWithStatus(w, http.StatusConflict, map[string]string{"error": conflict.Error()})
case errors.Is(err, sql.ErrNoRows):
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"})
default:
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "moved",
"path": destination,
"url": "/?folder=" + queryEscape(destination),
"count": moved,
})
}
func (s *Server) handleFolderArchivePath(w http.ResponseWriter, r *http.Request, folderPath string) {
folder, err := docs.NormalizeFolderPath(folderPath)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !s.canWriteFolder(r, folder) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
archived, err := s.documents.ArchiveFolder(r.Context(), folder)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "archived",
"count": archived,
})
}
func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
principal, _ := principalFromContext(r.Context())
if principal.Role != auth.RoleAdmin {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
if err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -812,6 +1186,17 @@ func trimEditSuffix(path string) (string, bool) {
return strings.TrimSuffix(path, "/edit"), true
}
func requestPathMatchesDocument(requestPath, documentPath string) bool {
requestPath = strings.Trim(requestPath, "/")
if requestPath == "" {
return documentPath == "index.md" || documentPath == "getting-started.md"
}
if strings.HasSuffix(requestPath, ".md") {
return requestPath == documentPath
}
return requestPath+".md" == documentPath || requestPath+"/index.md" == documentPath
}
func isContentAssetPath(path string) bool {
extension := strings.ToLower(filepath.Ext(path))
return extension != "" && extension != ".md"
@@ -845,16 +1230,39 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData
for _, prefix := range prefixes {
column := browserColumn{
Title: columnTitle(prefix),
Path: prefix,
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
}
if len(column.Items) > 0 {
columns = append(columns, column)
}
}
return browserData{Columns: columns}
}
func buildTagBrowser(results []docs.SearchResult, tag string) browserData {
items := make([]browserItem, 0, len(results))
for _, result := range results {
items = append(items, browserItem{
Name: displayDocumentName(result.Path, result.Title),
Path: result.Path,
URL: "/docs/" + strings.TrimSuffix(result.Path, ".md"),
})
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
return browserData{
Columns: []browserColumn{
{
Title: "#" + tag,
Path: "",
Items: items,
},
},
}
}
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
seenFolders := make(map[string]browserItem)
files := make([]browserItem, 0)

View File

@@ -14,6 +14,7 @@ import (
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
@@ -211,8 +212,13 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
}
listRequest := httptest.NewRequest(http.MethodGet, "/api/attachments", nil)
listRequest = listRequest.WithContext(withPrincipal(listRequest.Context(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}))
listRecorder := httptest.NewRecorder()
server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil))
server.handleAttachmentsList(listRecorder, listRequest)
var listPayload struct {
Attachments []struct {
Kind string `json:"kind"`
@@ -233,6 +239,17 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML)
}
grants, err := server.auth.ListResourcePermissions(context.Background(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}, auth.ResourceDocument, tt.wantPath)
if err != nil {
t.Fatalf("list promoted document permissions: %v", err)
}
if len(grants) != 1 || grants[0].UserID != "user:test-admin" || grants[0].Permission != auth.PermissionAdmin {
t.Fatalf("promoted document grants = %#v, want creator admin grant", grants)
}
})
}
}
@@ -376,11 +393,25 @@ func setupUploadTestServer(t *testing.T) (*Server, string) {
sourceDir := t.TempDir()
repository := docs.NewRepository(db)
documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default())
authRepo := auth.NewRepository(db)
authService, err := auth.NewService(authRepo, "http://localhost")
if err != nil {
t.Fatalf("create auth service: %v", err)
}
if _, err := authRepo.UpsertUser(context.Background(), auth.User{
ID: "user:test-admin",
Email: "test-admin@example.com",
DisplayName: "Test Admin",
Role: auth.RoleAdmin,
}); err != nil {
t.Fatalf("create test admin user: %v", err)
}
return &Server{
documents: documents,
repository: repository,
contentStore: contentStore,
auth: authService,
}, sourceDir
}
@@ -412,6 +443,10 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde
request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request = request.WithContext(withPrincipal(request.Context(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}))
return request
}

View File

@@ -67,10 +67,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
if !ok {
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
http.Redirect(w, r, "/login?next="+urlQueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if !auth.Allows(principal, required) {
if !allowsProtectedRoute(principal, required) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
@@ -78,6 +82,20 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
})
}
func allowsProtectedRoute(principal auth.Principal, required auth.Scope) bool {
if required == auth.ScopeAdmin {
return auth.Allows(principal, required)
}
if len(principal.Scopes) > 0 {
return auth.HasScope(principal.Scopes, required)
}
return principal.UserID != ""
}
func urlQueryEscape(value string) string {
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
}
func (s *Server) setupMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context())
@@ -151,8 +169,12 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
path := r.URL.Path
method := r.Method
switch {
case path == "/" || (path == "/permissions" && method == http.MethodGet):
return auth.ScopeDocsRead, true
case path == "/permissions" && method == http.MethodPost:
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/auth/"):
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" || strings.HasPrefix(path, "/api/auth/passkeys/me/") {
return auth.ScopeDocsRead, true
}
return "", false
@@ -169,6 +191,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeAdmin, true
case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet:
return auth.ScopeDocsWrite, true
case path == "/api/documents" || path == "/api/search" || (path == "/api/permissions" && method == http.MethodGet):
return auth.ScopeDocsRead, true
case path == "/api/permissions" && method == http.MethodPatch:
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/uploads"):
return auth.ScopeDocsWrite, true
case path == "/api/attachments":
@@ -181,11 +207,16 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
case strings.HasPrefix(path, "/api/content/"):
return auth.ScopeSyncRead, true
case strings.HasPrefix(path, "/api/comments"):
if method == http.MethodGet {
return auth.ScopeDocsRead, true
}
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/notifications"):
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/watch"):
return auth.ScopeDocsRead, true
case path == "/docs" || strings.HasPrefix(path, "/docs/") || strings.HasPrefix(path, "/documents/"):
return auth.ScopeDocsRead, true
case strings.HasSuffix(path, "/edit"):
return auth.ScopeDocsWrite, true
default:

View File

@@ -0,0 +1,431 @@
package httpserver
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
type permissionPageData struct {
ResourceType string
ResourceID string
Title string
Users []permissionUserData
}
type permissionUserData struct {
ID string
Email string
DisplayName string
Role string
CreatedAt string
LastSeenAt string
Permission string
PermissionText string
GrantCreatedAt string
GrantedBy string
GrantSummary string
}
func (s *Server) handlePermissionsPage(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
resourceType, resourceID := permissionResourceFromRequest(r)
if resourceType == "" {
resourceType = auth.ResourceDocument
}
if resourceType == auth.ResourceDocument {
resourceID = strings.TrimSpace(resourceID)
if resourceID == "" {
resourceID = strings.TrimSpace(r.URL.Query().Get("path"))
}
}
if resourceID == "" && resourceType != auth.ResourceGlobal && resourceType != auth.ResourceFolder {
s.renderError(w, r, http.StatusBadRequest, "resource is required")
return
}
if !s.canAdminResource(r, resourceType, resourceID, nil) {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
data, err := s.buildPermissionPageData(r, principal, resourceType, resourceID)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, r, http.StatusOK, "permissions.gohtml", layoutData{
Title: "Permissions: " + data.Title,
BodyClass: "page-permissions",
BodyTemplate: "permissions_content",
Data: data,
})
}
func (s *Server) handlePermissionsForm(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if err := r.ParseForm(); err != nil {
s.renderError(w, r, http.StatusBadRequest, "invalid permissions form")
return
}
resourceType := auth.ResourceType(r.FormValue("resourceType"))
resourceID := r.FormValue("resourceId")
updates := permissionUpdatesFromForm(r)
if _, err := s.auth.UpdateResourcePermissions(r.Context(), principal, resourceType, resourceID, updates); err != nil {
s.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
http.Redirect(w, r, "/permissions?resourceType="+queryEscape(string(resourceType))+"&resourceId="+queryEscape(resourceID), http.StatusSeeOther)
}
func (s *Server) handlePermissionsAPI(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handlePermissionsGetAPI(w, r)
case http.MethodPatch:
s.handlePermissionsPatchAPI(w, r)
default:
writeJSONWithStatus(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}
func (s *Server) handlePermissionsGetAPI(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
resourceType, resourceID := permissionResourceFromRequest(r)
if !s.canAdminResource(r, resourceType, resourceID, nil) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"})
return
}
permissions, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID)
if err != nil {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
users, err := s.auth.ListUsers(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"resourceType": resourceType,
"resourceId": resourceID,
"permissions": permissions,
"users": users,
})
}
func (s *Server) handlePermissionsPatchAPI(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
var req struct {
ResourceType auth.ResourceType `json:"resourceType"`
ResourceID string `json:"resourceId"`
Users []auth.ResourcePermissionUpdate `json:"users"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if !s.canAdminResource(r, req.ResourceType, req.ResourceID, nil) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"})
return
}
permissions, err := s.auth.UpdateResourcePermissions(r.Context(), principal, req.ResourceType, req.ResourceID, req.Users)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": permissions})
}
func (s *Server) buildPermissionPageData(r *http.Request, principal auth.Principal, resourceType auth.ResourceType, resourceID string) (permissionPageData, error) {
users, err := s.auth.ListUsers(r.Context())
if err != nil {
return permissionPageData{}, err
}
grants, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID)
if err != nil {
return permissionPageData{}, err
}
grantsByUser := make(map[string]auth.Permission, len(grants))
grantMetaByUser := make(map[string]auth.ResourcePermission, len(grants))
for _, grant := range grants {
grantsByUser[grant.UserID] = grant.Permission
grantMetaByUser[grant.UserID] = grant
}
usersByID := make(map[string]auth.User, len(users))
for _, user := range users {
usersByID[user.ID] = user
}
result := permissionPageData{
ResourceType: string(resourceType),
ResourceID: resourceID,
Title: permissionResourceTitle(resourceType, resourceID),
Users: make([]permissionUserData, 0, len(users)),
}
for _, user := range users {
if user.Disabled {
continue
}
permission := grantsByUser[user.ID]
grant := grantMetaByUser[user.ID]
result.Users = append(result.Users, permissionUserData{
ID: user.ID,
Email: user.Email,
DisplayName: user.DisplayName,
Role: string(user.Role),
CreatedAt: formatPermissionTime(user.CreatedAt),
LastSeenAt: formatPermissionTime(user.LastSeenAt),
Permission: string(permission),
PermissionText: permissionLabel(permission),
GrantCreatedAt: formatPermissionTime(grant.CreatedAt),
GrantedBy: grantActorLabel(grant.GrantedBy, usersByID),
GrantSummary: grantSummary(grant, usersByID),
})
}
return result, nil
}
func permissionResourceTitle(resourceType auth.ResourceType, resourceID string) string {
switch resourceType {
case auth.ResourceDocument:
return "Page " + resourceID
case auth.ResourceFolder:
if resourceID == "" {
return "Root folder"
}
return "Folder " + resourceID
case auth.ResourceCollection:
return "Collection " + resourceID
default:
return string(resourceType)
}
}
func permissionResourceFromRequest(r *http.Request) (auth.ResourceType, string) {
return auth.ResourceType(r.URL.Query().Get("resourceType")), r.URL.Query().Get("resourceId")
}
func permissionUpdatesFromForm(r *http.Request) []auth.ResourcePermissionUpdate {
var updates []auth.ResourcePermissionUpdate
for key, values := range r.PostForm {
if !strings.HasPrefix(key, "permission:") || len(values) == 0 {
continue
}
userID := strings.TrimPrefix(key, "permission:")
permission := auth.Permission(values[0])
updates = append(updates, auth.ResourcePermissionUpdate{UserID: userID, Permission: permission})
}
return updates
}
func permissionLabel(permission auth.Permission) string {
switch permission {
case auth.PermissionRead:
return "View"
case auth.PermissionWrite:
return "Edit"
case auth.PermissionAdmin:
return "Admin"
default:
return "No access"
}
}
func grantSummary(grant auth.ResourcePermission, usersByID map[string]auth.User) string {
if grant.ID == "" {
return "No explicit grant"
}
parts := []string{"Explicit " + permissionLabel(grant.Permission)}
if grant.CreatedAt.IsZero() {
return strings.Join(parts, " · ")
}
parts = append(parts, "granted "+formatPermissionTime(grant.CreatedAt))
if actor := grantActorLabel(grant.GrantedBy, usersByID); actor != "" {
parts = append(parts, "by "+actor)
}
return strings.Join(parts, " · ")
}
func grantActorLabel(userID string, usersByID map[string]auth.User) string {
if userID == "" {
return ""
}
if user, ok := usersByID[userID]; ok {
if user.DisplayName != "" {
return user.DisplayName
}
return user.Email
}
return userID
}
func formatPermissionTime(value time.Time) string {
if value.IsZero() {
return "Never"
}
return value.Local().Format("Jan 2, 2006 3:04 PM")
}
func (s *Server) filterReadableDocuments(r *http.Request, records []docs.DocumentRecord) ([]docs.DocumentRecord, error) {
filtered := make([]docs.DocumentRecord, 0, len(records))
for _, record := range records {
if ok, err := s.canReadDocumentRecord(r, record); err != nil {
return nil, err
} else if ok {
filtered = append(filtered, record)
}
}
return filtered, nil
}
func (s *Server) filterReadableSearchResults(r *http.Request, results []docs.SearchResult) ([]docs.SearchResult, error) {
filtered := make([]docs.SearchResult, 0, len(results))
for _, result := range results {
record, err := s.repository.GetDocumentByPath(r.Context(), result.Path)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue
}
return nil, err
}
if ok, err := s.canReadDocumentRecord(r, *record); err != nil {
return nil, err
} else if ok {
filtered = append(filtered, result)
}
}
return filtered, nil
}
func (s *Server) documentRecordForRequestPath(r *http.Request, requestPath string) (*docs.DocumentRecord, error) {
clean := strings.Trim(strings.TrimSpace(requestPath), "/")
if clean == "" {
return nil, sql.ErrNoRows
}
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
clean += ".md"
}
return s.repository.GetDocumentByPathOrAlias(r.Context(), clean)
}
func (s *Server) canReadDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionRead)
}
func (s *Server) canWriteDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionWrite)
}
func (s *Server) canAdminDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionAdmin)
}
func (s *Server) documentPermissionAllows(r *http.Request, path string, tags []string, required auth.Permission) (bool, error) {
principal, ok := principalFromContext(r.Context())
if !ok {
return false, nil
}
if principal.Role == auth.RoleAdmin {
return true, nil
}
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceDocument, path, tags)
if err != nil {
return false, err
}
return auth.PermissionAllows(permission, required), nil
}
func (s *Server) canAdminResource(r *http.Request, resourceType auth.ResourceType, resourceID string, tags []string) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
if principal.Role == auth.RoleAdmin {
return true
}
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, resourceType, resourceID, tags)
return err == nil && auth.PermissionAllows(permission, auth.PermissionAdmin)
}
func (s *Server) canWriteNewDocument(r *http.Request, pagePath string) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
if principal.Role == auth.RoleAdmin {
return true
}
return s.canWriteFolder(r, documentFolder(pagePath))
}
func (s *Server) canReadNewDocument(r *http.Request, pagePath string) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
if principal.Role == auth.RoleAdmin {
return true
}
return s.canReadFolder(r, documentFolder(pagePath))
}
func (s *Server) canReadFolder(r *http.Request, folder string) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
if principal.Role == auth.RoleAdmin {
return true
}
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil)
return err == nil && auth.PermissionAllows(permission, auth.PermissionRead)
}
func (s *Server) canWriteFolder(r *http.Request, folder string) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
if principal.Role == auth.RoleAdmin {
return true
}
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil)
return err == nil && auth.PermissionAllows(permission, auth.PermissionWrite)
}
func (s *Server) grantDocumentCreatorAdmin(r *http.Request, path string) error {
principal, ok := principalFromContext(r.Context())
if !ok || principal.UserID == "" {
return nil
}
return s.auth.EnsureResourcePermission(r.Context(), principal, auth.ResourceDocument, path, auth.ResourcePermissionUpdate{
UserID: principal.UserID,
Permission: auth.PermissionAdmin,
})
}
func lastPathSegment(path string) string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return ""
}
parts := strings.Split(trimmed, "/")
return parts[len(parts)-1]
}
func documentFolder(path string) string {
trimmed := strings.Trim(path, "/")
segment := lastPathSegment(trimmed)
if segment == "" || segment == trimmed {
return ""
}
return strings.TrimSuffix(trimmed, "/"+segment)
}
func queryEscape(value string) string {
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
}

View File

@@ -107,6 +107,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/password-reset", server.handlePasswordResetPage)
router.Get("/account", server.handleAccountPage)
router.Get("/account/tokens/new", server.handleTokenCreatePage)
router.Get("/permissions", server.handlePermissionsPage)
router.Post("/permissions", server.handlePermissionsForm)
router.Get("/device/verify", server.handleDeviceVerifyPage)
router.Get("/health", server.handleHealth)
router.Get("/healthz", server.handleHealthz)
@@ -122,6 +124,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/auth/logout", server.handleLogout)
router.Post("/api/auth/password/change", server.handleChangePassword)
router.Delete("/api/auth/account", server.handleDeleteAccount)
router.Post("/api/auth/passkeys/me/register/begin", server.handleCurrentUserPasskeyRegisterBegin)
router.Post("/api/auth/passkeys/me/register/finish", server.handleCurrentUserPasskeyRegisterFinish)
router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin)
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
@@ -146,9 +150,13 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument)
router.Get("/documents/{id}", server.handleDocumentByID)
router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentMutation)
router.Post("/api/folders/*", server.handleFolderMutation)
router.Get("/api/search", server.handleSearch)
router.Get("/api/permissions", server.handlePermissionsAPI)
router.Patch("/api/permissions", server.handlePermissionsAPI)
router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList)
router.Get("/attachments/{hash}", server.handleAttachment)

View File

@@ -251,6 +251,26 @@
}
});
document.querySelector("[data-passkey-add]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const support = await checkPasskeySupport();
if (!support.supported) {
setStatus("[data-passkey-add-status]", support.reason, true);
return;
}
if (support.reason) {
console.warn("Passkey support:", support.reason);
}
try {
const begin = await postJSON("/api/auth/passkeys/me/register/begin", {});
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
await postJSON(`/api/auth/passkeys/me/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
setStatus("[data-passkey-add-status]", "Passkey added. You can use it the next time you sign in.");
} catch (error) {
setStatus("[data-passkey-add-status]", webAuthnErrorMessage(error), true);
}
});
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
try {
await postJSON("/api/auth/logout", {});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because one or more lines are too long

View File

@@ -1,123 +1,423 @@
(function () {
'use strict';
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
const article = document.querySelector('[data-document-id]');
if (!article) return;
if (!documentId) return;
const documentId = article.dataset.documentId;
const documentHash = article.dataset.documentHash;
const canComment = article.dataset.canComment === 'true';
const workspaceShell = article.closest('.workspace-shell--document');
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
const stripTrack = document.querySelector('[data-comments-strip-track]');
const commentsAside = document.querySelector('[data-comments-aside]');
const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
const commentsSheetClose = document.querySelector('[data-close-comments]');
const commentToggle = document.querySelector('[data-comment-toggle]');
const watchButton = document.querySelector('[data-document-watch]');
const historyButtons = document.querySelectorAll('[data-comments-history]');
const commentVisibilityKey = `cairnquire:document-comments:${documentId}`;
const commentsByAnchor = new Map();
const elementsByAnchor = new Map();
let includeResolved = false;
let watching = false;
function readCommentVisibility() {
try {
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
} catch (err) {
return false;
}
}
function commentsHidden() {
return !workspaceShell?.classList.contains('comments-visible');
}
function setCommentsHidden(hidden) {
article.classList.toggle('comments-hidden', hidden);
workspaceShell?.classList.toggle('comments-visible', !hidden);
try {
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
} catch (err) {
// Storage can be unavailable in private browsing contexts.
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
commentToggle.setAttribute('aria-label', hidden ? 'Show comments' : 'Hide comments');
commentToggle.title = hidden ? 'Show comments' : 'Hide comments';
}
if (!hidden) {
renderCommentsAside();
updateStripMarkers();
}
}
setCommentsHidden(readCommentVisibility());
commentToggle?.addEventListener('click', () => setCommentsHidden(!commentsHidden()));
// Hash a paragraph's text content for anchoring
function hashParagraph(el) {
const text = (el.textContent || '').trim();
if (!text) return null;
// Simple hash: first 16 chars of base64 of char codes (deterministic)
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
hash = ((hash << 5) - hash) + text.charCodeAt(i);
hash &= hash;
}
return 'h' + Math.abs(hash).toString(36);
}
function addCommentButton(el, hash) {
const btn = document.createElement('button');
btn.className = 'comment-anchor-btn';
btn.title = 'Add comment';
btn.innerHTML = '+';
btn.addEventListener('click', () => promptComment(el, hash));
el.appendChild(btn);
function addCommentButton(el, anchorHash) {
const button = document.createElement('button');
button.className = 'comment-anchor-btn';
button.type = 'button';
button.title = 'Add comment';
button.setAttribute('aria-label', 'Add comment');
button.innerHTML = `
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
<path d="M12 8v4"></path><path d="M10 10h4"></path>
</svg>
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
<span class="sr-only">Add comment</span>`;
button.addEventListener('click', () => createComment(anchorHash));
el.appendChild(button);
}
async function promptComment(el, anchorHash) {
const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return;
function updateCommentButtonState(el, comments) {
const button = el.querySelector('.comment-anchor-btn');
const count = comments.filter(comment => !comment.resolvedAt).length;
if (button) {
const hasComments = count > 0;
button.classList.toggle('comment-anchor-btn--has-comments', hasComments);
const label = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
button.setAttribute('aria-label', label);
button.title = label;
const countNode = button.querySelector('.comment-anchor-count');
if (countNode) {
countNode.hidden = !hasComments;
countNode.textContent = count > 99 ? '99+' : String(count);
}
}
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
}
async function createComment(anchorHash, parentId) {
const promptText = parentId ? 'Reply to this comment:' : 'Comment on this section:';
const content = window.prompt(promptText);
if (!content || !content.trim()) return;
try {
const res = await fetch('/api/comments', {
const response = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId,
versionHash: documentHash,
content: text.trim(),
content: content.trim(),
anchorHash,
parentId: parentId || undefined,
}),
});
if (!res.ok) throw new Error(await res.text());
// Refresh comments display
await loadCommentsForAnchor(el, anchorHash);
if (!response.ok) throw new Error(await response.text());
await loadCommentsForAnchor(anchorHash);
} catch (err) {
window.alert('Failed to post comment: ' + err.message);
}
}
async function loadCommentsForAnchor(el, anchorHash) {
async function resolveThread(comment, anchorHash) {
if (!window.confirm('Resolve this comment thread?')) return;
try {
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`);
if (!res.ok) return;
const data = await res.json();
renderComments(el, data.comments || []);
const response = await fetch(`/api/comments/${encodeURIComponent(comment.id)}/resolve`, { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
await loadCommentsForAnchor(anchorHash);
} catch (err) {
window.alert('Failed to resolve comment: ' + err.message);
}
}
async function loadCommentsForAnchor(anchorHash) {
const element = elementsByAnchor.get(anchorHash);
if (!element) return;
const params = new URLSearchParams({ document_id: documentId, anchor_hash: anchorHash });
if (includeResolved) params.set('include_resolved', 'true');
try {
const response = await fetch(`/api/comments?${params}`);
if (!response.ok) return;
const data = await response.json();
const comments = data.comments || [];
commentsByAnchor.set(anchorHash, comments);
renderInlineComments(element, anchorHash, comments);
renderCommentsAside();
updateStripMarkers();
} catch (err) {
console.error('load comments', err);
}
}
function renderComments(el, comments) {
// Remove existing comment list
const existing = el.querySelector('.comment-list');
if (existing) existing.remove();
function buildCommentForest(comments) {
const nodes = new Map(comments.map(comment => [comment.id, { comment, children: [] }]));
const roots = [];
comments.forEach(comment => {
const node = nodes.get(comment.id);
const parent = comment.parentId ? nodes.get(comment.parentId) : null;
if (parent) parent.children.push(node);
else roots.push(node);
});
return roots;
}
function renderThreadNode(node, anchorHash, variant) {
const comment = node.comment;
const wrapper = document.createElement('div');
wrapper.className = `comment-thread${comment.resolvedAt ? ' is-resolved' : ''}`;
wrapper.dataset.commentId = comment.id;
const item = document.createElement('div');
item.className = variant === 'aside' ? 'comments-aside-item__comment' : 'comment-item';
const meta = document.createElement('div');
meta.className = variant === 'aside' ? 'comments-aside-item__meta' : 'comment-meta';
const author = document.createElement('strong');
author.textContent = comment.authorName || 'Anonymous';
const time = document.createElement('time');
time.textContent = formatDate(comment.createdAt);
meta.append(author, time);
if (comment.resolvedAt) {
const status = document.createElement('span');
status.className = 'comment-status';
status.textContent = 'Resolved';
meta.appendChild(status);
}
const body = document.createElement('div');
body.className = variant === 'aside' ? 'comments-aside-item__body' : 'comment-body';
body.textContent = comment.content;
item.append(meta, body);
if (canComment && !comment.resolvedAt) {
const actions = document.createElement('div');
actions.className = 'comment-actions';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'comment-action';
reply.textContent = 'Reply';
reply.addEventListener('click', () => createComment(anchorHash, comment.id));
actions.appendChild(reply);
if (!comment.parentId) {
const resolve = document.createElement('button');
resolve.type = 'button';
resolve.className = 'comment-action';
resolve.textContent = 'Resolve thread';
resolve.addEventListener('click', () => resolveThread(comment, anchorHash));
actions.appendChild(resolve);
}
item.appendChild(actions);
}
wrapper.appendChild(item);
if (node.children.length) {
const replies = document.createElement('div');
replies.className = 'comment-thread__replies';
node.children.forEach(child => replies.appendChild(renderThreadNode(child, anchorHash, variant)));
wrapper.appendChild(replies);
}
return wrapper;
}
function renderInlineComments(el, anchorHash, comments) {
el.querySelector('.comment-list')?.remove();
updateCommentButtonState(el, comments);
if (!comments.length) return;
const list = document.createElement('div');
list.className = 'comment-list';
comments.forEach(c => {
const item = document.createElement('div');
item.className = 'comment-item';
item.innerHTML = `
<div class="comment-meta">
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
<time>${formatDate(c.createdAt)}</time>
</div>
<div class="comment-body">${escapeHtml(c.content)}</div>
`;
list.appendChild(item);
});
buildCommentForest(comments).forEach(root => list.appendChild(renderThreadNode(root, anchorHash, 'inline')));
el.appendChild(list);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
function buildAsideItem(anchorHash, el, comments) {
if (!comments.length) return null;
const asideItem = document.createElement('div');
asideItem.className = 'comments-aside-item';
asideItem.dataset.anchorHash = anchorHash;
const target = document.createElement('button');
target.className = 'comments-aside-item__target';
target.type = 'button';
target.textContent = el.dataset.commentTarget || 'Commented section';
target.addEventListener('click', () => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
asideItem.appendChild(target);
buildCommentForest(comments).forEach(root => asideItem.appendChild(renderThreadNode(root, anchorHash, 'aside')));
return asideItem;
}
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString();
function renderCommentsInto(container) {
if (!container) return;
container.innerHTML = '';
let rendered = 0;
elementsByAnchor.forEach((el, anchorHash) => {
const item = buildAsideItem(anchorHash, el, commentsByAnchor.get(anchorHash) || []);
if (item) {
container.appendChild(item);
rendered++;
}
});
if (!rendered) {
container.innerHTML = `<p class="document-comments-aside__empty">${includeResolved ? 'No comments found.' : 'No active comments yet.'}</p>`;
}
}
// Initialize: hash all paragraphs in markdown body
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
function renderCommentsAside() {
renderCommentsInto(commentsAsideList);
renderCommentsInto(commentsAsideListMobile);
}
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
paragraphs.forEach(el => {
const hash = hashParagraph(el);
if (!hash) return;
el.dataset.anchorHash = hash;
el.classList.add('commentable');
addCommentButton(el, hash);
loadCommentsForAnchor(el, hash);
function updateStripMarkers() {
if (!stripTrack) return;
stripTrack.innerHTML = '';
const shell = document.querySelector('.document-shell');
if (!shell) return;
elementsByAnchor.forEach((el, anchorHash) => {
if (!(commentsByAnchor.get(anchorHash) || []).length) return;
const rect = el.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect();
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
const percent = (relativeTop / shell.scrollHeight) * 100;
const marker = document.createElement('button');
marker.className = 'document-comments-strip__marker';
marker.type = 'button';
marker.style.top = Math.max(0, Math.min(95, percent)) + '%';
marker.dataset.anchorHash = anchorHash;
marker.title = 'Jump to comments';
marker.addEventListener('click', () => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (commentsHidden()) setCommentsHidden(false);
commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
stripTrack.appendChild(marker);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
function initBidirectionalHover() {
const body = document.querySelector('.markdown-body');
if (!body || !commentsAsideList) return;
body.addEventListener('mouseover', event => toggleHighlight(event.target.closest('.commentable'), true));
body.addEventListener('mouseout', event => toggleHighlight(event.target.closest('.commentable'), false));
commentsAsideList.addEventListener('mouseover', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), true));
commentsAsideList.addEventListener('mouseout', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), false));
}
function toggleHighlight(el, active) {
if (!el) return;
const hash = el.dataset.anchorHash;
el.classList.toggle('is-highlighted', active);
commentsAsideList?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-highlighted', active);
stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-active', active);
}
function toggleAsideHighlight(item, active) {
if (!item) return;
toggleHighlight(elementsByAnchor.get(item.dataset.anchorHash), active);
}
function setCommentsSheetOpen(open) {
if (!commentsAside) return;
commentsAside.classList.toggle('is-open', open);
commentsSheetToggle?.setAttribute('aria-expanded', String(open));
if (open) {
document.body.classList.add('drawer-open');
renderCommentsAside();
} else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) {
document.body.classList.remove('drawer-open');
}
}
commentsSheetToggle?.addEventListener('click', () => setCommentsSheetOpen(!commentsAside?.classList.contains('is-open')));
commentsSheetClose?.addEventListener('click', () => setCommentsSheetOpen(false));
function setWatchState(nextWatching) {
watching = nextWatching;
if (!watchButton) return;
watchButton.setAttribute('aria-pressed', String(watching));
const label = watchButton.querySelector('[data-document-watch-label]');
if (label) label.textContent = watching ? 'Unwatch document' : 'Watch document';
}
async function loadWatchStatus() {
if (!watchButton) return;
try {
const response = await fetch(`/api/watch/status?document_id=${encodeURIComponent(documentId)}`);
if (!response.ok) return;
const data = await response.json();
setWatchState(Boolean(data.watching));
} catch (err) {
console.error('load watch status', err);
}
}
async function toggleWatch() {
if (!watchButton) return;
watchButton.disabled = true;
try {
const response = await fetch('/api/watch/document', {
method: watching ? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId }),
});
if (!response.ok) throw new Error(await response.text());
setWatchState(!watching);
} catch (err) {
window.alert('Failed to update watch: ' + err.message);
} finally {
watchButton.disabled = false;
}
}
watchButton?.addEventListener('click', toggleWatch);
function updateHistoryButtons() {
historyButtons.forEach(button => {
button.setAttribute('aria-pressed', String(includeResolved));
button.textContent = includeResolved ? 'Hide resolved' : 'Show resolved';
});
}
historyButtons.forEach(button => button.addEventListener('click', async () => {
includeResolved = !includeResolved;
updateHistoryButtons();
await Promise.all(Array.from(elementsByAnchor.keys(), loadCommentsForAnchor));
}));
function formatDate(iso) {
return new Date(iso).toLocaleString();
}
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
const commentables = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
commentables.forEach(el => {
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim();
const anchorHash = hashParagraph(el);
if (!anchorHash) return;
el.dataset.anchorHash = anchorHash;
el.dataset.commentTarget = targetText.length > 120 ? targetText.slice(0, 120) + '…' : targetText;
el.classList.add('commentable');
elementsByAnchor.set(anchorHash, el);
if (canComment) addCommentButton(el, anchorHash);
loadCommentsForAnchor(anchorHash);
});
renderCommentsAside();
initBidirectionalHover();
loadWatchStatus();
updateHistoryButtons();
const shell = document.querySelector('.document-shell');
shell?.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
setTimeout(updateStripMarkers, 100);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();

View File

@@ -11,15 +11,14 @@
var conflictApply = document.querySelector("[data-conflict-apply]");
var conflictDismiss = document.querySelector("[data-conflict-dismiss]");
var conflictResolution = document.querySelector("[data-conflict-resolution]");
var conflictResolutionMount = document.querySelector("[data-conflict-monaco-mount]");
var conflictResolutionMount = document.querySelector("[data-conflict-codemirror-mount]");
var conflictUseServer = document.querySelector("[data-conflict-use='server']");
var conflictUseLocal = document.querySelector("[data-conflict-use='local']");
var conflictUseBoth = document.querySelector("[data-conflict-use='both']");
var monacoContainer = document.querySelector("[data-monaco-mount]");
var monacoLoaded = false;
var monacoReadyPromise = null;
var monacoDarkTheme = "cairnquire-dark";
var monacoLightTheme = "cairnquire-light";
var codeMirrorContainer = document.querySelector("[data-codemirror-mount]");
var codeMirrorReadyPromise = null;
var codeMirrorModules = null;
var documentCompletionOptionsPromise = null;
if (!form || !textarea || statusNodes.length === 0) {
return;
@@ -36,6 +35,9 @@
var conflictDiffView = null;
var resolutionEditor = null;
var suppressResolutionEvents = false;
var doneLink = document.querySelector("[data-done-link]");
var isSaving = false;
var suppressBeforeUnload = false;
function setStatus(nextStatus) {
statusNodes.forEach(function (node) {
@@ -223,17 +225,20 @@
var value = getEditorValue();
if (value === lastSavedValue) {
setStatus("Saved");
return;
return "unchanged";
}
isSaving = true;
hideConflict();
setStatus("Saving");
if (!window.MDHubSync) {
isSaving = false;
setStatus("Queued");
return;
return "queued";
}
try {
var result = await window.MDHubSync.saveDocument(path, value, baseHash);
if (result.status === "saved") {
lastSavedValue = value;
@@ -245,15 +250,24 @@
}
}
setStatus("Saved");
return;
isSaving = false;
return "saved";
}
if (result.status === "conflict") {
showConflict(result.conflict, value);
return;
isSaving = false;
return "conflict";
}
setStatus("Queued");
return "queued";
} catch (err) {
setStatus("Queued");
throw err;
} finally {
isSaving = false;
}
}
async function applyResolvedConflict() {
@@ -317,138 +331,339 @@
}, 2000);
}
function loadMonaco() {
if (monacoReadyPromise) {
return monacoReadyPromise;
function doneHref() {
return doneLink ? doneLink.getAttribute("href") : "/docs/" + path.replace(/\.md$/, "");
}
function clearScheduledSave() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (monacoLoaded && window.monaco) {
monacoReadyPromise = Promise.resolve();
return monacoReadyPromise;
}
monacoLoaded = true;
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
function leaveEditorNow() {
clearScheduledSave();
suppressBeforeUnload = true;
window.location.href = doneHref();
}
monacoReadyPromise = new Promise(function (resolve) {
var script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js";
script.onload = function () {
require.config({
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs" },
function saveThenLeaveEditor() {
var href = doneHref();
if (currentConflict) {
setStatus("Conflict");
if (conflictNotice) {
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
clearScheduledSave();
if (isSaving) {
waitForSaveThenNavigate(href);
return;
}
setStatus("Saving");
saveNow()
.then(function (result) {
if (result === "saved" || result === "unchanged") {
suppressBeforeUnload = true;
window.location.href = href;
return;
}
if (result === "conflict") {
setStatus("Conflict");
return;
}
setStatus("Queued");
if (window.confirm("Could not save. Leave without saving?")) {
suppressBeforeUnload = true;
window.location.href = href;
}
})
.catch(function () {
setStatus("Queued");
if (window.confirm("Could not save. Leave without saving?")) {
suppressBeforeUnload = true;
window.location.href = href;
}
});
require(["vs/editor/editor.main"], function () {
monaco.editor.defineTheme(monacoDarkTheme, {
base: "vs-dark",
inherit: true,
rules: [
{ token: "comment", foreground: "8B9DAF", fontStyle: "italic" },
{ token: "keyword", foreground: "E8943A" },
{ token: "string", foreground: "C9A96E" },
{ token: "number", foreground: "C9A96E" },
{ token: "type", foreground: "E8943A" },
{ token: "tag", foreground: "E8943A" },
{ token: "attribute.name", foreground: "D4A05A" },
{ token: "attribute.value", foreground: "C9A96E" },
{ token: "delimiter", foreground: "B0B8C4" },
{ token: "variable", foreground: "D4D8DE" },
],
colors: {
"editor.background": "#1E1E1E",
"editor.foreground": "#E8ECF0",
"editor.lineHighlightBackground": "#282828",
"editor.selectionBackground": "#3A3A3A",
"editorLineNumber.foreground": "#555555",
"editorLineNumber.activeForeground": "#E8943A",
"editor.inactiveSelectionBackground": "#333333",
"editorCursor.foreground": "#E8943A",
"editorIndentGuide.background": "#2A2A2A",
"editorIndentGuide.activeBackground": "#3A3A3A",
"editorWhitespace.foreground": "#2A2A2A",
"editorGutter.background": "#1E1E1E",
"editorOverviewRuler.border": "#1E1E1E",
"scrollbarSlider.background": "#3A3A3A88",
"scrollbarSlider.hoverBackground": "#4A4A4A88",
"scrollbarSlider.activeBackground": "#5A5A5A88",
"minimap.background": "#1E1E1E",
},
}
function loadCodeMirror() {
if (codeMirrorReadyPromise) {
return codeMirrorReadyPromise;
}
codeMirrorReadyPromise = Promise.all([
import("/static/codemirror.bundle.js"),
]).then(function (modules) {
var bundle = modules[0];
codeMirrorModules = {
cm: bundle,
markdown: bundle,
vim: bundle,
};
registerVimCommands();
return codeMirrorModules;
});
monaco.editor.defineTheme(monacoLightTheme, {
base: "vs",
inherit: true,
rules: [
{ token: "comment", foreground: "6A7B8C", fontStyle: "italic" },
{ token: "keyword", foreground: "C67A2A" },
{ token: "string", foreground: "8A6D3B" },
{ token: "number", foreground: "8A6D3B" },
{ token: "type", foreground: "C67A2A" },
{ token: "tag", foreground: "C67A2A" },
{ token: "attribute.name", foreground: "A87D3A" },
{ token: "attribute.value", foreground: "8A6D3B" },
],
colors: {
"editor.background": "#FCFAF5",
"editor.foreground": "#1C2430",
"editor.lineHighlightBackground": "#F0EDE5",
"editor.selectionBackground": "#E8943A33",
"editorLineNumber.foreground": "#AAAAAA",
"editorLineNumber.activeForeground": "#C67A2A",
"editorCursor.foreground": "#C67A2A",
"editorIndentGuide.background": "#E8E5DD",
"editorGutter.background": "#FCFAF5",
},
});
return codeMirrorReadyPromise;
}
if (monacoContainer) {
editor = monaco.editor.create(monacoContainer, {
value: textarea.value,
language: "markdown",
theme: isDark ? monacoDarkTheme : monacoLightTheme,
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
lineHeight: 22,
minimap: { enabled: false },
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 12 },
renderLineHighlight: "line",
smoothScrolling: true,
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
bracketPairColorization: { enabled: true },
automaticLayout: true,
});
function registerVimCommands() {
var Vim = codeMirrorModules && codeMirrorModules.vim && codeMirrorModules.vim.Vim;
if (!Vim) {
return;
}
textarea.hidden = true;
monacoContainer.classList.add("monaco-mounted");
editor.onDidChangeModelContent(function () {
textarea.value = editor.getValue();
scheduleSave();
});
if (window.MDHubSync) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
Vim.defineEx("write", "w", function () {
saveNow().catch(function () {
setStatus("Queued");
});
});
Vim.defineEx("quit", "q", function () {
leaveEditorNow();
});
Vim.defineEx("wq", "wq", function () {
saveThenLeaveEditor();
});
Vim.defineEx("xit", "x", function () {
saveThenLeaveEditor();
});
Vim.map("<Esc>", ":q<CR>");
}
var darkMQ = window.matchMedia("(prefers-color-scheme: dark)");
darkMQ.addEventListener("change", function (e) {
monaco.editor.setTheme(e.matches ? monacoDarkTheme : monacoLightTheme);
});
function codeMirrorTheme() {
var EditorView = codeMirrorModules.cm.EditorView;
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
editor.focus();
return EditorView.theme({
"&": {
height: "100%",
minHeight: "60vh",
backgroundColor: "var(--panel)",
color: "var(--text)",
fontSize: "14px",
},
"&.cm-focused": {
outline: "none",
},
".cm-scroller": {
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
lineHeight: "22px",
},
".cm-content": {
padding: "12px 0",
caretColor: "var(--accent)",
},
".cm-line": {
padding: "0 14px",
},
".cm-gutters": {
backgroundColor: "var(--panel-strong)",
borderRight: "1px solid var(--border)",
color: "var(--muted)",
},
".cm-activeLine, .cm-activeLineGutter": {
backgroundColor: isDark ? "oklch(0.24 0.012 55)" : "#f0ede5",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "color-mix(in srgb, var(--accent) 22%, transparent)",
},
".cm-cursor": {
borderLeftColor: "var(--accent)",
},
".cm-panel": {
backgroundColor: "var(--panel-strong)",
borderTop: "1px solid var(--border)",
color: "var(--text)",
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
},
".cm-vim-panel input": {
color: "var(--text)",
},
}, { dark: isDark });
}
resolve();
function codeMirrorHighlighting() {
var cm = codeMirrorModules.cm;
var tags = codeMirrorModules.cm.tags;
var urlColor = window.matchMedia("(prefers-color-scheme: dark)").matches
? "oklch(0.78 0.14 190)"
: "#047c78";
return cm.syntaxHighlighting(cm.HighlightStyle.define([
{
tag: [tags.url, tags.link],
color: urlColor,
textDecoration: "underline",
textUnderlineOffset: "2px",
},
]));
}
function normalizeCompletionPath(doc) {
var docPath = doc && doc.path ? String(doc.path) : "";
return docPath.replace(/\.md$/, "");
}
function buildDocumentCompletionOptions(documents) {
var seen = new Set();
return (documents || [])
.map(function (doc) {
var label = normalizeCompletionPath(doc);
if (!label || seen.has(label) || label === path.replace(/\.md$/, "")) {
return null;
}
seen.add(label);
return {
label: label,
detail: doc.title && doc.title !== label ? doc.title : doc.path,
type: "file",
apply: function (view, completion, from, to) {
var insert = completion.label;
var after = view.state.doc.sliceString(to, Math.min(view.state.doc.length, to + 2));
if (after !== "]]") {
insert += "]]";
}
view.dispatch({
changes: { from: from, to: to, insert: insert },
selection: { anchor: from + insert.length },
});
},
};
document.head.appendChild(script);
})
.filter(Boolean)
.sort(function (a, b) {
return a.label.localeCompare(b.label);
});
return monacoReadyPromise;
}
function fetchDocumentCompletionOptions() {
return fetch("/api/documents", { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("Could not load documents");
}
return response.json();
})
.then(function (data) {
var documents = data.documents || [];
if (window.MDHubCache && window.MDHubCache.cacheDocuments) {
window.MDHubCache.cacheDocuments(documents).catch(function () {});
}
return buildDocumentCompletionOptions(documents);
});
}
function loadDocumentCompletionOptions() {
if (documentCompletionOptionsPromise) {
return documentCompletionOptionsPromise;
}
documentCompletionOptionsPromise = Promise.resolve()
.then(function () {
if (window.MDHubCache && window.MDHubCache.getCachedDocuments) {
return window.MDHubCache.getCachedDocuments().then(function (documents) {
if (documents && documents.length > 0) {
return buildDocumentCompletionOptions(documents);
}
return fetchDocumentCompletionOptions();
});
}
return fetchDocumentCompletionOptions();
})
.catch(function () {
return [];
});
return documentCompletionOptionsPromise;
}
function wikiLinkCompletions(context) {
var before = context.matchBefore(/\[\[([^\]\n]*)$/);
if (!before) {
return null;
}
return loadDocumentCompletionOptions().then(function (options) {
if (!options.length) {
return null;
}
return {
from: before.from + 2,
to: before.to,
options: options,
validFor: /^[^\]\n]*$/,
};
});
}
function createCodeMirrorEditor(mount, initialValue, options) {
var cm = codeMirrorModules.cm;
var markdownModule = codeMirrorModules.markdown;
var vimModule = codeMirrorModules.vim;
var onChange = options && options.onChange ? options.onChange : function () {};
var extensions = [
vimModule.vim({ status: true }),
cm.basicSetup,
markdownModule.markdown({
base: markdownModule.markdownLanguage,
}),
cm.EditorView.lineWrapping,
codeMirrorTheme(),
codeMirrorHighlighting(),
cm.autocompletion({
override: [wikiLinkCompletions],
activateOnTyping: true,
}),
cm.EditorView.updateListener.of(function (update) {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}),
];
mount.replaceChildren();
mount.classList.add("codemirror-mounted");
var view = new cm.EditorView({
doc: initialValue,
extensions: extensions,
parent: mount,
});
return {
view: view,
getValue: function () {
return view.state.doc.toString();
},
setValue: function (value) {
if (view.state.doc.toString() === value) {
return;
}
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
},
focus: function () {
view.focus();
},
hasTextFocus: function () {
return view.hasFocus;
},
destroy: function () {
view.destroy();
},
};
}
function ensureResolutionEditor() {
@@ -456,54 +671,42 @@
return;
}
loadMonaco().then(function () {
if (!window.monaco || resolutionEditor) {
loadCodeMirror().then(function () {
if (resolutionEditor) {
return;
}
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
resolutionEditor = monaco.editor.create(conflictResolutionMount, {
value: conflictResolution.value || resolvedConflictContent,
language: "markdown",
theme: isDark ? monacoDarkTheme : monacoLightTheme,
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
lineHeight: 22,
minimap: { enabled: false },
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 12 },
renderLineHighlight: "line",
smoothScrolling: true,
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
bracketPairColorization: { enabled: true },
automaticLayout: true,
});
conflictResolution.hidden = true;
conflictResolutionMount.classList.add("monaco-mounted");
resolutionEditor.onDidChangeModelContent(function () {
resolutionEditor = createCodeMirrorEditor(conflictResolutionMount, conflictResolution.value || resolvedConflictContent, {
onChange: function (value) {
if (suppressResolutionEvents) {
return;
}
resolvedConflictContent = resolutionEditor.getValue();
conflictResolution.value = resolvedConflictContent;
resolvedConflictContent = value;
conflictResolution.value = value;
if (conflictApply) {
conflictApply.disabled = false;
}
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
},
});
conflictResolution.hidden = true;
resolutionEditor.focus();
}).catch(function () {
conflictResolution.hidden = false;
});
}
if (monacoContainer) {
loadMonaco().catch(function () {
if (codeMirrorContainer) {
loadCodeMirror().then(function () {
editor = createCodeMirrorEditor(codeMirrorContainer, textarea.value, {
onChange: function (value) {
textarea.value = value;
scheduleSave();
},
});
textarea.hidden = true;
editor.focus();
}).catch(function () {
textarea.addEventListener("input", scheduleSave);
});
} else {
@@ -571,9 +774,72 @@
restorePendingConflict();
if (doneLink) {
doneLink.addEventListener("click", function (event) {
event.preventDefault();
saveThenLeaveEditor();
});
}
function waitForSaveThenNavigate(href) {
var checkInterval = setInterval(function () {
if (!isSaving) {
clearInterval(checkInterval);
if (currentConflict) {
setStatus("Conflict");
if (conflictNotice) {
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
suppressBeforeUnload = true;
window.location.href = href;
}
}, 100);
// Timeout after 10 seconds
setTimeout(function () {
clearInterval(checkInterval);
}, 10000);
}
// Warn on browser unload if there are unsaved changes
window.addEventListener("beforeunload", function (e) {
if (suppressBeforeUnload) {
return;
}
if (getEditorValue() !== lastSavedValue || currentConflict) {
e.preventDefault();
e.returnValue = "";
}
});
function editorSurfaceHasFocus(target) {
if (!target) {
return false;
}
if (target === textarea || target.closest("[data-codemirror-mount], [data-conflict-codemirror-mount], [data-document-editor], .cm-editor")) {
return true;
}
return Boolean(editor && editor.hasTextFocus && editor.hasTextFocus());
}
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
}
saveNow().catch(function () {
setStatus("Queued");
});
return;
}
if (e.metaKey && e.key === "Enter") {
e.preventDefault();
saveThenLeaveEditor();
return;
}
if (!editorSurfaceHasFocus(e.target)) {
return;
}
}, true);
})();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,101 @@
(function () {
'use strict';
function apiFolderPath(path) {
return (path || '')
.replace(/^\/+/, '')
.split('/')
.map(encodeURIComponent)
.join('/');
}
function parentFolder(path) {
var index = path.lastIndexOf('/');
return index < 0 ? '' : path.slice(0, index);
}
function baseName(path) {
var index = path.lastIndexOf('/');
return index < 0 ? path : path.slice(index + 1);
}
function postFolder(path, operation, body) {
return fetch('/api/folders/' + apiFolderPath(path) + '/' + operation, {
method: 'POST',
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
}).then(function (response) {
return response.json().then(function (data) {
if (!response.ok) throw new Error(data.error || 'Folder operation failed');
return data;
});
});
}
function closeMenu(button) {
var details = button.closest('details');
if (details) details.removeAttribute('open');
}
document.addEventListener('click', function (event) {
var renameButton = event.target.closest('[data-folder-rename-path]');
if (renameButton) {
var renamePath = renameButton.getAttribute('data-folder-rename-path');
var newName = window.prompt('Rename folder to:', baseName(renamePath));
if (newName === null) return;
newName = newName.trim().replace(/^\/+|\/+$/g, '');
if (newName === '' || newName === baseName(renamePath)) return;
var parent = parentFolder(renamePath);
var renameDestination = (parent ? parent + '/' : '') + newName;
renameButton.disabled = true;
postFolder(renamePath, 'move', { path: renameDestination })
.then(function (data) {
window.location.assign(data.url);
})
.catch(function (error) {
window.alert(error.message || 'Failed to rename folder');
renameButton.disabled = false;
});
return;
}
var moveButton = event.target.closest('[data-folder-move-path]');
if (moveButton) {
var movePath = moveButton.getAttribute('data-folder-move-path');
var destination = window.prompt('Move folder to:', movePath);
if (destination === null) return;
destination = destination.trim().replace(/^\/+|\/+$/g, '');
if (destination === '' || destination === movePath) return;
moveButton.disabled = true;
postFolder(movePath, 'move', { path: destination })
.then(function (data) {
window.location.assign(data.url);
})
.catch(function (error) {
window.alert(error.message || 'Failed to move folder');
moveButton.disabled = false;
});
return;
}
var archiveButton = event.target.closest('[data-folder-archive-path]');
if (archiveButton) {
var archivePath = archiveButton.getAttribute('data-folder-archive-path');
if (!archivePath) return;
if (!window.confirm('Archive this folder and all its documents? They will be hidden from the site but can be restored later.')) {
return;
}
archiveButton.disabled = true;
closeMenu(archiveButton);
postFolder(archivePath, 'archive')
.then(function () {
window.location.assign('/');
})
.catch(function (error) {
window.alert(error.message || 'Failed to archive folder');
archiveButton.disabled = false;
});
}
});
})();

View File

@@ -0,0 +1,471 @@
(function () {
"use strict";
if (document.querySelector("[data-editor-form]")) {
return;
}
var browser = document.querySelector(".miller-browser");
if (!browser) {
return;
}
var documentShell = document.querySelector(".document-shell[data-document-path]");
var markdownBody = document.querySelector(".markdown-body");
var mode = "browser";
var selectedColumnIndex = 0;
var selectedLink = null;
var selectedParagraphIndex = -1;
function getColumns() {
return Array.prototype.slice.call(browser.querySelectorAll(".miller-column"));
}
function getColumnLinks(columnIndex) {
var columns = getColumns();
if (!columns[columnIndex]) {
return [];
}
return Array.prototype.slice.call(columns[columnIndex].querySelectorAll("a[href]"));
}
function getParagraphTargets() {
if (!markdownBody) {
return [];
}
return Array.prototype.slice.call(
markdownBody.querySelectorAll(".commentable, p, h1, h2, h3, h4, h5, h6, li, blockquote, pre")
).filter(function (el) {
return (el.textContent || "").trim().length > 0;
});
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function isEditableTarget(target) {
if (!target) {
return false;
}
return Boolean(target.closest(
"input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox'], .monaco-editor"
));
}
function isKeyboardNavigationTarget(target) {
if (!target) {
return false;
}
return Boolean(target.closest(".miller-browser a[href], .markdown-body .is-keyboard-selected"));
}
function shouldIgnoreTarget(target) {
if (isEditableTarget(target)) {
return true;
}
if (isKeyboardNavigationTarget(target)) {
return false;
}
return Boolean(target && target.closest("a[href], button, summary, details, [role='button'], [role='menuitem']"));
}
function showKeyboardUI() {
document.body.classList.add("keyboard-navigation-active");
}
function clearBrowserSelection() {
browser.querySelectorAll(".is-keyboard-selected").forEach(function (node) {
node.classList.remove("is-keyboard-selected");
node.removeAttribute("aria-selected");
});
}
function clearParagraphSelection() {
getParagraphTargets().forEach(function (node) {
node.classList.remove("is-keyboard-selected");
node.removeAttribute("tabindex");
node.removeAttribute("aria-selected");
});
}
function scrollBrowserColumnIntoView(column) {
if (!column) {
return;
}
var leftEdge = column.offsetLeft;
var rightEdge = leftEdge + column.offsetWidth;
var targetLeft = browser.scrollLeft;
if (leftEdge < browser.scrollLeft) {
targetLeft = leftEdge - 8;
} else if (rightEdge > browser.scrollLeft + browser.clientWidth) {
targetLeft = rightEdge - browser.clientWidth + 8;
}
if (targetLeft !== browser.scrollLeft) {
browser.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" });
}
}
function expandColumnForKeyboard(column) {
getColumns().forEach(function (candidate) {
if (candidate !== column) {
candidate.classList.remove("is-preview-expanded");
candidate.style.removeProperty("--expanded-column-width");
}
});
if (!column || !column.matches(".miller-column:not(:first-child):not(:last-child)")) {
return;
}
var expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384);
column.style.setProperty("--expanded-column-width", expandedWidth + "px");
column.classList.add("is-preview-expanded");
}
function updateBrowserSelection(focusSelected) {
clearBrowserSelection();
var columns = getColumns();
var column = columns[selectedColumnIndex];
if (!column) {
return;
}
expandColumnForKeyboard(column);
scrollBrowserColumnIntoView(column);
if (!selectedLink || !column.contains(selectedLink)) {
selectedLink = column.querySelector("a.is-active[href]") || column.querySelector("a[href]");
}
if (!selectedLink) {
return;
}
selectedLink.classList.add("is-keyboard-selected");
selectedLink.setAttribute("aria-selected", "true");
if (focusSelected) {
selectedLink.focus({ preventScroll: true });
}
}
function selectBrowserColumn(columnIndex, focusSelected) {
var columns = getColumns();
if (!columns.length) {
return;
}
mode = "browser";
selectedColumnIndex = clamp(columnIndex, 0, columns.length - 1);
var links = getColumnLinks(selectedColumnIndex);
selectedLink = links.find(function (link) {
return link.classList.contains("is-active");
}) || links[0] || null;
clearParagraphSelection();
updateBrowserSelection(focusSelected);
}
function moveBrowserVertical(delta) {
var links = getColumnLinks(selectedColumnIndex);
if (!links.length) {
return;
}
var currentIndex = links.indexOf(selectedLink);
if (currentIndex < 0) {
currentIndex = links.findIndex(function (link) {
return link.classList.contains("is-active");
});
}
if (currentIndex < 0) {
currentIndex = 0;
}
selectedLink = links[clamp(currentIndex + delta, 0, links.length - 1)];
updateBrowserSelection(true);
}
function navigateTo(link) {
if (!link || !link.href) {
return;
}
window.location.href = link.href;
}
function moveBrowserRight() {
var columns = getColumns();
if (!columns.length) {
return;
}
if (selectedColumnIndex < columns.length - 1 && selectedLink && selectedLink.classList.contains("is-active")) {
selectBrowserColumn(selectedColumnIndex + 1, true);
return;
}
if (selectedColumnIndex < columns.length - 1 && !selectedLink) {
selectBrowserColumn(selectedColumnIndex + 1, true);
return;
}
if (selectedLink && !selectedLink.classList.contains("is-active")) {
navigateTo(selectedLink);
return;
}
if (markdownBody && getParagraphTargets().length) {
enterParagraphMode(0);
} else if (selectedLink) {
navigateTo(selectedLink);
}
}
function nearestParagraphIndex() {
var targets = getParagraphTargets();
if (!targets.length) {
return -1;
}
if (selectedParagraphIndex >= 0 && selectedParagraphIndex < targets.length) {
return selectedParagraphIndex;
}
var shellRect = documentShell ? documentShell.getBoundingClientRect() : null;
var targetY = shellRect ? shellRect.top + shellRect.height * 0.35 : window.innerHeight * 0.35;
var bestIndex = 0;
var bestDistance = Number.POSITIVE_INFINITY;
targets.forEach(function (el, index) {
var distance = Math.abs(el.getBoundingClientRect().top - targetY);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
});
return bestIndex;
}
function selectParagraph(index) {
var targets = getParagraphTargets();
if (!targets.length) {
return;
}
clearBrowserSelection();
clearParagraphSelection();
mode = "paragraph";
selectedParagraphIndex = clamp(index, 0, targets.length - 1);
var target = targets[selectedParagraphIndex];
target.classList.add("is-keyboard-selected");
target.setAttribute("aria-selected", "true");
target.setAttribute("tabindex", "-1");
target.focus({ preventScroll: true });
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function enterParagraphMode(offset) {
var index = nearestParagraphIndex();
if (index < 0) {
return;
}
selectParagraph(index + offset);
}
function moveParagraphVertical(delta) {
var targets = getParagraphTargets();
if (!targets.length) {
return;
}
var currentIndex = selectedParagraphIndex;
if (currentIndex < 0 || currentIndex >= targets.length) {
currentIndex = nearestParagraphIndex();
}
selectParagraph(currentIndex + delta);
}
function openSelectedParagraphInEditor() {
var editLink = document.querySelector(".document-actions-dropdown__item[href$='/edit']");
if (editLink) {
navigateTo(editLink);
return;
}
var path = documentShell ? documentShell.getAttribute("data-document-path") : "";
if (!path) {
return;
}
window.location.href = "/docs/" + path.replace(/\.md$/, "") + "/edit";
}
function focusSearch() {
var search = document.querySelector(".site-search input[type='search'], .site-search input[name='q']");
if (!search) {
return;
}
clearBrowserSelection();
clearParagraphSelection();
search.focus();
search.select();
}
function focusCommentsSidebar() {
var desktopToggle = document.querySelector("[data-comment-toggle]");
if (desktopToggle && desktopToggle.getAttribute("aria-pressed") === "false") {
desktopToggle.click();
}
var mobileToggle = document.querySelector("[data-toggle-comments]");
if (mobileToggle && window.matchMedia("(max-width: 760px)").matches && mobileToggle.getAttribute("aria-expanded") === "false") {
mobileToggle.click();
}
var focusTarget = document.querySelector("[data-comments-aside-list] .comments-aside-item") ||
document.querySelector("[data-comments-aside-list]") ||
document.querySelector("[data-comments-aside]");
if (!focusTarget) {
return;
}
clearBrowserSelection();
clearParagraphSelection();
focusTarget.setAttribute("tabindex", "-1");
focusTarget.focus({ preventScroll: false });
}
function focusDocumentActions() {
var dropdown = document.querySelector(".document-actions-dropdown");
if (!dropdown) {
return;
}
dropdown.open = true;
var focusTarget = dropdown.querySelector(".document-actions-dropdown__item[href], .document-actions-dropdown__item:not([disabled])") ||
dropdown.querySelector("summary");
if (!focusTarget) {
return;
}
clearBrowserSelection();
clearParagraphSelection();
focusTarget.focus();
}
function closeDocumentActions() {
var dropdown = document.querySelector(".document-actions-dropdown[open]");
if (dropdown) {
dropdown.open = false;
var summary = dropdown.querySelector("summary");
if (summary) {
summary.focus();
}
return true;
}
return false;
}
function handleEnter() {
if (mode === "paragraph") {
openSelectedParagraphInEditor();
return;
}
navigateTo(selectedLink);
}
function handleKeydown(event) {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || shouldIgnoreTarget(event.target)) {
return;
}
var key = event.key;
var normalized = key.length === 1 ? key.toLowerCase() : key;
if (event.code === "Backslash" || key === "\\") {
event.preventDefault();
focusSearch();
return;
}
if (normalized === "c") {
event.preventDefault();
focusCommentsSidebar();
return;
}
if (normalized === "e") {
event.preventDefault();
focusDocumentActions();
return;
}
if (key === "Escape" && closeDocumentActions()) {
event.preventDefault();
return;
}
if (key === "ArrowUp" || normalized === "k") {
event.preventDefault();
showKeyboardUI();
if (mode === "paragraph") {
moveParagraphVertical(-1);
} else {
moveBrowserVertical(-1);
}
return;
}
if (key === "ArrowDown" || normalized === "j") {
event.preventDefault();
showKeyboardUI();
if (mode === "paragraph") {
moveParagraphVertical(1);
} else {
moveBrowserVertical(1);
}
return;
}
if (key === "ArrowLeft" || normalized === "h") {
event.preventDefault();
showKeyboardUI();
if (mode === "paragraph") {
selectBrowserColumn(getColumns().length - 1, true);
} else {
selectBrowserColumn(selectedColumnIndex - 1, true);
}
return;
}
if (key === "ArrowRight" || normalized === "l") {
event.preventDefault();
showKeyboardUI();
if (mode === "paragraph") {
return;
}
moveBrowserRight();
return;
}
if (key === "Enter") {
event.preventDefault();
showKeyboardUI();
handleEnter();
}
}
function initSelection() {
var columns = getColumns();
if (!columns.length) {
return;
}
var activeLinks = Array.prototype.slice.call(browser.querySelectorAll("a.is-active[href]"));
var activeLink = activeLinks[activeLinks.length - 1] || browser.querySelector("a[href]");
selectedLink = activeLink;
if (activeLink) {
var activeColumn = activeLink.closest(".miller-column");
selectedColumnIndex = Math.max(0, columns.indexOf(activeColumn));
}
}
initSelection();
document.addEventListener("keydown", handleKeydown);
})();

View File

@@ -0,0 +1,86 @@
(function () {
'use strict';
const backdrop = document.querySelector('[data-mobile-drawer-backdrop]');
const pickerDrawer = document.querySelector('[data-picker-drawer]');
const metaDrawer = document.querySelector('[data-meta-drawer]');
const commentsAside = document.querySelector('[data-comments-aside]');
const pickerToggle = document.querySelector('[data-toggle-picker]');
const metaToggle = document.querySelector('[data-toggle-meta]');
const pickerClose = document.querySelector('[data-close-picker]');
const metaClose = document.querySelector('[data-close-meta]');
function anyDrawerOpen() {
return Boolean(
pickerDrawer?.classList.contains('is-open') ||
metaDrawer?.classList.contains('is-open') ||
commentsAside?.classList.contains('is-open')
);
}
function updateBackdrop() {
document.body.classList.toggle('drawer-open', anyDrawerOpen());
}
function setPickerOpen(open) {
if (!pickerDrawer) return;
pickerDrawer.classList.toggle('is-open', open);
if (pickerToggle) pickerToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
updateBackdrop();
}
function setMetaOpen(open) {
if (!metaDrawer) return;
metaDrawer.classList.toggle('is-open', open);
if (metaToggle) metaToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
updateBackdrop();
}
function closeAllDrawers() {
setPickerOpen(false);
setMetaOpen(false);
if (commentsAside) {
commentsAside.classList.remove('is-open');
const toggle = document.querySelector('[data-toggle-comments]');
if (toggle) toggle.setAttribute('aria-expanded', 'false');
}
updateBackdrop();
}
if (pickerToggle) {
pickerToggle.addEventListener('click', () => {
const willOpen = !pickerDrawer?.classList.contains('is-open');
closeAllDrawers();
setPickerOpen(willOpen);
});
}
if (metaToggle) {
metaToggle.addEventListener('click', () => {
const willOpen = !metaDrawer?.classList.contains('is-open');
closeAllDrawers();
setMetaOpen(willOpen);
});
}
if (pickerClose) pickerClose.addEventListener('click', () => setPickerOpen(false));
if (metaClose) metaClose.addEventListener('click', () => setMetaOpen(false));
if (backdrop) {
backdrop.addEventListener('click', closeAllDrawers);
}
// Close drawers on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllDrawers();
});
// Close action dropdowns when clicking outside
document.addEventListener('click', (e) => {
document.querySelectorAll('.document-actions-dropdown[open]').forEach((details) => {
if (!details.contains(e.target)) {
details.removeAttribute('open');
}
});
});
})();

View File

@@ -0,0 +1,42 @@
(function () {
'use strict';
function apiDocumentPath(path) {
return (path || '')
.replace(/^\/+/, '')
.split('/')
.map(encodeURIComponent)
.join('/');
}
document.addEventListener('click', function (event) {
var button = event.target.closest('[data-move-path]');
if (!button) return;
var currentPath = button.getAttribute('data-move-path');
var baseHash = button.getAttribute('data-move-hash');
var destination = window.prompt('Move document to:', currentPath);
if (destination === null || destination.trim() === '' || destination.trim() === currentPath) return;
button.disabled = true;
fetch('/api/documents/' + apiDocumentPath(currentPath) + '/move', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: destination.trim(), baseHash: baseHash }),
})
.then(function (response) {
return response.json().then(function (data) {
if (!response.ok) throw new Error(data.error || 'Move failed');
return data;
});
})
.then(function (data) {
window.location.assign(data.url);
})
.catch(function (error) {
window.alert(error.message || 'Failed to move document');
button.disabled = false;
});
});
})();

View File

@@ -7,6 +7,7 @@
const trigger = bell.querySelector('.notification-bell__trigger');
const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]');
if (!trigger || !countEl || !dropdown) return;
let isOpen = false;
trigger.addEventListener('click', (e) => {
@@ -76,17 +77,24 @@
notifications.forEach(n => {
const li = document.createElement('li');
li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
li.innerHTML = `
const button = document.createElement('button');
button.type = 'button';
button.className = 'notification-item__button';
button.innerHTML = `
<div class="notification-message">${escapeHtml(n.message)}</div>
<time class="notification-time">${formatDate(n.createdAt)}</time>
`;
if (!n.readAt) {
li.addEventListener('click', () => markRead(n.id));
}
button.addEventListener('click', async () => {
if (!n.readAt) await markRead(n.id, false);
const target = notificationURL(n);
if (target) window.location.assign(target);
});
li.appendChild(button);
list.appendChild(li);
});
// Add "Mark all read" button
if (notifications.some(notification => !notification.readAt)) {
const footer = document.createElement('li');
footer.className = 'notification-footer';
const btn = document.createElement('button');
@@ -95,12 +103,16 @@
footer.appendChild(btn);
list.appendChild(footer);
}
}
async function markRead(id) {
async function markRead(id, refresh = true) {
try {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
const response = await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
if (refresh) {
updateBell();
loadNotifications();
}
} catch (err) {
console.error('mark read', err);
}
@@ -108,7 +120,8 @@
async function markAllRead() {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
const response = await fetch('/api/notifications/read-all', { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
updateBell();
loadNotifications();
} catch (err) {
@@ -127,6 +140,11 @@
return d.toLocaleString();
}
function notificationURL(notification) {
if (notification.resourceType !== 'document' || !notification.resourceId) return '';
return '/documents/' + encodeURIComponent(notification.resourceId);
}
// Listen for WebSocket notification events
if (window.__cairnquireRealtime) {
window.__cairnquireRealtime.on('notification', () => {

View File

@@ -0,0 +1,36 @@
(() => {
const root = document.querySelector("[data-permission-bulk]");
if (!root) return;
const selectAll = root.querySelector("[data-permission-select-all]");
const bulkLevel = root.querySelector("[data-permission-bulk-level]");
const apply = root.querySelector("[data-permission-apply]");
const rows = Array.from(document.querySelectorAll(".permission-user-row"));
const checkedRows = () => rows.filter((row) => row.querySelector("[data-permission-user]")?.checked);
const updateSelectAll = () => {
const selected = checkedRows().length;
selectAll.checked = selected > 0 && selected === rows.length;
selectAll.indeterminate = selected > 0 && selected < rows.length;
};
selectAll?.addEventListener("change", () => {
rows.forEach((row) => {
const checkbox = row.querySelector("[data-permission-user]");
if (checkbox) checkbox.checked = selectAll.checked;
});
updateSelectAll();
});
rows.forEach((row) => {
row.querySelector("[data-permission-user]")?.addEventListener("change", updateSelectAll);
});
apply?.addEventListener("click", () => {
checkedRows().forEach((row) => {
const select = row.querySelector("[data-permission-select]");
if (select) select.value = bulkLevel.value;
});
});
})();

View File

@@ -144,7 +144,7 @@
const html = document.documentElement.outerHTML;
const title = document.title;
const pagePath = window.MDHubCache.normalizePath(window.location.pathname);
const pagePath = window.MDHubCache.normalizePath(window.location.pathname + window.location.search);
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
}
@@ -298,6 +298,7 @@
const currentPath = documentShell ? documentShell.getAttribute("data-document-path") : null;
const currentHash = documentShell ? documentShell.getAttribute("data-document-hash") : null;
const currentDocumentID = documentShell ? documentShell.getAttribute("data-document-id") : null;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
let reconnectTimer = null;
@@ -332,6 +333,11 @@
const change = payload.data;
if (change.type === "move" && currentDocumentID && change.documentId === currentDocumentID) {
window.location.assign("/documents/" + encodeURIComponent(currentDocumentID));
return;
}
if (isEditing && currentPath && change.path === currentPath) {
return;
}
@@ -373,4 +379,72 @@
}).catch(function () {});
}
});
// Add folder button handlers
if (browser) {
browser.querySelectorAll(".miller-column-add-folder").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var parentPath = button.getAttribute("data-folder-path");
var folderName = window.prompt("Folder name:");
if (!folderName || !folderName.trim()) return;
folderName = folderName.trim().replace(/[\\/]/g, "-");
var docPath = parentPath ? parentPath + "/" + folderName + "/index.md" : folderName + "/index.md";
var displayPath = docPath.replace(/\.md$/, "");
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# " + folderName + "\n\n", baseHash: "" }),
}).then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
var path = data.path || docPath;
var urlPath = path.replace(/\.md$/, "");
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
});
} else {
return res.json().then(function (data) {
throw new Error(data.error || "Failed to create folder");
});
}
}).catch(function (err) {
window.alert(err.message);
});
});
});
browser.querySelectorAll(".miller-column-new-file").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var parentPath = button.getAttribute("data-folder-path");
var fileName = window.prompt("File name:");
if (!fileName || !fileName.trim()) return;
fileName = fileName.trim().replace(/[\\/]/g, "-");
if (!fileName.endsWith(".md")) fileName += ".md";
var docPath = parentPath ? parentPath + "/" + fileName : fileName;
var displayPath = docPath.replace(/\.md$/, "");
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# " + fileName.replace(/\.md$/, "") + "\n\n", baseHash: "" }),
}).then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
var path = data.path || docPath;
var urlPath = path.replace(/\.md$/, "");
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
});
} else {
return res.json().then(function (data) {
throw new Error(data.error || "Failed to create file");
});
}
}).catch(function (err) {
window.alert(err.message);
});
});
});
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
const STATIC_CACHE = "md-hub-static-v2";
const STATIC_CACHE = "md-hub-static-v8";
const DB_NAME = "md-hub-cache";
const DB_VERSION = 3;
const STORE_PAGES = "pages";
@@ -8,7 +8,10 @@ const STATIC_ASSETS = [
"/static/sync.js",
"/static/realtime.js",
"/static/editor.js",
"/static/codemirror.bundle.js",
"/static/render.js",
"/static/keyboard-nav.js",
"/static/tags.js",
"/static/favicon.png",
"/static/cairnquire%20logo%402x.webp",
"/",
@@ -64,12 +67,13 @@ self.addEventListener("fetch", function (event) {
});
async function handleNavigation(request, url) {
const cacheKey = url.pathname + url.search;
try {
const response = await fetch(request);
cachePageResponse(url.pathname, response.clone());
cachePageResponse(cacheKey, response.clone());
return response;
} catch (_error) {
const cachedPage = await getCachedPage(url.pathname);
const cachedPage = await getCachedPage(cacheKey);
if (cachedPage && cachedPage.html) {
return new Response(markOfflineHTML(cachedPage.html), {
headers: {
@@ -80,6 +84,19 @@ async function handleNavigation(request, url) {
});
}
if (cacheKey !== "/") {
const fallback = await getCachedPage("/");
if (fallback && fallback.html) {
return new Response(markOfflineHTML(fallback.html), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-MDHub-Cache": "offline",
},
status: 200,
});
}
}
const fallback = await caches.match("/");
if (fallback) {
return fallback;

View File

@@ -0,0 +1,81 @@
(function () {
const url = new URL(window.location.href);
const tag = url.searchParams.get("tag");
if (!tag) return;
// Check if we're on a proper tag page or a fallback
const hasTagPreview = document.querySelector(".tag-preview");
if (hasTagPreview) return; // Server rendered tag page correctly
// We're on a fallback page (likely offline) — build tag view from cache
if (!window.MDHubCache) return;
window.MDHubCache.getCachedDocuments().then(function (docs) {
if (!docs || !docs.length) return;
const tagged = docs.filter(function (doc) {
return doc.tags && doc.tags.indexOf(tag) !== -1;
});
// Build a minimal tag browser
const shell = document.querySelector(".workspace-shell");
if (!shell) return;
const browser = shell.querySelector(".miller-browser");
const preview = shell.querySelector(".empty-preview") || shell.querySelector(".document-shell");
if (browser) {
// Replace browser with a single column of tagged docs
const itemsHtml = tagged
.sort(function (a, b) {
const aName = (a.title || a.path).toLowerCase();
const bName = (b.title || b.path).toLowerCase();
return aName < bName ? -1 : aName > bName ? 1 : 0;
})
.map(function (doc) {
const name = doc.title || doc.path;
const path = doc.path.replace(/\.md$/, "");
return (
'<li><a href="/docs/' +
escapeHtml(path) +
'" title="' +
escapeHtml(name) +
'"><span class="browser-item-label"><svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path></svg><span class="browser-item-name">' +
escapeHtml(name) +
"</span></span></a></li>"
);
})
.join("");
browser.innerHTML =
'<section class="miller-column miller-column--root" aria-label="#' +
escapeHtml(tag) +
'"><h2><span class="miller-column-title-wrap"><span class="miller-column-title-text">#' +
escapeHtml(tag) +
"</span></span></h2><ul>" +
itemsHtml +
"</ul></section>";
}
if (preview) {
preview.outerHTML =
'<section class="tag-preview"><p class="eyebrow">Tag</p><h1>#' +
escapeHtml(tag) +
'</h1><p class="lede">' +
tagged.length +
" document" +
(tagged.length === 1 ? "" : "s") +
" found</p></section>";
}
});
function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();

View File

@@ -1,6 +1,7 @@
package httpserver
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
@@ -29,7 +30,12 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
return
}
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, principal.UserID)
canRead, _, accessErr := s.syncAccessFilters(r)
snapshot, err := s.syncService.InitSyncFiltered(r.Context(), req.DeviceID, principal.UserID, canRead)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
s.logger.Error("sync init failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
@@ -60,7 +66,12 @@ func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
return
}
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta})
canRead, canWrite, accessErr := s.syncAccessFilters(r)
result, err := s.syncService.ApplyDeltaFiltered(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta}, canRead, canWrite)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
@@ -100,7 +111,12 @@ func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
return
}
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions)
canRead, canWrite, accessErr := s.syncAccessFilters(r)
snapshot, err := s.syncService.ResolveConflictsFiltered(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions, canRead, canWrite)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
@@ -148,3 +164,48 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(content)
}
func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.PathAccessFunc, func() error) {
var accessErr error
canRead := func(file sync.FileEntry) bool {
if accessErr != nil {
return false
}
record, err := s.repository.GetDocumentByPathOrAlias(r.Context(), file.Path)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return s.canReadNewDocument(r, file.Path)
}
accessErr = err
return false
}
ok, err := s.canReadDocumentRecord(r, *record)
if err != nil {
accessErr = err
return false
}
return ok
}
canWrite := func(path string) bool {
if accessErr != nil {
return false
}
record, err := s.repository.GetDocumentByPathOrAlias(r.Context(), path)
if err == nil && record != nil {
ok, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
accessErr = err
return false
}
return ok
}
if os.IsNotExist(err) {
return s.canWriteNewDocument(r, path)
}
if err != nil {
return s.canWriteNewDocument(r, path)
}
return false
}
return canRead, canWrite, func() error { return accessErr }
}

View File

@@ -66,6 +66,20 @@
</div>
<aside class="account-sidebar">
<section class="account-section">
<header class="account-section__header">
<div>
<p class="account-section__kicker">Security</p>
<h2>Passkeys</h2>
</div>
<p>Add a passkey to sign in with this device, a password manager, or a hardware security key.</p>
</header>
<form class="auth-form" data-passkey-add>
<button type="submit">Add passkey</button>
<p class="form-status" data-passkey-add-status></p>
</form>
</section>
<section class="account-section">
<header class="account-section__header">
<div>

View File

@@ -62,6 +62,8 @@
{{ template "document_edit_content" .Data }}
{{ else if eq .BodyTemplate "search_content" }}
{{ template "search_content" .Data }}
{{ else if eq .BodyTemplate "tag_content" }}
{{ template "tag_content" .Data }}
{{ else if eq .BodyTemplate "login_content" }}
{{ template "login_content" .Data }}
{{ else if eq .BodyTemplate "setup_content" }}
@@ -72,6 +74,8 @@
{{ template "account_content" .Data }}
{{ else if eq .BodyTemplate "token_create_content" }}
{{ template "token_create_content" .Data }}
{{ else if eq .BodyTemplate "permissions_content" }}
{{ template "permissions_content" .Data }}
{{ else if eq .BodyTemplate "device_verify_content" }}
{{ template "device_verify_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }}
@@ -103,11 +107,18 @@
<script src="/static/cache.js" defer></script>
<script src="/static/sync.js" defer></script>
<script src="/static/realtime.js" defer></script>
<script src="/static/notification-bell.js" defer></script>
<script src="/static/editor.js" defer></script>
<script src="/static/auth.js" defer></script>
<script src="/static/render.js" defer></script>
<script src="/static/comments.js" defer></script>
<script src="/static/keyboard-nav.js" defer></script>
<script src="/static/mobile.js" defer></script>
<script src="/static/archive.js" defer></script>
<script src="/static/folder-actions.js" defer></script>
<script src="/static/move.js" defer></script>
<script src="/static/permissions.js" defer></script>
<script src="/static/tags.js" defer></script>
</body>
</html>

View File

@@ -2,8 +2,18 @@
{{ define "document_content" }}
<section class="workspace-shell workspace-shell--document">
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
{{ template "browser" .Browser }}
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<article class="document-shell" data-document-id="{{ .ID }}" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
<div class="document-mobile-bar">
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
</button>
<span class="document-mobile-bar__title" aria-hidden="true">{{ .Title }}</span>
<button class="document-mobile-bar__btn document-mobile-bar__btn--meta" type="button" data-toggle-meta aria-label="Open document info" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
</button>
</div>
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
@@ -18,72 +28,181 @@
{{ end }}
</ol>
</nav>
<div class="document-meta">
<div class="document-meta" data-meta-drawer>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-meta aria-label="Close document info">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<div class="document-meta__header">
<h1>{{ .Title }}</h1>
{{ if .CanWrite }}
<div class="document-actions">
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
</div>
{{ end }}
</div>
<details class="document-meta-panel">
<summary>
<span>Document details</span>
<details class="document-actions-dropdown">
<summary aria-haspopup="true" aria-label="Document actions">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
</summary>
<dl class="meta-grid">
<div>
<dt>File</dt>
<dd><code>{{ .Path }}</code></dd>
<div class="document-actions-dropdown__menu">
<button class="document-actions-dropdown__item" type="button" data-document-watch aria-pressed="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path></svg>
<span data-document-watch-label>Watch document</span>
</button>
{{ if .CanWrite }}
<a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>Edit</span>
</a>
<button class="document-actions-dropdown__item" type="button" data-move-path="{{ .Path }}" data-move-hash="{{ .Hash }}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9V5h4"></path><path d="m5 5 6 6"></path><path d="M19 15v4h-4"></path><path d="m19 19-6-6"></path></svg>
<span>Move</span>
</button>
{{ if .CanAdmin }}
<a class="document-actions-dropdown__item" href="/permissions?resourceType=document&resourceId={{ .Path }}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
<span>Permissions</span>
</a>
{{ end }}
<button class="document-actions-dropdown__item document-actions-dropdown__item--danger" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
<span>Archive</span>
</button>
{{ end }}
<hr class="document-actions-dropdown__divider">
<div class="document-actions-dropdown__meta">
<div class="document-actions-dropdown__meta-row">
<span class="document-actions-dropdown__meta-label">File</span>
<code class="document-actions-dropdown__meta-value">{{ .Path }}</code>
</div>
<div>
<dt>Hash</dt>
<dd><code>{{ .Hash }}</code></dd>
<div class="document-actions-dropdown__meta-row">
<span class="document-actions-dropdown__meta-label">Hash</span>
<code class="document-actions-dropdown__meta-value">{{ .Hash }}</code>
</div>
{{ if .Tags }}
<div>
<dt>Tags</dt>
<dd>
<ul class="tag-list">
<div class="document-actions-dropdown__meta-row">
<span class="document-actions-dropdown__meta-label">Tags</span>
<ul class="document-actions-dropdown__tag-list">
{{ range .Tags }}
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
{{ end }}
</ul>
</dd>
</div>
{{ end }}
</dl>
</div>
</div>
</details>
</div>
</div>
<div class="markdown-body">
{{ .HTML }}
</div>
</article>
<aside class="document-comments-aside" data-comments-aside aria-label="Comments">
<div class="document-comments-strip" data-comments-strip>
<button
class="document-comments-strip__toggle"
type="button"
data-comment-toggle
aria-pressed="false"
aria-label="Toggle comments panel"
title="Toggle comments"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
</svg>
</button>
<div class="document-comments-strip__track" data-comments-strip-track></div>
</div>
<div class="document-comments-panel">
<div class="comments-toolbar">
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
</div>
<div class="document-comments-panel__list" data-comments-aside-list></div>
</div>
<!-- Mobile bottom sheet -->
<div class="document-comments-sheet" data-comments-sheet>
<div class="document-comments-sheet__handle">
<button class="document-comments-sheet__toggle" type="button" data-toggle-comments aria-expanded="false" aria-label="Toggle comments">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
</svg>
<span>Comments</span>
</button>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-comments aria-label="Close comments">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="document-comments-sheet__panel">
<div class="comments-toolbar">
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
</div>
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
</div>
</div>
</aside>
</section>
{{ end }}
{{ define "browser" }}
<nav class="miller-browser" aria-label="Documents">
<nav class="miller-browser" aria-label="Documents" data-picker-drawer>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-picker aria-label="Close file picker">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
{{ range .Columns }}
<section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}">
<h2 title="{{ .Title }}">
<h2 title="{{ .Title }}" {{ if $.CanWrite }}class="has-actions"{{ end }}>
<span class="miller-column-title-wrap">
{{ if eq .Title "Content" }}
{{ template "icon-library" }}
{{ end }}
<span class="miller-column-title-text">{{ .Title }}</span>
</span>
{{ if $.CanWrite }}
<span class="miller-column-actions">
<button type="button" class="miller-column-new-file" data-folder-path="{{ .Path }}" aria-label="New file in {{ .Title }}">
{{ template "icon-file-plus" }}
</button>
<button type="button" class="miller-column-add-folder" data-folder-path="{{ .Path }}" aria-label="Add folder to {{ .Title }}">
{{ template "icon-folder-plus" }}
</button>
</span>
{{ end }}
</h2>
<ul>
{{ range .Items }}
<li>
<li class="browser-item">
<a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}" title="{{ .Name }}">
<span class="browser-item-label">
{{ if .IsFolder }}{{ template "icon-folder" }}{{ else }}{{ template "icon-file-text" }}{{ end }}
<span class="browser-item-name">{{ .Name }}</span>
</span>
{{ if .IsFolder }}{{ template "icon-chevron-right" }}{{ end }}
</a>
{{ if and $.CanWrite .IsFolder }}
<details class="browser-item-actions document-actions-dropdown">
<summary aria-haspopup="true" aria-label="Actions for folder {{ .Name }}" title="Folder actions">
{{ template "icon-kebab" }}
</summary>
<div class="document-actions-dropdown__menu">
<button class="document-actions-dropdown__item" type="button" data-folder-rename-path="{{ .Path }}">
{{ template "icon-rename" }}
<span>Rename</span>
</button>
<button class="document-actions-dropdown__item" type="button" data-folder-move-path="{{ .Path }}">
{{ template "icon-folder-move" }}
<span>Move</span>
</button>
<button class="document-actions-dropdown__item document-actions-dropdown__item--danger" type="button" data-folder-archive-path="{{ .Path }}">
{{ template "icon-archive" }}
<span>Archive</span>
</button>
</div>
</details>
{{ end }}
{{ if .IsFolder }}{{ template "icon-chevron-right" }}{{ end }}
</li>
{{ end }}
</ul>
@@ -117,8 +236,57 @@
</svg>
{{ end }}
{{ define "icon-folder-plus" }}
<svg class="browser-icon browser-icon--folder" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
{{ end }}
{{ define "icon-file-plus" }}
<svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
{{ end }}
{{ define "icon-chevron-right" }}
<svg class="browser-item-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"></path>
</svg>
{{ end }}
{{ define "icon-kebab" }}
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
{{ end }}
{{ define "icon-rename" }}
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
{{ end }}
{{ define "icon-folder-move" }}
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 9V5h4"></path>
<path d="m5 5 6 6"></path>
<path d="M19 15v4h-4"></path>
<path d="m19 19-6-6"></path>
</svg>
{{ end }}
{{ define "icon-archive" }}
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
{{ end }}

View File

@@ -3,7 +3,7 @@
{{ define "document_edit_content" }}
<section class="workspace-shell workspace-shell--document-edit">
{{ template "browser" .Browser }}
<article class="document-shell document-shell--editor" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<article class="document-shell document-shell--editor" data-document-id="{{ .ID }}" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
@@ -24,7 +24,7 @@
<p class="eyebrow">Editing</p>
<h1>{{ .Title }}</h1>
</div>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}">Done</a>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}" data-done-link>Done</a>
</div>
<details class="document-meta-panel">
<summary>
@@ -70,7 +70,7 @@
<div class="editor-conflict__diff" data-conflict-diff></div>
<div class="editor-conflict__resolution">
<label for="conflict-resolution">Resolved document</label>
<div data-conflict-monaco-mount></div>
<div data-conflict-codemirror-mount></div>
<textarea id="conflict-resolution" data-conflict-resolution spellcheck="false"></textarea>
</div>
</div>
@@ -78,7 +78,7 @@
<form class="editor-form" data-editor-form data-document-path="{{ .Path }}">
<label class="sr-only" for="document-editor">Markdown editor</label>
<div data-monaco-mount></div>
<div data-codemirror-mount></div>
<textarea id="document-editor" name="content" data-document-editor spellcheck="false">{{ .Source }}</textarea>
</form>
</article>

View File

@@ -0,0 +1,71 @@
{{ define "permissions.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "permissions_content" }}
<section class="permissions-shell">
<header class="permissions-header">
<div>
<p class="eyebrow">Access control</p>
<h1>{{ .Title }}</h1>
</div>
{{ if eq .ResourceType "document" }}
<a class="account-action" href="/docs/{{ trimMd .ResourceID }}">Back to page</a>
{{ end }}
</header>
<form class="permissions-form" method="post" action="/permissions">
<input type="hidden" name="resourceType" value="{{ .ResourceType }}" />
<input type="hidden" name="resourceId" value="{{ .ResourceID }}" />
<section class="permissions-panel">
<header class="permissions-panel__header">
<div>
<p class="account-section__kicker">People</p>
<h2>User permissions</h2>
</div>
<div class="permission-bulk-actions" data-permission-bulk>
<label class="permission-select-all">
<input type="checkbox" data-permission-select-all />
<span>Select all</span>
</label>
<select data-permission-bulk-level aria-label="Access level for selected users">
<option value="">No access</option>
<option value="read">View</option>
<option value="write">Edit</option>
<option value="admin">Admin</option>
</select>
<button class="btn-secondary permission-apply" type="button" data-permission-apply>Apply</button>
</div>
</header>
<div class="permission-user-list">
{{ range .Users }}
<label class="permission-user-row">
<input class="permission-user-check" type="checkbox" data-permission-user />
<span class="permission-user-main">
<span class="permission-user-name">
<strong>{{ .DisplayName }}</strong>
<em>{{ .Role }}</em>
</span>
<span class="permission-user-email">{{ .Email }}</span>
<span class="permission-user-meta">Created {{ .CreatedAt }} · Last seen {{ .LastSeenAt }}</span>
</span>
<span class="permission-grant-meta">
<strong>{{ .PermissionText }}</strong>
<small>{{ .GrantSummary }}</small>
</span>
<span class="permission-select-wrap">
<select name="permission:{{ .ID }}" aria-label="Permission for {{ .DisplayName }}" data-permission-select>
<option value="" {{ if eq .Permission "" }}selected{{ end }}>No access</option>
<option value="read" {{ if eq .Permission "read" }}selected{{ end }}>View</option>
<option value="write" {{ if eq .Permission "write" }}selected{{ end }}>Edit</option>
<option value="admin" {{ if eq .Permission "admin" }}selected{{ end }}>Admin</option>
</select>
</span>
</label>
{{ end }}
</div>
<div class="permissions-footer">
<button class="btn-primary" type="submit">Save permissions</button>
</div>
</section>
</form>
</section>
{{ end }}

View File

@@ -0,0 +1,12 @@
{{ define "tag.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "tag_content" }}
<section class="workspace-shell workspace-shell--empty">
{{ template "browser" .Browser }}
<section class="tag-preview">
<p class="eyebrow">Tag</p>
<h1>#{{ .Tag }}</h1>
<p class="lede">{{ len .Results }} document{{ if ne (len .Results) 1 }}s{{ end }} found</p>
</section>
</section>
{{ end }}

View File

@@ -31,6 +31,9 @@ type Service struct {
sourceDir string
}
type FileAccessFunc func(FileEntry) bool
type PathAccessFunc func(string) bool
// NewService creates a new sync service.
func NewService(repo *Repository, docService *docs.Service, contentStore *store.ContentStore, sourceDir string, logger *slog.Logger) *Service {
return &Service{
@@ -44,10 +47,15 @@ func NewService(repo *Repository, docService *docs.Service, contentStore *store.
// InitSync creates a new snapshot from the current server state.
func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snapshot, error) {
return s.InitSyncFiltered(ctx, deviceID, userID, nil)
}
func (s *Service) InitSyncFiltered(ctx context.Context, deviceID, userID string, canRead FileAccessFunc) (*Snapshot, error) {
files, err := s.buildSnapshotFromDisk(ctx)
if err != nil {
return nil, fmt.Errorf("build snapshot: %w", err)
}
files = filterFiles(files, canRead)
snap, err := s.repo.CreateSnapshot(ctx, deviceID, userID, files)
if err != nil {
@@ -60,6 +68,10 @@ func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snaps
// ApplyDelta processes client changes and computes server delta + conflicts.
func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, clientDelta Delta) (*DeltaResult, error) {
return s.ApplyDeltaFiltered(ctx, snapshotID, userID, clientDelta, nil, nil)
}
func (s *Service) ApplyDeltaFiltered(ctx context.Context, snapshotID, userID string, clientDelta Delta, canRead FileAccessFunc, canWrite PathAccessFunc) (*DeltaResult, error) {
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
if err != nil {
return nil, fmt.Errorf("get snapshot: %w", err)
@@ -67,6 +79,14 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
if snap.UserID != userID {
return nil, ErrForbidden
}
for _, clientChange := range clientDelta.Changes {
if canWrite != nil && !canWrite(clientChange.Path) {
return nil, ErrForbidden
}
if clientChange.OldPath != "" && canWrite != nil && !canWrite(clientChange.OldPath) {
return nil, ErrForbidden
}
}
serverFiles := make(map[string]FileEntry)
for _, f := range snap.Files {
@@ -91,6 +111,7 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
if err != nil {
return nil, fmt.Errorf("build current snapshot: %w", err)
}
currentFiles = filterFiles(currentFiles, canRead)
currentMap := make(map[string]FileEntry)
for _, f := range currentFiles {
@@ -186,6 +207,7 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
if err != nil {
return nil, fmt.Errorf("build post-delta snapshot: %w", err)
}
latestFiles = filterFiles(latestFiles, canRead)
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, latestFiles)
if err != nil {
return nil, fmt.Errorf("create post-delta snapshot: %w", err)
@@ -200,6 +222,10 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
// ResolveConflicts applies resolved changes and creates a new snapshot.
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID string, resolutions []Resolution) (*Snapshot, error) {
return s.ResolveConflictsFiltered(ctx, snapshotID, userID, resolutions, nil, nil)
}
func (s *Service) ResolveConflictsFiltered(ctx context.Context, snapshotID, userID string, resolutions []Resolution, canRead FileAccessFunc, canWrite PathAccessFunc) (*Snapshot, error) {
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
if err != nil {
return nil, fmt.Errorf("get snapshot: %w", err)
@@ -207,6 +233,14 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
if snap.UserID != userID {
return nil, ErrForbidden
}
for _, resolution := range resolutions {
if canWrite != nil && !canWrite(resolution.Path) {
return nil, ErrForbidden
}
if resolution.NewPath != "" && canWrite != nil && !canWrite(resolution.NewPath) {
return nil, ErrForbidden
}
}
// Get current server state
if _, err := s.buildSnapshotFromDisk(ctx); err != nil {
@@ -255,6 +289,7 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
if err != nil {
return nil, fmt.Errorf("build resolved snapshot: %w", err)
}
files = filterFiles(files, canRead)
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
if err != nil {
@@ -265,6 +300,19 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
return newSnap, nil
}
func filterFiles(files []FileEntry, canRead FileAccessFunc) []FileEntry {
if canRead == nil {
return files
}
filtered := make([]FileEntry, 0, len(files))
for _, file := range files {
if canRead(file) {
filtered = append(filtered, file)
}
}
return filtered
}
// GetContent returns raw file content by hash.
func (s *Service) GetContent(hash string) ([]byte, error) {
if !isSHA256Hex(hash) {

View File

@@ -4,23 +4,28 @@ services:
context: .
dockerfile: Dockerfile
ports:
- "{$PORT}:{$PORT}"
- "${PORT:-8080}:${PORT:-8080}"
depends_on:
- mailpit
environment:
PORT: "${PORT:-8080}"
CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite"
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}"
CAIRNQUIRE_EMAIL_PROVIDER: "${CAIRNQUIRE_EMAIL_PROVIDER:-smtp}"
CAIRNQUIRE_EMAIL_SMTP_HOST: "${CAIRNQUIRE_EMAIL_SMTP_HOST:-mailpit}"
CAIRNQUIRE_EMAIL_SMTP_PORT: "${CAIRNQUIRE_EMAIL_SMTP_PORT:-1025}"
CAIRNQUIRE_EMAIL_POSTMARK_TOKEN: "${CAIRNQUIRE_EMAIL_POSTMARK_TOKEN:-}"
CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID: "${CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID:-}"
CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN: "${CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN:-}"
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
volumes:
- cairnquire_data:/workspace/data
- ./content:/workspace/content
- ./data:/workspace/data
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:{$PORT}/health"]
test: ["CMD", "curl", "--fail", "http://127.0.0.1:${PORT:-8080}/healthz"]
interval: 10s
timeout: 3s
retries: 5
@@ -34,3 +39,7 @@ services:
environment:
MP_UI_BIND_ADDR: "0.0.0.0:8025"
MP_SMTP_BIND_ADDR: "0.0.0.0:1025"
volumes:
cairnquire_data:
driver: local

22
entrypoint.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -eu
# Ensure runtime directories exist before chowning them; otherwise chown -R on a
# bind-mounted or freshly-created named volume can race with MkdirAll in the app.
mkdir -p /workspace/data/files /workspace/content
# Make the runtime dirs owned by appuser so the Go process can write attachments
# and read content even when /workspace/data is a host bind mount created as root
# (or a named volume initialized with the container's root uid).
chown -R appuser:appuser /workspace/data /workspace/content
# Drop to the unprivileged appuser and hand control to the container CMD.
# gosu handles signal forwarding and TTY correctly; plain `su` and `su -c` do not.
exec gosu appuser "$@"
# Smoke test (run on a host with Docker available):
# docker compose build app
# docker compose up -d app
# docker compose exec app id # should print: uid=<appuser-uid>(appuser) gid=<appuser-gid>(appuser)
# docker compose exec app ls -ld /workspace/data # should be owned by appuser
# curl -fsS http://localhost:${PORT:-8080}/healthz

View File

@@ -1,4 +1,3 @@
go 1.24.2
go 1.26.5
use ./apps/server

View File

@@ -1,5 +1,6 @@
[tools]
go = "1.24.2"
go = "1.26.5"
python = "latest"
[tasks.server-run]
description = "Run the Go server"
@@ -45,4 +46,4 @@ run = "true"
[tasks.vulncheck]
description = "Run govulncheck"
dir = "apps/server"
run = "go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./..."
run = "go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./..."