Compare commits

..

22 Commits

Author SHA1 Message Date
09f51d1ef4 Add queued email notifications for collaboration events 2026-07-22 08:51:42 -04:00
b6d2ded141 passkey existing 2026-06-16 17:05:15 -04:00
4655008154 text editor link autocomplete 2026-06-12 13:57:14 -04:00
0adb980cf7 codemirror 2026-06-12 12:12:49 -04:00
c39c50b9a0 dropdown menu and comment side panel 2026-06-10 08:29:02 -04:00
6f646c2505 fix done btn on edit 2026-06-09 21:06:26 -04:00
6c30fd8b63 update sync 2026-06-09 19:07:18 -04:00
c7dafae806 fix ports 2026-06-09 18:44:22 -04:00
04a1f2bb6d ops design and app
add more comments feature, more tags feature, and other frontend updates
2026-06-09 18:27:59 -04:00
1e939f307d Refine macOS menu bar drop target icon 2026-06-09 16:41:10 -04:00
632621b226 Add live search to macOS popover 2026-06-09 16:40:02 -04:00
4666000ca3 port fix 2026-06-05 12:12:52 -04:00
93e96beb74 ui css 2026-06-05 12:05:59 -04:00
81ceba1ebf port env 2026-06-04 10:07:25 -04:00
sirtimbly
be8d7213b3 Merge pull request 'master' (#1) from master into main
Reviewed-on: https://codeberg.org/sirtimbly/cairnquire/pulls/1
2026-06-04 14:53:48 +02:00
ddc7d5cbf5 accounts and email 2026-06-04 08:50:34 -04:00
73d505ac7e fix(ui): cap workspace browser at 36% max width
The empty-state workspace shell (used on index and search pages) was
using a 50/50 grid split. When the miller-browser only shows 1-2 columns
the left side looks too wide. Change to a 36/64 split so the browser
column maxes out at 36% while keeping its 300px minimum.
2026-06-01 22:56:54 -04:00
fb0673a1c5 sync app filled in 2026-06-01 10:53:28 -04:00
ebe0920f89 remove preact and create setup wizard 2026-06-01 10:10:36 -04:00
b3364447a1 server and web app 2026-05-31 23:08:53 -04:00
438b3b29a2 misc 2026-05-28 08:36:01 -04:00
fc63f9c44a server auth 2026-05-28 08:35:50 -04:00
153 changed files with 20396 additions and 4884 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.DS_Store
data
tmp
coverage
*.log
.build

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# 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.
CAIRNQUIRE_EMAIL_SMTP_HOST=mailpit
CAIRNQUIRE_EMAIL_SMTP_PORT=1025
CAIRNQUIRE_EMAIL_FROM=notifications@cairnquire.local
# Optional: INFO, DEBUG, WARN, or ERROR.
CAIRNQUIRE_LOG_LEVEL=INFO

11
.gitignore vendored
View File

@@ -1,11 +1,14 @@
data/
tmp/
node_modules/
dist/
coverage/
.env
.DS_Store
*.log
apps/web/node_modules/
apps/web/dist/
apps/server/bin/
# Swift Package Manager and Xcode
.build/
.swiftpm/
DerivedData/
*.xcuserstate
content

View File

@@ -11,9 +11,9 @@
| Document | Decision |
|----------|----------|
| [ADR-001: Tech Stack](adrs/adr-001-tech-stack.md) | Go backend + Preact frontend |
| [ADR-001: Tech Stack](adrs/adr-001-tech-stack.md) | Go server with embedded web assets |
| [ADR-002: Sync Protocol](adrs/adr-002-sync-protocol.md) | SimpleSync content-addressed protocol |
| [ADR-003: SSR Strategy](adrs/adr-003-ssr-strategy.md) | Go templates with Preact hydration |
| [ADR-003: SSR Strategy](adrs/adr-003-ssr-strategy.md) | Go templates with embedded browser scripts |
| [ADR-004: Auth Strategy](adrs/adr-004-auth-strategy.md) | Passkeys primary, passwords fallback |
| [ADR-005: Storage Strategy](adrs/adr-005-storage-strategy.md) | libsql + content-addressed filesystem |
| [ADR-006: Email Strategy](adrs/adr-006-email-strategy.md) | Postmark gateway, plain-text only |
@@ -84,8 +84,7 @@ cairnquire/
│ ├── security-specification.md
│ └── deployment-guide.md
├── apps/
── server/ # Go application
│ └── web/ # Preact frontend
── server/ # Go application, templates, and static assets
├── packages/
│ └── protocol/ # Shared types
├── docker-compose.yml

View File

@@ -1,51 +1,64 @@
# Project Status Report
**Generated:** 2026-04-29 14:57:37 UTC
**Monitoring Duration:** 14h 41m
**Iteration:** 86
**Branch:** codex/foundation-bootstrap
**Generated:** 2026-06-24
**Branch:** main
**Last Commit:** b6d2ded - passkey existing (2026-06-16)
## Quick Stats
| Metric | Value |
|--------|-------|
| Total Files | 101 |
| Go Files | 22 ( 2304 lines) |
| TypeScript Files | 3 ( 761 lines) |
| Markdown Files | 134 ( 27723 lines) |
| Docker Config | yes |
| Uncommitted Changes | 9 |
| Milestones Complete | 0/6 (0%) |
| Total Tracked Files | 189 |
| Go Files | 46 |
| Swift Files | 14 (macOS app) |
| Test Files | 10 |
| Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) |
| Milestones Complete | 3/6 (50%) |
## Recent Activity
## Recent Activity (since 2026-06-01 report)
**Last Commit:** 3d8ca8b - refactor: focus admin dashboard layout (3 hours ago)
16 commits on `main` between `fb0673a` (2026-06-01) and `b6d2ded` (2026-06-16):
71 files changed, +7908 / -812 lines.
Highlights:
- `b6d2ded` passkey existing-user login flow
- `4655008` text editor link autocomplete
- `0adb980` CodeMirror editor integration
- `c39c50b` dropdown menu + comment side panel (wires M4 comment UI)
- `6c30fd8` sync service update
- `04a1f2b` ops design + app scaffolding (`entrypoint.sh`)
- `632621b`/`1e939f3` macOS menu-bar app: live search popover, drop-target icon
- `ddc7d5c` accounts and email wiring
- `93e96be`/`73d505a` UI CSS expansion (~1900 lines) and workspace browser polish
- New untracked subsystems: `permissions_handlers.go` (RBAC sharing UI), `tags.js`/`tag.gohtml` (tagging), `keyboard-nav.js`, `mobile.js`, `archive.js`
## Milestone Progress
- [ ] Milestone 1: Foundation
- [ ] Milestone 2: Sync Protocol
- [ ] Milestone 3: Authentication
- [ ] Milestone 4: Collaboration
- [ ] Milestone 5: Search & UI
- [ ] Milestone 6: Production
- [x] Milestone 1: Foundation
- [x] Milestone 2: Sync Protocol
- [x] Milestone 3: Authentication
- [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending)
- [ ] Milestone 5: Search & UI (server FTS5 done; design-system CSS, keyboard nav, mobile responsive shipped; `/design` route, Flexsearch, a11y audits pending)
- [ ] Milestone 6: Production (not started)
**Current Focus:** Not Started
**Current Focus:** M4 Email Integration (Postmark adapter, templates, queue with retry)
### Error Handling (10 ignored errors)
## Untracked Subsystems (not in original plan)
Several errors are being silently ignored. Consider handling or logging these.
These shipped but are not milestone-tracked. Tracked via `notes/untracked-subsystems.md`:
- **Permissions / RBAC sharing UI** — `permissions_handlers.go` (431 lines), `permissions.gohtml`, `permissions.js`. Per-resource read/write/admin grants.
- **Tagging** — `tags.js`, `tag.gohtml`. Document tags.
- **CodeMirror inline editor** — `editor.js` (+620), `codemirror.bundle.js`, link autocomplete.
- **macOS menu-bar app** — 14 Swift files, live search popover, drag-drop target. Beyond web-first plan scope.
- **API tokens** — `token_create.gohtml`, `auth.APIKey` types.
- **Password reset flow** — `password_reset.gohtml`, `auth.PasswordResetToken` repository methods.
## 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
- `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient.
- `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused.
- `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark).
---
*This report auto-generated by Cairnquire progress monitor.*
*Next update in ~10 minutes or when filesystem changes detected.*
*This report updated 2026-06-24 by hand. Next refresh when M4 email work lands.*

View File

@@ -1,53 +1,54 @@
# ADR-001: Go Backend with Preact Frontend
# ADR-001: Go Server with Embedded Web Assets
## Status
Accepted
## Context
We need to choose a backend language/framework and frontend technology for a documentation platform with these requirements:
- Ultra-fast performance
- Minimal external dependencies
- No vendor lock-in
- Battle-tested security
- Server-side rendering for non-interactive pages
- Offline-capable web client
We need a documentation platform with fast page delivery, minimal external
dependencies, secure server-side rendering, and a straightforward deployment
artifact.
## Decision
**Backend**: Go 1.22+ with standard library plus minimal, well-audited packages
**Frontend**: Preact 10 with TypeScript, Vite build system
Use Go 1.24+ 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.
Browser scripts progressively add editing, offline caching, WebSocket updates,
comments, and authentication interactions to server-rendered pages.
## Consequences
### Positive
- Go's standard library is comprehensive and security-audited
- Single static binary deployment — no runtime dependencies
- Preact is 10KB (vs 40KB+ React) with identical API
- Go's `html/template` provides safe SSR with auto-escaping
- TypeScript gives type safety without runtime overhead
- Both ecosystems have excellent supply-chain security (Go modules with checksums, npm audit)
- One application binary and one build toolchain
- Go `html/template` provides auto-escaped server rendering
- Embedded assets deploy with the server binary
- Fewer supply-chain dependencies and fewer moving parts in CI
- Pages remain readable before browser scripts execute
### Negative
- Go template syntax is less expressive than JSX
- WebSocket sync logic must be implemented manually (no framework like Socket.io)
- Preact ecosystem smaller than React (mitigated by using mostly native APIs)
- Template syntax is less expressive than JSX
- Browser behavior uses small framework-free modules
- WebSocket and offline behavior must be maintained directly
## Alternatives Considered
### Separate Preact SPA
- **Pros**: Component model, client-side routing, TypeScript ecosystem
- **Cons**: Adds Node tooling, package dependencies, a second build pipeline,
and duplicate UI surfaces
- **Rejected**: The Go-served UI already covers the application surface
### Rust + Axum
- **Pros**: Memory safety guarantees, excellent performance
- **Cons**: Longer compile times, smaller talent pool, steeper learning curve for team
- **Rejected**: Go's pragmatic balance of safety and velocity better fits project timeline
### Deno Fresh
- **Pros**: Island architecture, native TypeScript, no build step
- **Cons**: Newer ecosystem, fewer audited libraries, Deno runtime less battle-tested than Go
- **Rejected**: Go's maturity and deployment simplicity preferred
- **Cons**: Longer compile times and steeper learning curve
- **Rejected**: Go better fits the project deployment model
### Next.js / React Server Components
- **Pros**: Mature ecosystem, built-in SSR
- **Cons**: Heavy bundle size, complex build system, RSC lock-in, many dependencies
- **Rejected**: Violates "minimal dependencies" and "no vendor lock-in" principles
- **Cons**: Heavy build system and more runtime complexity
- **Rejected**: Violates the minimal dependency goal
## References
- Go Security Policy: https://go.dev/security
- Preact Size Comparison: https://bundlephobia.com/package/preact@10.19.3
- Go `html/template`: https://pkg.go.dev/html/template

View File

@@ -1,67 +1,55 @@
# ADR-003: SSR Strategy - Go Templates with Preact Hydration
# ADR-003: SSR Strategy - Go Templates with Embedded Browser Scripts
## Status
Accepted
## Context
The application needs to serve document pages that are readable without JavaScript (for performance and accessibility), while providing rich interactivity (comments, editing, search) when JavaScript is available. We need a server-side rendering strategy that works with our Go backend and Preact frontend.
Document pages must be readable quickly and safely while still supporting
editing, authentication, comments, offline caching, and real-time updates.
## Decision
**Strategy C**: Go's `html/template` renders initial HTML for all pages. Preact hydrates interactive components on the client.
Go `html/template` renders complete HTML pages. The server embeds and serves
small framework-free browser scripts for progressive enhancement.
### Implementation Details
1. **Server Rendering**:
- Go handlers query database for document content
- Markdown parsed to HTML server-side (Goldmark)
- `html/template` renders full page with document HTML embedded
- No JavaScript required for reading
- Go handlers query document and account data
- Goldmark parses Markdown to HTML server-side
- `html/template` renders complete pages
- Pages are readable before JavaScript executes
2. **Client Hydration**:
- Preact components mount into designated DOM containers
- Comments, search, editor are Preact islands
- Static content remains server-rendered HTML
- `preact-iso` handles client-side navigation between pages
3. **Data Passing**:
- Initial state embedded as JSON in `<script type="application/json">` tags
- Preact reads initial state during hydration, avoids extra API calls
- CSP nonce applied to all inline scripts
2. **Browser Enhancement**:
- Embedded scripts handle editing, comments, authentication, offline cache,
service-worker behavior, and WebSocket updates
- Native browser APIs are preferred over a client framework
- There is no separate SPA route or frontend build pipeline
## Consequences
### Positive
- First contentful paint is instant (pure HTML)
- SEO-friendly without extra complexity
- Graceful degradation: works without JS
- Go templates are auto-escaping XSS-safe
- No V8/JS runtime embedded in Go binary
- Smaller client bundle (only interactive components)
- Fast first contentful paint
- Auto-escaped templates
- One deployable application artifact
- Small browser payloads and no Node build dependency
- Interactive features stay close to their HTTP endpoints
### Negative
- Some code duplication: markdown parsing logic in both Go and JS (for preview)
- Template syntax is verbose compared to JSX
- Hydration can cause flicker if not carefully managed
- Client state must reconcile with server-rendered HTML
- Browser modules need direct DOM event handling
- Shared UI abstractions require deliberate maintenance
- More complex browser interactions need focused integration tests
## Alternatives Considered
### Go Templates Only (No Preact)
- **Pros**: Zero client JS, maximum performance
- **Cons**: No interactive features (comments, real-time sync, search)
- **Rejected**: Doesn't meet collaboration requirements
### Separate Hydrated SPA
- **Pros**: Component framework and client-side routing
- **Cons**: Duplicates UI paths and introduces a second toolchain
- **Rejected**: The server-rendered UI already supports the required behavior
### Embedded JS Runtime (QuickJS/V8)
- **Pros**: True isomorphic rendering, JSX templates
- **Cons**: Adds C dependency, complex build, larger binary, security surface area
- **Rejected**: Violates minimal dependency principle
### Preact SSR via WASM
- **Pros**: Same templates on server and client
- **Cons**: WASM overhead, complex build pipeline, slower than Go templates
- **Rejected**: Go templates are faster and simpler
### Embedded JS Runtime
- **Pros**: Isomorphic rendering
- **Cons**: Larger binary and additional security surface
- **Rejected**: Go templates are simpler
## References
- Go html/template security: https://pkg.go.dev/html/template
- Preact hydration: https://preactjs.com/guide/v10/server-side-rendering
- Islands Architecture: https://jasonformat.com/islands-architecture/
- Go `html/template`: https://pkg.go.dev/html/template

View File

@@ -12,7 +12,7 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Browser │ │ File Editor │ │ Mobile / PWA │ │
│ │ (Preact SPA) │ │ (VS Code, │ │ (Responsive Web) │ │
│ │ (Go HTML UI) │ │ (VS Code, │ │ (Responsive Web) │ │
│ │ │ │ Vim, etc.) │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └────────────┬─────────────┘ │
│ │ │ │ │
@@ -142,7 +142,7 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT
8. Render HTML: AST → html/template with layout
9. Response: HTML with CSP nonce, Preact hydration marker
9. Response: HTML with embedded static assets and progressive enhancement scripts
```
## Data Flow: File Sync

View File

@@ -0,0 +1,70 @@
# macOS App API Gaps
## Missing API Endpoints
### 1. List Uploaded Attachments
**Status:** IMPLEMENTED (see `apps/server/internal/httpserver/handlers.go` `handleAttachmentsList`)
The app needs a way to show an index of all uploaded files. The current API only allows uploading (`POST /api/uploads`) and downloading (`GET /attachments/{hash}`).
**Needed:**
```
GET /api/attachments
Response: {
"attachments": [
{
"hash": "sha256",
"originalName": "filename.pdf",
"contentType": "application/pdf",
"sizeBytes": 12345,
"createdAt": "2024-01-01T00:00:00Z",
"url": "/attachments/{hash}"
}
]
}
```
### 2. Create Document from Content
**Status:** AVAILABLE BY PATH
The existing `POST /api/documents/{path}` works but requires knowing the path upfront. For the "new note from clipboard" feature, it would be cleaner to have:
```
POST /api/documents
Body: {
"content": "# Note Title\n\nBody...",
"folder": "inbox" // optional, defaults to root
}
Response: {
"path": "inbox/note-title.md",
"title": "Note Title",
"hash": "sha256"
}
```
The current workaround is to POST to `/api/documents/{path}` with the desired path.
### 3. Inbox/Upload Folder Concept
**Status:** NOT IMPLEMENTED
The server has no concept of a default upload folder or inbox. All documents live in a flat-ish hierarchy under the content source directory. The macOS app will:
- Allow the user to configure a default upload folder (e.g., "inbox/")
- Fall back to root if not configured
- Document this as a client-side convention
## Stub Implementations
The attachment list placeholder has been replaced with a repository-backed implementation. Native sync deltas now apply non-conflicting create/update/delete/rename changes and return `newSnapshotId`; create/update changes should include inline markdown `content` or reference a hash already present in the content store.
## Authentication
The app uses the existing Bearer token (API key) auth flow:
1. User creates an API token via the web UI at `/account/tokens/new`
2. Token is stored in macOS Keychain
3. All requests include `Authorization: Bearer <token>`
## Notes
- File sync uses the existing `/api/sync/*` protocol. Clients should persist `newSnapshotId` after each delta response.
- Upload uses `POST /api/uploads`. Markdown, plain-text, and HTML files are promoted into readable documents; other supported files remain attachments.
- Uploaded file listing uses the repository-backed `GET /api/attachments` endpoint. Its `url` field points to the rendered page for promoted documents.

View File

@@ -9,7 +9,7 @@
- [ ] Initialize monorepo structure
- `apps/server/` with Go module
- `apps/web/` with Vite + Preact + TypeScript
- embedded templates and static assets under `apps/server/internal/httpserver/`
- `packages/protocol/` with protobuf schema
- `docs/` with existing documentation
@@ -53,10 +53,10 @@
- Security headers middleware
- MIME type detection
- [ ] Initial frontend
- Preact app setup with Vite
- Basic routing (preact-iso)
- Hydration entry point
- [ ] Initial browser UI
- Go template routes
- Embedded framework-free browser scripts
- Progressive enhancement entry points
- CSS design system foundation
## Acceptance Criteria

View File

