Compare commits
16 Commits
93e96beb74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af7940a6d5 | |||
| 03c06c0dd9 | |||
| ae0939177c | |||
| 4454e918c2 | |||
| 09f51d1ef4 | |||
| b6d2ded141 | |||
| 4655008154 | |||
| 0adb980cf7 | |||
| c39c50b9a0 | |||
| 6f646c2505 | |||
| 6c30fd8b63 | |||
| c7dafae806 | |||
| 04a1f2bb6d | |||
| 1e939f307d | |||
| 632621b226 | |||
| 4666000ca3 |
17
.env.example
17
.env.example
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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...`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
36
.project/notes/untracked-subsystems.md
Normal file
36
.project/notes/untracked-subsystems.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
BIN
apps/server/cairnquire
Executable file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -169,3 +169,7 @@ func hasScope(scopes []Scope, required Scope) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func HasScope(scopes []Scope, required Scope) bool {
|
||||
return hasScope(scopes, required)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, `
|
||||
|
||||
157
apps/server/internal/collaboration/repository_test.go
Normal file
157
apps/server/internal/collaboration/repository_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
46
apps/server/internal/config/config_test.go
Normal file
46
apps/server/internal/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_watchers_user_folder_unique;
|
||||
DROP INDEX IF EXISTS idx_watchers_user_document_unique;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_document_aliases_document;
|
||||
DROP TABLE IF EXISTS document_aliases;
|
||||
@@ -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);
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
160
apps/server/internal/email/cloudflare.go
Normal file
160
apps/server/internal/email/cloudflare.go
Normal 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, "; ")
|
||||
}
|
||||
128
apps/server/internal/email/cloudflare_test.go
Normal file
128
apps/server/internal/email/cloudflare_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
29
apps/server/internal/email/collaboration_adapter.go
Normal file
29
apps/server/internal/email/collaboration_adapter.go
Normal 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)
|
||||
}
|
||||
14
apps/server/internal/email/internal.go
Normal file
14
apps/server/internal/email/internal.go
Normal 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
|
||||
}
|
||||
154
apps/server/internal/email/postmark.go
Normal file
154
apps/server/internal/email/postmark.go
Normal 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 }
|
||||
113
apps/server/internal/email/postmark_test.go
Normal file
113
apps/server/internal/email/postmark_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
356
apps/server/internal/email/queue.go
Normal file
356
apps/server/internal/email/queue.go
Normal 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
|
||||
}
|
||||
275
apps/server/internal/email/queue_test.go
Normal file
275
apps/server/internal/email/queue_test.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
149
apps/server/internal/email/templates.go
Normal file
149
apps/server/internal/email/templates.go
Normal 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"
|
||||
}
|
||||
119
apps/server/internal/email/templates_test.go
Normal file
119
apps/server/internal/email/templates_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/server/internal/email/test_helpers_test.go
Normal file
18
apps/server/internal/email/test_helpers_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
141
apps/server/internal/httpserver/collab_handlers_test.go
Normal file
141
apps/server/internal/httpserver/collab_handlers_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
431
apps/server/internal/httpserver/permissions_handlers.go
Normal file
431
apps/server/internal/httpserver/permissions_handlers.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
67
apps/server/internal/httpserver/static/codemirror.bundle.js
Normal file
67
apps/server/internal/httpserver/static/codemirror.bundle.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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 |
101
apps/server/internal/httpserver/static/folder-actions.js
Normal file
101
apps/server/internal/httpserver/static/folder-actions.js
Normal 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
471
apps/server/internal/httpserver/static/keyboard-nav.js
Normal file
471
apps/server/internal/httpserver/static/keyboard-nav.js
Normal 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);
|
||||
})();
|
||||
86
apps/server/internal/httpserver/static/mobile.js
Normal file
86
apps/server/internal/httpserver/static/mobile.js
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
42
apps/server/internal/httpserver/static/move.js
Normal file
42
apps/server/internal/httpserver/static/move.js
Normal 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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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', () => {
|
||||
|
||||
36
apps/server/internal/httpserver/static/permissions.js
Normal file
36
apps/server/internal/httpserver/static/permissions.js
Normal 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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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
@@ -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;
|
||||
|
||||
81
apps/server/internal/httpserver/static/tags.js
Normal file
81
apps/server/internal/httpserver/static/tags.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
71
apps/server/internal/httpserver/templates/permissions.gohtml
Normal file
71
apps/server/internal/httpserver/templates/permissions.gohtml
Normal 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 }}
|
||||
12
apps/server/internal/httpserver/templates/tag.gohtml
Normal file
12
apps/server/internal/httpserver/templates/tag.gohtml
Normal 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 }}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
22
entrypoint.sh
Executable 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
|
||||
@@ -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 ./..."
|
||||
|
||||
Reference in New Issue
Block a user