@@ -7,73 +7,74 @@
### Week 7: Comments & Notifications
- [ ] Comment system
- Comment creation endpoint (web)
- Comment threading (parent/child)
- Line/section anchoring (hash-based)
- Comment resolution (mark as resolved)
- Comment display in Preact UI
- [ ] Notification system
- Notification queue in database
- Per-file watcher subscriptions
- Per-folder watcher subscriptions
- Global notification settings
- Digest mode (hourly/daily batches)
- [ ] Web notification UI
- Notification bell with unread count
- Notification list dropdown
- Mark as read functionality
- Notification preferences page
- [x] Comment system (backend + UI wired)
- [x] Comment creation endpoint (web)
- [x] Comment threading (parent/child) - schema supports it
- [x] Line/section anchoring (hash-based)
- [x] Comment resolution (mark as resolved)
- [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
- [x] Per-file watcher subscriptions
- [x] Per-folder watcher subscriptions
- [ ] 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
- [ ] Notification preferences page
### Week 8: Email Integration & Conflict Resolution
- [ ] Postmark integration
- Go adapter setup
- Plain-text email templates
- Outgoing email queue with retry
- Webhook endpoint for inbound email
- [ ] Email notification types
- File changed notification
- New comment notification
- Mention notification (@username)
- Conflict alert notification
- Digest summary email
[ ] Email reply handling
- Parse inbound email (from, subject, body)
- Match to comment thread via In-Reply-To
- Create comment from email body
- Validate sender permission
- [ ] Conflict resolution UI
- Side-by-side diff view
- Accept local/server/merge options
- Manual merge editor
- Conflict notification email
- [x] Postmark integration
- [x] SMTP sender stub (NoOp + SMTP implementations)
- [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)
- [ ] Webhook endpoint for inbound email
- [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
- [ ] Parse inbound email (from, subject, body)
- [ ] Match to comment thread via In-Reply-To
- [ ] Create comment from email body
- [ ] Validate sender permission
- [x] Conflict resolution UI (M2 - exists in sync flow)
- [x] Side-by-side diff view
- [x] Accept local/server/merge options
- [x] Manual merge editor
- [ ] Conflict notification email
## Acceptance Criteria
### Functional
- [ ] Users can add comments to specific lines in a document
- [ ] Comments are linked to document version (hash)
[ ] Comment threading works (reply to reply)
- [ ] Users can watch files or folders for changes
- [ ] Email notifications sent within 1 minute of event
- [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)
- [x] Email notifications sent within 1 minute of event (Postmark adapter + queue with retry)
- [ ] Replying to notification email creates a comment
- [ ] Digest emails batch notifications by time window
- [ ] Conflicts display side-by-side diff
- [ ] Users can resolve conflicts via web UI
- [x] Conflicts display side-by-side diff (M2 sync)
- [x] Users can resolve conflicts via web UI (M2 sync)
- [ ] 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] Failed emails retried 3 times with exponential backoff — queue.go, verified by test
- [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
### Email Template Examples

View File

@@ -7,60 +7,60 @@
### Week 9: Search Implementation
- [ ] Server-side search index
- FTS5 virtual table setup
- Index population from documents
- Incremental index updates on file changes
- Search query endpoint
- [ ] Client-side search
- Flexsearch or Pagefind integration
- Index sync from server on load
- Incremental index updates via WebSocket
- Search UI (input, results, filters)
- [ ] Search features
- Full-text content search
- Tag filtering
- Title search (boosted)
- Recent results caching
- Search suggestions (autocomplete)
- [x] Server-side search index
- [x] FTS5 virtual table setup
- [x] Index population from documents
- [x] Incremental index updates on file changes
- [x] Search query endpoint
- [x] Client-side search
- [x] Search UI (input, results) - basic implementation
- [ ] Flexsearch or Pagefind integration
- [ ] Index sync from server on load
- [ ] Incremental index updates via WebSocket
- [x] Search features
- [x] Full-text content search
- [ ] Tag filtering
- [x] Title search (boosted) — via document title in macOS popover (`632621b`)
- [ ] Recent results caching
- [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
### Functional
- [ ] Search returns results in <50ms after initial sync
- [x] Search returns results in <50ms after initial sync (server-side FTS5)
- [ ] Search works offline after first load
- [ ] Search supports exact phrases (quoted)
- [ ] Tag filtering narrows search results

View File

@@ -9,7 +9,7 @@
- [ ] Unit tests
- Go: >80% coverage for all packages
- Preact: Component testing with uvu
- Browser behavior: integration testing for embedded scripts
- Mock external dependencies (email, filesystem)
- [ ] Integration tests

View File

@@ -2,8 +2,8 @@
## 2026-04-28
1. The plan and ADRs repeatedly describe a single Go binary, while Milestone 1 also requires `apps/web/` as a separate Vite/Preact app. The current implementation treats the web app as build-time static assets served by the Go binary.
1. Resolved: the separate Vite/Preact SPA was removed. The Go binary serves templates and embedded static assets directly.
2. Milestone 1 asks for `packages/protocol/` with a protobuf schema, but the sync ADR and protocol spec describe JSON messages over WebSocket. The protocol package currently preserves both a `.proto` placeholder and TypeScript-facing JSON model notes.
3. ADR-005 stores content under `data/files/...`, while Milestone 2 examples use `store/...`. The implementation follows ADR-005 and uses `data/files`.
4. The frontend stack still requires a Node package install step, but local automation is now centralized in `mise` tasks and the lockfile-backed `pnpm` workflow rather than ad hoc commands.
4. Resolved: the deployment and development toolchain no longer require Node or pnpm.
5. The foundation milestone specifies `golang-migrate`, but `go-libsql` integration constraints need a clean validation path. A lightweight internal migrator is used now so the project can move forward.

View File

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

View File

@@ -1,8 +1,8 @@
# Cairnquire - Project Plan
**Version:** 1.2\
**Date:** 2026-05-14\
**Status:** In Progress — Milestones 1-3 Complete
**Version:** 1.3\
**Date:** 2026-06-01\
**Status:** In Progress — Milestones 1-3 Complete, M4-M5 In Progress
## Vision
@@ -29,7 +29,7 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
- [x] Markdown-to-HTML pipeline with most common extensions enabled
- [x] Static file serving for attachments
- [x] Basic Dockerfile and docker-compose.yml
- [x] Dev mode with `air` live reload + Vite HMR proxy
- [x] Dev mode with `air` live reload
- [x] Miller column document browser with folder navigation
- [x] `index.md` default rendering for folders
- [x] Content-addressed attachment storage with secure serving
@@ -47,7 +47,7 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
**Notes:**
- Mermaid diagrams and math blocks now render client-side after server-rendered markdown loads.
- Dev mode uses `air` for Go live reload and Vite proxy for frontend HMR.
- Dev mode uses `air` for Go live reload.
---
@@ -93,7 +93,7 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
---
### Milestone 3: Authentication & Authorization (Weeks 5-6)
### Milestone 3: Authentication & Authorization (Weeks 5-6) ✅ COMPLETE
**Goal:** Secure access control with passkeys and granular permissions
@@ -103,6 +103,8 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
- [x] Role-based access control (RBAC)
- [x] Scoped API tokens with device-code flow for developer clients
- [ ] Content signing with Ed25519 (deferred optional hardening)
- [ ] Automatic session refresh (deferred polish)
- [ ] Multiple passkey management UI (deferred polish)
**Acceptance Criteria:**
@@ -131,47 +133,55 @@ 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
---
### Milestone 4: Collaboration Features (Weeks 7-8)
### Milestone 4: Collaboration Features (Weeks 7-8) 🔄 IN PROGRESS
**Goal:** Comments, notifications, and conflict resolution
- Comment system linked to file versions (by hash)
- Email notifications via Postmark adapter
- Web-based conflict resolution UI
- Notification preferences (per-file, per-folder, global, digest)
- Comment threading and email replies
- [x] Comment system linked to file versions (by hash) - backend complete
- [ ] Email notifications via Postmark adapter - SMTP stub exists
- [x] Web-based conflict resolution UI (Milestone 2)
- [ ] Notification preferences (per-file, per-folder, global, digest)
- [x] Comment threading schema - UI needs wiring
- [ ] Email replies create comments
**Acceptance Criteria:**
- [ ] Users can comment on specific lines/sections of a document
- [ ] Comments persist and are linked to document versions
- [ ] Email notifications sent for watched file changes
- [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)
- [ ] Replying to notification email creates a comment
- [ ] Conflicts display side-by-side diff with resolution actions
- [x] Conflicts display side-by-side diff with resolution actions (M2)
- [ ] Digest mode batches notifications by time window
---
### Milestone 5: Search & UI Polish (Weeks 9-10)
### Milestone 5: Search & UI Polish (Weeks 9-10) 🔄 IN PROGRESS
**Goal:** Fast search, design system, and production readiness
- Full-text search with SQLite FTS5
- Client-side search index sync
- Design system with browser-based CSS editor
- Responsive mobile layout
- Error boundaries and loading states
- Performance benchmarking
- Client-side Mermaid and KaTeX rendering
- [x] Full-text search with SQLite FTS5
- [ ] Client-side search index sync
- [ ] Design system with browser-based CSS editor
- [ ] Responsive mobile layout
- [ ] Error boundaries and loading states
- [ ] Performance benchmarking
- [x] Client-side Mermaid and KaTeX rendering
**Acceptance Criteria:**
- [ ] Search returns results in <50ms on client
- [x] Search returns results in <50ms on client (server-side)
- [ ] Search works offline after initial sync
- [ ] Design system documented at `/design` route
- [ ] All screens responsive down to 320px width
@@ -209,8 +219,8 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
┌─────────────────────────────────────────────────────────────┐
│ Client Browser │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Preact UI │ │ File Sync │ │ Search Index │ │
│ │ (hydrated) │ │ (IndexedDB) │ │ (FTS5) │ │
│ │ Go HTML UI │ │ File Sync │ │ Search Index │ │
│ │ + scripts │ │ (IndexedDB) │ │ (FTS5) │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
@@ -259,17 +269,14 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
| Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps |
| Templates | `html/template` | Stdlib, auto-escaping, SSR-safe |
### Frontend (Preact + TypeScript)
### Browser UI
| Component | Library | Justification |
| --------- | ------------------------------ | --------------------------------------- |
| Framework | `preact` (10KB) | React API, tiny bundle, fast hydration |
| Routing | `preact-iso` | Lightweight isomorphic routing |
| Markdown | `marked` + `micromark` | Server+client compatible |
| Search | `flexsearch` | Client-side full-text, zero server load |
| Styling | Native CSS + custom properties | No build-time CSS processing needed |
| Icons | Inline SVG | No icon font dependency |
| Build | `vite` | Fast HMR, modern output, minimal config |
| Component | Technology | Justification |
| ----------- | ------------------------------ | -------------------------------------- |
| Rendering | Go `html/template` | Server-rendered, auto-escaped HTML |
| Interaction | Embedded browser scripts | Small progressive-enhancement modules |
| Styling | Native CSS + custom properties | No build-time CSS processing needed |
| Icons | Inline SVG | No icon font dependency |
### Infrastructure
@@ -347,12 +354,8 @@ cairnquire/
│ │ │ ├── markdown/ # Markdown rendering pipeline
│ │ │ ├── realtime/ # WebSocket hub
│ │ │ └── store/ # Content-addressed filesystem
│ │ ├── static/ # Static assets
│ │ ├── static/ # Embedded static assets
│ │ └── templates/ # Go HTML templates
│ └── web/ # Preact frontend application
│ ├── src/
│ ├── index.html
│ └── vite.config.ts
├── content/ # Markdown source files
├── data/ # SQLite database & attachments
└── packages/
@@ -378,11 +381,25 @@ cairnquire/
1. Complete Milestone 1: Foundation
2. Complete Milestone 2: Sync Protocol
3. 🔄 Finish Milestone 3 hardening
- Add browser login/account management screens
- Complete security review checklist
4. Implement SQLite FTS5 search (Milestone 5)
5. Add Renovate with dependency update cooldown policies
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
5. 🔄 Finish Milestone 5: Search & UI Polish
- Tag filtering in search
- Design system /design route
- Dark mode toggle
- Mobile-responsive layout polish
- Accessibility improvements
6. Milestone 6: Production Hardening
- Increase test coverage from 39% to 80%+
- Add integration tests for auth, sync, collaboration
- Security audit checklist
- Backup/restore scripts
- Health check and monitoring endpoints
---

View File

@@ -8,10 +8,15 @@ Browser users authenticate with passkeys or password fallback. Successful login
Endpoints:
- `GET /setup`
- `POST /api/setup`
- `GET /login`
- `GET /password-reset?token=...`
- `GET /account`
- `GET /account/tokens/new`
- `POST /api/auth/register/password`
- `POST /api/auth/login/password`
- `POST /api/auth/password/reset`
- `POST /api/auth/logout`
- `GET /api/auth/me`
- `POST /api/auth/password/change`
@@ -21,7 +26,12 @@ Endpoints:
- `POST /api/auth/passkeys/login/begin`
- `POST /api/auth/passkeys/login/finish?challengeId=...`
The first registered user bootstraps as `admin`. Later public registrations become `viewer`; role changes are admin-managed from the account screen. Public registration endpoints only create new accounts. Adding or changing credentials on an existing account requires an authenticated browser session.
On an empty database, the one-time `/setup` wizard creates the first `admin`
account and records whether public signup is enabled. Later public
registrations are accepted only when that setting is enabled and always become
`viewer`; role changes are admin-managed from the account screen. Public
registration endpoints only create new accounts. Adding or changing
credentials on an existing account requires an authenticated browser session.
## API Tokens
@@ -31,7 +41,7 @@ Developer clients use bearer tokens:
Authorization: Bearer cq_pat_<id>_<secret>
```
The token is shown once. The server stores the token id and SHA-256 hash of the secret. Tokens can be scoped and revoked.
The token is shown once. The server stores the token id and SHA-256 hash of the secret. Tokens can be scoped and revoked. Browser users create tokens at `/account/tokens/new`.
Endpoints:
@@ -49,6 +59,24 @@ Supported scopes:
Authorization is role plus scope: a token must have the required scope, and the owning user role must allow that operation.
## Account Administration
Administrators update user roles and disabled-account state in one bulk form.
Disabled accounts cannot authenticate with passwords, passkeys, existing
sessions, or API tokens. At least one enabled administrator account must remain.
Administrators can send a password-reset email to a user who cannot log in.
Each click creates a fresh one-hour link and invalidates that user's earlier
unused reset links. Completing a reset changes the password, consumes the link,
and revokes the user's existing browser sessions.
Endpoints:
- `PATCH /api/admin/auth-users`
- `POST /api/admin/auth-users/{id}/password-reset`
- `GET /password-reset?token=...`
- `POST /api/auth/password/reset`
## Device Flow
CLI clients can avoid handling browser cookies by using the first-party device flow:
@@ -64,7 +92,9 @@ This is intentionally inspired by OAuth device authorization, but it is not a ge
## Route Protection
- Public: health, static assets, document reads, login/account entry pages, password/passkey begin-login/register endpoints, device start/token polling.
- Setup-only until configured: initial setup page and endpoint.
- Public: health, static assets, document reads, login and password-reset pages, password/passkey begin-login/register endpoints when signups are enabled, password-reset submission, device start/token polling.
- Authenticated browser pages: account management and token creation screens.
- Session or bearer token required: auth profile/logout, edit/save APIs, uploads, sync/content APIs, device approval.
- Admin role required: admin APIs and API token management.

View File

@@ -4,269 +4,124 @@
- Docker 24.0+
- Docker Compose 2.20+
- 1GB RAM minimum (2GB recommended)
- 10GB disk space
- TLS certificate (Let's Encrypt or custom)
- A reverse proxy that terminates TLS for the public hostname
- A writable checkout directory for `./data` and `./content`
## Quick Start
## Fresh Deployment
```bash
# Clone repository
git clone https://github.com/your-org/cairnquire.git
cd cairnquire
# Copy and edit configuration
cp .env.example .env
# Edit .env with your domain, email, Postmark API key
# Start services
docker-compose up -d
# Verify health
curl https://your-domain.com/health
```
## Configuration
Edit `.env` and set the HTTPS origin that users will open in their browser:
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DOMAIN` | Yes | — | Public domain name |
| `DB_PATH` | No | `/data/db.sqlite` | Database file path |
| `FILESTORE_PATH` | No | `/data/files` | Content-addressed file storage |
| `NOTES_PATH` | No | `/notes` | Watched markdown directory |
| `POSTMARK_API_KEY` | Yes | — | Postmark server API token |
| `POSTMARK_FROM` | No | `notifications@DOMAIN` | From email address |
| `SESSION_SECRET` | Yes | — | 32-byte hex for session signing |
| `ENV` | No | `production` | `development` or `production` |
### Docker Compose
```yaml
version: "3.8"
services:
app:
build: .
ports:
- "8080:8080"
volumes:
- ./data:/data
- ./notes:/notes:ro
environment:
- DOMAIN=${DOMAIN}
- DB_PATH=/data/db.sqlite
- FILESTORE_PATH=/data/files
- NOTES_PATH=/notes
- POSTMARK_API_KEY=${POSTMARK_API_KEY}
- SESSION_SECRET=${SESSION_SECRET}
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=100m
# Optional: Traefik for auto-HTTPS
traefik:
image: traefik:v3.0
command:
- "--api.insecure=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
labels:
- "traefik.enable=true"
restart: unless-stopped
```dotenv
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
```
## Production Checklist
### Pre-Deployment
- [ ] Change default `SESSION_SECRET` (generate: `openssl rand -hex 32`)
- [ ] Configure `POSTMARK_API_KEY` with valid token
- [ ] Set `DOMAIN` to public hostname
- [ ] Configure DNS A/AAAA records
- [ ] Set up SPF and DKIM for email domain
- [ ] Review `.env` file — no default secrets
### Security
- [ ] TLS 1.3 enforced (Traefik or reverse proxy)
- [ ] HSTS header configured
- [ ] Firewall rules: only 80/443 open
- [ ] Fail2ban or rate limiting on SSH
- [ ] Automatic security updates enabled
- [ ] Backup encryption key stored securely
### Monitoring
- [ ] Health check endpoint accessible
- [ ] Log rotation configured
- [ ] Disk space alerting (>80%)
- [ ] Memory usage alerting
- [ ] Uptime monitoring (external)
## Backup Procedures
### Automated Backup
Start the application and verify the local container health endpoint:
```bash
#!/bin/bash
# backup.sh
BACKUP_DIR="/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/md-hub-$TIMESTAMP.tar.gz"
# Create backup
tar czf "$BACKUP_FILE" \
-C /data \
db.sqlite \
files/
# Encrypt (optional)
gpg --symmetric --cipher-algo AES256 "$BACKUP_FILE"
rm "$BACKUP_FILE"
# Keep only last 30 days
find "$BACKUP_DIR" -name "md-hub-*.tar.gz.gpg" -mtime +30 -delete
docker compose up -d --build
docker compose ps
curl http://127.0.0.1:8080/health
```
**Cron**: `0 2 * * * /opt/cairnquire/backup.sh`
Compose starts Mailpit for local SMTP capture. Open `http://127.0.0.1:8025`
to inspect password-reset and notification messages. SMTP is also exposed on
`127.0.0.1:1025` for host-run development servers.
### Restore from Backup
Then open `https://cairnquire.example.com/setup`. On an empty database the
first-run wizard:
1. Creates the initial administrator account.
2. Sets its password.
3. Records whether visitors may create their own accounts.
4. Signs the administrator in.
The setup endpoint is only available until the first administrator has been
created. The signup setting can be changed later from the administrator's
account page.
## Environment Variables
The checked-in Compose file sets the container paths. For a normal single-host
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_SMTP_HOST` | No | `mailpit` | SMTP server used for notifications. |
| `CAIRNQUIRE_EMAIL_SMTP_PORT` | No | `1025` | SMTP server port. |
| `CAIRNQUIRE_EMAIL_FROM` | No | `notifications@cairnquire.local` | Notification sender address. |
| `CAIRNQUIRE_LOG_LEVEL` | No | `INFO` | Application log level. |
The server also accepts these lower-level overrides when it is run outside the
checked-in Compose deployment:
| Variable | Default | Description |
|----------|---------|-------------|
| `CAIRNQUIRE_SERVER_ADDR` | `:8080` | Listen address. |
| `CAIRNQUIRE_DATABASE_PATH` | `../../data/db.sqlite` | Local SQLite/libsql database path. |
| `CAIRNQUIRE_DATABASE_PRIMARY_URL` | Empty | Optional remote libsql primary URL for embedded-replica mode. |
| `CAIRNQUIRE_DATABASE_AUTH_TOKEN` | Empty | Optional auth token for the remote libsql primary. |
| `CAIRNQUIRE_CONTENT_SOURCE_DIR` | `../../content` | Writable Markdown source directory. |
| `CAIRNQUIRE_CONTENT_STORE_DIR` | `../../data/files` | Content-addressed attachment store. |
| `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. |
## Persistent Data
Compose mounts:
- `./data:/workspace/data` for the database and attachments
- `./content:/workspace/content` for editable Markdown source
Both mounts must remain writable because uploads, browser edits, and sync
writebacks modify them.
## Reverse Proxy
Expose container port `8080` through a TLS-terminating reverse proxy. Preserve
the original hostname and forwarded client address headers. The configured
`CAIRNQUIRE_PUBLIC_ORIGIN` must exactly match the external origin, including
`https://`.
## Backup And Restore
Stop writes or stop the app container before copying a fully consistent local
database backup:
```bash
# Stop application
docker-compose stop app
# Extract backup
tar xzf md-hub-20240115_020000.tar.gz -C /data
# Restart
docker-compose up -d app
# Verify
curl https://your-domain.com/health
docker compose stop app
tar czf cairnquire-backup.tgz data content
docker compose up -d app
```
## Upgrade Procedures
Restore by stopping the app, replacing `data` and `content` from the archive,
and starting the app again.
## Upgrade
### Minor Update (patch version)
```bash
docker-compose pull
docker-compose up -d
docker compose stop app
tar czf cairnquire-backup-before-upgrade.tgz data content
git pull
docker compose up -d --build
curl http://127.0.0.1:8080/health
```
### Major Update
1. Read release notes for breaking changes
2. Create backup: `./backup.sh`
3. Pull new image: `docker-compose pull`
4. Run database migrations (auto on startup)
5. Update configuration if needed
6. Deploy: `docker-compose up -d`
7. Verify health and functionality
8. Rollback if issues: restore from backup
Database migrations run automatically on startup.
## Troubleshooting
### Application Won't Start
```bash
# Check logs
docker-compose logs -f app
# Verify configuration
docker-compose exec app /server -check-config
# Test database
docker-compose exec app sqlite3 /data/db.sqlite ".tables"
docker compose logs -f app
docker compose ps
curl http://127.0.0.1:8080/health
```
### Sync Not Working
```bash
# Check file watcher
docker-compose exec app ls -la /notes
# Verify WebSocket
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
-H "Sec-WebSocket-Version: 13" \
https://your-domain.com/ws
```
### Email Not Sending
```bash
# Test Postmark connection
curl -X POST \
https://api.postmarkapp.com/server \
-H "X-Postmark-Server-Token: YOUR_TOKEN" \
-H "Accept: application/json"
# Check email queue
docker-compose exec app sqlite3 /data/db.sqlite \
"SELECT * FROM email_queue WHERE status='failed'"
```
## Scaling
### Vertical Scaling
- Increase container memory/CPU limits
- SQLite WAL mode handles concurrent reads well
- For heavy write loads, consider libsql replication
### Horizontal Scaling (Advanced)
```yaml
# Requires shared storage and load balancer
services:
app1:
# ... same config ...
environment:
- DB_PATH=/shared/db.sqlite
- FILESTORE_PATH=/shared/files
app2:
# ... same config ...
environment:
- DB_PATH=/shared/db.sqlite
- FILESTORE_PATH=/shared/files
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
ports:
- "80:80"
```
**Note**: Horizontal scaling requires:
- Shared filesystem (NFS, EFS)
- libsql replication or external database
- Sticky sessions for WebSocket
## Support
- **Documentation**: `/docs` route (when authenticated)
- **Health Check**: `GET /health`
- **Metrics**: `GET /metrics` (Prometheus format)
- **Logs**: `docker-compose logs -f app`
- **Issues**: https://github.com/your-org/cairnquire/issues
If passkey creation fails after deployment, confirm that the browser URL and
`CAIRNQUIRE_PUBLIC_ORIGIN` are the same HTTPS origin.

View File

@@ -408,7 +408,6 @@ RUN addgroup -g 1000 appgroup && \
# Read-only root filesystem
COPY --from=builder /app/server /server
COPY --from=builder /app/web/dist /web
# Data directory (writable)
RUN mkdir /data && chown appuser:appgroup /data

View File

@@ -192,6 +192,7 @@ Response:
"type": "update",
"path": "guide.md",
"hash": "client-hash",
"content": "# Guide\n\nClient edit\n",
"size": 58,
"modified": "2026-05-14T12:05:00Z"
}
@@ -212,6 +213,7 @@ Response:
"modified": "2026-05-14T12:03:00Z"
}
],
"newSnapshotId": "snap:device:new-timestamp",
"conflicts": [
{
"path": "guide.md",
@@ -225,6 +227,11 @@ Response:
}
```
For create/update deltas, native clients should send inline UTF-8 markdown in
`content`. The server stores the bytes content-addressably, verifies any claimed
`hash`, writes the markdown into the content tree, and returns a fresh
`newSnapshotId` for the client's next sync request.
### Resolve
`POST /api/sync/resolve`

View File

@@ -112,6 +112,7 @@ Upload client changes and receive server changes + conflicts.
"type": "update",
"path": "hello.md",
"hash": "d4e5f6...",
"content": "# Hello\n\nUpdated content\n",
"size": 50,
"modified": "2024-01-15T10:05:00Z"
}
@@ -131,6 +132,7 @@ Upload client changes and receive server changes + conflicts.
"modified": "2024-01-15T10:02:00Z"
}
],
"newSnapshotId": "snap:macbook-pro-1:1234567999",
"conflicts": [
{
"path": "hello.md",
@@ -144,6 +146,10 @@ Upload client changes and receive server changes + conflicts.
}
```
For client create/update changes, the server accepts inline UTF-8 markdown in
`content` and recomputes the SHA-256 hash before writing the file. If `content`
is omitted, the supplied `hash` must already exist in the server content store.
### POST /api/sync/resolve
Resolve conflicts and finalize the sync.

View File

@@ -37,7 +37,7 @@ Extend the existing web application so it works offline, supports concurrent edi
### Current State (Already Built)
- Server: Go HTTP + SQLite + file watcher + WebSocket hub
- Browser: Preact + IndexedDB cache + `navigator.onLine` detection
- Browser: Go-served HTML + embedded scripts + IndexedDB cache + `navigator.onLine` detection
- Documents served at `/:path*` with `index.md` default
- Real-time updates pushed via WebSocket when files change on disk

View File

@@ -1,10 +1,3 @@
FROM node:24-bookworm-slim AS web-build
WORKDIR /workspace/apps/web
COPY apps/web/package.json apps/web/package-lock.json* ./
RUN npm install
COPY apps/web/ ./
RUN npm run build
FROM golang:1.24-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/*
@@ -16,20 +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 --from=web-build /workspace/apps/web/dist /workspace/web-dist
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
ENV CAIRNQUIRE_WEB_DIST_DIR=/workspace/web-dist
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/cairnquire"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/usr/local/bin/cairnquire"]

View File

@@ -10,7 +10,7 @@ This repository now contains the Milestone 1 foundation scaffold:
- SQLite/libsql-oriented data layer with startup migrations
- Markdown rendering pipeline with wiki-link rewriting, tag extraction, and SSR templates
- Content-addressed attachment storage with secure serving
- Preact + Vite frontend scaffold for later hydration-focused UI work
- Go-served HTML templates with embedded CSS and browser scripts
- Docker, Compose, CI, OpenAPI, and local development targets
## Quick Start
@@ -21,6 +21,7 @@ This repository now contains the Milestone 1 foundation scaffold:
- Docker (optional)
- CGO-capable toolchain for `go-libsql`
- `air` for Go live reload (`go install github.com/air-verse/air@v1.65.1`)
- Xcode command-line tools for the optional macOS sync app and CLI
Install the pinned toolchain with:
@@ -30,39 +31,64 @@ mise install
### Development Mode (with live reload)
Run both the Go server and Vite dev server with live reload:
Run the Go server with live reload:
```bash
mise run dev
```
This starts:
- Go server with `air` live reload on http://localhost:8080
- Vite dev server with HMR on http://localhost:5173
- The Go server proxies `/app/*` requests to Vite, so access the app at http://localhost:8080/app/
You can also run them separately in two terminals:
```bash
# Terminal 1 - Go server with live reload
mise run dev-server
# Terminal 2 - Vite dev server
mise run dev-web
```
This starts the Go server with `air` live reload on `http://localhost:8080`.
### Production Mode
Build the web app and run the Go server without Vite proxy:
Run the Go server:
```bash
mise run web-install
mise run web-build
mise run server-run
```
The server listens on `http://localhost:8080` by default.
### macOS Sync and CLI
The menu bar sync app and companion CLI live in `apps/macos-sync`. For local
development, start the server with its local-only auth shortcut:
```bash
PORT=9000 CAIRNQUIRE_DEV_MODE=1 mise run server-run
cd apps/macos-sync
swift run cairnquire-cli config \
--server http://localhost:9000 \
--token dev \
--folder "$HOME/Documents/Cairnquire"
swift run cairnquire-cli sync
swift run CairnquireSync
```
The app automatically uses the `dev` token for localhost servers. For a
deployed server, create an API token from `/account/tokens/new` and store that token in
the app settings. See [`apps/macos-sync/README.md`](apps/macos-sync/README.md)
for upload, sync, and Keychain details.
### Fresh Server Deployment
The Compose deployment keeps its database and attachment store in `./data` and
its editable Markdown source in `./content`.
```bash
cp .env.example .env
# Edit CAIRNQUIRE_PUBLIC_ORIGIN in .env to match your HTTPS URL.
docker compose up -d --build
curl http://127.0.0.1:8080/health
```
Open `${CAIRNQUIRE_PUBLIC_ORIGIN}/setup` in a browser. The first-run wizard
creates the admin account and asks whether visitors may create accounts. The
setup endpoint closes after the first admin is created. An admin can change the
signup setting later from the account page. Compose also starts Mailpit for
local email testing; inspect outgoing messages at `http://127.0.0.1:8025`.
### Common commands
```bash
@@ -82,18 +108,27 @@ Configuration can come from:
Supported variables:
- `CAIRNQUIRE_SERVER_ADDR`
- `CAIRNQUIRE_DATABASE_PATH`
- `CAIRNQUIRE_DATABASE_PRIMARY_URL`
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN`
- `CAIRNQUIRE_CONTENT_SOURCE_DIR`
- `CAIRNQUIRE_CONTENT_STORE_DIR`
- `CAIRNQUIRE_WEB_DIST_DIR`
- `CAIRNQUIRE_DEV_VITE_URL` - Set to proxy `/app/*` to Vite dev server (e.g. `http://localhost:5173`)
- `CAIRNQUIRE_LOG_LEVEL`
- `CAIRNQUIRE_PUBLIC_ORIGIN` - Required for deployment. Exact public HTTPS origin used for passkeys and device-flow URLs, for example `https://cairnquire.example.com`.
- `CAIRNQUIRE_SERVER_ADDR` - Listen address. Defaults to `:8080`.
- `PORT` - Optional shorthand for the listen port, for example `PORT=9000`.
- `CAIRNQUIRE_DATABASE_PATH` - Local SQLite/libsql path. Defaults to `../../data/db.sqlite` when running from `apps/server`.
- `CAIRNQUIRE_DATABASE_PRIMARY_URL` - Optional remote libsql primary URL for an embedded replica.
- `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_SMTP_PORT` - SMTP port. Defaults to `1025`.
- `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.
## Dev Mode
With `CAIRNQUIRE_DEV_MODE=1`, API requests using `Authorization: Bearer dev`
authenticate as the local development admin. This is intended only for
localhost testing.
## Noted Gaps
- The docs describe both a single-binary system and a separate Vite/Preact app. This implementation keeps the Go server as the primary runtime and treats the web app as compiled static assets.
- The docs call for `golang-migrate`, but the current foundation uses an internal migration runner while `go-libsql` integration details are validated.
- The sync docs describe JSON messages while the Milestone 1 layout mentions a protobuf package; both are preserved in `packages/protocol/` pending a later protocol lock.

View File

@@ -0,0 +1,34 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "macos-sync",
platforms: [.macOS(.v13)],
products: [
.executable(
name: "CairnquireSync",
targets: ["CairnquireSyncApp"]
),
.executable(
name: "cairnquire-cli",
targets: ["cairnquire-cli"]
),
],
dependencies: [],
targets: [
.target(
name: "CairnquireSync",
dependencies: []
),
.executableTarget(
name: "CairnquireSyncApp",
dependencies: ["CairnquireSync"],
path: "Sources/CairnquireSyncApp"
),
.executableTarget(
name: "cairnquire-cli",
dependencies: ["CairnquireSync"],
path: "Sources/cairnquire-cli"
),
]
)

224
apps/macos-sync/README.md Normal file
View File

@@ -0,0 +1,224 @@
# Cairnquire macOS Sync
A macOS menubar application for syncing and uploading files to a Cairnquire server.
## Features
- **Tray Icon**: `tray.circle.fill` as the default menubar icon with upload state animations
- **Drop on Menubar**: Drag files directly onto the menubar icon — it bounces, then uploads
- **Upload Progress**: Icon switches to `tray.circle` (outline) while uploading
- **Menubar Popover**: Click the icon to see a beautiful status panel with quick actions
- **Auto Sync**: Watch a local folder and automatically sync markdown files to the server
- **Drag & Drop Upload**: Drop files onto the upload zone to upload and copy the URL to clipboard
- **Quick Upload**: Upload files via the menubar menu or popover
- **New Notes**: Create notes from clipboard content or a terminal `$EDITOR` buffer
- **Upload Index**: View all uploaded files with timestamps
- **Custom Icons**: Choose from 11 different SF Symbols for the menubar icon
- **CLI Tool**: Command-line interface for one-off uploads and note creation
## Requirements
- macOS 13.0+
- Xcode 15.0+ (for building)
- A running Cairnquire server
## Building
### Menubar App
```bash
cd apps/macos-sync
swift build
swift run CairnquireSync
```
For local development with Keychain access, sign the SwiftPM executable with a
stable Apple Development identity and run the signed binary directly:
```bash
cd apps/macos-sync
scripts/sign-dev.sh CairnquireSync
.build/debug/CairnquireSync
```
The script uses the first valid `Apple Development` code-signing identity. To
choose a specific identity, set `CAIRNQUIRE_CODESIGN_IDENTITY` to the certificate
name or SHA-1 hash shown by `security find-identity -v -p codesigning`.
Or open in Xcode:
```bash
open Package.swift
```
### CLI Tool
```bash
cd apps/macos-sync
swift build --product cairnquire-cli
swift run cairnquire-cli --help
```
You can run the built executable directly after building:
```bash
.build/debug/cairnquire-cli help
```
## Setup
1. Start your Cairnquire server
2. Create an API token from the web UI at `/account/tokens/new`
3. Open the app settings (click the menubar icon → Settings)
4. Enter your server URL and API token
5. Optionally configure a sync folder, default upload folder, and New Note behavior
## Usage
### Menubar App
**Left-click** the icon to open a beautiful status popover showing:
- Live sync status with color indicator
- Last sync time
- Quick action buttons (Sync, Upload, Note, Drop Zone)
- Recent uploads preview
- Settings and server links
**Right-click** the icon for the traditional menu with all options.
- **Drop on Icon**: Drag files directly onto the menubar icon — it bounces, then uploads
- **Sync Now**: Manually trigger a sync
- **Upload File**: Select files to upload
- **New Note**: Create a markdown note from clipboard text or open Terminal.app with `$EDITOR`, depending on Settings
- **Drop Zone**: Open a drag-and-drop upload window
- **View Uploads**: See all uploaded files with timestamps
- **Change Icon**: Pick from 11 SF Symbols in Settings (default: `tray.circle.fill`)
### CLI Tool
Configure the shared app and CLI settings first. Use the port where your server is actually listening:
```bash
cd apps/macos-sync
swift run cairnquire-cli config \
--server http://localhost:9000 \
--token dev \
--folder "$HOME/Documents/Cairnquire" \
--upload-folder Inbox \
--new-note editor
```
Run one bidirectional sync:
```bash
swift run cairnquire-cli sync
```
Start the menubar app to keep watching the configured folder when auto-sync is enabled:
```bash
swift run CairnquireSync
```
Other CLI commands:
```bash
# Upload a file
swift run cairnquire-cli upload ~/Documents/report.pdf
# Create a note from clipboard
swift run cairnquire-cli note --clipboard --title="Meeting Notes"
# Create a note from stdin
echo "Hello World" | swift run cairnquire-cli note -
# Create a note from arguments
swift run cairnquire-cli note "This is a note body"
# Show command-specific documentation
swift run cairnquire-cli help upload
swift run cairnquire-cli help sync
```
Readable uploads (`.md`, `.markdown`, `.txt`, `.html`, and `.htm`) are published as rendered documents. If the same inbox filename already exists, the app asks whether to keep both files, replace the existing document, or cancel. If identical attachment bytes were already uploaded, the app asks whether to copy the existing URL or cancel.
When New Note is set to `Open Terminal $EDITOR`, the menubar action opens a
temporary markdown buffer in Terminal.app. The app uploads the saved buffer
after the editor exits. It uses `$EDITOR`, then `$VISUAL`, and falls back to
`vi`. Configure GUI editor launchers to wait for the buffer to close, such as
`EDITOR="code --wait"`.
## Configuration
Configuration is stored in:
- Settings: `~/Library/Preferences/com.cairnquire.sync.plist`
- API Token: macOS Keychain
- Sync state: `~/Library/Application Support/CairnquireSync/`
SwiftPM debug executables are ad-hoc signed. macOS may ask for Keychain access
again after rebuilding because the replacement executable does not have a
stable Developer ID identity. A packaged, consistently signed app avoids that
development-time prompt. Updating settings preserves the existing Keychain
item instead of recreating it.
## Architecture
```
Sources/
├── CairnquireSync/ # Shared library
│ ├── API/
│ │ ├── Client.swift # HTTP client for Cairnquire API
│ │ └── Models.swift # API response models
│ ├── Sync/
│ │ ├── SyncEngine.swift # Bidirectional sync engine
│ │ └── FolderWatcher.swift # FSEvents-based folder watcher
│ ├── Upload/
│ │ └── UploadManager.swift # File upload handling
│ ├── Settings/
│ │ └── Config.swift # Configuration and keychain storage
│ └── UI/
│ ├── MenuBarController.swift # Main controller
│ ├── StatusPopoverView.swift # Menubar popover UI
│ ├── SettingsView.swift # Settings UI
│ ├── DropZoneView.swift # Drag & drop UI
│ └── UploadsListView.swift # Upload index UI
├── CairnquireSyncApp/ # Menubar app
│ ├── App.swift # App entry point
│ └── StatusItemView.swift # Custom status item view with drag & drop
└── cairnquire-cli/ # CLI tool
└── main.swift # CLI entry point
```
## Dev Mode
When running the server locally with `CAIRNQUIRE_DEV_MODE=1`, you can use the hardcoded API key `dev` instead of generating a real API token.
### Server
```bash
CAIRNQUIRE_DEV_MODE=1 mise run server-run
```
Or set in your config:
```json
{
"devMode": true
}
```
### macOS App
The app automatically detects localhost servers and defaults to the `dev` token. No configuration needed when running locally.
### CLI
```bash
cairnquire-cli config --server http://localhost:8080 --token dev
cairnquire-cli upload ~/Documents/report.pdf
```
## API Gaps
See `.project/macos-app-api-gaps.md` for documented API endpoints that need server-side implementation.
Readable uploads (`.md`, `.markdown`, `.txt`, `.html`, and `.htm`) are published as rendered documents. Other supported files remain attachments. The upload index returns the canonical URL for either kind.

View File

@@ -0,0 +1,281 @@
import Foundation
public typealias UploadProgressHandler = @Sendable (Double) -> Void
private final class UploadProgressTaskDelegate: NSObject, URLSessionTaskDelegate {
private let onProgress: UploadProgressHandler
init(onProgress: @escaping UploadProgressHandler) {
self.onProgress = onProgress
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64
) {
guard totalBytesExpectedToSend > 0 else { return }
let progress = min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1)
onProgress(progress)
}
}
public actor APIClient {
public let baseURL: URL
public var apiToken: String?
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
public init(baseURL: URL, apiToken: String? = nil) {
self.baseURL = baseURL
self.apiToken = apiToken
if self.apiToken == nil && Self.isLocalhost(baseURL) {
self.apiToken = "dev"
}
self.session = URLSession(configuration: .default)
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .iso8601
}
private static func isLocalhost(_ url: URL) -> Bool {
guard let host = url.host else { return false }
return host == "localhost" || host == "127.0.0.1"
}
public func setAPIToken(_ token: String?) {
self.apiToken = token
}
private func request(for path: String) -> URLRequest {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
if let token = apiToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
private func decodeResponse<T: Decodable>(data: Data, response: URLResponse) throws -> T {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError(error: "invalid response")
}
if httpResponse.statusCode >= 400 {
if let error = try? decoder.decode(APIError.self, from: data) {
throw error
}
throw APIError(error: "HTTP \(httpResponse.statusCode)")
}
if T.self == EmptyResponse.self {
return EmptyResponse() as! T
}
return try decoder.decode(T.self, from: data)
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await session.data(for: request)
return try decodeResponse(data: data, response: response)
}
private func performUpload<T: Decodable>(
_ request: URLRequest,
body: Data,
onProgress: UploadProgressHandler?
) async throws -> T {
onProgress?(0)
let delegate = onProgress.map { UploadProgressTaskDelegate(onProgress: $0) }
let (data, response) = try await session.upload(for: request, from: body, delegate: delegate)
onProgress?(1)
return try decodeResponse(data: data, response: response)
}
// MARK: - Uploads
public func uploadFile(
url: URL,
folder: String? = nil,
duplicateAction: UploadDuplicateAction? = nil,
onProgress: UploadProgressHandler? = nil
) async throws -> UploadResponse {
guard let token = apiToken else {
throw APIError(error: "no API token configured")
}
let boundary = UUID().uuidString
var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads"))
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
let data = try Data(contentsOf: url)
let filename = url.lastPathComponent
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
if let folder, !folder.isEmpty {
body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!)
body.append(folder.data(using: .utf8)!)
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
}
if let duplicateAction {
body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!)
body.append(duplicateAction.rawValue.data(using: .utf8)!)
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
}
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
return try await performUpload(request, body: body, onProgress: onProgress)
}
public func uploadData(
_ data: Data,
filename: String,
folder: String? = nil,
duplicateAction: UploadDuplicateAction? = nil,
onProgress: UploadProgressHandler? = nil
) async throws -> UploadResponse {
guard let token = apiToken else {
throw APIError(error: "no API token configured")
}
let boundary = UUID().uuidString
var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads"))
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
if let folder, !folder.isEmpty {
body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!)
body.append(folder.data(using: .utf8)!)
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
}
if let duplicateAction {
body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!)
body.append(duplicateAction.rawValue.data(using: .utf8)!)
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
}
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
return try await performUpload(request, body: body, onProgress: onProgress)
}
// MARK: - Attachments
public func listAttachments() async throws -> [AttachmentRecord] {
let request = self.request(for: "api/attachments")
let response: AttachmentsListResponse = try await perform(request)
return response.attachments
}
public func attachmentURL(hash: String) -> URL {
baseURL.appendingPathComponent("attachments/\(hash)")
}
// MARK: - Documents
public func listDocuments() async throws -> [DocumentRecord] {
let request = self.request(for: "api/documents")
let response: DocumentsListResponse = try await perform(request)
return response.documents
}
public func saveDocument(path: String, content: String, baseHash: String? = nil) async throws -> DocumentSaveResponse {
var request = self.request(for: "api/documents/\(path)")
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = DocumentSaveRequest(content: content, baseHash: baseHash)
request.httpBody = try encoder.encode(body)
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 {
var request = self.request(for: "api/sync/init")
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = SyncInitRequest(deviceId: deviceId, rootPath: rootPath)
request.httpBody = try encoder.encode(body)
return try await perform(request)
}
public func syncDelta(snapshotId: String, clientDelta: [Change]) async throws -> DeltaResponse {
var request = self.request(for: "api/sync/delta")
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = DeltaRequest(snapshotId: snapshotId, clientDelta: clientDelta)
request.httpBody = try encoder.encode(body)
return try await perform(request)
}
public func fetchContent(hash: String) async throws -> Data {
let request = self.request(for: "api/content/\(hash)")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError(error: "invalid response")
}
if httpResponse.statusCode >= 400 {
if let error = try? decoder.decode(APIError.self, from: data) {
throw error
}
throw APIError(error: "HTTP \(httpResponse.statusCode)")
}
return data
}
// MARK: - Health
public func healthCheck() async throws -> Bool {
let request = self.request(for: "health")
let (_, response) = try await session.data(for: request)
return (response as? HTTPURLResponse)?.statusCode == 200
}
}
public struct EmptyResponse: Decodable {}

View File

@@ -0,0 +1,195 @@
import Foundation
public struct AttachmentRecord: Codable, Identifiable {
public var id: String { hash }
public let hash: String
public let originalName: String
public let contentType: String
public let sizeBytes: Int
public let createdAt: Date
public let url: String
public let kind: String?
public let documentPath: String?
enum CodingKeys: String, CodingKey {
case hash
case originalName
case contentType
case sizeBytes
case createdAt
case url
case kind
case documentPath
}
}
public struct UploadResponse: Codable {
public let hash: String
public let contentType: String
public let attachmentUrl: String
public let url: String?
public let kind: String?
}
public struct DocumentRecord: Codable, Identifiable {
public let id: String
public let path: String
public let title: String
public let hash: String
public let createdAt: Date?
public let updatedAt: Date?
}
public struct DocumentSaveRequest: Codable {
public let content: String
public let baseHash: String?
}
public struct DocumentSaveResponse: Codable {
public let status: String
public let path: String
public let title: String
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
}
public struct SyncInitResponse: Codable {
public let snapshotId: String
public let serverSnapshot: [FileEntry]
enum CodingKeys: String, CodingKey {
case snapshotId
case serverSnapshot
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
snapshotId = try container.decode(String.self, forKey: .snapshotId)
serverSnapshot = try container.decodeIfPresent([FileEntry].self, forKey: .serverSnapshot) ?? []
}
}
public struct FileEntry: Codable {
public let path: String
public let hash: String
public let size: Int64
public let modified: Date
}
public struct DeltaRequest: Codable {
public let snapshotId: String
public let clientDelta: [Change]
}
public struct DeltaResponse: Codable {
public let serverDelta: [Change]
public let conflicts: [Conflict]
public let newSnapshotId: String?
enum CodingKeys: String, CodingKey {
case serverDelta
case conflicts
case newSnapshotId
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
serverDelta = try container.decodeIfPresent([Change].self, forKey: .serverDelta) ?? []
conflicts = try container.decodeIfPresent([Conflict].self, forKey: .conflicts) ?? []
newSnapshotId = try container.decodeIfPresent(String.self, forKey: .newSnapshotId)
}
}
public struct Change: Codable {
public let type: String
public let path: String
public let oldPath: String?
public let hash: String?
public let content: String?
public let size: Int64?
public let modified: Date?
public init(
type: String,
path: String,
oldPath: String?,
hash: String?,
content: String? = nil,
size: Int64?,
modified: Date?
) {
self.type = type
self.path = path
self.oldPath = oldPath
self.hash = hash
self.content = content
self.size = size
self.modified = modified
}
}
public struct Conflict: Codable {
public let path: String
public let serverHash: String
public let clientHash: String
public let serverModified: Date
public let clientModified: Date
}
public struct APIError: Error, Codable {
public let error: String
public let conflictType: String?
public let existingHash: String?
public let existingPath: String?
public let existingUrl: String?
public init(
error: String,
conflictType: String? = nil,
existingHash: String? = nil,
existingPath: String? = nil,
existingUrl: String? = nil
) {
self.error = error
self.conflictType = conflictType
self.existingHash = existingHash
self.existingPath = existingPath
self.existingUrl = existingUrl
}
public var isUploadConflict: Bool {
conflictType == "document" || conflictType == "attachment"
}
}
extension APIError: LocalizedError {
public var errorDescription: String? { error }
}
public struct AttachmentsListResponse: Codable {
public let attachments: [AttachmentRecord]
}
public struct DocumentsListResponse: Codable {
public let documents: [DocumentRecord]
}
public struct SearchResponse: Codable {
public let results: [SearchResult]
}
public enum UploadDuplicateAction: String {
case overwrite
case rename
case reuse
}

View File

@@ -0,0 +1,164 @@
import Foundation
import Security
public enum NewNoteAction: String, Codable, CaseIterable {
case clipboard
case editor
}
public struct SyncConfig: Codable, Equatable {
public var serverURL: String
public var apiToken: String?
public var syncFolder: String?
public var defaultUploadFolder: String?
public var newNoteAction: NewNoteAction?
public var autoSync: Bool
public var syncInterval: Int // seconds
public var deviceId: String
public var iconName: String // SF Symbol name
public static let iconOptions = [
"tray.circle.fill",
"arrow.triangle.2.circlepath",
"arrow.up.doc",
"doc.badge.arrow.up",
"arrow.up.and.down.and.arrow.left.and.right",
"cloud",
"externaldrive.connected.to.line.below",
"note.text",
"doc.text",
"arrow.clockwise.circle",
"rectangle.stack.badge.plus"
]
public init(
serverURL: String = "http://localhost:8080",
apiToken: String? = nil,
syncFolder: String? = nil,
defaultUploadFolder: String? = nil,
newNoteAction: NewNoteAction? = nil,
autoSync: Bool = true,
syncInterval: Int = 30,
deviceId: String? = nil,
iconName: String = "tray.circle.fill"
) {
self.serverURL = serverURL
self.apiToken = apiToken
self.syncFolder = syncFolder
self.defaultUploadFolder = defaultUploadFolder
self.newNoteAction = newNoteAction
self.autoSync = autoSync
self.syncInterval = syncInterval
self.deviceId = deviceId ?? SyncConfig.generateDeviceId()
self.iconName = iconName
}
private static func generateDeviceId() -> String {
if let existing = UserDefaults.standard.string(forKey: "cairnquire_device_id") {
return existing
}
let id = "mac-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12))"
UserDefaults.standard.set(id, forKey: "cairnquire_device_id")
return id
}
public var serverBaseURL: URL? {
URL(string: serverURL)
}
public var effectiveNewNoteAction: NewNoteAction {
newNoteAction ?? .clipboard
}
}
public actor ConfigStore {
public static let shared = ConfigStore()
private let defaults = UserDefaults.standard
private let keychainService = "com.cairnquire.sync"
private let configKey = "cairnquire_config"
public init() {}
nonisolated public func load() -> SyncConfig {
let defaults = UserDefaults.standard
if let data = defaults.data(forKey: configKey),
let config = try? JSONDecoder().decode(SyncConfig.self, from: data) {
var config = config
// Load token from keychain
if let token = loadTokenFromKeychain() {
config.apiToken = token
}
return config
}
return SyncConfig()
}
public func save(_ config: SyncConfig) {
let defaults = UserDefaults.standard
var configToSave = config
let token = config.apiToken
configToSave.apiToken = nil
if let data = try? JSONEncoder().encode(configToSave) {
defaults.set(data, forKey: configKey)
}
if let token = token {
saveTokenToKeychain(token)
} else {
deleteTokenFromKeychain()
}
}
private nonisolated func saveTokenToKeychain(_ token: String) {
let data = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: "api_token"
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecItemNotFound {
var newItem = query
newItem.merge(attributes) { _, new in new }
SecItemAdd(newItem as CFDictionary, nil)
}
}
private nonisolated func loadTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: "api_token",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
return nil
}
return token
}
private nonisolated func deleteTokenFromKeychain() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: "api_token"
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,103 @@
import Foundation
import CoreServices
public protocol FolderWatcherDelegate: AnyObject {
func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String])
}
public class FolderWatcher {
public weak var delegate: FolderWatcherDelegate?
private var stream: FSEventStreamRef?
private let folderPath: String
private var debounceTimer: Timer?
private var pendingPaths: Set<String> = []
private let queue = DispatchQueue(label: "com.cairnquire.folderwatcher")
public init(folderPath: String) {
self.folderPath = folderPath
}
public func start() {
stop()
let pathsToWatch = [folderPath as CFString]
var context = FSEventStreamContext(
version: 0,
info: Unmanaged.passUnretained(self).toOpaque(),
retain: nil,
release: nil,
copyDescription: nil
)
let callback: FSEventStreamCallback = { (streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIds) in
let watcher = Unmanaged<FolderWatcher>.fromOpaque(clientCallBackInfo!).takeUnretainedValue()
watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
}
stream = FSEventStreamCreate(
kCFAllocatorDefault,
callback,
&context,
pathsToWatch as CFArray,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.5, // latency in seconds
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
)
if let stream = stream {
FSEventStreamSetDispatchQueue(stream, queue)
FSEventStreamStart(stream)
}
}
public func stop() {
if let stream = stream {
FSEventStreamStop(stream)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
self.stream = nil
}
}
private func handleEvents(numEvents: Int, eventPaths: UnsafeMutableRawPointer, eventFlags: UnsafePointer<FSEventStreamEventFlags>) {
guard let paths = Unmanaged<CFArray>.fromOpaque(eventPaths).takeUnretainedValue() as? [String] else { return }
var changedPaths: [String] = []
for i in 0..<numEvents {
let path = paths[i]
let flags = eventFlags[i]
// Filter for relevant file events
if flags & UInt32(kFSEventStreamEventFlagItemCreated) != 0 ||
flags & UInt32(kFSEventStreamEventFlagItemModified) != 0 ||
flags & UInt32(kFSEventStreamEventFlagItemRemoved) != 0 ||
flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 {
// Only track markdown files
if path.hasSuffix(".md") {
changedPaths.append(path)
}
}
}
guard !changedPaths.isEmpty else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.pendingPaths.formUnion(changedPaths)
self.debounceTimer?.invalidate()
self.debounceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in
guard let self = self else { return }
let paths = Array(self.pendingPaths)
self.pendingPaths.removeAll()
self.delegate?.folderWatcher(self, didDetectChanges: paths)
}
}
}
deinit {
stop()
}
}

View File

@@ -0,0 +1,269 @@
import Foundation
public actor SyncEngine {
private let client: APIClient
private let config: SyncConfig
private let localState: LocalSyncState
private var snapshotId: String?
public init(client: APIClient, config: SyncConfig) {
self.client = client
self.config = config
self.localState = LocalSyncState(deviceId: config.deviceId)
}
public func performSync() async throws {
guard let syncFolder = config.syncFolder else {
return
}
let folderURL = URL(fileURLWithPath: syncFolder)
try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)
// 1. Initialize sync
if snapshotId == nil {
let initResponse = try await client.syncInit(deviceId: config.deviceId, rootPath: syncFolder)
self.snapshotId = initResponse.snapshotId
try await applyInitialServerSnapshot(initResponse.serverSnapshot, baseURL: folderURL)
try await localState.updateSnapshot(initResponse.serverSnapshot)
}
guard let currentSnapshotId = snapshotId else { return }
// 2. Compute local delta
let localFiles = try scanLocalFolder(folderURL)
let localDelta = try await localState.computeDelta(localFiles: localFiles)
let outboundDelta = try addContent(to: localDelta, baseURL: folderURL)
// 3. Send local changes and check for server changes
let deltaResponse = try await client.syncDelta(snapshotId: currentSnapshotId, clientDelta: outboundDelta)
// 4. Apply server changes
try await applyServerDelta(deltaResponse.serverDelta, baseURL: folderURL)
// The server returns its current bytes in serverDelta for conflicts.
// Until the native app has a merge UI, use the server copy locally.
self.snapshotId = deltaResponse.newSnapshotId ?? currentSnapshotId
// 5. Update local state after applying inbound changes
try await localState.updateSnapshot(scanLocalFolder(folderURL))
}
private func scanLocalFolder(_ folderURL: URL) throws -> [FileEntry] {
var files: [FileEntry] = []
let fileManager = FileManager.default
guard let enumerator = fileManager.enumerator(
at: folderURL,
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
options: [.skipsHiddenFiles]
) else {
return files
}
for case let fileURL as URL in enumerator {
let relativePath = fileURL.path.replacingOccurrences(of: folderURL.path + "/", with: "")
// Only sync markdown files for now
guard fileURL.pathExtension == "md" else { continue }
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
let modificationDate = attributes[.modificationDate] as? Date ?? Date()
let fileSize = attributes[.size] as? Int64 ?? 0
// Compute hash
let content = try Data(contentsOf: fileURL)
let hash = SHA256.hash(data: content).hexString
files.append(FileEntry(
path: relativePath,
hash: hash,
size: fileSize,
modified: modificationDate
))
}
return files
}
private func applyServerDelta(_ changes: [Change], baseURL: URL) async throws {
for change in changes {
let fileURL = try localFileURL(for: change.path, baseURL: baseURL)
switch change.type {
case "create", "update":
guard let hash = change.hash else {
throw SyncEngineError.missingHash(change.path)
}
try await writeServerContent(hash: hash, to: fileURL)
case "delete":
try? FileManager.default.removeItem(at: fileURL)
case "rename":
if let oldPath = change.oldPath {
let oldURL = try localFileURL(for: oldPath, baseURL: baseURL)
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try? FileManager.default.moveItem(at: oldURL, to: fileURL)
}
default:
break
}
}
}
private func applyInitialServerSnapshot(_ files: [FileEntry], baseURL: URL) async throws {
for file in files {
let fileURL = try localFileURL(for: file.path, baseURL: baseURL)
if !FileManager.default.fileExists(atPath: fileURL.path) {
try await writeServerContent(hash: file.hash, to: fileURL)
}
}
}
private func addContent(to changes: [Change], baseURL: URL) throws -> [Change] {
try changes.map { change in
guard change.type == "create" || change.type == "update" else {
return change
}
let fileURL = try localFileURL(for: change.path, baseURL: baseURL)
let data = try Data(contentsOf: fileURL)
guard let content = String(data: data, encoding: .utf8) else {
throw SyncEngineError.invalidUTF8(change.path)
}
return Change(
type: change.type,
path: change.path,
oldPath: change.oldPath,
hash: change.hash,
content: content,
size: change.size,
modified: change.modified
)
}
}
private func writeServerContent(hash: String, to fileURL: URL) async throws {
let content = try await client.fetchContent(hash: hash)
guard SHA256.hash(data: content).hexString == hash.lowercased() else {
throw SyncEngineError.hashMismatch(fileURL.lastPathComponent)
}
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try content.write(to: fileURL, options: .atomic)
}
private func localFileURL(for path: String, baseURL: URL) throws -> URL {
let root = baseURL.standardizedFileURL
let fileURL = root.appendingPathComponent(path).standardizedFileURL
guard fileURL.path.hasPrefix(root.path + "/"),
fileURL.pathExtension.lowercased() == "md" else {
throw SyncEngineError.invalidPath(path)
}
return fileURL
}
}
public enum SyncEngineError: LocalizedError {
case invalidPath(String)
case invalidUTF8(String)
case missingHash(String)
case hashMismatch(String)
public var errorDescription: String? {
switch self {
case .invalidPath(let path):
return "Invalid sync path: \(path)"
case .invalidUTF8(let path):
return "Sync only supports UTF-8 markdown files: \(path)"
case .missingHash(let path):
return "Server sync response is missing a content hash: \(path)"
case .hashMismatch(let path):
return "Downloaded sync content failed hash verification: \(path)"
}
}
}
// MARK: - SHA256 Helper
import CryptoKit
extension SHA256.Digest {
var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Local Sync State
public actor LocalSyncState {
private let deviceId: String
private var lastSnapshot: [String: FileEntry] = [:] // path -> entry
public init(deviceId: String) {
self.deviceId = deviceId
}
public func updateSnapshot(_ files: [FileEntry]) async throws {
lastSnapshot = Dictionary(uniqueKeysWithValues: files.map { ($0.path, $0) })
try persist()
}
public func computeDelta(localFiles: [FileEntry]) async throws -> [Change] {
var changes: [Change] = []
let localMap = Dictionary(uniqueKeysWithValues: localFiles.map { ($0.path, $0) })
// Check for new or modified files
for file in localFiles {
if let last = lastSnapshot[file.path] {
if last.hash != file.hash {
changes.append(Change(
type: "update",
path: file.path,
oldPath: nil,
hash: file.hash,
size: file.size,
modified: file.modified
))
}
} else {
changes.append(Change(
type: "create",
path: file.path,
oldPath: nil,
hash: file.hash,
size: file.size,
modified: file.modified
))
}
}
// Check for deleted files
for (path, _) in lastSnapshot where localMap[path] == nil {
changes.append(Change(
type: "delete",
path: path,
oldPath: nil,
hash: nil,
size: nil,
modified: nil
))
}
return changes
}
private func persist() throws {
// Persist to local JSON file
let entries = Array(lastSnapshot.values)
let data = try JSONEncoder().encode(entries)
let url = stateFileURL()
try data.write(to: url)
}
private func stateFileURL() -> URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dir = appSupport.appendingPathComponent("CairnquireSync", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("sync_state_\(deviceId).json")
}
}

View File

@@ -0,0 +1,141 @@
import SwiftUI
import UniformTypeIdentifiers
public struct DropZoneView: View {
@State private var isDragging = false
@State private var uploadResult: UploadResult?
@State private var error: String?
@State private var isUploading = false
@State private var uploadProgress: Double = 0
public let uploadManager: UploadManager
public init(uploadManager: UploadManager) {
self.uploadManager = uploadManager
}
public var body: some View {
VStack(spacing: 20) {
Image(systemName: "arrow.up.doc.fill")
.font(.system(size: 48))
.foregroundColor(isDragging ? .blue : .secondary)
Text("Drop files here")
.font(.title2)
if isUploading {
ProgressView(value: uploadProgress)
.frame(width: 180)
Text("\(Int(uploadProgress * 100))%")
.font(.caption)
.foregroundColor(.secondary)
} else if let result = uploadResult {
VStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 32))
.foregroundColor(.green)
Text("Uploaded \(result.filename)")
.font(.headline)
Text("URL copied to clipboard")
.font(.caption)
.foregroundColor(.secondary)
Text(result.url.absoluteString)
.font(.caption2)
.foregroundColor(.blue)
.lineLimit(1)
.truncationMode(.middle)
.padding(.horizontal)
}
} else if let error = error {
VStack(spacing: 8) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 32))
.foregroundColor(.red)
Text("Upload failed")
.font(.headline)
Text(error)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.frame(width: 300, height: 250)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isDragging ? Color.blue.opacity(0.1) : Color.secondary.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(isDragging ? Color.blue : Color.secondary.opacity(0.3), style: StrokeStyle(lineWidth: 2, dash: [8]))
)
)
.onDrop(of: [.fileURL], isTargeted: $isDragging) { providers in
guard let provider = providers.first else { return false }
_ = provider.loadObject(ofClass: URL.self) { url, error in
guard let url = url else { return }
Task {
await MainActor.run {
isUploading = true
uploadProgress = 0
self.error = nil
self.uploadResult = nil
}
do {
let result = try await uploadFileResolvingDuplicates(url, onProgress: { progress in
Task { @MainActor in
uploadProgress = min(max(progress, 0), 1)
}
})
await MainActor.run {
uploadResult = result
isUploading = false
uploadProgress = 0
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
isUploading = false
uploadProgress = 0
}
}
}
}
return true
}
}
private func uploadFileResolvingDuplicates(
_ url: URL,
onProgress: UploadProgressHandler?
) async throws -> UploadResult {
var duplicateAction: UploadDuplicateAction?
while true {
do {
return try await uploadManager.uploadFile(
url,
duplicateAction: duplicateAction,
onProgress: onProgress
)
} catch let error as APIError where error.isUploadConflict {
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
throw UploadCancelledError()
}
duplicateAction = action
}
}
}
}
#Preview {
// This preview won't work without a real UploadManager
Text("DropZoneView Preview")
}

View File

@@ -0,0 +1,547 @@
import SwiftUI
import Combine
public struct ClipboardNotice: Identifiable, Equatable {
public let id = UUID()
public let filename: String
}
@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?
@Published public var syncStatus: String = "Ready"
@Published public var pendingChanges = 0
@Published public var uploadProgress: Double? = nil
@Published public private(set) var recentUploads: [AttachmentRecord] = []
@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>()
private let configStore = ConfigStore.shared
private var apiClient: APIClient
private var syncEngine: SyncEngine?
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?
private var dropZoneWindow: NSWindow?
public override init() {
let config = ConfigStore.shared.load()
self.config = config
self.apiClient = APIClient(baseURL: config.serverBaseURL ?? URL(string: "http://localhost:8080")!, apiToken: config.apiToken)
self.uploadManager = UploadManager(client: apiClient, config: config)
self.syncEngine = SyncEngine(client: apiClient, config: config)
super.init()
Task {
await setupSync()
}
Task {
await refreshRecentUploads()
}
}
public func updateConfig(_ newConfig: SyncConfig) {
config = newConfig
Task {
await configStore.save(newConfig)
if let url = newConfig.serverBaseURL {
await apiClient.setAPIToken(newConfig.apiToken)
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
uploadManager = UploadManager(client: apiClient, config: newConfig)
syncEngine = SyncEngine(client: apiClient, config: newConfig)
resetSearch()
await refreshRecentUploads()
await setupSync()
}
}
}
private func setupSync() async {
// Stop existing watcher
folderWatcher?.stop()
syncTimer?.invalidate()
guard config.autoSync, let syncFolder = config.syncFolder else {
syncStatus = "Auto-sync disabled"
return
}
// Setup folder watcher
let watcher = FolderWatcher(folderPath: syncFolder)
watcher.delegate = self
watcher.start()
self.folderWatcher = watcher
// Setup periodic sync
syncTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(config.syncInterval), repeats: true) { [weak self] _ in
Task { [weak self] in
await self?.performSync()
}
}
// Initial sync
await performSync()
}
public func performSync() async {
guard !isSyncing else { return }
guard config.syncFolder != nil else {
syncStatus = "No sync folder configured"
return
}
isSyncing = true
syncStatus = "Syncing..."
do {
let engine = syncEngine ?? SyncEngine(client: apiClient, config: config)
syncEngine = engine
try await engine.performSync()
lastSyncTime = Date()
syncStatus = "Synced"
} catch {
syncStatus = "Sync failed: \(error.localizedDescription)"
}
isSyncing = false
}
// MARK: - Window Management
public func showSettings() {
if let window = settingsWindow {
present(window)
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "Cairnquire Settings"
window.titlebarAppearsTransparent = true
window.isReleasedWhenClosed = false
window.delegate = self
window.contentView = NSHostingView(rootView: SettingsView(config: config, onSave: { [weak self, weak window] newConfig in
self?.updateConfig(newConfig)
window?.close()
}))
window.center()
settingsWindow = window
present(window)
}
public func showUploads() {
if let window = uploadsWindow {
present(window)
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Uploaded Files"
window.isReleasedWhenClosed = false
window.delegate = self
window.contentView = NSHostingView(rootView: UploadsListView(client: apiClient))
window.center()
uploadsWindow = window
present(window)
}
public func showDropZone() {
if let window = dropZoneWindow {
present(window)
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 250),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "Upload File"
window.isReleasedWhenClosed = false
window.delegate = self
window.contentView = NSHostingView(rootView: DropZoneView(uploadManager: uploadManager))
window.center()
dropZoneWindow = window
present(window)
}
public func windowWillClose(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
if window === settingsWindow {
settingsWindow = nil
} else if window === uploadsWindow {
uploadsWindow = nil
} else if window === dropZoneWindow {
dropZoneWindow = nil
}
}
private func present(_ window: NSWindow) {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
}
public func quickUpload() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
guard panel.runModal() == .OK else { return }
Task {
await uploadURLList(panel.urls)
}
}
public func uploadFiles(_ urls: [URL]) {
Task {
await uploadURLList(urls)
}
}
private func uploadURLList(_ urls: [URL]) async {
guard !urls.isEmpty else { return }
let total = Double(urls.count)
for (index, url) in urls.enumerated() {
let baseProgress = Double(index) / total
let fileWeight = 1 / total
uploadProgress = baseProgress
syncStatus = "Uploading \(url.lastPathComponent)"
let onProgress: UploadProgressHandler = { [weak self] fileProgress in
let combinedProgress = baseProgress + min(max(fileProgress, 0), 1) * fileWeight
Task { @MainActor in
self?.uploadProgress = combinedProgress
}
}
do {
let result = try await uploadFileResolvingDuplicates(url, onProgress: onProgress)
uploadProgress = Double(index + 1) / total
syncStatus = "Uploaded \(result.filename)"
clipboardNotice = ClipboardNotice(filename: result.filename)
await refreshRecentUploads()
} catch {
syncStatus = "Upload failed: \(error.localizedDescription)"
}
}
uploadProgress = nil
}
private func uploadFileResolvingDuplicates(
_ url: URL,
onProgress: UploadProgressHandler?
) async throws -> UploadResult {
var duplicateAction: UploadDuplicateAction?
while true {
do {
return try await uploadManager.uploadFile(
url,
duplicateAction: duplicateAction,
onProgress: onProgress
)
} catch let error as APIError where error.isUploadConflict {
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
throw UploadCancelledError()
}
duplicateAction = action
}
}
}
private func uploadDataResolvingDuplicates(_ data: Data, filename: String) async throws -> UploadResult {
var duplicateAction: UploadDuplicateAction?
while true {
do {
return try await uploadManager.uploadData(
data,
filename: filename,
duplicateAction: duplicateAction
)
} catch let error as APIError where error.isUploadConflict {
guard let action = promptForUploadDuplicate(error, filename: filename) else {
throw UploadCancelledError()
}
duplicateAction = action
}
}
}
public func refreshRecentUploads() async {
guard !isLoadingRecentUploads else { return }
isLoadingRecentUploads = true
defer { isLoadingRecentUploads = false }
do {
recentUploads = Array(try await apiClient.listAttachments().prefix(3))
recentUploadsError = nil
} catch {
recentUploadsError = error.localizedDescription
}
}
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:
createNoteFromClipboard()
case .editor:
createNoteInEditor()
}
}
public func createNoteFromClipboard() {
guard let text = NSPasteboard.general.string(forType: .string) else {
syncStatus = "Clipboard is empty"
return
}
Task {
do {
let result = try await uploadManager.createNote(from: text)
syncStatus = "Created note: \(result.title)"
} catch {
syncStatus = "Failed to create note: \(error.localizedDescription)"
}
}
}
private func createNoteInEditor() {
let draftURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cairnquire-\(UUID().uuidString)")
.appendingPathExtension("md")
let completionURL = draftURL.appendingPathExtension("done")
do {
try Data().write(to: draftURL, options: .atomic)
try launchTerminalEditor(draftURL: draftURL, completionURL: completionURL)
syncStatus = "Waiting for $EDITOR to exit"
} catch {
try? FileManager.default.removeItem(at: draftURL)
syncStatus = "Failed to open $EDITOR: \(error.localizedDescription)"
return
}
Task { [weak self] in
await self?.uploadEditorDraft(draftURL: draftURL, completionURL: completionURL)
}
}
private func uploadEditorDraft(draftURL: URL, completionURL: URL) async {
defer {
try? FileManager.default.removeItem(at: draftURL)
try? FileManager.default.removeItem(at: completionURL)
}
while !FileManager.default.fileExists(atPath: completionURL.path) {
try? await Task.sleep(for: .milliseconds(500))
}
do {
let data = try Data(contentsOf: draftURL)
guard !data.isEmpty else {
syncStatus = "Editor note was empty; nothing uploaded"
return
}
let filename = Self.editorNoteFilename()
let result = try await uploadDataResolvingDuplicates(data, filename: filename)
syncStatus = "Uploaded \(result.filename)"
clipboardNotice = ClipboardNotice(filename: result.filename)
await refreshRecentUploads()
} catch {
syncStatus = "Failed to upload editor note: \(error.localizedDescription)"
}
}
private func launchTerminalEditor(draftURL: URL, completionURL: URL) throws {
let draftPath = Self.shellQuote(draftURL.path)
let completionPath = Self.shellQuote(completionURL.path)
let shellCommand = "trap '/usr/bin/touch \(completionPath)' EXIT; editor=\"${EDITOR:-${VISUAL:-vi}}\"; eval \"$editor \(draftPath)\""
let script = "tell application \"Terminal\" to do script \"\(Self.appleScriptEscape(shellCommand))\""
let process = Process()
let errorPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", "tell application \"Terminal\" to activate", "-e", script]
process.standardError = errorPipe
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
throw NSError(
domain: "CairnquireSync",
code: Int(process.terminationStatus),
userInfo: [NSLocalizedDescriptionKey: message ?? "Terminal could not be opened"]
)
}
}
private static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func appleScriptEscape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}
private static func editorNoteFilename() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
return "Note \(formatter.string(from: Date())).md"
}
}
extension MenuBarController: FolderWatcherDelegate {
public nonisolated func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String]) {
Task { @MainActor in
pendingChanges += paths.count
syncStatus = "\(pendingChanges) pending changes"
await performSync()
pendingChanges = 0
}
}
}

View File

@@ -0,0 +1,262 @@
import SwiftUI
public struct SettingsView: View {
@State public var config: SyncConfig
public var onSave: (SyncConfig) -> Void
@State private var serverURL: String = ""
@State private var apiToken: String = ""
@State private var syncFolder: String = ""
@State private var defaultUploadFolder: String = ""
@State private var newNoteAction: NewNoteAction = .clipboard
@State private var autoSync: Bool = true
@State private var syncInterval: Double = 30
@State private var selectedIcon: String = "tray.circle.fill"
private let labelWidth: CGFloat = 120
public init(config: SyncConfig, onSave: @escaping (SyncConfig) -> Void) {
self.config = config
self.onSave = onSave
}
public var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
serverSection
Divider().padding(.vertical, 12)
syncSection
Divider().padding(.vertical, 12)
uploadsSection
Divider().padding(.vertical, 12)
appearanceSection
}
.padding(20)
}
.scrollIndicators(.hidden)
Divider()
saveButton
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.frame(width: 520, height: 640)
.onAppear {
serverURL = config.serverURL
apiToken = config.apiToken ?? ""
syncFolder = config.syncFolder ?? ""
defaultUploadFolder = config.defaultUploadFolder ?? ""
newNoteAction = config.effectiveNewNoteAction
autoSync = config.autoSync
syncInterval = Double(config.syncInterval)
selectedIcon = config.iconName
}
}
// MARK: - Sections
private var serverSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Server", icon: "server.rack")
formRow(label: "Server URL") {
TextField("", text: $serverURL)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
formRow(label: "API Token") {
SecureField("", text: $apiToken)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
HStack {
Spacer()
.frame(width: labelWidth)
Text("Create an API token from the web app at /account/tokens/new")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.frame(width: 280, alignment: .leading)
}
}
}
private var syncSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Sync", icon: "arrow.triangle.2.circlepath")
formRow(label: "Sync Folder") {
HStack(spacing: 8) {
TextField("", text: $syncFolder)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
Button("Choose...") {
chooseFolder()
}
.controlSize(.small)
}
}
formRow(label: "") {
Toggle("Auto Sync", isOn: $autoSync)
}
if autoSync {
formRow(label: "Interval") {
HStack(spacing: 12) {
Slider(value: $syncInterval, in: 5...300, step: 5)
.frame(width: 160)
Text("\(Int(syncInterval))s")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 40, alignment: .trailing)
}
}
}
}
}
private var uploadsSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Uploads", icon: "arrow.up.doc")
formRow(label: "Upload Folder") {
TextField("e.g. inbox", text: $defaultUploadFolder)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
formRow(label: "New Note") {
Picker("", selection: $newNoteAction) {
Text("From Clipboard").tag(NewNoteAction.clipboard)
Text("Open Terminal $EDITOR").tag(NewNoteAction.editor)
}
.pickerStyle(.menu)
.frame(width: 200)
}
HStack {
Spacer()
.frame(width: labelWidth)
Text("Notes and uploads will be saved to this folder. Editor notes upload after the editor exits.")
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 280, alignment: .leading)
}
}
}
private var appearanceSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Appearance", icon: "paintbrush")
formRow(label: "Menu Icon") {
Picker("", selection: $selectedIcon) {
ForEach(SyncConfig.iconOptions, id: \.self) { icon in
HStack(spacing: 8) {
Image(systemName: icon)
.frame(width: 20, alignment: .center)
Text(iconNameToLabel(icon))
}
.tag(icon)
}
}
.pickerStyle(.menu)
.frame(width: 180)
}
}
}
private var saveButton: some View {
HStack {
Spacer()
Button {
let newConfig = SyncConfig(
serverURL: serverURL,
apiToken: apiToken.isEmpty ? nil : apiToken,
syncFolder: syncFolder.isEmpty ? nil : syncFolder,
defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder,
newNoteAction: newNoteAction,
autoSync: autoSync,
syncInterval: Int(syncInterval),
deviceId: config.deviceId,
iconName: selectedIcon
)
onSave(newConfig)
} label: {
Text("Save")
.frame(width: 80)
}
.keyboardShortcut(.defaultAction)
.controlSize(.large)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity)
}
// MARK: - Helpers
private func sectionHeader(_ title: String, icon: String) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
.foregroundColor(.accentColor)
.font(.system(size: 16, weight: .medium))
Text(title)
.font(.system(size: 16, weight: .semibold))
Spacer()
}
.padding(.bottom, 4)
}
private func formRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text(label)
.font(.system(size: 13))
.foregroundColor(.primary)
.frame(width: labelWidth, alignment: .trailing)
content()
Spacer()
}
}
private func chooseFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "Select Folder"
if panel.runModal() == .OK {
syncFolder = panel.url?.path ?? ""
}
}
private func iconNameToLabel(_ name: String) -> String {
let labels: [String: String] = [
"tray.circle.fill": "Tray (Default)",
"arrow.triangle.2.circlepath": "Sync",
"arrow.up.doc": "Upload",
"doc.badge.arrow.up": "Doc Upload",
"arrow.up.and.down.and.arrow.left.and.right": "Transfer",
"cloud": "Cloud",
"externaldrive.connected.to.line.below": "Server",
"note.text": "Notes",
"doc.text": "Documents",
"arrow.clockwise.circle": "Refresh",
"rectangle.stack.badge.plus": "Stack"
]
return labels[name] ?? name
}
}
#Preview {
SettingsView(config: SyncConfig()) { _ in }
}

View File

@@ -0,0 +1,436 @@
import SwiftUI
public struct StatusPopoverView: View {
@ObservedObject public var controller: MenuBarController
@FocusState private var searchFocused: Bool
public init(controller: MenuBarController) {
self.controller = controller
}
public var body: some View {
VStack(spacing: 0) {
// Header with status
headerSection
Divider()
searchSection
Divider()
// Quick actions
actionsSection
Divider()
// Recent uploads preview (last 3)
recentUploadsSection
Divider()
// Footer
footerSection
}
.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
private var headerSection: some View {
VStack(spacing: 8) {
HStack {
statusIndicator
VStack(alignment: .leading, spacing: 2) {
Text(controller.syncStatus)
.font(.system(size: 13, weight: .medium))
if let lastSync = controller.lastSyncTime {
Text("Last sync: \(timeAgo(lastSync))")
.font(.caption2)
.foregroundColor(.secondary)
} else {
Text("Never synced")
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer()
if controller.isSyncing {
ProgressView()
.scaleEffect(0.6)
}
}
if controller.pendingChanges > 0 {
Text("\(controller.pendingChanges) pending changes")
.font(.caption)
.foregroundColor(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 16)
}
private var statusIndicator: some View {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
.overlay(
Circle()
.stroke(statusColor.opacity(0.3), lineWidth: 2)
.frame(width: 14, height: 14)
)
}
private var statusColor: Color {
if controller.isSyncing { return .blue }
if controller.syncStatus.contains("fail") || controller.syncStatus.contains("error") {
return .red
}
if controller.pendingChanges > 0 { return .orange }
return .green
}
private var actionsSection: some View {
HStack(spacing: 16) {
ActionButton(
icon: "arrow.clockwise",
label: "Sync",
color: .blue
) {
Task {
await controller.performSync()
}
}
ActionButton(
icon: "arrow.up.doc",
label: "Upload",
color: .purple
) {
controller.quickUpload()
}
ActionButton(
icon: "doc.badge.plus",
label: "Note",
color: .green
) {
controller.createNote()
}
ActionButton(
icon: "square.and.arrow.down",
label: "Drop",
color: .orange
) {
controller.showDropZone()
}
}
.padding(.horizontal, 16)
.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) {
HStack {
Text("Recent Uploads")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.secondary)
Spacer()
Button("View All") {
controller.showUploads()
}
.font(.caption)
.buttonStyle(.plain)
.foregroundColor(.accentColor)
}
if controller.isLoadingRecentUploads && controller.recentUploads.isEmpty {
ProgressView()
.scaleEffect(0.6)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
} else if controller.recentUploads.isEmpty {
Text(controller.recentUploadsError == nil ? "No recent uploads" : "Unable to load recent uploads")
.font(.caption2)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
} else {
ForEach(controller.recentUploads) { upload in
RecentUploadRow(upload: upload, baseURL: controller.config.serverBaseURL)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private var footerSection: some View {
HStack(spacing: 12) {
Button {
controller.showSettings()
} label: {
HStack(spacing: 4) {
Image(systemName: "gear")
Text("Settings")
}
.font(.caption)
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
Button {
NSApp.terminate(nil)
} label: {
HStack(spacing: 4) {
Image(systemName: "power")
.font(.caption2)
Text("Quit")
}
.font(.caption)
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
Spacer()
if let url = controller.config.serverBaseURL {
Button {
NSWorkspace.shared.open(url)
} label: {
HStack(spacing: 4) {
Text("Open Server")
Image(systemName: "arrow.up.right.square")
.font(.caption2)
}
.font(.caption)
}
.buttonStyle(.plain)
.foregroundColor(.accentColor)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
// MARK: - Helpers
private func timeAgo(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
private struct RecentUploadRow: View {
let upload: AttachmentRecord
let baseURL: URL?
var body: some View {
Button(action: openUpload) {
HStack(spacing: 8) {
Image(systemName: upload.kind == "document" ? "doc.text" : "paperclip")
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(upload.originalName)
.font(.caption)
.lineLimit(1)
Text(timeAgo(upload.createdAt))
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
}
private func openUpload() {
guard let baseURL,
let url = URL(string: upload.url, relativeTo: baseURL)?.absoluteURL else {
return
}
NSWorkspace.shared.open(url)
}
private func timeAgo(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
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 {
let icon: String
let label: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 20, weight: .medium))
.foregroundColor(color)
.frame(width: 40, height: 40)
.background(color.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
Text(label)
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.primary)
}
}
.buttonStyle(.plain)
}
}
#Preview {
// Preview won't work without a real controller
Text("StatusPopoverView Preview")
}

View File

@@ -0,0 +1,244 @@
import SwiftUI
public struct UploadsListView: View {
@State private var attachments: [AttachmentRecord] = []
@State private var isLoading = false
@State private var error: String?
public let client: APIClient
public init(client: APIClient) {
self.client = client
}
public var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Uploaded Files")
.font(.system(size: 16, weight: .semibold))
Spacer()
Button(action: loadAttachments) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 13))
}
.buttonStyle(.plain)
.disabled(isLoading)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
Divider()
// Content
if isLoading {
Spacer()
ProgressView()
.scaleEffect(0.8)
Spacer()
} else if let error = error {
Spacer()
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32))
.foregroundColor(.orange)
Text("Failed to load uploads")
.font(.headline)
Text(error)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding()
Spacer()
} else if attachments.isEmpty {
Spacer()
VStack(spacing: 12) {
Image(systemName: "tray")
.font(.system(size: 40))
.foregroundColor(.secondary.opacity(0.5))
Text("No uploads yet")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.secondary)
Text("Drop files on the menubar icon to upload")
.font(.caption)
.foregroundColor(.secondary.opacity(0.7))
}
Spacer()
} else {
List(attachments) { attachment in
AttachmentRow(attachment: attachment, client: client)
.listRowSeparator(.visible)
.listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20))
}
.listStyle(.plain)
}
}
.frame(width: 400, height: 500)
.onAppear {
loadAttachments()
}
}
private func loadAttachments() {
isLoading = true
error = nil
Task {
do {
let items = try await client.listAttachments()
await MainActor.run {
attachments = items
isLoading = false
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
isLoading = false
}
}
}
}
}
struct AttachmentRow: View {
let attachment: AttachmentRecord
let client: APIClient
@State private var showingCopied = false
var body: some View {
HStack(spacing: 12) {
// File icon
fileIcon
// Info
VStack(alignment: .leading, spacing: 3) {
Text(attachment.originalName)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
HStack(spacing: 8) {
Text(attachment.contentType)
.font(.caption2)
.foregroundColor(.secondary)
Text("·")
.font(.caption2)
.foregroundColor(.secondary.opacity(0.5))
Text(formatBytes(attachment.sizeBytes))
.font(.caption2)
.foregroundColor(.secondary)
Text("·")
.font(.caption2)
.foregroundColor(.secondary.opacity(0.5))
Text(formatDate(attachment.createdAt))
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer()
// Copy button
Button(action: copyURL) {
Image(systemName: showingCopied ? "checkmark" : "doc.on.doc")
.font(.system(size: 12))
.foregroundColor(showingCopied ? .green : .accentColor)
.frame(width: 28, height: 28)
.background(showingCopied ? Color.green.opacity(0.1) : Color.accentColor.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.contextMenu {
Button("Copy URL") {
copyURL()
}
Button("Open in Browser") {
if let url = URL(string: attachment.url, relativeTo: client.baseURL)?.absoluteURL {
NSWorkspace.shared.open(url)
}
}
}
}
private var fileIcon: some View {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(iconColor.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: iconName)
.font(.system(size: 16))
.foregroundColor(iconColor)
}
}
private var iconName: String {
switch attachment.contentType {
case let type where type.hasPrefix("image/"):
return "photo"
case let type where type.hasPrefix("video/"):
return "film"
case let type where type.hasPrefix("audio/"):
return "music.note"
case "application/pdf":
return "doc.text"
case let type where type.hasPrefix("text/"):
return "doc.plaintext"
default:
return "doc"
}
}
private var iconColor: Color {
switch attachment.contentType {
case let type where type.hasPrefix("image/"):
return .purple
case let type where type.hasPrefix("video/"):
return .red
case let type where type.hasPrefix("audio/"):
return .pink
case "application/pdf":
return .red
case let type where type.hasPrefix("text/"):
return .blue
default:
return .gray
}
}
private func copyURL() {
let url = URL(string: attachment.url, relativeTo: client.baseURL)?.absoluteURL
?? client.baseURL.appendingPathComponent("attachments/\(attachment.hash)")
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url.absoluteString, forType: .string)
showingCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
showingCopied = false
}
}
private func formatBytes(_ bytes: Int) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(bytes))
}
private func formatDate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
#Preview {
Text("UploadsListView Preview")
}

View File

@@ -0,0 +1,141 @@
import Foundation
import AppKit
public actor UploadManager {
private let client: APIClient
private let config: SyncConfig
public init(client: APIClient, config: SyncConfig) {
self.client = client
self.config = config
}
public func uploadFile(
_ url: URL,
duplicateAction: UploadDuplicateAction? = nil,
onProgress: UploadProgressHandler? = nil
) async throws -> UploadResult {
let response = try await client.uploadFile(
url: url,
folder: config.defaultUploadFolder,
duplicateAction: duplicateAction,
onProgress: onProgress
)
let fullURL = resolvedURL(response.url ?? response.attachmentUrl)
// Copy URL to clipboard
await MainActor.run {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(fullURL.absoluteString, forType: .string)
}
return UploadResult(
filename: url.lastPathComponent,
hash: response.hash,
url: fullURL,
copiedToClipboard: true
)
}
public func uploadData(
_ data: Data,
filename: String,
duplicateAction: UploadDuplicateAction? = nil,
onProgress: UploadProgressHandler? = nil
) async throws -> UploadResult {
let response = try await client.uploadData(
data,
filename: filename,
folder: config.defaultUploadFolder,
duplicateAction: duplicateAction,
onProgress: onProgress
)
let fullURL = resolvedURL(response.url ?? response.attachmentUrl)
// Copy URL to clipboard
await MainActor.run {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(fullURL.absoluteString, forType: .string)
}
return UploadResult(
filename: filename,
hash: response.hash,
url: fullURL,
copiedToClipboard: true
)
}
public func createNote(from text: String, title: String? = nil) async throws -> DocumentSaveResponse {
let noteTitle = title ?? generateTitle(from: text)
let folder = config.defaultUploadFolder ?? ""
let path = folder.isEmpty ? "\(sanitizeFilename(noteTitle)).md" : "\(folder)/\(sanitizeFilename(noteTitle)).md"
let content = "# \(noteTitle)\n\n\(text)"
return try await client.saveDocument(path: path, content: content)
}
private func generateTitle(from text: String) -> String {
let lines = text.split(separator: "\n", omittingEmptySubsequences: true)
if let firstLine = lines.first {
let title = String(firstLine).trimmingCharacters(in: .whitespaces)
if title.count > 60 {
return String(title.prefix(60)) + "..."
}
return title
}
return "Note \(ISO8601DateFormatter().string(from: Date()))"
}
private func sanitizeFilename(_ name: String) -> String {
let invalidChars = CharacterSet(charactersIn: ":/\\?%*|\"<>").union(.controlCharacters)
return name.components(separatedBy: invalidChars).joined(separator: "-")
}
private func resolvedURL(_ path: String) -> URL {
URL(string: path, relativeTo: client.baseURL)?.absoluteURL ?? client.baseURL.appendingPathComponent(path)
}
}
public struct UploadResult {
public let filename: String
public let hash: String
public let url: URL
public let copiedToClipboard: Bool
}
public struct UploadCancelledError: LocalizedError {
public init() {}
public var errorDescription: String? { "Upload cancelled" }
}
@MainActor
public func promptForUploadDuplicate(_ error: APIError, filename: String) -> UploadDuplicateAction? {
let alert = NSAlert()
alert.alertStyle = .warning
if error.conflictType == "document" {
alert.messageText = "A document named \(filename) already exists"
alert.informativeText = "Choose whether to replace the inbox document or keep both files."
alert.addButton(withTitle: "Keep Both")
alert.addButton(withTitle: "Replace")
alert.addButton(withTitle: "Cancel")
switch alert.runModal() {
case .alertFirstButtonReturn:
return .rename
case .alertSecondButtonReturn:
return .overwrite
default:
return nil
}
}
alert.messageText = "\(filename) was already uploaded"
alert.informativeText = "The server already has the same file content. Use its existing URL?"
alert.addButton(withTitle: "Copy Existing URL")
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn ? .reuse : nil
}

View File

@@ -0,0 +1,382 @@
import SwiftUI
import Combine
import CairnquireSync
@main
struct CairnquireSyncApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings {
EmptyView()
}
}
}
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem!
var controller: MenuBarController!
var popover: NSPopover!
var contextMenu: NSMenu!
var dropButton: DropStatusButton!
private var clipboardHUDPanel: NSPanel?
private var clipboardHUDDismissWorkItem: DispatchWorkItem?
func applicationDidFinishLaunching(_ notification: Notification) {
controller = MenuBarController()
setupStatusItem()
setupPopover()
setupContextMenu()
// Observe config changes to update icon
controller.$config
.sink { [weak self] config in
self?.updateIcon(config: config)
}
.store(in: &controller.cancellables)
// Observe upload progress to animate icon
controller.$uploadProgress
.sink { [weak self] progress in
self?.updateIconForUpload(progress: progress)
}
.store(in: &controller.cancellables)
controller.$clipboardNotice
.compactMap { $0 }
.sink { [weak self] notice in
self?.showClipboardHUD(notice)
}
.store(in: &controller.cancellables)
// Hide dock icon
NSApp.setActivationPolicy(.accessory)
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
// Create custom drop-aware button
let button = DropStatusButton()
button.imagePosition = .imageOnly
button.target = self
button.action = #selector(statusItemButtonClicked(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
button.appDelegate = self
// Replace the default button with our custom one
if let oldButton = statusItem.button {
oldButton.superview?.addSubview(button)
button.frame = oldButton.frame
button.autoresizingMask = [.width, .height]
oldButton.isHidden = true
}
dropButton = button
updateIcon(config: controller.config)
}
func updateIcon(config: SyncConfig) {
guard controller.uploadProgress == nil else { return }
let iconName = config.iconName
dropButton.image = makeMenuBarIcon(named: iconName, accessibilityDescription: "Cairnquire")
dropButton.setDropTargetActive(false)
}
private func updateIconForUpload(progress: Double?) {
if progress != nil {
// During upload: show tray.circle (outline)
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 }
let originalOrigin = button.frame.origin
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
button.animator().setFrameOrigin(NSPoint(
x: originalOrigin.x,
y: originalOrigin.y - 6
))
} completionHandler: {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.15
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().setFrameOrigin(originalOrigin)
}
}
}
private func showClipboardHUD(_ notice: ClipboardNotice) {
clipboardHUDDismissWorkItem?.cancel()
let panel = clipboardHUDPanel ?? makeClipboardHUDPanel()
panel.contentView = NSHostingView(rootView: ClipboardHUDView(filename: notice.filename))
positionClipboardHUD(panel)
panel.alphaValue = 0
panel.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.15
panel.animator().alphaValue = 1
}
let dismissWorkItem = DispatchWorkItem { [weak self, weak panel] in
guard let self, let panel else { return }
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
panel.animator().alphaValue = 0
} completionHandler: {
panel.orderOut(nil)
}
self.clipboardHUDDismissWorkItem = nil
}
clipboardHUDDismissWorkItem = dismissWorkItem
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: dismissWorkItem)
}
private func makeClipboardHUDPanel() -> NSPanel {
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 280, height: 72),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.level = .floating
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.hidesOnDeactivate = false
panel.collectionBehavior = [.canJoinAllSpaces, .transient, .ignoresCycle]
clipboardHUDPanel = panel
return panel
}
private func positionClipboardHUD(_ panel: NSPanel) {
guard let buttonWindow = dropButton.window else { return }
let buttonFrame = buttonWindow.convertToScreen(dropButton.convert(dropButton.bounds, to: nil))
let panelSize = panel.frame.size
let visibleFrame = buttonWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero
let centeredX = buttonFrame.midX - panelSize.width / 2
let origin = NSPoint(
x: min(max(centeredX, visibleFrame.minX + 8), visibleFrame.maxX - panelSize.width - 8),
y: buttonFrame.minY - panelSize.height - 8
)
panel.setFrameOrigin(origin)
}
private func setupPopover() {
popover = NSPopover()
popover.behavior = .transient
popover.contentViewController = NSHostingController(
rootView: StatusPopoverView(controller: controller)
)
}
private func setupContextMenu() {
contextMenu = NSMenu()
contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s"))
contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u"))
contextMenu.addItem(NSMenuItem(title: "New Note", action: #selector(createNote), keyEquivalent: "n"))
contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d"))
contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v"))
contextMenu.addItem(NSMenuItem.separator())
contextMenu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
contextMenu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
}
@objc private func statusItemButtonClicked(_ sender: AnyObject?) {
guard let event = NSApp.currentEvent else { return }
switch event.type {
case .rightMouseUp:
statusItem.menu = contextMenu
statusItem.button?.performClick(nil)
statusItem.menu = nil
default:
togglePopover()
}
}
private func togglePopover() {
if popover.isShown {
closePopover()
} else {
showPopover()
}
}
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
guard let self = self, self.popover.isShown else { return }
self.closePopover()
}
}
private func closePopover() {
popover.performClose(nil)
}
// MARK: - Actions
@objc private func syncNow() {
Task { @MainActor in
await controller.performSync()
}
}
@objc private func uploadFile() {
Task { @MainActor in
controller.quickUpload()
}
}
@objc private func createNote() {
Task { @MainActor in
controller.createNote()
}
}
@objc private func showDropZone() {
Task { @MainActor in
controller.showDropZone()
}
}
@objc private func showUploads() {
Task { @MainActor in
controller.showUploads()
}
}
@objc private func showSettings() {
Task { @MainActor in
controller.showSettings()
}
}
@objc private func quit() {
NSApp.terminate(nil)
}
}
private struct ClipboardHUDView: View {
let filename: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 26))
.foregroundColor(.green)
VStack(alignment: .leading, spacing: 3) {
Text("URL Copied to Clipboard")
.font(.system(size: 13, weight: .semibold))
Text(filename)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
}
.padding(.horizontal, 16)
.frame(width: 280, height: 72)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Drop Status Button
class DropStatusButton: NSButton {
weak var appDelegate: AppDelegate?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
isBordered = false
bezelStyle = .regularSquare
imageScaling = .scaleProportionallyDown
registerForDraggedTypes([.fileURL])
}
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?) {
setDropTargetActive(false)
if let appDelegate = appDelegate {
appDelegate.updateIcon(config: appDelegate.controller.config)
}
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
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
appDelegate?.controller.uploadFiles(urls)
}
return true
}
}

View File

@@ -0,0 +1,406 @@
import Foundation
import Darwin
import CairnquireSync
// MARK: - CLI
enum CLIError: Error {
case invalidArguments
case missingServerURL
case missingToken
case fileNotFound
}
final class TerminalProgress: @unchecked Sendable {
private let lock = NSLock()
private var lastPercent = -1
func update(_ progress: Double) {
let percent = min(max(Int(progress * 100), 0), 100)
lock.lock()
defer { lock.unlock() }
guard percent != lastPercent else { return }
lastPercent = percent
print("\rUploading... \(percent)%", terminator: "")
fflush(stdout)
}
func finishLine() {
print("")
}
}
func readClipboard() -> String? {
let task = Process()
task.launchPath = "/usr/bin/pbpaste"
let pipe = Pipe()
task.standardOutput = pipe
do {
try task.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8)
} catch {
return nil
}
}
struct CLI {
static func run() async {
let args = Array(CommandLine.arguments.dropFirst())
do {
try await execute(args: args)
} catch {
print("Error: \(error.localizedDescription)")
exit(1)
}
}
static func execute(args: [String]) async throws {
guard let command = args.first else {
printUsage()
return
}
switch command {
case "upload", "up":
try await uploadCommand(args: Array(args.dropFirst()))
case "note", "n":
try await noteCommand(args: Array(args.dropFirst()))
case "sync":
try await syncCommand(args: Array(args.dropFirst()))
case "config":
try await configCommand(args: Array(args.dropFirst()))
case "help", "-h", "--help":
printHelp(for: args.dropFirst().first)
default:
print("Unknown command: \(command)")
printUsage()
}
}
// MARK: - Commands
static func uploadCommand(args: [String]) async throws {
if args.contains("--help") || args.contains("-h") {
printUploadUsage()
return
}
guard let filePath = args.first else {
printUploadUsage()
throw CLIError.invalidArguments
}
let config = ConfigStore.shared.load()
guard let token = config.apiToken else {
print("No API token configured. Run: cairnquire-cli config --token <token>")
throw CLIError.missingToken
}
guard let baseURL = config.serverBaseURL else {
print("No server URL configured. Run: cairnquire-cli config --server <url>")
throw CLIError.missingServerURL
}
let fileURL = URL(fileURLWithPath: filePath)
guard FileManager.default.fileExists(atPath: filePath) else {
print("File not found: \(filePath)")
throw CLIError.fileNotFound
}
let client = APIClient(baseURL: baseURL, apiToken: token)
let manager = UploadManager(client: client, config: config)
print("Uploading \(fileURL.lastPathComponent)...")
let progress = TerminalProgress()
let result = try await uploadFileResolvingDuplicates(manager: manager, fileURL: fileURL, progress: progress)
progress.finishLine()
print("✓ Uploaded successfully")
print(" URL: \(result.url)")
print(" Hash: \(result.hash)")
print(" Copied to clipboard")
}
static func noteCommand(args: [String]) async throws {
if args.contains("--help") || args.contains("-h") {
printNoteUsage()
return
}
let config = ConfigStore.shared.load()
guard let token = config.apiToken else {
print("No API token configured. Run: cairnquire-cli config --token <token>")
throw CLIError.missingToken
}
guard let baseURL = config.serverBaseURL else {
print("No server URL configured. Run: cairnquire-cli config --server <url>")
throw CLIError.missingServerURL
}
let content: String
if args.contains("--clipboard") || args.isEmpty {
guard let clipboard = readClipboard() else {
print("Clipboard is empty")
throw CLIError.invalidArguments
}
content = clipboard
} else {
// Read from stdin or args
if args.first == "-" {
content = String(data: FileHandle.standardInput.availableData, encoding: .utf8) ?? ""
} else {
content = args.joined(separator: " ")
}
}
guard !content.isEmpty else {
print("No content provided")
throw CLIError.invalidArguments
}
let title = args.first(where: { $0.hasPrefix("--title=") })?.replacingOccurrences(of: "--title=", with: "")
let client = APIClient(baseURL: baseURL, apiToken: token)
let manager = UploadManager(client: client, config: config)
print("Creating note...")
let result = try await manager.createNote(from: content, title: title)
print("✓ Note created")
print(" Path: \(result.path)")
print(" Title: \(result.title)")
print(" Hash: \(result.hash)")
}
static func syncCommand(args: [String]) async throws {
if args.contains("--help") || args.contains("-h") {
printSyncUsage()
return
}
let config = ConfigStore.shared.load()
guard let token = config.apiToken else {
print("No API token configured")
throw CLIError.missingToken
}
guard let baseURL = config.serverBaseURL else {
print("No server URL configured")
throw CLIError.missingServerURL
}
guard let syncFolder = config.syncFolder else {
print("No sync folder configured")
throw CLIError.invalidArguments
}
let client = APIClient(baseURL: baseURL, apiToken: token)
let engine = SyncEngine(client: client, config: config)
print("Syncing \(syncFolder)...")
try await engine.performSync()
print("✓ Sync complete")
}
static func configCommand(args: [String]) async throws {
if args.contains("--help") || args.contains("-h") {
printConfigUsage()
return
}
var config = ConfigStore.shared.load()
var i = 0
while i < args.count {
let arg = args[i]
guard i + 1 < args.count else {
print("Missing value for \(arg)")
throw CLIError.invalidArguments
}
let value = args[i + 1]
switch arg {
case "--server":
config.serverURL = value
case "--token":
config.apiToken = value
case "--folder":
config.syncFolder = value
case "--upload-folder":
config.defaultUploadFolder = value
case "--new-note":
guard let action = NewNoteAction(rawValue: value) else {
print("Invalid new note action: \(value). Use clipboard or editor.")
throw CLIError.invalidArguments
}
config.newNoteAction = action
default:
print("Unknown config option: \(arg)")
throw CLIError.invalidArguments
}
i += 2
}
await ConfigStore.shared.save(config)
print("Configuration saved:")
print(" Server: \(config.serverURL)")
print(" Sync Folder: \(config.syncFolder ?? "(none)")")
print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")")
print(" New Note: \(config.effectiveNewNoteAction.rawValue)")
print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")")
}
static func uploadFileResolvingDuplicates(
manager: UploadManager,
fileURL: URL,
progress: TerminalProgress
) async throws -> UploadResult {
var duplicateAction: UploadDuplicateAction?
while true {
do {
return try await manager.uploadFile(
fileURL,
duplicateAction: duplicateAction,
onProgress: { value in progress.update(value) }
)
} catch let error as APIError where error.isUploadConflict {
progress.finishLine()
duplicateAction = try promptForDuplicate(error, filename: fileURL.lastPathComponent)
print("Uploading \(fileURL.lastPathComponent)...")
}
}
}
static func promptForDuplicate(_ error: APIError, filename: String) throws -> UploadDuplicateAction {
if error.conflictType == "document" {
print("A document named \(filename) already exists in the inbox.")
if let existingPath = error.existingPath, !existingPath.isEmpty {
print("Existing document: \(existingPath)")
}
print("[k] Keep both [r] Replace existing [c] Cancel")
print("Choice: ", terminator: "")
fflush(stdout)
switch readLine()?.lowercased() {
case "k", "keep", "keep-both":
return .rename
case "r", "replace", "overwrite":
return .overwrite
default:
throw UploadCancelledError()
}
}
print("\(filename) was already uploaded.")
if let existingURL = error.existingUrl, !existingURL.isEmpty {
print("Existing URL: \(existingURL)")
}
print("[u] Use existing URL [c] Cancel")
print("Choice: ", terminator: "")
fflush(stdout)
switch readLine()?.lowercased() {
case "u", "use", "reuse":
return .reuse
default:
throw UploadCancelledError()
}
}
static func printUsage() {
print("""
Cairnquire CLI - macOS sync and upload tool
USAGE:
cairnquire-cli <command> [options]
COMMANDS:
upload, up <file> Upload a file and copy URL to clipboard
note, n [text] Create a new note from clipboard or arguments
sync Sync the configured folder once
config [options] Configure the shared app and CLI settings
help [command] Show general or command-specific help
EXAMPLES:
cairnquire-cli upload ~/Documents/report.pdf
cairnquire-cli note --clipboard --title="Meeting Notes"
echo "Hello World" | cairnquire-cli note -
cairnquire-cli config --server http://localhost:8080 --token cq_pat_xxx
cairnquire-cli sync
Run 'cairnquire-cli help <command>' for details.
""")
}
static func printHelp(for command: String?) {
switch command {
case "upload", "up":
printUploadUsage()
case "note", "n":
printNoteUsage()
case "sync":
printSyncUsage()
case "config":
printConfigUsage()
default:
printUsage()
}
}
static func printUploadUsage() {
print("""
USAGE:
cairnquire-cli upload <file>
Upload one file and copy its canonical Cairnquire URL to the clipboard.
Readable text files become rendered documents. Other files remain attachments.
If the inbox name or attachment content already exists, the CLI asks what to do.
""")
}
static func printNoteUsage() {
print("""
USAGE:
cairnquire-cli note [--clipboard] [--title="Title"]
cairnquire-cli note <text>
cairnquire-cli note -
Create a markdown note from clipboard content, arguments, or stdin.
""")
}
static func printSyncUsage() {
print("""
USAGE:
cairnquire-cli sync
Run one bidirectional sync for the folder configured with:
cairnquire-cli config --folder <path>
The menubar app also watches this folder when auto-sync is enabled in Settings.
""")
}
static func printConfigUsage() {
print("""
USAGE:
cairnquire-cli config [options]
OPTIONS:
--server <url> Set the Cairnquire server URL
--token <token> Set the API token
--folder <path> Set the local folder used by sync
--upload-folder <path> Set the server folder for readable uploads
--new-note <action> Set New Note behavior: clipboard or editor
The menubar app and CLI use the same saved configuration.
""")
}
}
// MARK: - Entry Point
@main
struct CairnquireCLI {
static func main() async {
await CLI.run()
}
}

View File

@@ -0,0 +1,63 @@
#!/bin/zsh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PACKAGE_DIR"
product="${1:-CairnquireSync}"
case "$product" in
CairnquireSync)
signing_identifier="com.cairnquire.sync"
;;
cairnquire-cli)
signing_identifier="com.cairnquire.cli"
;;
*)
echo "Unsupported product: $product" >&2
echo "Usage: scripts/sign-dev.sh [CairnquireSync|cairnquire-cli]" >&2
exit 2
;;
esac
identity="${CAIRNQUIRE_CODESIGN_IDENTITY:-}"
if [[ -z "$identity" ]]; then
identity="$(
security find-identity -v -p codesigning \
| awk -F '"' '/Apple Development/ { print $2; exit }'
)"
fi
if [[ -z "$identity" ]]; then
echo "No Apple Development signing identity found." >&2
echo "Create one in Xcode, then verify with:" >&2
echo " security find-identity -v -p codesigning" >&2
exit 1
fi
swift build --product "$product"
executable=".build/debug/$product"
if [[ ! -f "$executable" ]]; then
executable="$(find .build -path "*/debug/$product" -type f -perm -111 | head -n 1)"
fi
if [[ -z "$executable" || ! -f "$executable" ]]; then
echo "Built executable not found for product: $product" >&2
exit 1
fi
codesign \
--force \
--sign "$identity" \
--identifier "$signing_identifier" \
--timestamp=none \
"$executable"
echo "Signed $executable"
codesign -dvvv --requirements - "$executable" 2>&1 | sed -n '1,80p'
echo
echo "Run the signed executable directly:"
echo " $executable"
echo
echo "Avoid 'swift run $product' after signing; it can rebuild and replace the signed executable."

BIN
apps/server/cairnquire Executable file

Binary file not shown.

View File

@@ -3,16 +3,25 @@ module github.com/tim/cairnquire/apps/server
go 1.24.2
require (
github.com/fsnotify/fsnotify v1.9.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-webauthn/webauthn v0.15.0
github.com/gorilla/websocket v1.5.3
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.43.0
)
require (
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/sys v0.37.0 // indirect
)

View File

@@ -1,26 +1,54 @@
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc=
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@@ -7,11 +7,15 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"time"
"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"
"github.com/tim/cairnquire/apps/server/internal/email"
"github.com/tim/cairnquire/apps/server/internal/httpserver"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/realtime"
@@ -20,12 +24,13 @@ import (
)
type App struct {
cfg config.Config
logger *slog.Logger
db database.DB
docs *docs.Service
hub *realtime.Hub
server *http.Server
cfg config.Config
logger *slog.Logger
db database.DB
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) {
@@ -51,26 +56,66 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
repo := docs.NewRepository(db.SQL())
service := docs.NewService(cfg.Content.SourceDir, contentStore, renderer, repo, logger)
hub := realtime.NewHub(logger)
service.OnChange(func(change docs.DocumentChange) {
hub.Broadcast(realtime.Event{Type: "document_version", Data: change})
})
if _, err := service.SyncSourceDir(ctx); err != nil {
logger.Warn("initial content sync failed", "error", err)
}
syncRepo := sync.NewRepository(db.SQL())
syncService := sync.NewService(syncRepo, service, contentStore, cfg.Content.SourceDir, logger)
authRepo := auth.NewRepository(db.SQL())
authService, err := auth.NewService(authRepo, cfg.Auth.PublicOrigin)
if err != nil {
return nil, fmt.Errorf("build auth service: %w", err)
}
collabRepo := collaboration.NewRepository(db.SQL())
var emailSender collaboration.EmailSender
var emailQueue *email.Queue
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
switch cfg.Email.ResolvedProvider() {
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)
}
// 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,
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 {
logger.Warn("notify document changed", "error", err)
}
})
handler, err := httpserver.New(httpserver.Dependencies{
Config: cfg,
Logger: logger,
Documents: service,
Repository: repo,
ContentStore: contentStore,
Hub: hub,
SyncService: syncService,
SyncRepo: syncRepo,
Config: cfg,
Logger: logger,
Documents: service,
Repository: repo,
ContentStore: contentStore,
Hub: hub,
SyncService: syncService,
SyncRepo: syncRepo,
Auth: authService,
Collaboration: collabService,
})
if err != nil {
return nil, fmt.Errorf("build http handler: %w", err)
@@ -86,18 +131,22 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
}
return &App{
cfg: cfg,
logger: logger,
db: db,
docs: service,
hub: hub,
server: server,
cfg: cfg,
logger: logger,
db: db,
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()
@@ -151,3 +200,9 @@ func (a *App) syncPoll(ctx context.Context) {
func (a *App) Close() error {
return a.db.Close()
}
func formatDocumentID(path string) string {
clean := strings.TrimPrefix(path, "/")
clean = strings.TrimSuffix(clean, ".md")
return "doc:" + clean
}

View File

@@ -7,15 +7,19 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
const fileWatcherDebounce = 500 * time.Millisecond
type FileWatcher struct {
watcher *fsnotify.Watcher
rootDir string
onChange func(path string)
logger *slog.Logger
debounce time.Duration
}
func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
@@ -25,9 +29,10 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
}
fw := &FileWatcher{
watcher: watcher,
rootDir: rootDir,
logger: logger,
watcher: watcher,
rootDir: rootDir,
logger: logger,
debounce: fileWatcherDebounce,
}
if err := fw.addWatches(); err != nil {
@@ -39,19 +44,150 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
}
func (fw *FileWatcher) addWatches() error {
return filepath.Walk(fw.rootDir, func(path string, info os.FileInfo, err error) error {
return filepath.WalkDir(fw.rootDir, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if err := fw.watcher.Add(path); err != nil {
fw.logger.Warn("watch directory", "path", path, "error", err)
}
if !entry.IsDir() {
return nil
}
if path != fw.rootDir && fw.shouldIgnorePath(path) {
return filepath.SkipDir
}
if err := fw.watcher.Add(path); err != nil {
fw.logger.Warn("watch directory", "path", path, "error", err)
}
return nil
})
}
func (fw *FileWatcher) addWatchIfDirectory(path string) {
if fw.shouldIgnorePath(path) {
return
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return
}
if err := fw.watcher.Add(path); err != nil {
fw.logger.Warn("watch new directory", "path", path, "error", err)
}
}
func (fw *FileWatcher) flushPending(pending map[string]struct{}) {
if len(pending) == 0 || fw.onChange == nil {
return
}
var changedPath string
for path := range pending {
changedPath = path
break
}
clear(pending)
fw.onChange(changedPath)
}
func (fw *FileWatcher) scheduleSync(path string, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
pending[path] = struct{}{}
if *timer == nil {
*timer = time.NewTimer(fw.debounce)
*timerC = (*timer).C
return
}
if !(*timer).Stop() {
select {
case <-(*timer).C:
default:
}
}
(*timer).Reset(fw.debounce)
}
func (fw *FileWatcher) stopTimer(timer *time.Timer) {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
func (fw *FileWatcher) isMarkdownPath(path string) bool {
return strings.EqualFold(filepath.Ext(path), ".md")
}
func (fw *FileWatcher) shouldIgnorePath(path string) bool {
rel, err := filepath.Rel(fw.rootDir, path)
if err != nil {
return true
}
if rel == "." {
return false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return true
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if part == "" {
continue
}
if strings.HasPrefix(part, ".") {
return true
}
if isTempFileName(part) {
return true
}
}
return false
}
func isTempFileName(name string) bool {
if strings.HasSuffix(name, "~") {
return true
}
if strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") {
return true
}
switch strings.ToLower(filepath.Ext(name)) {
case ".swp", ".swo", ".tmp", ".temp", ".bak", ".orig", ".part", ".crdownload":
return true
}
switch name {
case ".DS_Store", "Thumbs.db":
return true
default:
return false
}
}
func (fw *FileWatcher) isProcessableEvent(event fsnotify.Event) bool {
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
return false
}
if fw.shouldIgnorePath(event.Name) {
return false
}
return fw.isMarkdownPath(event.Name)
}
func (fw *FileWatcher) handleEvent(event fsnotify.Event, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
if event.Op&(fsnotify.Create|fsnotify.Rename) != 0 {
fw.addWatchIfDirectory(event.Name)
}
if fw.isProcessableEvent(event) {
fw.scheduleSync(event.Name, pending, timer, timerC)
}
}
func (fw *FileWatcher) SetOnChange(fn func(path string)) {
fw.onChange = fn
}
@@ -59,24 +195,24 @@ func (fw *FileWatcher) SetOnChange(fn func(path string)) {
func (fw *FileWatcher) Run(ctx context.Context) {
defer fw.watcher.Close()
pending := make(map[string]struct{})
var timer *time.Timer
var timerC <-chan time.Time
defer fw.stopTimer(timer)
for {
select {
case <-ctx.Done():
return
case <-timerC:
fw.flushPending(pending)
timer = nil
timerC = nil
case event, ok := <-fw.watcher.Events:
if !ok {
return
}
if fw.shouldProcess(event) {
if event.Op&fsnotify.Create == fsnotify.Create {
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
fw.watcher.Add(event.Name)
}
}
if fw.onChange != nil {
fw.onChange(event.Name)
}
}
fw.handleEvent(event, pending, &timer, &timerC)
case err, ok := <-fw.watcher.Errors:
if !ok {
return
@@ -85,17 +221,3 @@ func (fw *FileWatcher) Run(ctx context.Context) {
}
}
}
func (fw *FileWatcher) shouldProcess(event fsnotify.Event) bool {
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
return false
}
name := filepath.Base(event.Name)
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, "~") {
return false
}
if filepath.Ext(event.Name) == ".md" || event.Op&fsnotify.Remove != 0 {
return true
}
return false
}

View File

@@ -0,0 +1,84 @@
package app
import (
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/fsnotify/fsnotify"
)
func TestFileWatcherFiltersProcessableEvents(t *testing.T) {
t.Parallel()
root := t.TempDir()
watcher := &FileWatcher{rootDir: root, logger: slog.Default()}
tests := []struct {
name string
path string
op fsnotify.Op
want bool
}{
{name: "markdown write", path: "guide.md", op: fsnotify.Write, want: true},
{name: "markdown create", path: "nested/guide.md", op: fsnotify.Create, want: true},
{name: "case insensitive markdown", path: "guide.MD", op: fsnotify.Write, want: true},
{name: "markdown delete", path: "guide.md", op: fsnotify.Remove, want: true},
{name: "non markdown write", path: "logo.webp", op: fsnotify.Write, want: false},
{name: "non markdown delete", path: "logo.webp", op: fsnotify.Remove, want: false},
{name: "hidden markdown file", path: ".guide.md", op: fsnotify.Write, want: false},
{name: "hidden directory", path: ".cache/guide.md", op: fsnotify.Write, want: false},
{name: "backup file", path: "guide.md~", op: fsnotify.Write, want: false},
{name: "vim swap", path: "guide.md.swp", op: fsnotify.Write, want: false},
{name: "emacs lock file", path: "#guide.md#", op: fsnotify.Write, want: false},
{name: "chmod only", path: "guide.md", op: fsnotify.Chmod, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := fsnotify.Event{Name: filepath.Join(root, tt.path), Op: tt.op}
if got := watcher.isProcessableEvent(event); got != tt.want {
t.Fatalf("isProcessableEvent() = %v, want %v", got, tt.want)
}
})
}
}
func TestFileWatcherDebouncesPendingSyncs(t *testing.T) {
t.Parallel()
root := t.TempDir()
watcher := &FileWatcher{
rootDir: root,
logger: slog.Default(),
debounce: 10 * time.Millisecond,
}
var changed []string
watcher.SetOnChange(func(path string) {
changed = append(changed, path)
})
pending := make(map[string]struct{})
var timer *time.Timer
var timerC <-chan time.Time
defer watcher.stopTimer(timer)
watcher.scheduleSync(filepath.Join(root, "one.md"), pending, &timer, &timerC)
watcher.scheduleSync(filepath.Join(root, "two.md"), pending, &timer, &timerC)
select {
case <-timerC:
watcher.flushPending(pending)
case <-time.After(250 * time.Millisecond):
t.Fatal("debounce timer did not fire")
}
if len(changed) != 1 {
t.Fatalf("onChange calls = %d, want 1", len(changed))
}
if len(pending) != 0 {
t.Fatalf("pending changes = %d, want 0", len(pending))
}
}

View File

@@ -0,0 +1,175 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
const (
passwordTime uint32 = 3
passwordMemory uint32 = 64 * 1024
passwordThreads uint8 = 4
passwordKeyLen uint32 = 32
passwordSaltLen = 16
)
func randomBytes(length int) ([]byte, error) {
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return nil, err
}
return buf, nil
}
func randomToken(length int) (string, error) {
buf, err := randomBytes(length)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func randomHex(length int) (string, error) {
buf, err := randomBytes(length)
if err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func constantTimeEqualHex(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func hashPassword(password string) (string, error) {
salt, err := randomBytes(passwordSaltLen)
if err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, passwordTime, passwordMemory, passwordThreads, passwordKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
passwordMemory,
passwordTime,
passwordThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
func verifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, fmt.Errorf("invalid password hash")
}
var memory uint64
var timeCost uint64
var threads uint64
for _, param := range strings.Split(parts[3], ",") {
keyValue := strings.SplitN(param, "=", 2)
if len(keyValue) != 2 {
return false, fmt.Errorf("invalid password hash parameters")
}
value, err := strconv.ParseUint(keyValue[1], 10, 32)
if err != nil {
return false, fmt.Errorf("parse password hash parameter: %w", err)
}
switch keyValue[0] {
case "m":
memory = value
case "t":
timeCost = value
case "p":
threads = value
}
}
if memory == 0 || timeCost == 0 || threads == 0 {
return false, fmt.Errorf("missing password hash parameters")
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("decode password salt: %w", err)
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, fmt.Errorf("decode password hash: %w", err)
}
actual := argon2.IDKey([]byte(password), salt, uint32(timeCost), uint32(memory), uint8(threads), uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1, nil
}
func normalizeScopes(scopes []Scope) []Scope {
if len(scopes) == 0 {
return []Scope{ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite}
}
seen := make(map[Scope]struct{}, len(scopes))
var normalized []Scope
for _, scope := range scopes {
switch scope {
case ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite, ScopeAdmin:
if _, ok := seen[scope]; !ok {
seen[scope] = struct{}{}
normalized = append(normalized, scope)
}
}
}
if len(normalized) == 0 {
return []Scope{ScopeDocsRead}
}
return normalized
}
func scopesToString(scopes []Scope) string {
normalized := normalizeScopes(scopes)
parts := make([]string, 0, len(normalized))
for _, scope := range normalized {
parts = append(parts, string(scope))
}
return strings.Join(parts, ",")
}
func scopesFromString(raw string) []Scope {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
scopes := make([]Scope, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
scopes = append(scopes, Scope(trimmed))
}
}
return normalizeScopes(scopes)
}
func hasScope(scopes []Scope, required Scope) bool {
for _, scope := range scopes {
if scope == ScopeAdmin || scope == required {
return true
}
}
return false
}
func HasScope(scopes []Scope, required Scope) bool {
return hasScope(scopes, required)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,884 @@
package auth
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const (
SessionCookieName = "cairnquire_session"
sessionTTL = 24 * time.Hour
challengeTTL = 5 * time.Minute
deviceCodeTTL = 15 * time.Minute
passwordResetTTL = time.Hour
devicePollSeconds = 5
)
type EmailSender interface {
Send(ctx context.Context, to []string, subject, body string) error
}
type Service struct {
repo *Repository
webauthn *webauthn.WebAuthn
publicOrigin string
email EmailSender
}
func NewService(repo *Repository, publicOrigin string) (*Service, error) {
if publicOrigin == "" {
publicOrigin = "http://localhost:8080"
}
parsed, err := url.Parse(publicOrigin)
if err != nil {
return nil, fmt.Errorf("parse public origin: %w", err)
}
rpID := parsed.Hostname()
if rpID == "" {
return nil, fmt.Errorf("public origin must include host")
}
web, err := webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: "Cairnquire",
RPOrigins: []string{publicOrigin},
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementPreferred,
UserVerification: protocol.VerificationPreferred,
},
AttestationPreference: protocol.PreferNoAttestation,
})
if err != nil {
return nil, err
}
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
}
func (s *Service) SetEmailSender(sender EmailSender) {
s.email = sender
}
func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
return s.repo.GetInstanceSettings(ctx)
}
func (s *Service) CompleteInitialSetup(ctx context.Context, email, displayName, password string, signupsEnabled bool) (User, error) {
email = normalizeEmail(email)
if email == "" {
return User{}, fmt.Errorf("email is required")
}
if len(password) < 12 {
return User{}, fmt.Errorf("password must be at least 12 characters")
}
passwordHash, err := hashPassword(password)
if err != nil {
return User{}, err
}
return s.repo.CompleteInitialSetup(ctx, User{
Email: email,
DisplayName: strings.TrimSpace(displayName),
PasswordHash: passwordHash,
Role: RoleAdmin,
}, signupsEnabled)
}
func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (User, error) {
if err := s.requirePublicSignups(ctx); err != nil {
return User{}, err
}
email = normalizeEmail(email)
if email == "" {
return User{}, fmt.Errorf("email is required")
}
if len(password) < 12 {
return User{}, fmt.Errorf("password must be at least 12 characters")
}
if _, err := s.repo.GetUserByEmail(ctx, email); err == nil {
return User{}, fmt.Errorf("user already exists")
} else if err != nil && !isNoRows(err) {
return User{}, err
}
passwordHash, err := hashPassword(password)
if err != nil {
return User{}, err
}
return s.repo.UpsertUser(ctx, User{
Email: email,
DisplayName: strings.TrimSpace(displayName),
PasswordHash: passwordHash,
Role: RoleViewer,
})
}
func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAgent string) (Principal, string, error) {
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil {
if isNoRows(err) {
_, _ = verifyPassword(password, mustDummyPasswordHash())
}
return Principal{}, "", fmt.Errorf("invalid credentials")
}
if user.PasswordHash == "" {
return Principal{}, "", fmt.Errorf("invalid credentials")
}
ok, err := verifyPassword(password, user.PasswordHash)
if err != nil || !ok || user.Disabled {
return Principal{}, "", fmt.Errorf("invalid credentials")
}
principal, token, err := s.createSession(ctx, user, ip, userAgent)
if err == nil {
s.repo.Audit(ctx, user.ID, "auth.password.login", "user", user.ID, ip, userAgent, nil)
}
return principal, token, err
}
func (s *Service) BeginPasskeyRegistration(ctx context.Context, email, displayName, role string) (any, string, error) {
if err := s.requirePublicSignups(ctx); err != nil {
return nil, "", err
}
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil {
if !isNoRows(err) {
return nil, "", err
}
user, err = s.repo.UpsertUser(ctx, User{
Email: normalizeEmail(email),
DisplayName: strings.TrimSpace(displayName),
Role: RoleViewer,
})
if err != nil {
return nil, "", err
}
} else {
return nil, "", fmt.Errorf("user already exists")
}
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) FinishPasskeyRegistration(ctx context.Context, challengeID string, r *http.Request) (User, error) {
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
if err != nil {
return User{}, err
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return User{}, err
}
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.register", "user", user.ID, clientIP(r), r.UserAgent(), nil)
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 {
return nil, "", err
}
if user.Disabled {
return nil, "", fmt.Errorf("invalid credentials")
}
assertion, session, err := s.webauthn.BeginLogin(user)
if err != nil {
return nil, "", err
}
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_login", *session, challengeTTL)
if err != nil {
return nil, "", err
}
return assertion, challengeID, nil
}
func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r *http.Request) (Principal, string, error) {
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_login")
if err != nil {
return Principal{}, "", err
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return Principal{}, "", err
}
if user.Disabled {
return Principal{}, "", fmt.Errorf("invalid credentials")
}
credential, err := s.webauthn.FinishLogin(user, session, r)
if err != nil {
return Principal{}, "", err
}
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
return Principal{}, "", err
}
principal, token, err := s.createSession(ctx, user, clientIP(r), r.UserAgent())
if err == nil {
s.repo.Audit(ctx, user.ID, "auth.passkey.login", "user", user.ID, clientIP(r), r.UserAgent(), nil)
}
return principal, token, err
}
func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Principal, error) {
session, user, err := s.repo.ValidateSession(ctx, hashSecret(token))
if err != nil {
return Principal{}, err
}
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil
}
func (s *Service) RevokeSession(ctx context.Context, sessionID string) error {
return s.repo.RevokeSession(ctx, sessionID)
}
func (s *Service) ChangePassword(ctx context.Context, principal Principal, currentPassword, newPassword string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
if len(newPassword) < 12 {
return fmt.Errorf("password must be at least 12 characters")
}
user, err := s.repo.GetUserByID(ctx, principal.UserID)
if err != nil {
return err
}
if user.PasswordHash != "" {
ok, err := verifyPassword(currentPassword, user.PasswordHash)
if err != nil || !ok {
return fmt.Errorf("invalid credentials")
}
}
passwordHash, err := hashPassword(newPassword)
if err != nil {
return err
}
if err := s.repo.UpdatePasswordHash(ctx, user.ID, passwordHash); err != nil {
return err
}
s.repo.Audit(ctx, user.ID, "auth.password.change", "user", user.ID, "", "", nil)
return nil
}
func (s *Service) DeleteAccount(ctx context.Context, principal Principal, currentPassword string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
user, err := s.repo.GetUserByID(ctx, principal.UserID)
if err != nil {
return err
}
if user.Role == RoleAdmin {
admins, err := s.repo.CountAdmins(ctx)
if err != nil {
return err
}
if admins <= 1 {
return fmt.Errorf("cannot delete the last admin account")
}
}
if user.PasswordHash != "" {
ok, err := verifyPassword(currentPassword, user.PasswordHash)
if err != nil || !ok {
return fmt.Errorf("invalid credentials")
}
}
s.repo.Audit(ctx, user.ID, "auth.account.delete", "user", user.ID, "", "", nil)
return s.repo.DeleteUser(ctx, user.ID)
}
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
return s.repo.ListUsers(ctx)
}
func (s *Service) UpdateSignupsEnabled(ctx context.Context, actor Principal, enabled bool) (InstanceSettings, error) {
if !Allows(actor, ScopeAdmin) {
return InstanceSettings{}, fmt.Errorf("admin role required")
}
if err := s.repo.UpdateSignupsEnabled(ctx, enabled); err != nil {
return InstanceSettings{}, err
}
return s.repo.GetInstanceSettings(ctx)
}
func (s *Service) EnsureDevUser(ctx context.Context) (User, error) {
const email = "dev@localhost"
settings, err := s.repo.GetInstanceSettings(ctx)
if err != nil {
return User{}, err
}
if !settings.SetupComplete {
return s.CompleteInitialSetup(ctx, email, "Development Admin", "development-only-password", true)
}
user, err := s.repo.GetUserByEmail(ctx, email)
if err == nil {
return user, nil
}
if !isNoRows(err) {
return User{}, err
}
return s.repo.UpsertUser(ctx, User{Email: email, DisplayName: "Development Admin", Role: RoleAdmin})
}
func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principal, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return Principal{}, err
}
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil
}
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return User{}, err
}
users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{
ID: userID,
Role: Role(role),
Disabled: user.Disabled,
}})
if err != nil {
return User{}, err
}
return users[0], nil
}
func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates []UserAccessUpdate) ([]User, error) {
if !Allows(actor, ScopeAdmin) {
return nil, fmt.Errorf("admin role required")
}
if len(updates) == 0 {
return nil, fmt.Errorf("at least one user update is required")
}
normalized := make([]UserAccessUpdate, 0, len(updates))
seen := make(map[string]struct{}, len(updates))
for _, update := range updates {
update.ID = strings.TrimSpace(update.ID)
if update.ID == "" {
return nil, fmt.Errorf("user id is required")
}
if _, ok := seen[update.ID]; ok {
return nil, fmt.Errorf("duplicate user update")
}
seen[update.ID] = struct{}{}
update.Role = Role(strings.ToLower(strings.TrimSpace(string(update.Role))))
switch update.Role {
case RoleViewer, RoleEditor, RoleAdmin:
default:
return nil, fmt.Errorf("invalid role")
}
normalized = append(normalized, update)
}
if err := s.repo.UpdateUserAccess(ctx, normalized); err != nil {
return nil, err
}
users := make([]User, 0, len(normalized))
for _, update := range normalized {
user, err := s.repo.GetUserByID(ctx, update.ID)
if err != nil {
return nil, err
}
s.repo.Audit(ctx, actor.UserID, "auth.user.access.update", "user", user.ID, "", "", map[string]any{
"role": user.Role,
"disabled": user.Disabled,
})
users = append(users, user)
}
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")
}
if s.email == nil {
return fmt.Errorf("email sender is not configured")
}
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return err
}
secret, err := randomToken(32)
if err != nil {
return err
}
id, err := randomToken(18)
if err != nil {
return err
}
now := time.Now().UTC()
if err := s.repo.CreatePasswordResetToken(ctx, PasswordResetToken{
ID: "reset:" + id,
UserID: user.ID,
TokenHash: hashSecret(secret),
CreatedAt: now,
ExpiresAt: now.Add(passwordResetTTL),
InitiatedBy: actor.UserID,
}); err != nil {
return err
}
link := s.publicOrigin + "/password-reset?token=" + url.QueryEscape(secret)
body := fmt.Sprintf("A Cairnquire administrator requested a password reset for your account.\n\nSet a new password within one hour:\n%s\n\nIf you did not expect this message, contact your administrator.", link)
if err := s.email.Send(ctx, []string{user.Email}, "Reset your Cairnquire password", body); err != nil {
s.repo.ExpirePasswordResetToken(ctx, hashSecret(secret))
return err
}
s.repo.Audit(ctx, actor.UserID, "auth.password.reset.request", "user", user.ID, "", "", nil)
return nil
}
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
if len(newPassword) < 12 {
return fmt.Errorf("password must be at least 12 characters")
}
if strings.TrimSpace(token) == "" {
return fmt.Errorf("password reset token is required")
}
passwordHash, err := hashPassword(newPassword)
if err != nil {
return err
}
userID, err := s.repo.CompletePasswordReset(ctx, hashSecret(token), passwordHash)
if err != nil {
return err
}
s.repo.Audit(ctx, userID, "auth.password.reset.complete", "user", userID, "", "", nil)
return nil
}
func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) {
user, err := s.repo.GetUserByID(ctx, userID)
if err != nil {
return CreatedAPIKey{}, err
}
if strings.TrimSpace(name) == "" {
name = "API token"
}
tokenID, err := randomHex(12)
if err != nil {
return CreatedAPIKey{}, err
}
secret, err := randomHex(32)
if err != nil {
return CreatedAPIKey{}, err
}
now := time.Now().UTC()
keyID := "api:" + tokenID
token := "cq_pat_" + tokenID + "_" + secret
record := APIKey{
ID: keyID,
UserID: user.ID,
Name: strings.TrimSpace(name),
KeyHash: hashSecret(secret),
Scopes: normalizeScopes(scopes),
CreatedAt: now,
ExpiresAt: expiresAt,
}
if err := s.repo.CreateAPIKey(ctx, record); err != nil {
return CreatedAPIKey{}, err
}
return CreatedAPIKey{Record: record, Token: token}, nil
}
func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Principal, error) {
keyID, secret, err := parseAPIToken(token)
if err != nil {
return Principal{}, err
}
key, user, err := s.repo.ValidateAPIKey(ctx, keyID, hashSecret(secret))
if err != nil {
return Principal{}, err
}
if user.Disabled {
return Principal{}, fmt.Errorf("account disabled")
}
expiresAt := time.Time{}
if key.ExpiresAt != nil {
expiresAt = *key.ExpiresAt
}
return principalFromUser(user, "api_token", "", key.ID, key.Scopes, expiresAt), nil
}
func (s *Service) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
return s.repo.ListAPIKeys(ctx, userID)
}
func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
return s.repo.RevokeAPIKey(ctx, userID, keyID)
}
func (s *Service) StartDeviceFlow(ctx context.Context, name string, scopes []Scope) (DeviceStart, error) {
deviceSecret, err := randomToken(32)
if err != nil {
return DeviceStart{}, err
}
rawUserCode, err := randomToken(5)
if err != nil {
return DeviceStart{}, err
}
userCode := formatUserCode(rawUserCode)
idPart, err := randomToken(12)
if err != nil {
return DeviceStart{}, err
}
now := time.Now().UTC()
device := DeviceCode{
ID: "dev:" + idPart,
DeviceCodeHash: hashSecret(deviceSecret),
UserCodeHash: hashSecret(normalizeUserCode(userCode)),
UserCodeDisplay: userCode,
Name: strings.TrimSpace(name),
Scopes: normalizeScopes(scopes),
Status: "pending",
CreatedAt: now,
ExpiresAt: now.Add(deviceCodeTTL),
}
if device.Name == "" {
device.Name = "Device"
}
if err := s.repo.CreateDeviceCode(ctx, device); err != nil {
return DeviceStart{}, err
}
verifyURI := strings.TrimRight(s.publicOrigin, "/") + "/device/verify"
return DeviceStart{
DeviceCode: deviceSecret,
UserCode: userCode,
VerificationURI: verifyURI,
VerificationURIComplete: verifyURI + "?user_code=" + url.QueryEscape(userCode),
ExpiresIn: int(deviceCodeTTL.Seconds()),
Interval: devicePollSeconds,
}, nil
}
func (s *Service) ApproveDeviceFlow(ctx context.Context, principal Principal, userCode string) (DeviceCode, error) {
if principal.UserID == "" {
return DeviceCode{}, fmt.Errorf("authentication required")
}
return s.repo.ApproveDeviceCode(ctx, hashSecret(normalizeUserCode(userCode)), principal.UserID)
}
func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (CreatedAPIKey, string, error) {
device, err := s.repo.GetDeviceCodeByDeviceCode(ctx, hashSecret(deviceCode))
if err != nil {
if errorsIsNoRows(err) {
return CreatedAPIKey{}, "invalid_device_code", err
}
return CreatedAPIKey{}, "", err
}
s.repo.TouchDevicePoll(ctx, device.ID)
switch device.Status {
case "pending":
return CreatedAPIKey{}, "authorization_pending", sql.ErrNoRows
case "denied", "expired":
return CreatedAPIKey{}, device.Status, fmt.Errorf("device flow %s", device.Status)
case "approved":
created, err := s.CreateAPIKey(ctx, device.UserID, device.Name, device.Scopes, nil)
if err == nil {
s.repo.FinishDeviceCode(ctx, device.ID)
}
return created, "", err
default:
return CreatedAPIKey{}, "invalid_state", fmt.Errorf("invalid device status")
}
}
func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) {
if user.Disabled {
return Principal{}, "", fmt.Errorf("account disabled")
}
token, err := randomToken(32)
if err != nil {
return Principal{}, "", err
}
session, err := s.repo.CreateSession(ctx, user.ID, hashSecret(token), ip, userAgent, sessionTTL)
if err != nil {
return Principal{}, "", err
}
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), token, nil
}
func (s *Service) requirePublicSignups(ctx context.Context) error {
settings, err := s.repo.GetInstanceSettings(ctx)
if err != nil {
return err
}
if !settings.SetupComplete {
return fmt.Errorf("initial setup is required")
}
if !settings.SignupsEnabled {
return fmt.Errorf("signups are disabled")
}
return nil
}
func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal {
return Principal{
UserID: user.ID,
Email: user.Email,
DisplayName: user.DisplayName,
Role: user.Role,
Scopes: scopes,
Method: method,
SessionID: sessionID,
APIKeyID: apiKeyID,
ExpiresAt: expiresAt,
}
}
func parseAPIToken(token string) (string, string, error) {
parts := strings.SplitN(token, "_", 4)
if len(parts) != 4 || parts[0] != "cq" || parts[1] != "pat" {
return "", "", fmt.Errorf("invalid api token")
}
return "api:" + parts[2], parts[3], nil
}
func normalizeEmail(email string) string {
return strings.TrimSpace(strings.ToLower(email))
}
func formatUserCode(raw string) string {
clean := strings.ToUpper(strings.NewReplacer("-", "", "_", "").Replace(raw))
if len(clean) > 8 {
clean = clean[:8]
}
if len(clean) > 4 {
return clean[:4] + "-" + clean[4:]
}
return clean
}
func normalizeUserCode(code string) string {
return strings.ToUpper(strings.NewReplacer("-", "", " ", "", "_", "").Replace(strings.TrimSpace(code)))
}
func errorsIsNoRows(err error) bool {
return err == sql.ErrNoRows
}
var dummyPasswordHash string
func mustDummyPasswordHash() string {
if dummyPasswordHash == "" {
hash, err := hashPassword("not-the-password")
if err != nil {
panic(err)
}
dummyPasswordHash = hash
}
return dummyPasswordHash
}
func clientIP(r *http.Request) string {
if r == nil {
return ""
}
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
return strings.TrimSpace(parts[0])
}
return r.RemoteAddr
}
func RoleAllows(role Role, required Scope) bool {
switch required {
case ScopeDocsRead, ScopeSyncRead:
return role == RoleViewer || role == RoleEditor || role == RoleAdmin
case ScopeDocsWrite, ScopeSyncWrite:
return role == RoleEditor || role == RoleAdmin
case ScopeAdmin:
return role == RoleAdmin
default:
return false
}
}
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
}
if len(principal.Scopes) > 0 && !hasScope(principal.Scopes, required) {
return false
}
return RoleAllows(principal.Role, required)
}

View File

@@ -0,0 +1,439 @@
package auth
import (
"context"
"database/sql"
"errors"
"net/url"
"strings"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/database"
)
type testEmailSender struct {
to []string
subject string
body string
err error
}
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
s.to = append([]string(nil), to...)
s.subject = subject
s.body = body
return s.err
}
func setupAuthTestService(t *testing.T) *Service {
t.Helper()
db, err := sql.Open("libsql", "file:"+t.TempDir()+"/auth.db")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
service, err := NewService(NewRepository(db), "http://localhost:8080")
if err != nil {
t.Fatalf("new auth service: %v", err)
}
return service
}
func setupInitialAdmin(t *testing.T, service *Service, signupsEnabled bool) User {
t.Helper()
user, err := service.CompleteInitialSetup(context.Background(), "admin@example.com", "Admin", "correct horse battery staple", signupsEnabled)
if err != nil {
t.Fatalf("CompleteInitialSetup() error = %v", err)
}
return user
}
func TestPasswordLoginCreatesValidSession(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user, err := service.CompleteInitialSetup(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", true)
if err != nil {
t.Fatalf("CompleteInitialSetup() error = %v", err)
}
if user.Email != "dev@example.com" {
t.Fatalf("email = %q, want normalized", user.Email)
}
principal, token, err := service.LoginPassword(ctx, "dev@example.com", "correct horse battery staple", "127.0.0.1", "test")
if err != nil {
t.Fatalf("LoginPassword() error = %v", err)
}
if token == "" {
t.Fatal("expected session token")
}
if principal.Role != RoleAdmin {
t.Fatalf("role = %s, want initial setup user to be admin", principal.Role)
}
validated, err := service.ValidateSessionToken(ctx, token)
if err != nil {
t.Fatalf("ValidateSessionToken() error = %v", err)
}
if validated.UserID != principal.UserID {
t.Fatalf("validated user = %q, want %q", validated.UserID, principal.UserID)
}
}
func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user := setupInitialAdmin(t, service, true)
expires := time.Now().UTC().Add(time.Hour)
created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires)
if err != nil {
t.Fatalf("CreateAPIKey() error = %v", err)
}
if !strings.HasPrefix(created.Token, "cq_pat_") {
t.Fatalf("token prefix = %q, want cq_pat_", created.Token)
}
if created.Record.KeyHash != "" && strings.Contains(created.Record.KeyHash, created.Token) {
t.Fatal("record contains raw token")
}
principal, err := service.ValidateBearerToken(ctx, created.Token)
if err != nil {
t.Fatalf("ValidateBearerToken() error = %v", err)
}
if principal.APIKeyID != created.Record.ID {
t.Fatalf("api key id = %q, want %q", principal.APIKeyID, created.Record.ID)
}
if !Allows(principal, ScopeDocsRead) {
t.Fatal("expected docs:read to be allowed")
}
if Allows(principal, ScopeAdmin) {
t.Fatal("did not expect admin scope to be allowed")
}
}
func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user := setupInitialAdmin(t, service, true)
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
if err != nil {
t.Fatalf("StartDeviceFlow() error = %v", err)
}
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "authorization_pending" || err == nil {
t.Fatalf("PollDeviceFlow() before approval code=%q err=%v, want authorization_pending", code, err)
}
_, err = service.ApproveDeviceFlow(ctx, principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)), start.UserCode)
if err != nil {
t.Fatalf("ApproveDeviceFlow() error = %v", err)
}
created, code, err := service.PollDeviceFlow(ctx, start.DeviceCode)
if err != nil {
t.Fatalf("PollDeviceFlow() after approval code=%q err=%v", code, err)
}
if created.Token == "" {
t.Fatal("expected device flow to mint api token")
}
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "expired" || err == nil {
t.Fatalf("PollDeviceFlow() after token issuance code=%q err=%v, want expired", code, err)
}
}
func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user := setupInitialAdmin(t, service, true)
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil {
t.Fatalf("ChangePassword() error = %v", err)
}
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "correct horse battery staple", "127.0.0.1", "test"); err == nil {
t.Fatal("old password still works")
}
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
t.Fatalf("new password login error = %v", err)
}
}
func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
user := setupInitialAdmin(t, service, true)
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
t.Fatal("expected demoting last admin to fail")
}
if _, err := service.UpdateUserAccess(ctx, principal, []UserAccessUpdate{{ID: user.ID, Role: RoleAdmin, Disabled: true}}); err == nil {
t.Fatal("expected disabling last admin to fail")
}
if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil {
t.Fatal("expected deleting last admin to fail")
}
}
func TestDisabledUserCannotAuthenticateWithPasswordSessionOrAPIKey(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
if err != nil {
t.Fatalf("LoginPassword() before disable error = %v", err)
}
apiKey, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead}, nil)
if err != nil {
t.Fatalf("CreateAPIKey() error = %v", err)
}
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
if _, err := service.UpdateUserAccess(ctx, adminPrincipal, []UserAccessUpdate{{ID: user.ID, Role: RoleViewer, Disabled: true}}); err != nil {
t.Fatalf("UpdateUserAccess() error = %v", err)
}
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
t.Fatal("disabled user password login succeeded")
}
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
t.Fatal("disabled user session remained valid")
}
if _, err := service.ValidateBearerToken(ctx, apiKey.Token); err == nil {
t.Fatal("disabled user api token remained valid")
}
}
func TestPasswordResetEmailReplacesPriorLinkAndRevokesSessions(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
if err != nil {
t.Fatalf("LoginPassword() before reset error = %v", err)
}
sender := &testEmailSender{}
service.SetEmailSender(sender)
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
t.Fatalf("SendPasswordReset() first error = %v", err)
}
firstToken := passwordResetTokenFromBody(t, sender.body)
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
t.Fatalf("SendPasswordReset() second error = %v", err)
}
secondToken := passwordResetTokenFromBody(t, sender.body)
if firstToken == secondToken {
t.Fatal("expected each password reset email to contain a fresh token")
}
if err := service.ResetPassword(ctx, firstToken, "new correct horse battery staple"); err == nil {
t.Fatal("first password reset link remained valid after requesting a new one")
}
if err := service.ResetPassword(ctx, secondToken, "new correct horse battery staple"); err != nil {
t.Fatalf("ResetPassword() error = %v", err)
}
if err := service.ResetPassword(ctx, secondToken, "another correct horse battery staple"); err == nil {
t.Fatal("password reset link was reusable")
}
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
t.Fatal("password reset did not revoke existing sessions")
}
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
t.Fatal("old password still works after reset")
}
if _, _, err := service.LoginPassword(ctx, user.Email, "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
t.Fatalf("new password login error = %v", err)
}
}
func TestPasswordResetEmailFailureInvalidatesLink(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
sender := &testEmailSender{err: errors.New("smtp unavailable")}
service.SetEmailSender(sender)
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err == nil {
t.Fatal("expected password reset email delivery failure")
}
token := passwordResetTokenFromBody(t, sender.body)
if err := service.ResetPassword(ctx, token, "new correct horse battery staple"); err == nil {
t.Fatal("password reset link remained valid after email delivery failed")
}
}
func passwordResetTokenFromBody(t *testing.T, body string) string {
t.Helper()
for _, line := range strings.Split(body, "\n") {
if !strings.HasPrefix(line, "http") {
continue
}
parsed, err := url.Parse(line)
if err != nil {
t.Fatalf("parse password reset URL: %v", err)
}
if token := parsed.Query().Get("token"); token != "" {
return token
}
}
t.Fatalf("password reset body does not contain a link: %q", body)
return ""
}
func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
setupInitialAdmin(t, service, true)
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil {
t.Fatalf("RegisterPasswordUser() error = %v", err)
}
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "another correct horse", "viewer"); err == nil {
t.Fatal("expected duplicate password registration to fail")
}
if _, _, err := service.BeginPasskeyRegistration(ctx, "dev@example.com", "Dev", "viewer"); err == nil {
t.Fatal("expected public passkey registration for existing account to fail")
}
}
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()
admin := setupInitialAdmin(t, service, false)
if admin.Role != RoleAdmin {
t.Fatalf("initial role = %s, want admin", admin.Role)
}
if _, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer"); err == nil || err.Error() != "signups are disabled" {
t.Fatalf("RegisterPasswordUser() error = %v, want signups are disabled", err)
}
principal := principalFromUser(admin, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
settings, err := service.UpdateSignupsEnabled(ctx, principal, true)
if err != nil {
t.Fatalf("UpdateSignupsEnabled() error = %v", err)
}
if !settings.SignupsEnabled {
t.Fatal("expected signups to be enabled")
}
viewer, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "admin")
if err != nil {
t.Fatalf("RegisterPasswordUser() after enabling signups error = %v", err)
}
if viewer.Role != RoleViewer {
t.Fatalf("public signup role = %s, want viewer", viewer.Role)
}
}
func TestResourcePermissionsArePrivateByDefault(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
viewer, err := service.repo.UpsertUser(ctx, User{
Email: "viewer@example.com",
DisplayName: "Viewer",
Role: RoleViewer,
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "private/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() error = %v", err)
}
if permission != PermissionNone {
t.Fatalf("viewer permission = %q, want no access", permission)
}
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
permission, err = service.EffectiveResourcePermission(ctx, adminPrincipal, ResourceDocument, "private/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() admin error = %v", err)
}
if permission != PermissionAdmin {
t.Fatalf("admin permission = %q, want admin", permission)
}
}
func TestResourcePermissionsUseMostPermissiveGrant(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
viewer, err := service.repo.UpsertUser(ctx, User{
Email: "viewer@example.com",
DisplayName: "Viewer",
Role: RoleViewer,
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceDocument, "shared/page.md", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionRead},
}); err != nil {
t.Fatalf("grant document read: %v", err)
}
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceFolder, "shared", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionWrite},
}); err != nil {
t.Fatalf("grant folder write: %v", err)
}
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "shared/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() folder error = %v", err)
}
if permission != PermissionWrite {
t.Fatalf("folder/document combined permission = %q, want write", permission)
}
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceCollection, "team", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionAdmin},
}); err != nil {
t.Fatalf("grant collection admin: %v", err)
}
permission, err = service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "other/page.md", []string{"team"})
if err != nil {
t.Fatalf("EffectiveResourcePermission() collection error = %v", err)
}
if permission != PermissionAdmin {
t.Fatalf("collection permission = %q, want admin", permission)
}
}

View File

@@ -0,0 +1,174 @@
package auth
import (
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type Role string
const (
RoleViewer Role = "viewer"
RoleEditor Role = "editor"
RoleAdmin Role = "admin"
)
type Scope string
const (
ScopeDocsRead Scope = "docs:read"
ScopeDocsWrite Scope = "docs:write"
ScopeSyncRead Scope = "sync:read"
ScopeSyncWrite Scope = "sync:write"
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"`
DisplayName string `json:"displayName"`
Role Role `json:"role"`
Scopes []Scope `json:"scopes,omitempty"`
Method string `json:"method"`
SessionID string `json:"sessionId,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
}
type User struct {
ID string
Email string
DisplayName string
Role Role
Disabled bool
PasswordHash string
CreatedAt time.Time
LastSeenAt time.Time
Credentials []webauthn.Credential
}
type UserAccessUpdate struct {
ID string `json:"id"`
Role Role `json:"role"`
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
TokenHash string
CreatedAt time.Time
ExpiresAt time.Time
UsedAt *time.Time
InitiatedBy string
}
func (u User) WebAuthnID() []byte {
return []byte(u.ID)
}
func (u User) WebAuthnName() string {
return u.Email
}
func (u User) WebAuthnDisplayName() string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.Email
}
func (u User) WebAuthnCredentials() []webauthn.Credential {
return u.Credentials
}
type Session struct {
ID string
UserID string
TokenHash string
CreatedAt time.Time
ExpiresAt time.Time
RevokedAt *time.Time
}
type APIKey struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
KeyHash string `json:"-"`
Scopes []Scope `json:"scopes"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
}
type CreatedAPIKey struct {
Record APIKey `json:"record"`
Token string `json:"token"`
}
type InstanceSettings struct {
SetupComplete bool `json:"setupComplete"`
SignupsEnabled bool `json:"signupsEnabled"`
}
type DeviceCode struct {
ID string
UserID string
DeviceCodeHash string
UserCodeHash string
UserCodeDisplay string
Name string
Scopes []Scope
Status string
CreatedAt time.Time
ExpiresAt time.Time
ApprovedAt *time.Time
LastPolledAt *time.Time
}
type DeviceStart struct {
DeviceCode string `json:"deviceCode"`
UserCode string `json:"userCode"`
VerificationURI string `json:"verificationUri"`
VerificationURIComplete string `json:"verificationUriComplete"`
ExpiresIn int `json:"expiresIn"`
Interval int `json:"interval"`
}

View File

@@ -0,0 +1,304 @@
package collaboration
import (
"context"
"database/sql"
"fmt"
"time"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
// Comments
func (r *Repository) CreateComment(ctx context.Context, comment Comment) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO comments (id, document_id, version_hash, parent_id, author_id, content, anchor_hash, anchor_line, created_at, resolved_at, resolved_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, comment.ID, comment.DocumentID, comment.VersionHash, comment.ParentID, comment.AuthorID, comment.Content, comment.AnchorHash, comment.AnchorLine, formatTime(comment.CreatedAt), comment.ResolvedAt, comment.ResolvedBy)
if err != nil {
return fmt.Errorf("insert comment: %w", err)
}
return nil
}
func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error) {
var c Comment
var parentID, anchorHash sql.NullString
var anchorLine sql.NullInt64
var resolvedAt, resolvedBy sql.NullString
var createdAt string
err := r.db.QueryRowContext(ctx, `
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.id = ?
`, id).Scan(
&c.ID, &c.DocumentID, &c.VersionHash, &parentID, &c.AuthorID, &c.AuthorName,
&c.Content, &anchorHash, &anchorLine, &createdAt, &resolvedAt, &resolvedBy,
)
if err != nil {
return nil, err
}
c.ParentID = nullString(parentID.String)
c.AnchorHash = nullString(anchorHash.String)
if anchorLine.Valid {
c.AnchorLine = ptr(int(anchorLine.Int64))
}
c.CreatedAt, _ = parseTime(createdAt)
if resolvedAt.Valid && resolvedAt.String != "" {
t, _ := parseTime(resolvedAt.String)
c.ResolvedAt = &t
c.ResolvedBy = nullString(resolvedBy.String)
}
return &c, nil
}
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
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)
if err != nil {
return nil, fmt.Errorf("list comments: %w", err)
}
defer rows.Close()
return scanComments(rows)
}
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
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)
if err != nil {
return nil, fmt.Errorf("list comments by anchor: %w", err)
}
defer rows.Close()
return scanComments(rows)
}
func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE comments SET resolved_at = ?, resolved_by = ?
WHERE id = ?
`, formatTime(time.Now().UTC()), userID, commentID)
if err != nil {
return fmt.Errorf("resolve comment: %w", err)
}
return nil
}
func scanComments(rows *sql.Rows) ([]Comment, error) {
var comments []Comment
for rows.Next() {
var c Comment
var parentID, anchorHash sql.NullString
var anchorLine sql.NullInt64
var resolvedAt, resolvedBy sql.NullString
var createdAt string
if err := rows.Scan(
&c.ID, &c.DocumentID, &c.VersionHash, &parentID, &c.AuthorID, &c.AuthorName,
&c.Content, &anchorHash, &anchorLine, &createdAt, &resolvedAt, &resolvedBy,
); err != nil {
return nil, fmt.Errorf("scan comment: %w", err)
}
c.ParentID = nullString(parentID.String)
c.AnchorHash = nullString(anchorHash.String)
if anchorLine.Valid {
c.AnchorLine = ptr(int(anchorLine.Int64))
}
c.CreatedAt, _ = parseTime(createdAt)
if resolvedAt.Valid && resolvedAt.String != "" {
t, _ := parseTime(resolvedAt.String)
c.ResolvedAt = &t
c.ResolvedBy = nullString(resolvedBy.String)
}
comments = append(comments, c)
}
return comments, rows.Err()
}
// Notifications
func (r *Repository) CreateNotification(ctx context.Context, n Notification) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, read_at, emailed_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, n.ID, n.UserID, n.Type, n.ResourceType, n.ResourceID, n.Message, n.ReadAt, n.EmailedAt, formatTime(n.CreatedAt))
if err != nil {
return fmt.Errorf("insert notification: %w", err)
}
return nil
}
func (r *Repository) ListNotifications(ctx context.Context, userID string, unreadOnly bool, limit int) ([]Notification, error) {
query := `
SELECT id, user_id, type, resource_type, resource_id, message, read_at, emailed_at, created_at
FROM notifications
WHERE user_id = ?`
args := []any{userID}
if unreadOnly {
query += ` AND read_at IS NULL`
}
query += ` ORDER BY created_at DESC`
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
var notifications []Notification
for rows.Next() {
var n Notification
var readAt, emailedAt sql.NullString
var createdAt string
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.ResourceType, &n.ResourceID, &n.Message, &readAt, &emailedAt, &createdAt); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
if readAt.Valid && readAt.String != "" {
t, _ := parseTime(readAt.String)
n.ReadAt = &t
}
if emailedAt.Valid && emailedAt.String != "" {
t, _ := parseTime(emailedAt.String)
n.EmailedAt = &t
}
n.CreatedAt, _ = parseTime(createdAt)
notifications = append(notifications, n)
}
return notifications, rows.Err()
}
func (r *Repository) CountUnreadNotifications(ctx context.Context, userID string) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM notifications WHERE user_id = ? AND read_at IS NULL
`, userID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err)
}
return count, nil
}
func (r *Repository) MarkNotificationRead(ctx context.Context, notificationID string, userID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notifications SET read_at = ? WHERE id = ? AND user_id = ?
`, formatTime(time.Now().UTC()), notificationID, userID)
if err != nil {
return fmt.Errorf("mark notification read: %w", err)
}
return nil
}
func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL
`, formatTime(time.Now().UTC()), userID)
if err != nil {
return fmt.Errorf("mark all notifications read: %w", err)
}
return nil
}
// Watchers
func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error {
_, err := r.db.ExecContext(ctx, `
INSERT 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)
}
return nil
}
func (r *Repository) RemoveWatcher(ctx context.Context, userID string, documentID *string, folderPath *string) error {
_, err := r.db.ExecContext(ctx, `
DELETE FROM watchers WHERE user_id = ? AND document_id IS ? AND folder_path IS ?
`, userID, documentID, folderPath)
if err != nil {
return fmt.Errorf("remove watcher: %w", err)
}
return nil
}
func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT user_id, document_id, folder_path, created_at
FROM watchers
WHERE document_id = ?
`, documentID)
if err != nil {
return nil, fmt.Errorf("list document watchers: %w", err)
}
defer rows.Close()
return scanWatchers(rows)
}
func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath 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)
if err != nil {
return nil, fmt.Errorf("list folder watchers: %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, `
SELECT COUNT(*) FROM watchers WHERE user_id = ? AND document_id = ?
`, userID, documentID).Scan(&count)
if err != nil {
return false, fmt.Errorf("check watching: %w", err)
}
return count > 0, nil
}
func scanWatchers(rows *sql.Rows) ([]Watcher, error) {
var watchers []Watcher
for rows.Next() {
var w Watcher
var docID, folderPath sql.NullString
var createdAt string
if err := rows.Scan(&w.UserID, &docID, &folderPath, &createdAt); err != nil {
return nil, fmt.Errorf("scan watcher: %w", err)
}
w.DocumentID = nullString(docID.String)
w.FolderPath = nullString(folderPath.String)
w.CreatedAt, _ = parseTime(createdAt)
watchers = append(watchers, w)
}
return watchers, rows.Err()
}

View File

@@ -0,0 +1,385 @@
package collaboration
import (
"context"
"fmt"
"log/slog"
"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 NoOpEmailSender struct{}
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
slog.Info("email stub", "to", to, "subject", subject, "body_len", len(body))
return nil
}
type Service struct {
repo *Repository
email EmailSender
queue EmailQueue
renderer EmailRenderer
users UserLookup
hub *realtime.Hub
logger *slog.Logger
publicOrigin string
}
type Options struct {
EmailSender EmailSender
EmailQueue EmailQueue
EmailRenderer EmailRenderer
UserLookup UserLookup
PublicOrigin string
}
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
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: opts.EmailSender,
queue: opts.EmailQueue,
renderer: opts.EmailRenderer,
users: opts.UserLookup,
hub: hub,
logger: logger,
publicOrigin: opts.PublicOrigin,
}
}
// Comments
func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, input CreateCommentInput) (*Comment, error) {
if principal.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
if input.Content == "" {
return nil, fmt.Errorf("content is required")
}
if input.DocumentID == "" {
return nil, fmt.Errorf("document_id is required")
}
comment := Comment{
ID: randomID("comment"),
DocumentID: input.DocumentID,
VersionHash: input.VersionHash,
AuthorID: principal.UserID,
Content: input.Content,
CreatedAt: time.Now().UTC(),
}
if input.ParentID != nil && *input.ParentID != "" {
comment.ParentID = input.ParentID
}
if input.AnchorHash != nil && *input.AnchorHash != "" {
comment.AnchorHash = input.AnchorHash
}
if input.AnchorLine != nil && *input.AnchorLine > 0 {
comment.AnchorLine = input.AnchorLine
}
if err := s.repo.CreateComment(ctx, comment); err != nil {
return nil, err
}
// Create notifications for watchers
if err := s.notifyWatchersOfComment(ctx, comment); 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)
}
func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) {
return s.repo.ListCommentsByDocument(ctx, documentID)
}
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash)
}
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)
}
// Notifications
func (s *Service) ListNotifications(ctx context.Context, principal auth.Principal, unreadOnly bool, limit int) ([]Notification, error) {
if principal.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
return s.repo.ListNotifications(ctx, principal.UserID, unreadOnly, limit)
}
func (s *Service) GetBellStatus(ctx context.Context, principal auth.Principal) (*NotificationBell, error) {
if principal.UserID == "" {
return &NotificationBell{UnreadCount: 0, Items: []Notification{}}, nil
}
count, err := s.repo.CountUnreadNotifications(ctx, principal.UserID)
if err != nil {
return nil, err
}
items, err := s.repo.ListNotifications(ctx, principal.UserID, false, 20)
if err != nil {
return nil, err
}
return &NotificationBell{
UnreadCount: count,
Items: items,
}, nil
}
func (s *Service) MarkNotificationRead(ctx context.Context, principal auth.Principal, notificationID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.MarkNotificationRead(ctx, notificationID, principal.UserID)
}
func (s *Service) MarkAllNotificationsRead(ctx context.Context, principal auth.Principal) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.MarkAllNotificationsRead(ctx, principal.UserID)
}
// Watchers
func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, documentID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID,
DocumentID: &documentID,
CreatedAt: time.Now().UTC(),
})
}
func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal, documentID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
}
func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.AddWatcher(ctx, Watcher{
UserID: principal.UserID,
FolderPath: &folderPath,
CreatedAt: time.Now().UTC(),
})
}
func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")
}
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
}
func (s *Service) IsWatching(ctx context.Context, principal auth.Principal, documentID string) (bool, error) {
if principal.UserID == "" {
return false, nil
}
return s.repo.IsWatching(ctx, principal.UserID, documentID)
}
// Document change notifications
func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, documentPath string, actorName string) error {
watchers, err := s.repo.ListWatchersByDocument(ctx, documentID)
if err != nil {
return fmt.Errorf("list document watchers: %w", err)
}
folderWatchers, err := s.repo.ListWatchersByFolder(ctx, documentPath)
if err != nil {
return fmt.Errorf("list folder watchers: %w", err)
}
seen := make(map[string]bool)
for _, w := range append(watchers, folderWatchers...) {
if seen[w.UserID] {
continue
}
seen[w.UserID] = true
msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
notification := Notification{
ID: randomID("notif"),
UserID: w.UserID,
Type: "file_changed",
ResourceType: "document",
ResourceID: documentID,
Message: msg,
CreatedAt: time.Now().UTC(),
}
if err := s.repo.CreateNotification(ctx, notification); err != nil {
s.logger.Warn("create notification", "error", err)
continue
}
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{
ActorName: actorName,
DocumentPath: documentPath,
ViewURL: s.documentURL(documentPath),
})
}
// Broadcast bell update to all connected clients
s.broadcastNotification("")
return nil
}
func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID)
if err != nil {
return err
}
for _, w := range watchers {
if w.UserID == comment.AuthorID {
continue
}
msg := fmt.Sprintf("New comment on document")
if comment.AnchorHash != nil {
msg = fmt.Sprintf("New comment on a section of the document")
}
notification := Notification{
ID: randomID("notif"),
UserID: w.UserID,
Type: "comment",
ResourceType: "document",
ResourceID: comment.DocumentID,
Message: msg,
CreatedAt: time.Now().UTC(),
}
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,
CommentBody: comment.Content,
ViewURL: s.documentURL(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 = user.DisplayName
}
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) documentURL(documentPath string) string {
if s.publicOrigin == "" {
return ""
}
return s.publicOrigin + "/" + documentPath
}
func (s *Service) broadcastNotification(userID string) {
if s.hub == nil {
return
}
s.hub.Broadcast(realtime.Event{
Type: "notification",
Data: map[string]string{"userId": userID},
})
}
func (s *Service) SetEmailSender(email EmailSender) {
if email != nil {
s.email = email
}
}

View File

@@ -0,0 +1,51 @@
package collaboration
import "time"
type Comment struct {
ID string `json:"id"`
DocumentID string `json:"documentId"`
VersionHash string `json:"versionHash"`
ParentID *string `json:"parentId,omitempty"`
AuthorID string `json:"authorId"`
AuthorName string `json:"authorName"`
Content string `json:"content"`
AnchorHash *string `json:"anchorHash,omitempty"`
AnchorLine *int `json:"anchorLine,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ResolvedAt *time.Time `json:"resolvedAt,omitempty"`
ResolvedBy *string `json:"resolvedBy,omitempty"`
}
type CreateCommentInput struct {
DocumentID string `json:"documentId"`
VersionHash string `json:"versionHash"`
ParentID *string `json:"parentId,omitempty"`
Content string `json:"content"`
AnchorHash *string `json:"anchorHash,omitempty"`
AnchorLine *int `json:"anchorLine,omitempty"`
}
type Notification struct {
ID string `json:"id"`
UserID string `json:"userId"`
Type string `json:"type"`
ResourceType string `json:"resourceType"`
ResourceID string `json:"resourceId"`
Message string `json:"message"`
ReadAt *time.Time `json:"readAt,omitempty"`
EmailedAt *time.Time `json:"emailedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type Watcher struct {
UserID string `json:"userId"`
DocumentID *string `json:"documentId,omitempty"`
FolderPath *string `json:"folderPath,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type NotificationBell struct {
UnreadCount int `json:"unreadCount"`
Items []Notification `json:"items"`
}

View File

@@ -0,0 +1,71 @@
package collaboration
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strings"
"time"
)
func randomID(prefix string) string {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return prefix + ":" + hex.EncodeToString(buf)
}
func hashAnchor(text string) string {
normalized := strings.TrimSpace(text)
sum := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(sum[:8])
}
func parseTime(raw string) (time.Time, error) {
if raw == "" {
return time.Time{}, nil
}
return time.Parse(time.RFC3339, raw)
}
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
func ptr[T any](v T) *T {
return &v
}
func nullString(s string) *string {
if s == "" {
return nil
}
return &s
}
func scanNullString(ns *string) string {
if ns == nil {
return ""
}
return *ns
}
func coalesceString(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func formatDocumentID(path string) string {
clean := strings.TrimPrefix(path, "/")
clean = strings.TrimSuffix(clean, ".md")
return "doc:" + clean
}
func documentPathFromID(id string) string {
return strings.TrimPrefix(id, "doc:")
}

View File

@@ -5,14 +5,17 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
)
type Config struct {
Server ServerConfig `json:"server"`
Database DatabaseConfig `json:"database"`
Content ContentConfig `json:"content"`
Web WebConfig `json:"web"`
Auth AuthConfig `json:"auth"`
Email EmailConfig `json:"email"`
LogLevel string `json:"logLevel"`
DevMode bool `json:"devMode"`
}
type ServerConfig struct {
@@ -30,9 +33,36 @@ type ContentConfig struct {
StoreDir string `json:"storeDir"`
}
type WebConfig struct {
DistDir string `json:"distDir"`
DevViteURL string `json:"devViteUrl"`
type AuthConfig struct {
PublicOrigin string `json:"publicOrigin"`
}
type EmailConfig struct {
// Provider selects the sender implementation. Valid values: "noop",
// "smtp", "postmark". When empty, the loader infers from the other
// fields: PostmarkToken wins, then SMTPHost, then NoOp.
Provider string `json:"provider"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
PostmarkToken string `json:"postmarkToken"`
PostmarkAPIURL string `json:"postmarkApiUrl"`
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.PostmarkToken != "" {
return "postmark"
}
if e.SMTPHost != "" {
return "smtp"
}
return "noop"
}
func Default() Config {
@@ -47,9 +77,14 @@ func Default() Config {
SourceDir: filepath.Join("..", "..", "content"),
StoreDir: filepath.Join("..", "..", "data", "files"),
},
Web: WebConfig{
DistDir: filepath.Join("..", "web", "dist"),
DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"),
Auth: AuthConfig{
PublicOrigin: "http://localhost:8080",
},
Email: EmailConfig{
SMTPHost: "localhost",
SMTPPort: 1025,
From: "notifications@cairnquire.local",
InstanceName: "Cairnquire",
},
LogLevel: "INFO",
}
@@ -65,14 +100,34 @@ func Load() (Config, error) {
}
overrideString(&cfg.Server.Addr, "CAIRNQUIRE_SERVER_ADDR")
if port := os.Getenv("PORT"); port != "" {
cfg.Server.Addr = ":" + port
}
overrideString(&cfg.Database.Path, "CAIRNQUIRE_DATABASE_PATH")
overrideString(&cfg.Database.PrimaryURL, "CAIRNQUIRE_DATABASE_PRIMARY_URL")
overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN")
overrideString(&cfg.Content.SourceDir, "CAIRNQUIRE_CONTENT_SOURCE_DIR")
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR")
overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL")
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.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
}
}
overrideString(&cfg.Email.From, "CAIRNQUIRE_EMAIL_FROM")
overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL")
if devMode := os.Getenv("CAIRNQUIRE_DEV_MODE"); devMode != "" {
parsed, err := strconv.ParseBool(devMode)
if err != nil {
return Config{}, fmt.Errorf("parse CAIRNQUIRE_DEV_MODE: %w", err)
}
cfg.DevMode = parsed
}
if cfg.Server.Addr == "" {
return Config{}, fmt.Errorf("server.addr must not be empty")

View File

@@ -0,0 +1,90 @@
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT,
revoked_at TEXT,
ip_address TEXT,
user_agent TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
credential_id BLOB NOT NULL UNIQUE,
credential_json TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
CREATE TABLE IF NOT EXISTS auth_challenges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('webauthn_registration', 'webauthn_login')),
session_json TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_auth_challenges_user_type ON auth_challenges(user_id, type);
CREATE TABLE IF NOT EXISTS device_codes (
id TEXT PRIMARY KEY,
user_id TEXT,
device_code_hash TEXT NOT NULL UNIQUE,
user_code_hash TEXT NOT NULL UNIQUE,
user_code_display TEXT NOT NULL,
name TEXT NOT NULL,
scopes TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'approved', 'denied', 'expired')),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
approved_at TEXT,
last_polled_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_device_codes_user_code_hash ON device_codes(user_code_hash);
CREATE INDEX IF NOT EXISTS idx_device_codes_device_code_hash ON device_codes(device_code_hash);
CREATE TABLE IF NOT EXISTS permissions (
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
);
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
actor_user_id TEXT,
event_type TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(actor_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
ALTER TABLE api_keys ADD COLUMN revoked_at TEXT;

View File

@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_watchers_folder;
DROP INDEX IF EXISTS idx_watchers_document;
DROP TABLE IF EXISTS watchers;
DROP INDEX IF EXISTS idx_notifications_created_at;
DROP INDEX IF EXISTS idx_notifications_unread;
DROP INDEX IF EXISTS idx_notifications_user;
DROP TABLE IF EXISTS notifications;
DROP INDEX IF EXISTS idx_comments_author;
DROP INDEX IF EXISTS idx_comments_parent;
DROP INDEX IF EXISTS idx_comments_document;
DROP TABLE IF EXISTS comments;

View File

@@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
version_hash TEXT NOT NULL,
parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
anchor_hash TEXT,
anchor_line INTEGER,
created_at TEXT NOT NULL,
resolved_at TEXT,
resolved_by TEXT REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_comments_document ON comments(document_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
CREATE INDEX IF NOT EXISTS idx_comments_author ON comments(author_id);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK(type IN ('file_changed', 'comment', 'mention', 'conflict')),
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
message TEXT NOT NULL,
read_at TEXT,
emailed_at TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at) WHERE read_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at);
CREATE TABLE IF NOT EXISTS watchers (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
document_id TEXT REFERENCES documents(id) ON DELETE CASCADE,
folder_path TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, document_id, folder_path)
);
CREATE INDEX IF NOT EXISTS idx_watchers_document ON watchers(document_id);
CREATE INDEX IF NOT EXISTS idx_watchers_folder ON watchers(folder_path);

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS instance_settings;

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS instance_settings (
id INTEGER PRIMARY KEY CHECK(id = 1),
setup_completed_at TEXT,
signups_enabled INTEGER NOT NULL DEFAULT 0 CHECK(signups_enabled IN (0, 1)),
updated_at TEXT NOT NULL
);
INSERT OR IGNORE INTO instance_settings (id, setup_completed_at, signups_enabled, updated_at)
SELECT
1,
CASE WHEN EXISTS(SELECT 1 FROM users) THEN datetime('now') ELSE NULL END,
CASE WHEN EXISTS(SELECT 1 FROM users) THEN 1 ELSE 0 END,
datetime('now');

View File

@@ -0,0 +1 @@
-- SQLite column removal requires rebuilding the attachments table.

View File

@@ -0,0 +1 @@
ALTER TABLE attachments ADD COLUMN document_path TEXT;

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS password_reset_tokens;
ALTER TABLE users DROP COLUMN disabled;

View File

@@ -0,0 +1,16 @@
ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0 CHECK(disabled IN (0, 1));
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
initiated_by TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(initiated_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);

View File

@@ -0,0 +1 @@
ALTER TABLE documents DROP COLUMN archived_at;

View File

@@ -0,0 +1 @@
ALTER TABLE documents ADD COLUMN archived_at TEXT;

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,13 +14,14 @@ type Repository struct {
}
type DocumentRecord struct {
ID string `json:"id"`
Path string `json:"path"`
CurrentHash string `json:"currentHash"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
Path string `json:"path"`
CurrentHash string `json:"currentHash"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
}
type UserRecord struct {
@@ -38,6 +39,7 @@ type AttachmentRecord struct {
ContentType string
SizeBytes int64
CreatedAt time.Time
DocumentPath string
}
type PersistDocumentInput struct {
@@ -100,18 +102,64 @@ func (r *Repository) PersistDocument(ctx context.Context, input PersistDocumentI
}
func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) {
return r.getDocumentByPath(ctx, path, false)
}
func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, path string) (*DocumentRecord, error) {
return r.getDocumentByPath(ctx, path, true)
}
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
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
query := `
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
FROM documents
WHERE path = ?
`, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated)
`
if !includeArchived {
query += ` AND archived_at IS NULL`
}
err := r.db.QueryRowContext(ctx, query, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived)
if err != nil {
return nil, err
}
@@ -119,6 +167,13 @@ func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*Docum
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)
}
@@ -128,8 +183,9 @@ func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*Docum
func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, path, current_hash, title, tags, created_at, updated_at
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
FROM documents
WHERE archived_at IS NULL
ORDER BY path ASC
`)
if err != nil {
@@ -137,6 +193,25 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
}
defer rows.Close()
return scanDocumentRows(rows)
}
func (r *Repository) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
FROM documents
WHERE archived_at IS NOT NULL
ORDER BY archived_at DESC
`)
if err != nil {
return nil, fmt.Errorf("list archived documents: %w", err)
}
defer rows.Close()
return scanDocumentRows(rows)
}
func scanDocumentRows(rows *sql.Rows) ([]DocumentRecord, error) {
var records []DocumentRecord
for rows.Next() {
var (
@@ -144,13 +219,21 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
tagsJSON string
created string
updated string
archived sql.NullString
)
if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated); err != nil {
if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived); err != nil {
return nil, fmt.Errorf("scan document: %w", 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)
}
@@ -160,6 +243,41 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
return records, rows.Err()
}
func (r *Repository) ArchiveDocument(ctx context.Context, path string) error {
now := time.Now().UTC().Format(time.RFC3339)
result, err := r.db.ExecContext(ctx, `
UPDATE documents SET archived_at = ? WHERE path = ? AND archived_at IS NULL
`, now, path)
if err != nil {
return fmt.Errorf("archive document %s: %w", path, err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("archive document rows affected %s: %w", path, err)
}
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *Repository) RestoreDocument(ctx context.Context, path string) error {
result, err := r.db.ExecContext(ctx, `
UPDATE documents SET archived_at = NULL WHERE path = ? AND archived_at IS NOT NULL
`, path)
if err != nil {
return fmt.Errorf("restore document %s: %w", path, err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("restore document rows affected %s: %w", path, err)
}
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *Repository) ListUsers(ctx context.Context) ([]UserRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
@@ -238,13 +356,14 @@ func (r *Repository) UpsertUser(ctx context.Context, user UserRecord) (UserRecor
func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at, document_path)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(hash) DO UPDATE SET
original_name = excluded.original_name,
content_type = excluded.content_type,
size_bytes = excluded.size_bytes
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339)); err != nil {
size_bytes = excluded.size_bytes,
document_path = excluded.document_path
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339), record.DocumentPath); err != nil {
return fmt.Errorf("save attachment: %w", err)
}
return nil
@@ -256,10 +375,10 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
created string
)
err := r.db.QueryRowContext(ctx, `
SELECT hash, original_name, content_type, size_bytes, created_at
SELECT hash, original_name, content_type, size_bytes, created_at, COALESCE(document_path, '')
FROM attachments
WHERE hash = ?
`, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created)
`, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created, &record.DocumentPath)
if err != nil {
return nil, err
}
@@ -271,6 +390,33 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
return &record, nil
}
func (r *Repository) ListAttachments(ctx context.Context) ([]AttachmentRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT hash, original_name, content_type, size_bytes, created_at, COALESCE(document_path, '')
FROM attachments
ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("list attachments: %w", err)
}
defer rows.Close()
var attachments []AttachmentRecord
for rows.Next() {
var record AttachmentRecord
var created string
if err := rows.Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created, &record.DocumentPath); err != nil {
return nil, fmt.Errorf("scan attachment: %w", err)
}
record.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return nil, fmt.Errorf("parse attachment created_at: %w", err)
}
attachments = append(attachments, record)
}
return attachments, rows.Err()
}
type SearchResult struct {
Path string `json:"path"`
Title string `json:"title"`
@@ -281,7 +427,7 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
SELECT d.path, d.title
FROM document_search ds
JOIN documents d ON d.rowid = ds.rowid
WHERE document_search MATCH ?
WHERE document_search MATCH ? AND d.archived_at IS NULL
ORDER BY rank
LIMIT 50
`, query)
@@ -302,6 +448,32 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
return results, rows.Err()
}
func (r *Repository) SearchDocumentsByTag(ctx context.Context, tag string) ([]SearchResult, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT d.path, d.title
FROM documents d
WHERE EXISTS (
SELECT 1 FROM json_each(d.tags) WHERE json_each.value = ?
) AND d.archived_at IS NULL
ORDER BY d.path
`, tag)
if err != nil {
return nil, fmt.Errorf("search documents by tag: %w", err)
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var result SearchResult
if err := rows.Scan(&result.Path, &result.Title); err != nil {
return nil, fmt.Errorf("scan tag search result: %w", err)
}
results = append(results, result)
}
return results, rows.Err()
}
func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error {
if _, err := r.db.ExecContext(ctx, `
UPDATE document_search SET content = ?

View File

@@ -123,6 +123,53 @@ func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
return s.repo.ListDocuments(ctx)
}
func (s *Service) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) {
return s.repo.ListArchivedDocuments(ctx)
}
func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return err
}
record, _, err := s.resolveRecord(ctx, path)
if err != nil {
return err
}
if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil {
return err
}
if s.onChange != nil {
s.onChange(DocumentChange{
Path: record.Path,
Title: record.Title,
Hash: record.CurrentHash,
})
}
return nil
}
func (s *Service) RestoreDocument(ctx context.Context, path string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.repo.RestoreDocument(ctx, path); err != nil {
return err
}
if _, err := s.syncSourceDirLocked(ctx); err != nil {
return err
}
return nil
}
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
if _, err := s.SyncSourceDir(ctx); err != nil {
return nil, err
@@ -190,7 +237,17 @@ func (s *Service) SaveSourcePageWithBaseHash(ctx context.Context, requestPath st
record, _, err := s.resolveRecord(ctx, requestPath)
if err != nil {
return nil, err
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if baseHash != "" {
return nil, err
}
path, pathErr := normalizeWritableDocumentPath(requestPath)
if pathErr != nil {
return nil, pathErr
}
record = &DocumentRecord{Path: path}
}
if baseHash != "" && record.CurrentHash != baseHash {
@@ -270,6 +327,21 @@ func normalizeRequestPathCandidates(path string) []string {
return []string{path}
}
func normalizeWritableDocumentPath(path string) (string, error) {
path = strings.Trim(path, "/")
if path == "" {
return "", fmt.Errorf("document path is required")
}
path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
if path == "." || strings.HasPrefix(path, "../") || path == ".." || filepath.IsAbs(path) {
return "", fmt.Errorf("invalid document path")
}
if !strings.HasSuffix(strings.ToLower(path), ".md") {
path += ".md"
}
return path, nil
}
func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
content, err := os.ReadFile(path)
if err != nil {
@@ -292,7 +364,7 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
return nil, fmt.Errorf("store content file %s: %w", path, err)
}
existing, err := s.repo.GetDocumentByPath(ctx, relative)
existing, err := s.repo.GetDocumentByPathIncludingArchived(ctx, relative)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
@@ -322,6 +394,10 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
s.logger.Warn("index search content", "path", relative, "error", err)
}
if existing != nil && existing.ArchivedAt != nil {
return nil, nil
}
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
return &DocumentChange{
Path: relative,

View File

@@ -4,11 +4,13 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/markdown"
@@ -110,6 +112,134 @@ func TestSaveSourcePageWithBaseHashAcceptsMatchingHash(t *testing.T) {
}
}
func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(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)
}
serverContent := "# Hello\n\nServer edit"
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
t.Fatalf("write server edit: %v", err)
}
_, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nQueued offline edit", page.Hash)
var conflict *DocumentConflictError
if !errors.As(err, &conflict) {
t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err)
}
resolvedContent := conflict.CurrentContent + "\n\nResolved local addition"
updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", resolvedContent, conflict.CurrentHash)
if err != nil {
t.Fatalf("SaveSourcePageWithBaseHash() latest hash error = %v", err)
}
if updated.Hash == conflict.CurrentHash {
t.Fatal("expected resolved save to produce a new hash")
}
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
if err != nil {
t.Fatalf("read hello.md: %v", err)
}
if string(content) != resolvedContent {
t.Fatalf("resolved content = %q, want %q", string(content), resolvedContent)
}
}
func TestSaveSourcePageCreatesNewDocument(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
content := "# Inbox Note\n\nCreated from clipboard"
page, err := service.SaveSourcePageWithBaseHash(ctx, "inbox/note", content, "")
if err != nil {
t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err)
}
if page.Path != "inbox/note.md" {
t.Fatalf("page path = %q, want inbox/note.md", page.Path)
}
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "note.md"))
if err != nil {
t.Fatalf("read new document: %v", err)
}
if string(written) != content {
t.Fatalf("new document content = %q, want %q", string(written), content)
}
}
func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
service, _ := setupDocsTestService(t)
ctx := context.Background()
_, err := service.SaveSourcePageWithBaseHash(ctx, "../outside", "# Outside\n", "")
if err == nil {
t.Fatal("expected traversal path to be rejected")
}
}
func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
service, _ := setupDocsTestService(t)
ctx := context.Background()
if _, err := service.SyncSourceDir(ctx); err != nil {
t.Fatalf("initial SyncSourceDir() error = %v", err)
}
var changed []DocumentChange
service.OnChange(func(change DocumentChange) {
changed = append(changed, change)
})
if err := service.ArchiveDocument(ctx, "hello.md"); err != nil {
t.Fatalf("ArchiveDocument() error = %v", err)
}
if len(changed) != 1 {
t.Fatalf("changes after archive = %d, want 1", len(changed))
}
changed = nil
changes, err := service.SyncSourceDir(ctx)
if err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
if len(changes) != 0 {
t.Fatalf("sync changes = %#v, want none for archived document", changes)
}
if len(changed) != 0 {
t.Fatalf("broadcast changes = %#v, want none for archived document", changed)
}
}
func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
service, sourceDir := setupDocsTestService(t)
ctx := context.Background()
for i := 0; i < 100; i++ {
name := filepath.Join(sourceDir, fmt.Sprintf("note-%03d.md", i))
content := fmt.Sprintf("# Note %03d\n\nDeterministic body %03d\n", i, i)
if err := os.WriteFile(name, []byte(content), 0o644); err != nil {
t.Fatalf("create %s: %v", name, err)
}
}
start := time.Now()
changes, err := service.SyncSourceDir(ctx)
if err != nil {
t.Fatalf("SyncSourceDir() error = %v", err)
}
if elapsed := time.Since(start); elapsed > 10*time.Second {
t.Fatalf("SyncSourceDir() took %s, want <= 10s", elapsed)
}
if len(changes) != 101 {
t.Fatalf("changes = %d, want 101", len(changes))
}
}
func setupDocsTestService(t *testing.T) (*Service, string) {
t.Helper()

View File

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

View File

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

View File

@@ -0,0 +1,141 @@
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},
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 fmt.Errorf("postmark server token not configured")
}
if p.from == "" {
return fmt.Errorf("postmark from address not configured")
}
if len(to) == 0 {
return 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 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 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 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 }

View File

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

View File

@@ -0,0 +1,344 @@
package email
import (
"context"
"database/sql"
"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.
if row.Attempts >= 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", row.Attempts, "error", err)
return nil
}
backoff := q.backoff(row.Attempts)
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)
}
// TemporaryError (Postmark 5xx) is retried with the same backoff as
// other errors; the max-attempts cap still bounds total work.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err)
return nil
}
func (q *Queue) backoff(attempts int) time.Duration {
// 2^attempts seconds, capped: 2s, 4s, 8s, 16s, ... up to maxBackoff.
d := time.Duration(math.Pow(2, float64(attempts))) * time.Second
if d > q.maxBackoff {
d = q.maxBackoff
}
if d < time.Second {
d = time.Second
}
return d
}
// PendingCount returns the number of rows in pending status, primarily for
// tests and operator dashboards.
func (q *Queue) PendingCount(ctx context.Context) (int, error) {
var count int
err := q.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM email_queue WHERE status = 'pending'`).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
// ListFailed returns rows that have exhausted their retries, for operator
// inspection.
func (q *Queue) ListFailed(ctx context.Context, limit int) ([]QueueRow, error) {
if limit <= 0 {
limit = 50
}
rows, err := q.db.QueryContext(ctx, `
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
FROM email_queue
WHERE status = 'failed'
ORDER BY created_at DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []QueueRow
for rows.Next() {
row, err := scanQueueRow(rows)
if err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func scanQueueRow(rows *sql.Rows) (QueueRow, error) {
var r QueueRow
var sentAt, lastError string
var nextAttempt, createdAt string
if err := rows.Scan(&r.ID, &r.NotificationID, &r.ToAddress, &r.Subject, &r.Body, &r.Status,
&r.Attempts, &r.MaxAttempts, &lastError, &nextAttempt, &sentAt, &createdAt); err != nil {
return QueueRow{}, fmt.Errorf("scan queue row: %w", err)
}
r.LastError = lastError
var err error
r.NextAttemptAt, err = time.Parse(time.RFC3339, nextAttempt)
if err != nil {
return QueueRow{}, fmt.Errorf("parse next_attempt_at: %w", err)
}
r.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
if err != nil {
return QueueRow{}, fmt.Errorf("parse created_at: %w", err)
}
if sentAt != "" {
t, err := time.Parse(time.RFC3339, sentAt)
if err == nil {
r.SentAt = &t
}
}
return r, nil
}
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
func truncateErr(s string) string {
if len(s) > 512 {
return s[:512] + "..."
}
return s
}
// randomQueueID produces an id with enough entropy to avoid collisions. We
// keep a small helper in-package to avoid an import cycle on auth.
func randomQueueID() (string, error) {
id, err := randomToken(18)
if err != nil {
return "", err
}
return "email:" + id, nil
}

View File

@@ -0,0 +1,248 @@
package email
import (
"context"
"database/sql"
"errors"
"strings"
"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 >= 1
}); err != nil {
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, 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_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 int
attempts int
successCount int
}
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
f.attempts++
if f.attempts <= f.failTimes {
return errors.New("transient failure")
}
f.successCount++
return nil
}
func waitFor(ctx context.Context, cond func() bool) error {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if cond() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}

View File

@@ -0,0 +1,67 @@
package email
import (
"context"
"fmt"
"log/slog"
"net/smtp"
"github.com/tim/cairnquire/apps/server/internal/config"
)
type Sender interface {
Send(ctx context.Context, to []string, subject, body string) error
}
type SMTPConfig struct {
Host string
Port int
From string
}
type SMTPSender struct {
cfg SMTPConfig
logger *slog.Logger
}
func NewSMTPSender(cfg config.EmailConfig, logger *slog.Logger) *SMTPSender {
return &SMTPSender{
cfg: SMTPConfig{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
From: cfg.From,
},
logger: logger,
}
}
func (s *SMTPSender) Send(ctx context.Context, to []string, subject, body string) error {
if s.cfg.Host == "" {
return fmt.Errorf("email host not configured")
}
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
msg := []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s\r\n", s.cfg.From, to[0], subject, body))
s.logger.Info("sending email", "to", to, "subject", subject, "addr", addr)
// Use SMTP without auth for local testing (Mailpit/MailHog)
var auth smtp.Auth
err := smtp.SendMail(addr, auth, s.cfg.From, to, msg)
if err != nil {
return fmt.Errorf("send email: %w", err)
}
return nil
}
type NoOpSender struct {
logger *slog.Logger
}
func NewNoOpSender(logger *slog.Logger) *NoOpSender {
return &NoOpSender{logger: logger}
}
func (n *NoOpSender) Send(ctx context.Context, to []string, subject, body string) error {
n.logger.Info("email noop", "to", to, "subject", subject, "body_len", len(body))
return nil
}

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,10 @@ type adminUserInput struct {
Role string `json:"role"`
}
type adminSettingsInput struct {
SignupsEnabled bool `json:"signupsEnabled"`
}
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
var input adminUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
@@ -95,7 +99,6 @@ func (s *Server) handleAdminWorkspace(w http.ResponseWriter, r *http.Request) {
"databasePath": s.config.Database.Path,
"documentCount": len(documents),
"userCount": len(users),
"webDistDir": s.config.Web.DistDir,
},
"documents": documents,
})
@@ -113,6 +116,30 @@ func (s *Server) handleAdminWorkspaceSync(w http.ResponseWriter, r *http.Request
})
}
func (s *Server) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": settings})
}
func (s *Server) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
var input adminSettingsInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
settings, err := s.auth.UpdateSignupsEnabled(r.Context(), principal, input.SignupsEnabled)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": settings})
}
func (s *Server) normalizeAdminUserInput(input adminUserInput) (docs.UserRecord, bool) {
email := strings.TrimSpace(strings.ToLower(input.Email))
if email == "" {

View File

@@ -0,0 +1,954 @@
package httpserver
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"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"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/realtime"
"github.com/tim/cairnquire/apps/server/internal/store"
"github.com/tim/cairnquire/apps/server/internal/sync"
)
type testEmailSender struct {
body string
to []string
}
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
s.to = append([]string(nil), to...)
s.body = body
return nil
}
type apiTestServer struct {
handler http.Handler
auth *auth.Service
docs *docs.Service
userID string
root string
}
func newAPITestServer(t *testing.T) *apiTestServer {
return newAPITestServerWithSetup(t, true)
}
func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
return newAPITestServerWithSetupAndDevMode(t, setupComplete, false)
}
func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMode bool) *apiTestServer {
t.Helper()
ctx := context.Background()
root := t.TempDir()
sourceDir := filepath.Join(root, "content")
storeDir := filepath.Join(root, "files")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatalf("create source dir: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nInitial"), 0o644); err != nil {
t.Fatalf("write source fixture: %v", err)
}
db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(root, "db.sqlite")})
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
t.Fatalf("apply migrations: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
docRepo := docs.NewRepository(db.SQL())
docService := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), docRepo, logger)
if _, err := docService.SyncSourceDir(ctx); err != nil {
t.Fatalf("sync source fixture: %v", err)
}
authService, err := auth.NewService(auth.NewRepository(db.SQL()), "http://localhost:8080")
if err != nil {
t.Fatalf("create auth service: %v", err)
}
var user auth.User
if setupComplete {
user, err = authService.CompleteInitialSetup(ctx, "editor@example.com", "Editor", "very secure password", false)
if err != nil {
t.Fatalf("create test user: %v", err)
}
}
syncRepo := sync.NewRepository(db.SQL())
handler, err := New(Dependencies{
Config: config.Config{
Content: config.ContentConfig{
SourceDir: sourceDir,
StoreDir: storeDir,
},
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
DevMode: devMode,
},
Logger: logger,
Documents: docService,
Repository: docRepo,
ContentStore: contentStore,
Hub: realtime.NewHub(logger),
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
SyncRepo: syncRepo,
Auth: authService,
})
if err != nil {
t.Fatalf("create http server: %v", err)
}
return &apiTestServer{
handler: handler,
auth: authService,
docs: docService,
userID: user.ID,
root: root,
}
}
func TestDevModeLogoutSuppressesAutomaticBrowserPrincipal(t *testing.T) {
server := newAPITestServerWithSetupAndDevMode(t, true, true)
devMe := httptest.NewRecorder()
server.handler.ServeHTTP(devMe, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
if devMe.Code != http.StatusOK || !bytes.Contains(devMe.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev me response = %d %q, want dev principal", devMe.Code, devMe.Body.String())
}
logout := httptest.NewRecorder()
server.handler.ServeHTTP(logout, jsonRequest(http.MethodPost, "/api/auth/logout", map[string]any{}))
if logout.Code != http.StatusOK {
t.Fatalf("logout response = %d %q, want OK", logout.Code, logout.Body.String())
}
devLogoutCookie := cookieByName(t, logout.Result().Cookies(), devLogoutCookieName)
if devLogoutCookie.Value != "1" {
t.Fatalf("dev logout cookie = %#v, want opt-out marker", devLogoutCookie)
}
loggedOutMeRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
loggedOutMeRequest.AddCookie(devLogoutCookie)
loggedOutMe := httptest.NewRecorder()
server.handler.ServeHTTP(loggedOutMe, loggedOutMeRequest)
if loggedOutMe.Code != http.StatusUnauthorized {
t.Fatalf("logged out me response = %d %q, want unauthorized", loggedOutMe.Code, loggedOutMe.Body.String())
}
loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
loginPageRequest.AddCookie(devLogoutCookie)
loginPage := httptest.NewRecorder()
server.handler.ServeHTTP(loginPage, loginPageRequest)
if loginPage.Code != http.StatusOK || !bytes.Contains(loginPage.Body.Bytes(), []byte("data-dev-login")) {
t.Fatalf("login page response = %d %q, want dev login button", loginPage.Code, loginPage.Body.String())
}
devLoginRequest := jsonRequest(http.MethodPost, "/api/auth/login/dev", map[string]any{})
devLoginRequest.AddCookie(devLogoutCookie)
devLogin := httptest.NewRecorder()
server.handler.ServeHTTP(devLogin, devLoginRequest)
if devLogin.Code != http.StatusOK || !bytes.Contains(devLogin.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev login response = %d %q, want dev principal", devLogin.Code, devLogin.Body.String())
}
clearedByDevLogin := cookieByName(t, devLogin.Result().Cookies(), devLogoutCookieName)
if clearedByDevLogin.MaxAge != -1 {
t.Fatalf("dev login cookie = %#v, want MaxAge -1", clearedByDevLogin)
}
devTokenRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
devTokenRequest.AddCookie(devLogoutCookie)
devTokenRequest.Header.Set("Authorization", "Bearer dev")
devToken := httptest.NewRecorder()
server.handler.ServeHTTP(devToken, devTokenRequest)
if devToken.Code != http.StatusOK || !bytes.Contains(devToken.Body.Bytes(), []byte(`"method":"dev"`)) {
t.Fatalf("dev token response = %d %q, want dev principal", devToken.Code, devToken.Body.String())
}
loginRequest := jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "very secure password",
})
loginRequest.AddCookie(devLogoutCookie)
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, loginRequest)
if login.Code != http.StatusOK {
t.Fatalf("login response = %d %q, want OK", login.Code, login.Body.String())
}
sessionCookie := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
if sessionCookie.Value == "" {
t.Fatalf("session cookie = %#v, want value", sessionCookie)
}
clearedDevLogoutCookie := cookieByName(t, login.Result().Cookies(), devLogoutCookieName)
if clearedDevLogoutCookie.MaxAge != -1 {
t.Fatalf("cleared dev logout cookie = %#v, want MaxAge -1", clearedDevLogoutCookie)
}
}
func cookieByName(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie {
t.Helper()
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
t.Fatalf("response did not include cookie %q; cookies=%#v", name, cookies)
return nil
}
func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
server := newAPITestServerWithSetup(t, false)
index := httptest.NewRecorder()
server.handler.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/", nil))
if index.Code != http.StatusSeeOther || index.Header().Get("Location") != "/setup" {
t.Fatalf("initial index response = %d %q, want redirect to /setup", index.Code, index.Header().Get("Location"))
}
setupPage := httptest.NewRecorder()
server.handler.ServeHTTP(setupPage, httptest.NewRequest(http.MethodGet, "/setup", nil))
if setupPage.Code != http.StatusOK || !bytes.Contains(setupPage.Body.Bytes(), []byte("Initial setup")) {
t.Fatalf("setup page response = %d %q", setupPage.Code, setupPage.Body.String())
}
setup := httptest.NewRecorder()
server.handler.ServeHTTP(setup, jsonRequest(http.MethodPost, "/api/setup", map[string]any{
"email": "owner@example.com",
"displayName": "Owner",
"password": "correct horse battery staple",
"signupsEnabled": false,
}))
if setup.Code != http.StatusCreated {
t.Fatalf("setup status = %d, want %d; body=%s", setup.Code, http.StatusCreated, setup.Body.String())
}
if len(setup.Result().Cookies()) == 0 {
t.Fatal("expected setup to create a browser session")
}
settings, err := server.auth.GetInstanceSettings(context.Background())
if err != nil {
t.Fatalf("GetInstanceSettings() error = %v", err)
}
if !settings.SetupComplete || settings.SignupsEnabled {
t.Fatalf("settings = %#v, want setup complete with signups disabled", settings)
}
register := httptest.NewRecorder()
server.handler.ServeHTTP(register, jsonRequest(http.MethodPost, "/api/auth/register/password", map[string]any{
"email": "viewer@example.com",
"displayName": "Viewer",
"password": "correct horse battery staple",
}))
if register.Code != http.StatusBadRequest || !bytes.Contains(register.Body.Bytes(), []byte("signups are disabled")) {
t.Fatalf("register response = %d %q, want disabled signup error", register.Code, register.Body.String())
}
}
func TestTokenCreationUsesDedicatedAccountPage(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 {
t.Fatalf("account status = %d, want %d", account.Code, http.StatusOK)
}
if bytes.Contains(account.Body.Bytes(), []byte("data-token-create")) {
t.Fatal("account page should not render the token creation form")
}
if !bytes.Contains(account.Body.Bytes(), []byte(`href="/account/tokens/new"`)) {
t.Fatal("account page should link to the token creation page")
}
createRequest := httptest.NewRequest(http.MethodGet, "/account/tokens/new", nil)
createRequest.AddCookie(session)
create := httptest.NewRecorder()
server.handler.ServeHTTP(create, createRequest)
if create.Code != http.StatusOK || !bytes.Contains(create.Body.Bytes(), []byte("data-token-create")) {
t.Fatalf("token creation page response = %d %q", create.Code, create.Body.String())
}
}
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{}
server.auth.SetEmailSender(sender)
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)
updateRequest := jsonRequest(http.MethodPatch, "/api/admin/auth-users", map[string]any{
"users": []map[string]any{{
"id": server.userID,
"role": "admin",
"disabled": false,
}},
})
updateRequest.AddCookie(session)
update := httptest.NewRecorder()
server.handler.ServeHTTP(update, updateRequest)
if update.Code != http.StatusOK {
t.Fatalf("bulk user update response = %d %q", update.Code, update.Body.String())
}
sendRequest := jsonRequest(http.MethodPost, "/api/admin/auth-users/"+url.PathEscape(server.userID)+"/password-reset", map[string]any{})
sendRequest.AddCookie(session)
send := httptest.NewRecorder()
server.handler.ServeHTTP(send, sendRequest)
if send.Code != http.StatusOK {
t.Fatalf("send password reset response = %d %q", send.Code, send.Body.String())
}
token := passwordResetTokenFromEmail(t, sender.body)
page := httptest.NewRecorder()
server.handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/password-reset?token="+url.QueryEscape(token), nil))
if page.Code != http.StatusOK || !bytes.Contains(page.Body.Bytes(), []byte("data-password-reset")) {
t.Fatalf("password reset page response = %d %q", page.Code, page.Body.String())
}
reset := httptest.NewRecorder()
server.handler.ServeHTTP(reset, jsonRequest(http.MethodPost, "/api/auth/password/reset", map[string]string{
"token": token,
"newPassword": "new very secure password",
}))
if reset.Code != http.StatusOK {
t.Fatalf("password reset response = %d %q", reset.Code, reset.Body.String())
}
newLogin := httptest.NewRecorder()
server.handler.ServeHTTP(newLogin, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com",
"password": "new very secure password",
}))
if newLogin.Code != http.StatusOK {
t.Fatalf("new password login response = %d %q", newLogin.Code, newLogin.Body.String())
}
}
func passwordResetTokenFromEmail(t *testing.T, body string) string {
t.Helper()
for _, line := range strings.Split(body, "\n") {
if !strings.HasPrefix(line, "http") {
continue
}
parsed, err := url.Parse(line)
if err != nil {
t.Fatalf("parse password reset URL: %v", err)
}
if token := parsed.Query().Get("token"); token != "" {
return token
}
}
t.Fatalf("password reset body does not contain a link: %q", body)
return ""
}
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper()
return s.tokenForUser(t, s.userID, scopes...)
}
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.ScopeDocsRead, auth.ScopeDocsWrite)
body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
request.Header.Set("Content-Type", contentType)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
response := recorder.Result()
if response.StatusCode != http.StatusCreated {
t.Fatalf("upload status = %d, want %d; body=%s", response.StatusCode, http.StatusCreated, recorder.Body.String())
}
var payload struct {
Hash string `json:"hash"`
ContentType string `json:"contentType"`
AttachmentURL string `json:"attachmentUrl"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if payload.Hash == "" {
t.Fatal("expected upload response hash")
}
if payload.ContentType != "application/pdf" {
t.Fatalf("contentType = %q, want application/pdf", payload.ContentType)
}
if payload.AttachmentURL != "/attachments/"+payload.Hash {
t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash)
}
download := httptest.NewRequest(http.MethodGet, payload.AttachmentURL, nil)
downloadRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(downloadRecorder, download)
downloadResponse := downloadRecorder.Result()
if downloadResponse.StatusCode != http.StatusOK {
t.Fatalf("download status = %d, want %d", downloadResponse.StatusCode, http.StatusOK)
}
if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType {
t.Fatalf("download content-type = %q, want %q", got, payload.ContentType)
}
if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.pdf"` {
t.Fatalf("content-disposition = %q, want sanitized filename", got)
}
if downloadRecorder.Body.String() != "%PDF-1.4\n" {
t.Fatalf("download body = %q", downloadRecorder.Body.String())
}
}
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)
body, contentType := multipartBody(t, "file", "note.txt", []byte("plain attachment\n"))
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
request.Header.Set("Content-Type", contentType)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
}
}
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)
request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("sync init status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
var payload struct {
SnapshotID string `json:"snapshotId"`
ServerSnapshot []sync.FileEntry `json:"serverSnapshot"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode sync init response: %v", err)
}
if payload.SnapshotID == "" {
t.Fatal("expected snapshot id")
}
if len(payload.ServerSnapshot) != 1 || payload.ServerSnapshot[0].Path != "hello.md" {
t.Fatalf("server snapshot = %#v, want hello.md entry", payload.ServerSnapshot)
}
fetch := httptest.NewRequest(http.MethodGet, "/api/content/"+payload.ServerSnapshot[0].Hash, nil)
fetch.Header.Set("Authorization", "Bearer "+token)
fetchRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(fetchRecorder, fetch)
if fetchRecorder.Code != http.StatusOK {
t.Fatalf("content fetch status = %d, want %d", fetchRecorder.Code, http.StatusOK)
}
if fetchRecorder.Body.String() != "# Hello\n\nInitial" {
t.Fatalf("content fetch body = %q", fetchRecorder.Body.String())
}
}
func TestAPISyncInitRejectsTokenWithoutSyncWriteScope(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, 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())
}
var initPayload struct {
SnapshotID string `json:"snapshotId"`
}
if err := json.Unmarshal(initRecorder.Body.Bytes(), &initPayload); err != nil {
t.Fatalf("decode sync init response: %v", err)
}
if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer update"), 0o644); err != nil {
t.Fatalf("write server update: %v", err)
}
deltaRequest := jsonRequest(http.MethodPost, "/api/sync/delta", map[string]any{
"snapshotId": initPayload.SnapshotID,
"clientDelta": []sync.Change{},
})
deltaRequest.Header.Set("Authorization", "Bearer "+token)
deltaRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(deltaRecorder, deltaRequest)
if deltaRecorder.Code != http.StatusOK {
t.Fatalf("sync delta status = %d, want %d; body=%s", deltaRecorder.Code, http.StatusOK, deltaRecorder.Body.String())
}
var deltaPayload struct {
ServerDelta []sync.Change `json:"serverDelta"`
Conflicts []sync.Conflict `json:"conflicts"`
}
if err := json.Unmarshal(deltaRecorder.Body.Bytes(), &deltaPayload); err != nil {
t.Fatalf("decode sync delta response: %v", err)
}
if len(deltaPayload.ServerDelta) != 1 {
t.Fatalf("serverDelta = %#v, want one update", deltaPayload.ServerDelta)
}
change := deltaPayload.ServerDelta[0]
if change.Type != sync.ChangeUpdate || change.Path != "hello.md" || change.Hash == "" {
t.Fatalf("server delta change = %#v, want update for hello.md with hash", change)
}
if len(deltaPayload.Conflicts) != 0 {
t.Fatalf("conflicts = %#v, want none", deltaPayload.Conflicts)
}
}
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(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)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
var list struct {
Documents []struct {
Path string `json:"path"`
Hash string `json:"hash"`
} `json:"documents"`
}
if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil {
t.Fatalf("decode documents: %v", err)
}
if len(list.Documents) != 1 {
t.Fatalf("documents = %#v, want one document", list.Documents)
}
if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer edit"), 0o644); err != nil {
t.Fatalf("write server edit: %v", err)
}
save := jsonRequest(http.MethodPost, "/api/documents/hello", map[string]string{
"content": "# Hello\n\nClient edit",
"baseHash": list.Documents[0].Hash,
})
save.Header.Set("Authorization", "Bearer "+token)
saveRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(saveRecorder, save)
if saveRecorder.Code != http.StatusConflict {
t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusConflict, saveRecorder.Body.String())
}
var conflict struct {
Status string `json:"status"`
Path string `json:"path"`
BaseHash string `json:"baseHash"`
CurrentHash string `json:"currentHash"`
CurrentContent string `json:"currentContent"`
}
if err := json.Unmarshal(saveRecorder.Body.Bytes(), &conflict); err != nil {
t.Fatalf("decode conflict response: %v", err)
}
if conflict.Status != "conflict" || conflict.Path != "hello.md" {
t.Fatalf("conflict payload = %#v, want conflict for hello.md", conflict)
}
if conflict.BaseHash != list.Documents[0].Hash {
t.Fatalf("baseHash = %q, want %q", conflict.BaseHash, list.Documents[0].Hash)
}
if conflict.CurrentHash == "" || conflict.CurrentHash == conflict.BaseHash {
t.Fatalf("currentHash = %q, baseHash = %q", conflict.CurrentHash, conflict.BaseHash)
}
if conflict.CurrentContent != "# Hello\n\nServer edit" {
t.Fatalf("currentContent = %q", conflict.CurrentContent)
}
}
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.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background()
nestedDir := filepath.Join(server.root, "content", "guide")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatalf("create nested source dir: %v", err)
}
if err := os.WriteFile(filepath.Join(nestedDir, "setup.md"), []byte("# Setup\n\nNested"), 0o644); err != nil {
t.Fatalf("write nested source fixture: %v", err)
}
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
t.Fatalf("sync nested source fixture: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/documents/guide/setup.md/archive", nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
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, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
if strings.Contains(documents.Body.String(), "guide/setup.md") {
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
}
}
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
server := newAPITestServer(t)
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 {
t.Fatalf("write special source fixture: %v", err)
}
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
t.Fatalf("sync special source fixture: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/documents/"+url.PathEscape("@dani.md")+"/archive", nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
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, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
if strings.Contains(documents.Body.String(), "@dani.md") {
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
}
}
func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(fieldName, filename)
if err != nil {
t.Fatalf("create multipart file: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write multipart file: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
return body, writer.FormDataContentType()
}
func jsonRequest(method, path string, payload any) *http.Request {
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
request := httptest.NewRequest(method, path, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
return request
}

View File

@@ -1,43 +0,0 @@
package httpserver
import (
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
)
func (s *Server) appFileServer() http.Handler {
if s.config.Web.DevViteURL != "" {
viteURL, err := url.Parse(s.config.Web.DevViteURL)
if err == nil {
proxy := httputil.NewSingleHostReverseProxy(viteURL)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = viteURL.Host
// Add back the /app/ prefix that was stripped by http.StripPrefix
req.URL.Path = "/app" + req.URL.Path
}
return proxy
}
}
files := http.FileServer(http.Dir(s.config.Web.DistDir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cleanPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/"))
if cleanPath == "." {
cleanPath = "index.html"
}
target := filepath.Join(s.config.Web.DistDir, cleanPath)
if info, err := os.Stat(target); err == nil && !info.IsDir() {
files.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, filepath.Join(s.config.Web.DistDir, "index.html"))
})
}

View File

@@ -0,0 +1,23 @@
package httpserver
import (
"context"
"net/http"
"github.com/tim/cairnquire/apps/server/internal/auth"
)
type authContextKey struct{}
func withPrincipal(ctx context.Context, principal auth.Principal) context.Context {
return context.WithValue(ctx, authContextKey{}, principal)
}
func principalFromContext(ctx context.Context) (auth.Principal, bool) {
principal, ok := ctx.Value(authContextKey{}).(auth.Principal)
return principal, ok
}
func requirePrincipal(r *http.Request) (auth.Principal, bool) {
return principalFromContext(r.Context())
}

View File

@@ -0,0 +1,681 @@
package httpserver
import (
"encoding/json"
"html/template"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/auth"
)
type passwordRegisterRequest struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
Role string `json:"role"`
}
type passwordLoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type initialSetupRequest struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
SignupsEnabled bool `json:"signupsEnabled"`
}
type passkeyBeginRequest struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
}
type passkeyFinishRequest struct {
ChallengeID string `json:"challengeId"`
}
type createTokenRequest struct {
Name string `json:"name"`
Scopes []auth.Scope `json:"scopes"`
ExpiresAt *time.Time `json:"expiresAt"`
}
type deviceStartRequest struct {
Name string `json:"name"`
Scopes []auth.Scope `json:"scopes"`
}
type changePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
type passwordResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"newPassword"`
}
type deleteAccountRequest struct {
CurrentPassword string `json:"currentPassword"`
}
type updateUserRoleRequest struct {
Role string `json:"role"`
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if _, ok := requirePrincipal(r); ok {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
nextPath := r.URL.Query().Get("next")
if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
nextPath = "/account"
}
settings, err := s.auth.GetInstanceSettings(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "load registration settings")
return
}
s.renderTemplate(w, r, http.StatusOK, "login.gohtml", layoutData{
Title: "Sign in",
BodyClass: "page-auth",
BodyTemplate: "login_content",
Data: struct {
Next string
SignupsEnabled bool
DevMode bool
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled, DevMode: s.config.DevMode},
})
}
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "load initial setup state")
return
}
if settings.SetupComplete {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, r, http.StatusOK, "setup.gohtml", layoutData{
Title: "Initial setup",
BodyClass: "page-auth",
BodyTemplate: "setup_content",
})
}
func (s *Server) handlePasswordResetPage(w http.ResponseWriter, r *http.Request) {
s.renderTemplate(w, r, http.StatusOK, "password_reset.gohtml", layoutData{
Title: "Reset password",
BodyClass: "page-auth",
BodyTemplate: "password_reset_content",
Data: struct {
Token string
}{Token: strings.TrimSpace(r.URL.Query().Get("token"))},
})
}
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req initialSetupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if _, err := s.auth.CompleteInitialSetup(r.Context(), req.Email, req.DisplayName, req.Password, req.SignupsEnabled); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "initial admin created; sign in to continue"})
return
}
setSessionCookie(w, r, token, principal.ExpiresAt)
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"principal": principal})
}
func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{
Title: "Account",
BodyClass: "page-account",
BodyTemplate: "account_content",
Data: principal,
})
}
func (s *Server) handleTokenCreatePage(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, r, http.StatusOK, "token_create.gohtml", layoutData{
Title: "Create access token",
BodyClass: "page-account",
BodyTemplate: "token_create_content",
Data: principal,
})
}
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handlePasswordRegister(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passwordRegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
user, err := s.auth.RegisterPasswordUser(r.Context(), req.Email, req.DisplayName, req.Password, req.Role)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": publicUser(user)})
}
func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passwordLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent())
if err != nil {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
setSessionCookie(w, r, token, principal.ExpiresAt)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if principal.SessionID != "" {
_ = s.auth.RevokeSession(r.Context(), principal.SessionID)
}
clearSessionCookie(w)
if s.config.DevMode {
setDevLogoutCookie(w, r)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
func (s *Server) handleDevLogin(w http.ResponseWriter, r *http.Request) {
if !s.config.DevMode || s.devUserID == "" {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "dev login is not available"})
return
}
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
clearDevLogoutCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := s.auth.ChangePassword(r.Context(), principal, req.CurrentPassword, req.NewPassword); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
}
func (s *Server) handlePasswordReset(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passwordResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := s.auth.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
}
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
var req deleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := s.auth.DeleteAccount(r.Context(), principal, req.CurrentPassword); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "account_deleted"})
}
func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passkeyBeginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
options, challengeID, err := s.auth.BeginPasskeyRegistration(r.Context(), req.Email, req.DisplayName, req.Role)
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) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
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.FinishPasskeyRegistration(r.Context(), 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) 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
}
var req passkeyBeginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
options, challengeID, err := s.auth.BeginPasskeyLogin(r.Context(), req.Email)
if err != nil {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
}
func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
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
}
principal, token, err := s.auth.FinishPasskeyLogin(r.Context(), challengeID, r)
if err != nil {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
setSessionCookie(w, r, token, principal.ExpiresAt)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
keys, err := s.auth.ListAPIKeys(r.Context(), principal.UserID)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": keys})
}
func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
var req createTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
created, err := s.auth.CreateAPIKey(r.Context(), principal.UserID, req.Name, req.Scopes, req.ExpiresAt)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusCreated, created)
}
func (s *Server) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
keyID := chi.URLParam(r, "id")
if !strings.HasPrefix(keyID, "api:") {
keyID = "api:" + keyID
}
if err := s.auth.RevokeAPIKey(r.Context(), principal.UserID, keyID); err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "token not found"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req deviceStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
start, err := s.auth.StartDeviceFlow(r.Context(), req.Name, req.Scopes)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, start)
}
func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
return
}
var req struct {
UserCode string `json:"userCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
device, err := s.auth.ApproveDeviceFlow(r.Context(), principal, req.UserCode)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired code"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "approved", "device": map[string]any{"name": device.Name, "scopes": device.Scopes}})
}
func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req struct {
DeviceCode string `json:"deviceCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
created, code, err := s.auth.PollDeviceFlow(r.Context(), req.DeviceCode)
if err != nil {
status := http.StatusBadRequest
if code == "authorization_pending" {
status = http.StatusAccepted
}
writeJSONWithStatus(w, status, map[string]string{"error": code})
return
}
writeJSON(w, http.StatusOK, created)
}
func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
userCode := template.HTMLEscapeString(r.URL.Query().Get("user_code"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html><html><body><main><h1>Approve Device</h1><p>Sign in, then approve this code with <code>POST /api/device/approve</code>.</p><p>User code: <strong>` + userCode + `</strong></p></main></body></html>`))
}
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
s.renderTemplate(w, r, http.StatusOK, "device_verify.gohtml", layoutData{
Title: "Approve device",
BodyClass: "page-auth",
BodyTemplate: "device_verify_content",
Data: struct {
UserCode string
}{UserCode: userCode},
})
}
func (s *Server) handleAdminUpdateUserRole(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
userID := chi.URLParam(r, "id")
var req updateUserRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
user, err := s.auth.UpdateUserRole(r.Context(), principal, userID, req.Role)
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) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.auth.ListUsers(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
public := make([]map[string]any, 0, len(users))
for _, user := range users {
public = append(public, publicUser(user))
}
writeJSON(w, http.StatusOK, map[string]any{"users": public})
}
func (s *Server) handleAdminUpdateAuthUsers(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
var req struct {
Users []auth.UserAccessUpdate `json:"users"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
users, err := s.auth.UpdateUserAccess(r.Context(), principal, req.Users)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
public := make([]map[string]any, 0, len(users))
for _, user := range users {
public = append(public, publicUser(user))
}
writeJSON(w, http.StatusOK, map[string]any{"users": public})
}
func (s *Server) handleAdminSendPasswordReset(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if err := s.auth.SendPasswordReset(r.Context(), principal, chi.URLParam(r, "id")); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset_sent"})
}
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
if s.authLimiter.Allow(authRateLimitKey(r)) {
return true
}
writeJSONWithStatus(w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"})
return false
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
clearDevLogoutCookie(w)
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: isSecureRequest(r),
})
}
func setDevLogoutCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: devLogoutCookieName,
Value: "1",
Path: "/",
MaxAge: int((24 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: isSecureRequest(r),
})
}
func clearDevLogoutCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: devLogoutCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
func publicUser(user auth.User) map[string]any {
return map[string]any{
"id": user.ID,
"email": user.Email,
"displayName": user.DisplayName,
"role": user.Role,
"disabled": user.Disabled,
"createdAt": user.CreatedAt,
"lastSeenAt": user.LastSeenAt,
}
}
func requireSessionPrincipal(w http.ResponseWriter, principal auth.Principal) bool {
if principal.Method == "session" {
return true
}
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "browser session required"})
return false
}
func requestIP(r *http.Request) string {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
}
return r.RemoteAddr
}
func isSecureRequest(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}

View File

@@ -0,0 +1,308 @@
package httpserver
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
)
// Comments
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
documentID := r.URL.Query().Get("document_id")
if documentID == "" {
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")
var comments []collaboration.Comment
if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
} else {
comments, err = s.collaboration.ListComments(r.Context(), documentID)
}
if err != nil {
s.logger.Warn("list comments", "error", err)
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"comments": comments})
}
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var input collaboration.CreateCommentInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
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 {
s.logger.Warn("create comment", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusCreated, comment)
}
func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
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()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// Notifications
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
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
}
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
if err != nil {
s.logger.Warn("list notifications", "error", err)
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": notifications})
}
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
notificationID := chi.URLParam(r, "id")
if err := s.collaboration.MarkNotificationRead(r.Context(), principal, notificationID); err != nil {
s.logger.Warn("mark notification read", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if err := s.collaboration.MarkAllNotificationsRead(r.Context(), principal); err != nil {
s.logger.Warn("mark all notifications read", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleNotificationBell(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSON(w, http.StatusOK, collaboration.NotificationBell{UnreadCount: 0, Items: []collaboration.Notification{}})
return
}
bell, err := s.collaboration.GetBellStatus(r.Context(), principal)
if err != nil {
s.logger.Warn("notification bell", "error", err)
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, bell)
}
// Watchers
func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var req struct {
DocumentID string `json:"documentId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("watch document", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "watching"})
}
func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var req struct {
DocumentID string `json:"documentId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("unwatch document", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unwatched"})
}
func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var req struct {
FolderPath string `json:"folderPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil {
s.logger.Warn("watch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "watching"})
}
func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var req struct {
FolderPath string `json:"folderPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil {
s.logger.Warn("unwatch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unwatched"})
}
func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
writeJSON(w, http.StatusOK, map[string]bool{"watching": false})
return
}
documentID := r.URL.Query().Get("document_id")
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
if err != nil {
s.logger.Warn("is watching", "error", err)
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]bool{"watching": watching})
}
// Document page with comments
func (s *Server) renderDocumentPageWithComments(w http.ResponseWriter, r *http.Request, pagePath string) {
// This is a helper that could be used to inject comments into the template
// For now, comments are fetched client-side via JS
s.renderDocumentPage(w, r, pagePath)
}

View File

@@ -10,23 +10,27 @@ import (
"io"
"io/fs"
"net/http"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
type layoutData struct {
Title string
WebEnabled bool
BodyClass string
BodyTemplate string
Data any
Principal *auth.Principal
}
type indexData struct {
@@ -41,6 +45,10 @@ type documentData struct {
Hash string
HTML template.HTML
Breadcrumbs []breadcrumb
CanRead bool
CanWrite bool
CanAdmin bool
CanComment bool
}
type documentEditData struct {
@@ -51,6 +59,7 @@ type documentEditData struct {
Hash string
Source string
Breadcrumbs []breadcrumb
CanWrite bool
}
type breadcrumb struct {
@@ -64,11 +73,13 @@ type errorData struct {
}
type browserData struct {
Columns []browserColumn
Columns []browserColumn
CanWrite bool
}
type browserColumn struct {
Title string
Path string
Items []browserItem
}
@@ -89,20 +100,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"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
browser := buildBrowser(items, activeFolder)
browser.CanWrite = s.canWriteFolder(r, activeFolder)
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
WebEnabled: s.webEnabled,
BodyClass: "page-index",
BodyTemplate: "index_content",
Data: indexData{
Browser: buildBrowser(items, activeFolder),
Browser: browser,
},
})
}
@@ -115,10 +138,14 @@ 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, http.StatusOK, "search.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query,
WebEnabled: s.webEnabled,
BodyClass: "page-search",
BodyTemplate: "search_content",
Data: struct {
@@ -131,12 +158,49 @@ 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, "")
}
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
if isContentAssetPath(pagePath) {
s.handleContentAsset(w, r, pagePath)
return
}
if editPath, ok := trimEditSuffix(pagePath); ok {
s.renderDocumentEditor(w, r, editPath)
return
@@ -144,6 +208,64 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
s.renderDocumentPage(w, r, pagePath)
}
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) {
s.renderError(w, r, http.StatusNotFound, "asset not found")
return
}
fullPath := filepath.Join(s.config.Content.SourceDir, cleanPath)
sourceRoot, err := filepath.Abs(s.config.Content.SourceDir)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "resolve content root")
return
}
assetAbs, err := filepath.Abs(fullPath)
if err != nil {
s.renderError(w, r, http.StatusNotFound, "asset not found")
return
}
relative, err := filepath.Rel(sourceRoot, assetAbs)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
s.renderError(w, r, http.StatusNotFound, "asset not found")
return
}
file, err := os.Open(assetAbs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
s.renderError(w, r, http.StatusNotFound, "asset not found")
return
}
s.renderError(w, r, http.StatusInternalServerError, "read asset")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.IsDir() {
s.renderError(w, r, http.StatusNotFound, "asset not found")
return
}
buffer := make([]byte, 512)
n, _ := file.Read(buffer)
if _, err := file.Seek(0, io.SeekStart); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "read asset")
return
}
contentType := http.DetectContentType(buffer[:n])
if !isAllowedContentType(contentType) {
s.renderError(w, r, http.StatusNotFound, "asset not found")
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
if err != nil {
@@ -154,26 +276,47 @@ 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
}
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
}
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content",
Data: documentEditData{
Browser: buildBrowser(items, page.Path),
Browser: browser,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
Source: page.Content,
Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: canWrite,
},
})
}
@@ -188,31 +331,73 @@ 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
}
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
}
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
Browser: buildBrowser(items, page.Path),
Browser: browser,
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,
},
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.handleReadyz(w, r)
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
@@ -226,11 +411,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
contentStatus = err.Error()
}
writeJSON(w, http.StatusOK, map[string]string{
ready := dbStatus == "ok" && contentStatus == "ok"
payload := map[string]string{
"status": "ok",
"database": dbStatus,
"contentStore": contentStatus,
})
}
if !ready {
payload["status"] = "unavailable"
writeJSONWithStatus(w, http.StatusServiceUnavailable, payload)
return
}
writeJSON(w, http.StatusOK, payload)
}
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
@@ -248,21 +440,35 @@ func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results, err := s.repository.SearchDocuments(ctx, query)
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)
}
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})
}
@@ -285,6 +491,13 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "read upload")
return
}
duplicateAction := r.FormValue("duplicateAction")
switch duplicateAction {
case "", "overwrite", "rename", "reuse":
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid duplicate action"})
return
}
contentType := http.DetectContentType(content)
if !isAllowedContentType(contentType) {
@@ -298,24 +511,151 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
existingAttachment, err := s.repository.GetAttachment(r.Context(), record.Hash)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing attachment")
return
}
if existingAttachment != nil && duplicateAction != "overwrite" && duplicateAction != "rename" {
existingURL := attachmentRecordURL(existingAttachment)
if duplicateAction == "reuse" {
writeUploadSuccess(w, http.StatusOK, existingAttachment.Hash, existingAttachment.ContentType, attachmentRecordKind(existingAttachment), existingURL)
return
}
writeUploadConflict(w, "attachment", existingAttachment.Hash, existingAttachment.DocumentPath, existingURL)
return
}
uploadURL := "/attachments/" + record.Hash
uploadKind := "attachment"
documentPath := ""
if path, readable, err := readableDocumentPath(header.Filename, r.FormValue("folder")); err != nil {
s.renderError(w, r, http.StatusBadRequest, "invalid document upload path")
return
} else if readable && isReadableContentType(contentType) && utf8.Valid(content) {
if _, err := s.documents.ListDocuments(r.Context()); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
return
}
existing, err := s.repository.GetDocumentByPath(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":
path, err = s.availableDocumentPath(r.Context(), path)
if err != nil {
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
default:
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"
}
if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
Hash: record.Hash,
OriginalName: header.Filename,
ContentType: contentType,
SizeBytes: record.Size,
CreatedAt: time.Now().UTC(),
DocumentPath: documentPath,
}); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "persist upload metadata")
return
}
writeJSONWithStatus(w, http.StatusCreated, map[string]string{
"hash": record.Hash,
"contentType": contentType,
"attachmentUrl": "/attachments/" + record.Hash,
writeUploadSuccess(w, http.StatusCreated, record.Hash, contentType, uploadKind, uploadURL)
}
func (s *Server) availableDocumentPath(ctx context.Context, path string) (string, error) {
extension := filepath.Ext(path)
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)
if errors.Is(err, sql.ErrNoRows) {
return candidate, nil
}
if err != nil {
return "", err
}
if existing == nil {
return candidate, nil
}
}
}
func writeUploadConflict(w http.ResponseWriter, conflictType, hash, path, url string) {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "upload already exists",
"conflictType": conflictType,
"existingHash": hash,
"existingPath": path,
"existingUrl": url,
})
}
func writeUploadSuccess(w http.ResponseWriter, status int, hash, contentType, kind, url string) {
writeJSONWithStatus(w, status, map[string]string{
"hash": hash,
"contentType": contentType,
"kind": kind,
"url": url,
"attachmentUrl": url,
})
}
func attachmentRecordURL(record *docs.AttachmentRecord) string {
if record.DocumentPath != "" {
return documentPageURL(record.DocumentPath)
}
return "/attachments/" + record.Hash
}
func attachmentRecordKind(record *docs.AttachmentRecord) string {
if record.DocumentPath != "" {
return "document"
}
return "attachment"
}
func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
attachment, err := s.repository.GetAttachment(r.Context(), hash)
@@ -341,7 +681,13 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
}
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, status int, name string, data layoutData) {
if data.Principal == nil {
if principal, ok := principalFromContext(r.Context()); ok {
p := principal
data.Principal = &p
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
@@ -350,9 +696,8 @@ func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string,
}
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderTemplate(w, status, "error.gohtml", layoutData{
s.renderTemplate(w, r, status, "error.gohtml", layoutData{
Title: http.StatusText(status),
WebEnabled: s.webEnabled,
BodyClass: "page-error",
BodyTemplate: "error_content",
Data: errorData{
@@ -370,6 +715,7 @@ func isAllowedContentType(contentType string) bool {
"image/webp",
"application/pdf",
"text/plain; charset=utf-8",
"text/html; charset=utf-8",
}
for _, candidate := range allowed {
if strings.EqualFold(candidate, contentType) {
@@ -379,17 +725,102 @@ func isAllowedContentType(contentType string) bool {
return false
}
func isReadableContentType(contentType string) bool {
return strings.EqualFold(contentType, "text/plain; charset=utf-8") ||
strings.EqualFold(contentType, "text/html; charset=utf-8")
}
func sanitizeFilename(name string) string {
name = filepath.Base(name)
return strings.ReplaceAll(name, "\"", "")
}
func readableDocumentPath(filename string, folder string) (string, bool, error) {
extension := strings.ToLower(filepath.Ext(filename))
switch extension {
case ".md", ".markdown", ".txt", ".html", ".htm":
default:
return "", false, nil
}
name := strings.TrimSuffix(sanitizeFilename(filename), filepath.Ext(filename))
if strings.TrimSpace(name) == "" {
return "", false, fmt.Errorf("document filename is required")
}
folder = strings.TrimSpace(folder)
if filepath.IsAbs(filepath.FromSlash(folder)) {
return "", false, fmt.Errorf("upload folder must be relative")
}
folder = filepath.ToSlash(filepath.Clean(filepath.FromSlash(folder)))
if folder == "." {
folder = ""
}
if folder == ".." || strings.HasPrefix(folder, "../") {
return "", false, fmt.Errorf("invalid upload folder")
}
return pathpkg.Join(folder, name+".md"), true, nil
}
func documentPageURL(documentPath string) string {
documentPath = strings.TrimSuffix(filepath.ToSlash(documentPath), ".md")
parts := strings.Split(documentPath, "/")
for index, part := range parts {
parts[index] = url.PathEscape(part)
}
return "/docs/" + strings.Join(parts, "/")
}
func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
records, err := s.repository.ListAttachments(r.Context())
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 {
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"
}
results = append(results, map[string]any{
"hash": record.Hash,
"originalName": record.OriginalName,
"contentType": record.ContentType,
"sizeBytes": record.SizeBytes,
"createdAt": record.CreatedAt.Format(time.RFC3339),
"kind": uploadKind,
"documentPath": record.DocumentPath,
"url": uploadURL,
})
}
writeJSON(w, http.StatusOK, map[string]any{"attachments": results})
}
func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
records, err := s.documents.ListDocuments(r.Context())
if err != nil {
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 {
@@ -397,14 +828,37 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
"path": record.Path,
"title": record.Title,
"hash": record.CurrentHash,
"tags": record.Tags,
})
}
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
}
func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) {
pagePath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*"))
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid document path"})
return
}
switch {
case strings.HasSuffix(pagePath, "/archive"):
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
case strings.HasSuffix(pagePath, "/restore"):
s.handleDocumentRestorePath(w, r, strings.TrimSuffix(pagePath, "/restore"))
default:
s.handleDocumentSavePath(w, r, pagePath)
}
}
func unescapeDocumentRoutePath(path string) (string, error) {
if path == "" {
return "", nil
}
return url.PathUnescape(path)
}
func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
@@ -418,6 +872,27 @@ func (s *Server) handleDocumentSave(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 {
@@ -440,6 +915,12 @@ func (s *Server) handleDocumentSave(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",
@@ -459,6 +940,65 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload)
}
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) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "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) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "restored"})
}
func trimEditSuffix(path string) (string, bool) {
if path == "edit" {
return "", true
@@ -469,6 +1009,11 @@ func trimEditSuffix(path string) (string, bool) {
return strings.TrimSuffix(path, "/edit"), true
}
func isContentAssetPath(path string) bool {
extension := strings.ToLower(filepath.Ext(path))
return extension != "" && extension != ".md"
}
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
paths := make([]string, 0, len(records))
titleByPath := make(map[string]string, len(records))
@@ -497,16 +1042,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)
}
columns = append(columns, column)
}
return browserData{Columns: columns}
}
func buildTagBrowser(results []docs.SearchResult, tag string) browserData {
items := make([]browserItem, 0, len(results))
for _, result := range results {
items = append(items, browserItem{
Name: displayDocumentName(result.Path, result.Title),
Path: result.Path,
URL: "/docs/" + strings.TrimSuffix(result.Path, ".md"),
})
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
return browserData{
Columns: []browserColumn{
{
Title: "#" + tag,
Path: "",
Items: items,
},
},
}
}
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
seenFolders := make(map[string]browserItem)
files := make([]browserItem, 0)

View File

@@ -1,10 +1,25 @@
package httpserver
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"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"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/markdown"
"github.com/tim/cairnquire/apps/server/internal/store"
)
func TestBuildBrowserUsesFolderIndex(t *testing.T) {
@@ -63,3 +78,506 @@ func TestTrimEditSuffix(t *testing.T) {
})
}
}
func TestIsContentAssetPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{path: "logo.webp", want: true},
{path: "guide/logo.png", want: true},
{path: "guide/page", want: false},
{path: "guide/page.md", want: false},
{path: "guide/page/edit", want: false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := isContentAssetPath(tt.path); got != tt.want {
t.Fatalf("isContentAssetPath(%q) = %t, want %t", tt.path, got, tt.want)
}
})
}
}
func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) {
root := t.TempDir()
image := []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89,
}
if err := os.WriteFile(filepath.Join(root, "logo.png"), image, 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
server := &Server{
config: config.Config{
Content: config.ContentConfig{SourceDir: root},
},
}
request := httptest.NewRequest(http.MethodGet, "/docs/logo.png", nil)
recorder := httptest.NewRecorder()
server.handleContentAsset(recorder, request, "logo.png")
response := recorder.Result()
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
}
if got := response.Header.Get("Content-Type"); got != "image/png" {
t.Fatalf("content-type = %q, want image/png", got)
}
}
func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
tests := []struct {
name string
filename string
content string
wantPath string
wantURL string
wantHTML string
}{
{
name: "markdown",
filename: "release notes.md",
content: "# Release Notes\n\nReadable markdown",
wantPath: "inbox/release notes.md",
wantURL: "/docs/inbox/release%20notes",
wantHTML: "<p>Readable markdown</p>",
},
{
name: "html",
filename: "status.html",
content: "<p>Readable HTML</p>",
wantPath: "inbox/status.md",
wantURL: "/docs/inbox/status",
wantHTML: "<p>Readable HTML</p>",
},
{
name: "plain text",
filename: "summary.txt",
content: "Readable plain text",
wantPath: "inbox/summary.md",
wantURL: "/docs/inbox/summary",
wantHTML: "<p>Readable plain text</p>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server, sourceDir := setupUploadTestServer(t)
request := multipartUploadRequest(t, tt.filename, []byte(tt.content), "inbox")
recorder := httptest.NewRecorder()
server.handleUpload(recorder, request)
response := recorder.Result()
if response.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
}
var payload struct {
Hash string `json:"hash"`
Kind string `json:"kind"`
URL string `json:"url"`
AttachmentURL string `json:"attachmentUrl"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Kind != "document" {
t.Fatalf("kind = %q, want document", payload.Kind)
}
if payload.URL != tt.wantURL || payload.AttachmentURL != tt.wantURL {
t.Fatalf("urls = (%q, %q), want %q", payload.URL, payload.AttachmentURL, tt.wantURL)
}
written, err := os.ReadFile(filepath.Join(sourceDir, filepath.FromSlash(tt.wantPath)))
if err != nil {
t.Fatalf("read promoted document: %v", err)
}
if string(written) != tt.content {
t.Fatalf("promoted content = %q, want %q", string(written), tt.content)
}
attachment, err := server.repository.GetAttachment(context.Background(), payload.Hash)
if err != nil {
t.Fatalf("lookup upload history record: %v", err)
}
if attachment.DocumentPath != tt.wantPath {
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, listRequest)
var listPayload struct {
Attachments []struct {
Kind string `json:"kind"`
URL string `json:"url"`
} `json:"attachments"`
}
if err := json.NewDecoder(listRecorder.Result().Body).Decode(&listPayload); err != nil {
t.Fatalf("decode upload history response: %v", err)
}
if len(listPayload.Attachments) != 1 || listPayload.Attachments[0].Kind != "document" || listPayload.Attachments[0].URL != tt.wantURL {
t.Fatalf("upload history = %#v, want rendered document URL %q", listPayload.Attachments, tt.wantURL)
}
page, err := server.documents.LoadPage(context.Background(), tt.wantPath)
if err != nil {
t.Fatalf("load promoted page: %v", err)
}
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)
}
})
}
}
func TestHandleUploadKeepsPDFAsAttachment(t *testing.T) {
for _, filename := range []string{"brief.pdf", "renamed.md"} {
t.Run(filename, func(t *testing.T) {
server, _ := setupUploadTestServer(t)
request := multipartUploadRequest(t, filename, []byte("%PDF-1.4\n"), "inbox")
recorder := httptest.NewRecorder()
server.handleUpload(recorder, request)
response := recorder.Result()
if response.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
}
var payload struct {
Hash string `json:"hash"`
Kind string `json:"kind"`
URL string `json:"url"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Kind != "attachment" {
t.Fatalf("kind = %q, want attachment", payload.Kind)
}
if payload.URL != "/attachments/"+payload.Hash {
t.Fatalf("url = %q, want hash attachment URL", payload.URL)
}
})
}
}
func TestHandleUploadRequiresResolutionForExistingDocumentName(t *testing.T) {
server, sourceDir := setupUploadTestServer(t)
first := httptest.NewRecorder()
server.handleUpload(first, multipartUploadRequest(t, "report.md", []byte("# First"), "inbox"))
if first.Result().StatusCode != http.StatusCreated {
t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
}
conflict := httptest.NewRecorder()
server.handleUpload(conflict, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox"))
if conflict.Result().StatusCode != http.StatusConflict {
t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
}
var payload struct {
ConflictType string `json:"conflictType"`
ExistingPath string `json:"existingPath"`
}
if err := json.NewDecoder(conflict.Result().Body).Decode(&payload); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if payload.ConflictType != "document" || payload.ExistingPath != "inbox/report.md" {
t.Fatalf("conflict = %#v, want inbox document conflict", payload)
}
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report.md"))
if err != nil {
t.Fatalf("read original document: %v", err)
}
if string(written) != "# First" {
t.Fatalf("original document = %q, want unchanged content", string(written))
}
renamed := httptest.NewRecorder()
server.handleUpload(renamed, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox", "rename"))
if renamed.Result().StatusCode != http.StatusCreated {
t.Fatalf("renamed upload status = %d, want %d", renamed.Result().StatusCode, http.StatusCreated)
}
renamedContent, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report-2.md"))
if err != nil {
t.Fatalf("read renamed document: %v", err)
}
if string(renamedContent) != "# Second" {
t.Fatalf("renamed document = %q, want second content", string(renamedContent))
}
}
func TestHandleUploadCanReuseExistingAttachment(t *testing.T) {
server, _ := setupUploadTestServer(t)
content := []byte("%PDF-1.4\n")
first := httptest.NewRecorder()
server.handleUpload(first, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
if first.Result().StatusCode != http.StatusCreated {
t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
}
conflict := httptest.NewRecorder()
server.handleUpload(conflict, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
if conflict.Result().StatusCode != http.StatusConflict {
t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
}
var conflictPayload struct {
ConflictType string `json:"conflictType"`
ExistingURL string `json:"existingUrl"`
}
if err := json.NewDecoder(conflict.Result().Body).Decode(&conflictPayload); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if conflictPayload.ConflictType != "attachment" {
t.Fatalf("conflict type = %q, want attachment", conflictPayload.ConflictType)
}
reused := httptest.NewRecorder()
server.handleUpload(reused, multipartUploadRequest(t, "brief.pdf", content, "inbox", "reuse"))
if reused.Result().StatusCode != http.StatusOK {
t.Fatalf("reused upload status = %d, want %d", reused.Result().StatusCode, http.StatusOK)
}
var reusedPayload struct {
URL string `json:"url"`
}
if err := json.NewDecoder(reused.Result().Body).Decode(&reusedPayload); err != nil {
t.Fatalf("decode reused response: %v", err)
}
if reusedPayload.URL != conflictPayload.ExistingURL {
t.Fatalf("reused URL = %q, want %q", reusedPayload.URL, conflictPayload.ExistingURL)
}
}
func setupUploadTestServer(t *testing.T) (*Server, string) {
t.Helper()
db, err := sql.Open("libsql", "file:"+filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
contentStore, err := store.New(t.TempDir())
if err != nil {
t.Fatalf("create content store: %v", err)
}
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
}
func multipartUploadRequest(t *testing.T, filename string, content []byte, folder string, duplicateAction ...string) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if folder != "" {
if err := writer.WriteField("folder", folder); err != nil {
t.Fatalf("write folder: %v", err)
}
}
if len(duplicateAction) > 0 && duplicateAction[0] != "" {
if err := writer.WriteField("duplicateAction", duplicateAction[0]); err != nil {
t.Fatalf("write duplicate action: %v", err)
}
}
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create file part: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write file part: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
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
}
func TestHandleHealthzReturnsOK(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleHealthz(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
}
func TestHandleReadyzReportsOKWhenDatabaseAndStoreAreReachable(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
if payload["database"] != "ok" {
t.Fatalf("database = %q, want ok", payload["database"])
}
if payload["contentStore"] != "ok" {
t.Fatalf("contentStore = %q, want ok", payload["contentStore"])
}
}
func TestHandleReadyzReportsUnavailableWhenContentStoreMissing(t *testing.T) {
server := setupHealthTestServer(t)
server.config.Content.StoreDir = filepath.Join(t.TempDir(), "does-not-exist")
recorder := httptest.NewRecorder()
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "unavailable" {
t.Fatalf("status = %q, want unavailable", payload["status"])
}
if payload["contentStore"] == "ok" {
t.Fatalf("contentStore = %q, want non-ok", payload["contentStore"])
}
}
func TestHandleHealthAliasForReadyz(t *testing.T) {
server := setupHealthTestServer(t)
recorder := httptest.NewRecorder()
server.handleHealth(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
var payload map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "ok" {
t.Fatalf("status = %q, want ok", payload["status"])
}
}
func TestHealthzAndReadyzAreRegisteredOnRouter(t *testing.T) {
server := newAPITestServer(t)
for _, path := range []string{"/healthz", "/readyz", "/health"} {
t.Run(path, func(t *testing.T) {
recorder := httptest.NewRecorder()
server.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
})
}
}
func setupHealthTestServer(t *testing.T) *Server {
t.Helper()
root := t.TempDir()
storeDir := filepath.Join(root, "files")
if err := os.MkdirAll(storeDir, 0o755); err != nil {
t.Fatalf("create store dir: %v", err)
}
db, err := sql.Open("libsql", "file:"+filepath.Join(root, "test.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
contentStore, err := store.New(storeDir)
if err != nil {
t.Fatalf("create content store: %v", err)
}
repository := docs.NewRepository(db)
documents := docs.NewService(t.TempDir(), contentStore, markdown.NewRenderer(), repository, slog.Default())
return &Server{
config: config.Config{
Content: config.ContentConfig{StoreDir: storeDir},
},
documents: documents,
repository: repository,
contentStore: contentStore,
}
}

View File

@@ -5,11 +5,16 @@ import (
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/tim/cairnquire/apps/server/internal/auth"
)
const devLogoutCookieName = "cairnquire_dev_logout"
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
timeoutMiddleware := middleware.Timeout(timeout)
return func(next http.Handler) http.Handler {
@@ -49,6 +54,173 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
})
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := s.authenticateRequest(r)
if ok {
r = r.WithContext(withPrincipal(r.Context(), principal))
}
required, protected := requiredScopeForPath(r)
if !protected {
next.ServeHTTP(w, r)
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 !allowsProtectedRoute(principal, required) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
next.ServeHTTP(w, r)
})
}
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())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "lookup initial setup state"})
return
}
if settings.SetupComplete || isSetupAllowedPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
writeJSONWithStatus(w, http.StatusServiceUnavailable, map[string]string{"error": "initial setup is required"})
})
}
func isSetupAllowedPath(path string) bool {
return path == "/setup" ||
path == "/api/setup" ||
path == "/health" ||
path == "/healthz" ||
path == "/readyz" ||
path == "/sw.js" ||
strings.HasPrefix(path, "/static/")
}
func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
var token string
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
} else if r.URL.Path == "/ws" {
token = r.URL.Query().Get("token")
}
if token != "" {
if s.config.DevMode && token == "dev" {
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
return principal, err == nil
}
principal, err := s.auth.ValidateBearerToken(r.Context(), token)
if err == nil {
return principal, true
}
s.logger.Warn("invalid bearer token", "error", err)
return auth.Principal{}, false
}
cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil || cookie.Value == "" {
if s.devUserID == "" || hasDevLogoutCookie(r) {
return auth.Principal{}, false
}
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
return principal, err == nil
}
principal, err := s.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil {
return auth.Principal{}, false
}
return principal, true
}
func hasDevLogoutCookie(r *http.Request) bool {
cookie, err := r.Cookie(devLogoutCookieName)
return err == nil && cookie.Value == "1"
}
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" || strings.HasPrefix(path, "/api/auth/passkeys/me/") {
return auth.ScopeDocsRead, true
}
return "", false
case strings.HasPrefix(path, "/api/device/"):
if path == "/api/device/approve" || path == "/api/device/verify" {
return auth.ScopeDocsRead, true
}
return "", false
case strings.HasPrefix(path, "/api/tokens"):
return auth.ScopeDocsRead, true
case path == "/ws":
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/admin/"):
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":
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/sync/"):
if method == http.MethodPost {
return auth.ScopeSyncWrite, true
}
return auth.ScopeSyncRead, true
case strings.HasPrefix(path, "/api/content/"):
return auth.ScopeSyncRead, true
case strings.HasPrefix(path, "/api/comments"):
return auth.ScopeDocsRead, 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/"):
return auth.ScopeDocsRead, true
case strings.HasSuffix(path, "/edit"):
return auth.ScopeDocsWrite, true
default:
return "", false
}
}
type statusWriter struct {
http.ResponseWriter
status int

View File

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

View File

@@ -0,0 +1,49 @@
package httpserver
import (
"net/http"
"sync"
"time"
)
type rateLimiter struct {
mu sync.Mutex
window time.Duration
limit int
attempts map[string][]time.Time
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{
window: window,
limit: limit,
attempts: make(map[string][]time.Time),
}
}
func (l *rateLimiter) Allow(key string) bool {
now := time.Now()
cutoff := now.Add(-l.window)
l.mu.Lock()
defer l.mu.Unlock()
attempts := l.attempts[key]
kept := attempts[:0]
for _, attempt := range attempts {
if attempt.After(cutoff) {
kept = append(kept, attempt)
}
}
if len(kept) >= l.limit {
l.attempts[key] = kept
return false
}
kept = append(kept, now)
l.attempts[key] = kept
return true
}
func authRateLimitKey(r *http.Request) string {
return requestIP(r) + ":" + r.URL.Path
}

View File

@@ -0,0 +1,19 @@
package httpserver
import (
"testing"
"time"
)
func TestRateLimiterRejectsAfterLimit(t *testing.T) {
limiter := newRateLimiter(2, time.Minute)
if !limiter.Allow("ip:path") {
t.Fatal("first attempt should be allowed")
}
if !limiter.Allow("ip:path") {
t.Fatal("second attempt should be allowed")
}
if limiter.Allow("ip:path") {
t.Fatal("third attempt should be rate limited")
}
}

View File

@@ -1,17 +1,20 @@
package httpserver
import (
"context"
"embed"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"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/docs"
"github.com/tim/cairnquire/apps/server/internal/realtime"
@@ -23,27 +26,32 @@ import (
var assets embed.FS
type Dependencies struct {
Config config.Config
Logger *slog.Logger
Documents *docs.Service
Repository *docs.Repository
ContentStore *store.ContentStore
Hub *realtime.Hub
SyncService *sync.Service
SyncRepo *sync.Repository
Config config.Config
Logger *slog.Logger
Documents *docs.Service
Repository *docs.Repository
ContentStore *store.ContentStore
Hub *realtime.Hub
SyncService *sync.Service
SyncRepo *sync.Repository
Auth *auth.Service
Collaboration *collaboration.Service
}
type Server struct {
config config.Config
logger *slog.Logger
documents *docs.Service
repository *docs.Repository
contentStore *store.ContentStore
hub *realtime.Hub
syncService *sync.Service
syncRepo *sync.Repository
templates *template.Template
webEnabled bool
config config.Config
logger *slog.Logger
documents *docs.Service
repository *docs.Repository
contentStore *store.ContentStore
hub *realtime.Hub
syncService *sync.Service
syncRepo *sync.Repository
auth *auth.Service
collaboration *collaboration.Service
authLimiter *rateLimiter
templates *template.Template
devUserID string
}
func New(deps Dependencies) (http.Handler, error) {
@@ -60,19 +68,27 @@ func New(deps Dependencies) (http.Handler, error) {
}
server := &Server{
config: deps.Config,
logger: deps.Logger,
documents: deps.Documents,
repository: deps.Repository,
contentStore: deps.ContentStore,
hub: deps.Hub,
syncService: deps.SyncService,
syncRepo: deps.SyncRepo,
templates: templates,
config: deps.Config,
logger: deps.Logger,
documents: deps.Documents,
repository: deps.Repository,
contentStore: deps.ContentStore,
hub: deps.Hub,
syncService: deps.SyncService,
syncRepo: deps.SyncRepo,
auth: deps.Auth,
collaboration: deps.Collaboration,
authLimiter: newRateLimiter(5, time.Minute),
templates: templates,
}
if _, err := os.Stat(deps.Config.Web.DistDir); err == nil {
server.webEnabled = true
if deps.Config.DevMode {
devUser, err := deps.Auth.EnsureDevUser(context.Background())
if err != nil {
return nil, fmt.Errorf("ensure dev user: %w", err)
}
server.devUserID = devUser.ID
deps.Logger.Info("dev mode enabled", "dev_user_id", devUser.ID)
}
router := chi.NewRouter()
@@ -82,25 +98,81 @@ func New(deps Dependencies) (http.Handler, error) {
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
router.Use(server.requestLogger)
router.Use(server.securityHeaders)
router.Use(server.apiKeyMiddleware)
router.Use(server.setupMiddleware)
router.Use(server.authMiddleware)
router.Get("/", server.handleIndex)
router.Get("/setup", server.handleSetupPage)
router.Get("/login", server.handleLoginPage)
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)
router.Get("/readyz", server.handleReadyz)
router.Get("/sw.js", server.handleServiceWorker)
router.Get("/ws", server.handleWebSocket)
router.Get("/api/auth/me", server.handleAuthMe)
router.Post("/api/setup", server.handleSetup)
router.Post("/api/auth/register/password", server.handlePasswordRegister)
router.Post("/api/auth/login/password", server.handlePasswordLogin)
router.Post("/api/auth/login/dev", server.handleDevLogin)
router.Post("/api/auth/password/reset", server.handlePasswordReset)
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)
router.Post("/api/auth/passkeys/login/finish", server.handlePasskeyLoginFinish)
router.Get("/api/tokens", server.handleListAPITokens)
router.Post("/api/tokens", server.handleCreateAPIToken)
router.Delete("/api/tokens/{id}", server.handleRevokeAPIToken)
router.Post("/api/device/start", server.handleDeviceStart)
router.Post("/api/device/approve", server.handleDeviceApprove)
router.Post("/api/device/token", server.handleDeviceToken)
router.Get("/api/device/verify", server.handleDeviceVerify)
router.Post("/api/admin/login", server.handleAdminLogin)
router.Get("/api/admin/users", server.handleAdminUsers)
router.Post("/api/admin/users", server.handleAdminCreateUser)
router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole)
router.Get("/api/admin/auth-users", server.handleAdminAuthUsers)
router.Patch("/api/admin/auth-users", server.handleAdminUpdateAuthUsers)
router.Post("/api/admin/auth-users/{id}/password-reset", server.handleAdminSendPasswordReset)
router.Get("/api/admin/settings", server.handleAdminSettings)
router.Patch("/api/admin/settings", server.handleAdminSettingsUpdate)
router.Get("/api/admin/workspace", server.handleAdminWorkspace)
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument)
router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentSave)
router.Post("/api/documents/*", server.handleDocumentMutation)
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)
// Collaboration endpoints
router.Get("/api/comments", server.handleListComments)
router.Post("/api/comments", server.handleCreateComment)
router.Post("/api/comments/{id}/resolve", server.handleResolveComment)
router.Get("/api/notifications", server.handleListNotifications)
router.Post("/api/notifications/{id}/read", server.handleMarkNotificationRead)
router.Post("/api/notifications/read-all", server.handleMarkAllNotificationsRead)
router.Get("/api/notifications/bell", server.handleNotificationBell)
router.Post("/api/watch/document", server.handleWatchDocument)
router.Post("/api/watch/folder", server.handleWatchFolder)
router.Delete("/api/watch/document", server.handleUnwatchDocument)
router.Delete("/api/watch/folder", server.handleUnwatchFolder)
router.Get("/api/watch/status", server.handleIsWatching)
// Sync protocol endpoints
router.Post("/api/sync/init", server.handleSyncInit)
router.Post("/api/sync/delta", server.handleSyncDelta)
@@ -109,18 +181,9 @@ func New(deps Dependencies) (http.Handler, error) {
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
if server.webEnabled {
router.Get("/app", server.handleAppRedirect)
router.Handle("/app/*", http.StripPrefix("/app/", server.appFileServer()))
}
return router, nil
}
func (s *Server) handleAppRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app/", http.StatusMovedPermanently)
}
func mustSub(path string) http.FileSystem {
sub, err := fsSub(path)
if err != nil {

View File

@@ -0,0 +1,45 @@
(function () {
'use strict';
function apiDocumentPath(path) {
return (path || '')
.replace(/^\/+/, '')
.split('/')
.map(encodeURIComponent)
.join('/');
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-archive-path]');
if (!btn) return;
var path = btn.getAttribute('data-archive-path');
if (!path) return;
if (!confirm('Archive this document? It will be hidden from the site but can be restored later.')) {
return;
}
btn.disabled = true;
btn.textContent = 'Archiving...';
fetch('/api/documents/' + apiDocumentPath(path) + '/archive', {
method: 'POST',
credentials: 'same-origin',
})
.then(function (res) {
if (res.ok) {
window.location.href = '/';
return;
}
return res.json().then(function (data) {
throw new Error(data.error || 'Archive failed');
});
})
.catch(function (err) {
alert(err.message || 'Failed to archive document');
btn.disabled = false;
btn.textContent = 'Archive';
});
});
})();

View File

@@ -0,0 +1,487 @@
(() => {
// Tab switching
document.querySelectorAll('.auth-tab-list').forEach(tabList => {
const tabs = tabList.querySelectorAll('[data-tab]');
const panels = tabList.closest('.auth-tabs').querySelectorAll('[data-tab-panel]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
tabs.forEach(t => {
t.classList.remove('is-active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('is-active');
tab.setAttribute('aria-selected', 'true');
panels.forEach(p => {
if (p.dataset.tabPanel === target) {
p.classList.add('is-active');
p.hidden = false;
} else {
p.classList.remove('is-active');
p.hidden = true;
}
});
});
});
});
// Register toggle
const registerToggle = document.querySelector('[data-register-toggle]');
const registerPanel = document.querySelector('[data-register-panel]');
const loginSection = document.querySelector('[data-login-section]');
const registerCancel = document.querySelector('[data-register-cancel]');
const authHeading = document.querySelector('[data-auth-heading]');
if (registerToggle && registerPanel && loginSection) {
registerToggle.addEventListener('click', () => {
loginSection.hidden = true;
registerPanel.hidden = false;
if (authHeading) authHeading.textContent = 'Create an Account';
document.title = 'Create an Account';
});
}
if (registerCancel && registerPanel && loginSection) {
registerCancel.addEventListener('click', () => {
registerPanel.hidden = true;
loginSection.hidden = false;
if (authHeading) authHeading.textContent = 'Log In';
document.title = 'Sign in';
// Clear any registration form status messages
document.querySelector('[data-passkey-register-status]').textContent = '';
document.querySelector('[data-register-status]').textContent = '';
});
}
const jsonHeaders = { "Content-Type": "application/json" };
const postJSON = async (url, body, options = {}) => {
const response = await fetch(url, {
method: options.method || "POST",
headers: jsonHeaders,
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || `Request failed (${response.status})`);
}
return data;
};
const setStatus = (selector, message, isError = false) => {
const target = document.querySelector(selector);
if (!target) return;
target.textContent = message;
target.dataset.state = isError ? "error" : "ok";
};
const authNext = () => document.querySelector("[data-auth-next]")?.value || "/account";
const formJSON = (form) => Object.fromEntries(new FormData(form).entries());
document.querySelector("[data-initial-setup]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
try {
await postJSON("/api/setup", {
email: form.elements.email.value,
displayName: form.elements.displayName.value,
password: form.elements.password.value,
signupsEnabled: form.elements.signupsEnabled.checked,
});
window.location.href = "/account";
} catch (error) {
setStatus("[data-setup-status]", error.message, true);
}
});
const base64URLToBuffer = (value) => {
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
};
const bufferToBase64URL = (value) => {
const bytes = new Uint8Array(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
};
const credentialToJSON = (credential) => {
if (credential instanceof ArrayBuffer) return bufferToBase64URL(credential);
if (ArrayBuffer.isView(credential)) return bufferToBase64URL(credential.buffer);
if (Array.isArray(credential)) return credential.map(credentialToJSON);
if (credential && typeof credential === "object") {
const next = {};
for (const [key, value] of Object.entries(credential)) next[key] = credentialToJSON(value);
return next;
}
return credential;
};
const normalizeCreateOptions = (options) => {
const publicKey = { ...options.publicKey };
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
publicKey.user = { ...publicKey.user, id: base64URLToBuffer(publicKey.user.id) };
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map((credential) => ({
...credential,
id: base64URLToBuffer(credential.id),
}));
return { publicKey };
};
const normalizeRequestOptions = (options) => {
const publicKey = { ...options.publicKey };
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map((credential) => ({
...credential,
id: base64URLToBuffer(credential.id),
}));
return { publicKey };
};
document.querySelector("[data-auth-login]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postJSON("/api/auth/login/password", formJSON(event.currentTarget));
window.location.href = authNext();
} catch (error) {
setStatus("[data-login-status]", error.message, true);
}
});
document.querySelector("[data-dev-login]")?.addEventListener("click", async () => {
try {
await postJSON("/api/auth/login/dev", {});
window.location.href = authNext();
} catch (error) {
setStatus("[data-dev-login-status]", error.message, true);
}
});
document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postJSON("/api/auth/register/password", formJSON(event.currentTarget));
setStatus("[data-register-status]", "Account created. Sign in with your password.");
event.currentTarget.reset();
} catch (error) {
setStatus("[data-register-status]", error.message, true);
}
});
const webAuthnErrorMessage = (error) => {
console.error("WebAuthn error:", error.name, error.message, error);
switch (error.name) {
case "NotAllowedError":
return "Passkey creation was cancelled or no authenticator is available. If you dismissed a prompt, try again. If no prompt appeared, your device may not support passkeys.";
case "NotSupportedError":
return "This browser or device does not support passkeys.";
case "SecurityError":
return "Passkeys require a secure context. Use https:// or localhost.";
case "InvalidStateError":
return "A passkey may already exist for this account.";
default:
return error.message || "Passkey failed. Check the console for details.";
}
};
const checkPasskeySupport = async () => {
if (!window.PublicKeyCredential) {
return { supported: false, reason: "Passkeys are not available in this browser." };
}
try {
if (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!available) {
return { supported: true, reason: "No platform authenticator found (e.g., Touch ID, Windows Hello). You can still use a security key if you have one." };
}
}
return { supported: true };
} catch {
return { supported: true };
}
};
document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const support = await checkPasskeySupport();
if (!support.supported) {
setStatus("[data-passkey-register-status]", support.reason, true);
return;
}
if (support.reason) {
console.warn("Passkey support:", support.reason);
}
try {
const begin = await postJSON("/api/auth/passkeys/register/begin", formJSON(event.currentTarget));
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
await postJSON(`/api/auth/passkeys/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
setStatus("[data-passkey-register-status]", "Passkey created. Sign in with your passkey.");
} catch (error) {
setStatus("[data-passkey-register-status]", webAuthnErrorMessage(error), true);
}
});
document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const support = await checkPasskeySupport();
if (!support.supported) {
setStatus("[data-passkey-login-status]", support.reason, true);
return;
}
if (support.reason) {
console.warn("Passkey support:", support.reason);
}
try {
const begin = await postJSON("/api/auth/passkeys/login/begin", formJSON(event.currentTarget));
const credential = await navigator.credentials.get(normalizeRequestOptions(begin.options));
await postJSON(`/api/auth/passkeys/login/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
window.location.href = authNext();
} catch (error) {
setStatus("[data-passkey-login-status]", webAuthnErrorMessage(error), true);
}
});
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", {});
} catch (error) {
console.warn("Logout request failed:", error);
} finally {
window.location.href = "/login";
}
});
const loadTokens = async () => {
const list = document.querySelector("[data-token-list]");
if (!list) return;
const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json());
if (!data.tokens?.length) {
const empty = document.createElement("li");
empty.className = "token-empty";
empty.textContent = "No access tokens yet. Create one when you connect the macOS app, CLI, or another trusted client.";
list.replaceChildren(empty);
return;
}
list.replaceChildren(
...data.tokens.map((token) => {
const item = document.createElement("li");
item.className = `token-row${token.revokedAt ? " is-revoked" : ""}`;
const meta = document.createElement("span");
meta.className = "token-meta";
const name = document.createElement("strong");
name.textContent = token.name;
const details = document.createElement("small");
details.textContent = `${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
meta.append(name, details);
const button = document.createElement("button");
button.type = "button";
button.textContent = "Revoke";
button.disabled = Boolean(token.revokedAt);
button.addEventListener("click", async () => {
await fetch(`/api/tokens/${encodeURIComponent(token.id.replace(/^api:/, ""))}`, {
method: "DELETE",
credentials: "same-origin",
});
await loadTokens();
});
item.append(meta, button);
return item;
}),
);
};
document.querySelector("[data-token-create]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const scopes = [...form.querySelectorAll('input[name="scope"]:checked')].map((input) => input.value);
try {
const created = await postJSON("/api/tokens", { name: form.elements.name.value, scopes });
const output = document.querySelector("[data-token-output]");
if (output) {
output.hidden = false;
output.textContent = created.token;
}
setStatus("[data-token-status]", "Token created. Copy it now; it will not be shown again.");
form.reset();
await loadTokens();
} catch (error) {
setStatus("[data-token-status]", error.message, true);
}
});
document.querySelector("[data-password-change]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postJSON("/api/auth/password/change", formJSON(event.currentTarget));
setStatus("[data-password-status]", "Password changed.");
event.currentTarget.reset();
} catch (error) {
setStatus("[data-password-status]", error.message, true);
}
});
document.querySelector("[data-password-reset]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postJSON("/api/auth/password/reset", formJSON(event.currentTarget));
setStatus("[data-password-reset-status]", "Password reset. You can sign in with your new password.");
event.currentTarget.querySelector('input[name="newPassword"]').value = "";
} catch (error) {
setStatus("[data-password-reset-status]", error.message, true);
}
});
document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => {
event.preventDefault();
if (!confirm("Delete this account permanently?")) return;
try {
await postJSON("/api/auth/account", formJSON(event.currentTarget), { method: "DELETE" });
window.location.href = "/login";
} catch (error) {
setStatus("[data-delete-status]", error.message, true);
}
});
document.querySelector("[data-device-approve]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postJSON("/api/device/approve", formJSON(event.currentTarget));
setStatus("[data-device-status]", "Device approved.");
} catch (error) {
if (error.message === "authentication required") {
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`;
return;
}
setStatus("[data-device-status]", error.message, true);
}
});
const loadAdminUsers = async () => {
const section = document.querySelector("[data-admin-users-section]");
const list = document.querySelector("[data-admin-user-list]");
if (!section || !list) return;
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
if (me?.principal?.role !== "admin") return;
section.hidden = false;
const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json());
list.replaceChildren(
...(data.users || []).map((user) => {
const row = document.createElement("div");
row.className = "user-row";
row.dataset.userId = user.id;
const identity = document.createElement("span");
const name = document.createElement("strong");
name.textContent = user.displayName;
const email = document.createElement("small");
email.textContent = user.email;
identity.append(name, email);
const select = document.createElement("select");
for (const role of ["viewer", "editor", "admin"]) {
const option = document.createElement("option");
option.value = role;
option.textContent = role;
option.selected = user.role === role;
select.append(option);
}
select.name = "role";
select.setAttribute("aria-label", `Role for ${user.displayName}`);
const disabledLabel = document.createElement("label");
disabledLabel.className = "user-disable-toggle";
const disabled = document.createElement("input");
disabled.type = "checkbox";
disabled.name = "disabled";
disabled.checked = Boolean(user.disabled);
const disabledText = document.createElement("span");
disabledText.textContent = "Disabled";
disabledLabel.append(disabled, disabledText);
const reset = document.createElement("button");
reset.type = "button";
reset.textContent = "Send password reset";
reset.addEventListener("click", async () => {
if (!confirm(`Send a new password reset email to ${user.email}? Any earlier reset link will stop working.`)) return;
try {
await postJSON(`/api/admin/auth-users/${encodeURIComponent(user.id)}/password-reset`, {});
setStatus("[data-admin-users-status]", `Password reset email sent to ${user.email}.`);
} catch (error) {
setStatus("[data-admin-users-status]", error.message, true);
}
});
row.append(identity, select, disabledLabel, reset);
return row;
}),
);
};
document.querySelector("[data-admin-users]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const users = [...form.querySelectorAll("[data-user-id]")].map((row) => ({
id: row.dataset.userId,
role: row.querySelector('select[name="role"]').value,
disabled: row.querySelector('input[name="disabled"]').checked,
}));
try {
await postJSON("/api/admin/auth-users", { users }, { method: "PATCH" });
setStatus("[data-admin-users-status]", "User access settings saved.");
await loadAdminUsers();
} catch (error) {
setStatus("[data-admin-users-status]", error.message, true);
}
});
const loadAdminSettings = async () => {
const section = document.querySelector("[data-admin-settings-section]");
const form = document.querySelector("[data-admin-settings]");
if (!section || !form) return;
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
if (me?.principal?.role !== "admin") return;
section.hidden = false;
const data = await fetch("/api/admin/settings", { credentials: "same-origin" }).then((response) => response.json());
form.elements.signupsEnabled.checked = Boolean(data.settings?.signupsEnabled);
};
document.querySelector("[data-admin-settings]")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const form = event.currentTarget;
await postJSON("/api/admin/settings", { signupsEnabled: form.elements.signupsEnabled.checked }, { method: "PATCH" });
setStatus("[data-admin-settings-status]", "Registration setting saved.");
} catch (error) {
setStatus("[data-admin-settings-status]", error.message, true);
}
});
loadTokens().catch(() => {});
loadAdminUsers().catch(() => {});
loadAdminSettings().catch(() => {});
})();

View File

@@ -5,6 +5,7 @@
const STORE_CONTENT = "content";
const STORE_PAGES = "pages";
const STORE_PENDING_EDITS = "pending_edits";
const CACHE_ENTRY_LIMIT = 100;
function openDB() {
return new Promise(function (resolve, reject) {
@@ -39,6 +40,27 @@
return path.replace(/\/$/, "") || "/";
}
function pruneStore(store, limit) {
const request = store.getAll();
request.onsuccess = function () {
const records = request.result || [];
if (records.length <= limit) {
return;
}
records
.sort(function (a, b) {
return (a.cachedAt || 0) - (b.cachedAt || 0);
})
.slice(0, records.length - limit)
.forEach(function (record) {
if (record && record.path) {
store.delete(record.path);
}
});
};
}
function cacheDocuments(documents) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
@@ -63,6 +85,7 @@
const tx = db.transaction(STORE_CONTENT, "readwrite");
const store = tx.objectStore(STORE_CONTENT);
store.put({ path: path, html: html, title: title, cachedAt: Date.now() });
pruneStore(store, CACHE_ENTRY_LIMIT);
tx.oncomplete = function () {
resolve();
};
@@ -117,6 +140,7 @@
title: title,
cachedAt: Date.now(),
});
pruneStore(store, CACHE_ENTRY_LIMIT);
tx.oncomplete = function () {
resolve();
};

Some files were not shown because too many files have changed in this diff Show More