Compare commits

..

10 Commits

Author SHA1 Message Date
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
127 changed files with 13900 additions and 4517 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,36 +1,34 @@
# Project Status Report
**Generated:** 2026-04-29 14:57:37 UTC
**Monitoring Duration:** 14h 41m
**Iteration:** 86
**Branch:** codex/foundation-bootstrap
**Generated:** 2026-06-01
**Branch:** main
## Quick Stats
| Metric | Value |
|--------|-------|
| Total Files | 101 |
| Go Files | 22 ( 2304 lines) |
| TypeScript Files | 3 ( 761 lines) |
| Markdown Files | 134 ( 27723 lines) |
| Total Files | ~120 |
| Go Files | 44 (~6000+ lines) |
| Swift Files | 12 (macOS app) |
| Markdown Files | 150+ |
| Docker Config | yes |
| Uncommitted Changes | 9 |
| Milestones Complete | 0/6 (0%) |
| Test Coverage | 39% |
| Milestones Complete | 3/6 (50%) |
## Recent Activity
**Last Commit:** 3d8ca8b - refactor: focus admin dashboard layout (3 hours ago)
**Last Commit:** fb0673a - sync app filled in
## 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 (infrastructure done, features in progress)
- [ ] Milestone 5: Search & UI (core search done, polish needed)
- [ ] Milestone 6: Production (not started)
**Current Focus:** Not Started
**Current Focus:** M4 Collaboration + M5 UI Polish
### Error Handling (10 ignored errors)

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,65 +7,66 @@
### 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
- [x] Comment system (backend complete, UI partial)
- [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)
- [ ] Comment display in the Go-served browser UI (JS exists but needs wiring)
- [ ] Notification system
- Notification queue in database
- Per-file watcher subscriptions
- Per-folder watcher subscriptions
- Global notification settings
- Digest mode (hourly/daily batches)
- [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)
- [ ] Web notification UI
- Notification bell with unread count
- Notification list dropdown
- Mark as read functionality
- Notification preferences page
- [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
- [x] SMTP sender stub (NoOp + SMTP implementations)
- [ ] Postmark adapter
- [ ] 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
- [ ] 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
- [ ] 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] 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)
- [ ] Email notifications sent within 1 minute of event (NoOp sender currently)
- [ ] 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

View File

@@ -7,24 +7,24 @@
### 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
- [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
- [ ] Client-side search
- Flexsearch or Pagefind integration
- Index sync from server on load
- Incremental index updates via WebSocket
- Search UI (input, results, filters)
- [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
- [ ] Search features
- Full-text content search
- Tag filtering
- Title search (boosted)
- Recent results caching
- Search suggestions (autocomplete)
- [x] Search features
- [x] Full-text content search
- [ ] Tag filtering
- [ ] Title search (boosted)
- [ ] Recent results caching
- [ ] Search suggestions (autocomplete)
### Week 10: Design System & Performance
@@ -60,7 +60,7 @@
## 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

@@ -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:**
@@ -136,42 +138,43 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
---
### 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 +212,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 +262,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 +347,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 +374,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/*
@@ -20,7 +13,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
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
@@ -29,7 +21,5 @@ 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"]

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,263 @@
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)
}
// 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,185 @@
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 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 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,438 @@
import SwiftUI
import Combine
public struct ClipboardNotice: Identifiable, Equatable {
public let id = UUID()
public let filename: String
}
@MainActor
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
@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?
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 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)
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 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,306 @@
import SwiftUI
public struct StatusPopoverView: View {
@ObservedObject public var controller: MenuBarController
public init(controller: MenuBarController) {
self.controller = controller
}
public var body: some View {
VStack(spacing: 0) {
// Header with status
headerSection
Divider()
// Quick actions
actionsSection
Divider()
// Recent uploads preview (last 3)
recentUploadsSection
Divider()
// Footer
footerSection
}
.frame(width: 320)
.padding(.vertical, 12)
}
// 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 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())
}
}
// 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,374 @@
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
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: "Cairnquire")
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: "Cairnquire")
dropButton.image = image
}
private func updateIconForUpload(progress: Double?) {
if progress != nil {
// During upload: show tray.circle (outline)
let image = NSImage(systemSymbolName: "tray.circle", accessibilityDescription: "Uploading")
dropButton.image = image
} else {
// Upload complete: restore normal icon
updateIcon(config: controller.config)
}
}
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
await controller.refreshRecentUploads()
}
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() {
registerForDraggedTypes([.fileURL])
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
// Tint icon slightly to indicate drop zone is active
if let image = self.image {
self.image = image.tinted(with: .systemBlue)
}
return .copy
}
override func draggingExited(_ sender: NSDraggingInfo?) {
// Restore original icon
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 {
return false
}
appDelegate?.bounceIcon()
Task { @MainActor in
appDelegate?.controller.uploadFiles(urls)
}
return true
}
}
extension NSImage {
func tinted(with color: NSColor) -> NSImage {
let image = self.copy() as! NSImage
image.isTemplate = false
let rect = NSRect(origin: .zero, size: image.size)
image.lockFocus()
color.set()
rect.fill(using: .sourceAtop)
image.unlockFocus()
return image
}
}

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."

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"
@@ -51,26 +55,46 @@ 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
if cfg.Email.SMTPHost != "" {
emailSender = email.NewSMTPSender(cfg.Email, logger)
authService.SetEmailSender(emailSender)
} else {
emailSender = email.NewNoOpSender(logger)
}
collabService := collaboration.NewService(collabRepo, emailSender, hub, logger)
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)
@@ -151,3 +175,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,171 @@
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
}

View File

@@ -0,0 +1,748 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) {
now := time.Now().UTC()
if user.ID == "" {
user.ID = "user:" + user.Email
}
if user.Role == "" {
user.Role = RoleViewer
}
if user.DisplayName == "" {
user.DisplayName = user.Email
}
if _, err := r.db.ExecContext(ctx, `
INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
display_name = excluded.display_name,
password_hash = COALESCE(excluded.password_hash, users.password_hash),
role = excluded.role,
last_seen_at = excluded.last_seen_at
`, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(user.Role), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
return User{}, fmt.Errorf("upsert user: %w", err)
}
return r.GetUserByEmail(ctx, user.Email)
}
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) {
var user User
var created, lastSeen, passwordHash, role string
var disabled int
err := r.db.QueryRowContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), disabled, created_at, COALESCE(last_seen_at, '')
FROM users
WHERE email = ?
`, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &disabled, &created, &lastSeen)
if err != nil {
return User{}, err
}
user.PasswordHash = passwordHash
user.Role = Role(role)
user.Disabled = disabled == 1
user.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return User{}, fmt.Errorf("parse user created_at: %w", err)
}
if lastSeen != "" {
user.LastSeenAt, err = time.Parse(time.RFC3339, lastSeen)
if err != nil {
return User{}, fmt.Errorf("parse user last_seen_at: %w", err)
}
}
user.Credentials, err = r.ListCredentials(ctx, user.ID)
if err != nil {
return User{}, err
}
return user, nil
}
func (r *Repository) GetUserByID(ctx context.Context, userID string) (User, error) {
var email string
if err := r.db.QueryRowContext(ctx, `SELECT email FROM users WHERE id = ?`, userID).Scan(&email); err != nil {
return User{}, err
}
return r.GetUserByEmail(ctx, email)
}
func (r *Repository) CountUsers(ctx context.Context) (int, error) {
var count int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
func (r *Repository) CountAdmins(ctx context.Context) (int, error) {
var count int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
func (r *Repository) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
var setupCompleted string
var signupsEnabled int
if err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(setup_completed_at, ''), signups_enabled
FROM instance_settings
WHERE id = 1
`).Scan(&setupCompleted, &signupsEnabled); err != nil {
return InstanceSettings{}, fmt.Errorf("get instance settings: %w", err)
}
return InstanceSettings{
SetupComplete: setupCompleted != "",
SignupsEnabled: signupsEnabled == 1,
}, nil
}
func (r *Repository) CompleteInitialSetup(ctx context.Context, user User, signupsEnabled bool) (User, error) {
now := time.Now().UTC()
if user.ID == "" {
user.ID = "user:" + user.Email
}
if user.DisplayName == "" {
user.DisplayName = user.Email
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return User{}, fmt.Errorf("begin initial setup: %w", err)
}
defer func() { _ = tx.Rollback() }()
var setupCompleted string
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(setup_completed_at, '')
FROM instance_settings
WHERE id = 1
`).Scan(&setupCompleted); err != nil {
return User{}, fmt.Errorf("get initial setup state: %w", err)
}
if setupCompleted != "" {
return User{}, fmt.Errorf("initial setup has already been completed")
}
var userCount int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
return User{}, fmt.Errorf("count users during initial setup: %w", err)
}
if userCount != 0 {
return User{}, fmt.Errorf("initial setup requires an empty user database")
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(RoleAdmin), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
return User{}, fmt.Errorf("create initial admin: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE instance_settings
SET setup_completed_at = ?, signups_enabled = ?, updated_at = ?
WHERE id = 1 AND setup_completed_at IS NULL
`, now.Format(time.RFC3339), boolInt(signupsEnabled), now.Format(time.RFC3339)); err != nil {
return User{}, fmt.Errorf("complete initial setup: %w", err)
}
if err := tx.Commit(); err != nil {
return User{}, fmt.Errorf("commit initial setup: %w", err)
}
return r.GetUserByEmail(ctx, user.Email)
}
func (r *Repository) UpdateSignupsEnabled(ctx context.Context, enabled bool) error {
result, err := r.db.ExecContext(ctx, `
UPDATE instance_settings
SET signups_enabled = ?, updated_at = ?
WHERE id = 1 AND setup_completed_at IS NOT NULL
`, boolInt(enabled), time.Now().UTC().Format(time.RFC3339))
if err != nil {
return fmt.Errorf("update signup setting: %w", err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return fmt.Errorf("initial setup is required")
}
return nil
}
func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), disabled, created_at, COALESCE(last_seen_at, '')
FROM users
ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var user User
var created, lastSeen, role string
var disabled int
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &disabled, &created, &lastSeen); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
user.Role = Role(role)
user.Disabled = disabled == 1
user.CreatedAt, _ = time.Parse(time.RFC3339, created)
if lastSeen != "" {
user.LastSeenAt, _ = time.Parse(time.RFC3339, lastSeen)
}
users = append(users, user)
}
return users, rows.Err()
}
func (r *Repository) UpdatePasswordHash(ctx context.Context, userID, passwordHash string) error {
result, err := r.db.ExecContext(ctx, `
UPDATE users
SET password_hash = ?, last_seen_at = ?
WHERE id = ?
`, passwordHash, time.Now().UTC().Format(time.RFC3339), userID)
if err != nil {
return err
}
if rows, _ := result.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *Repository) UpdateUserRole(ctx context.Context, userID string, role Role) error {
result, err := r.db.ExecContext(ctx, `
UPDATE users
SET role = ?, last_seen_at = ?
WHERE id = ?
`, string(role), time.Now().UTC().Format(time.RFC3339), userID)
if err != nil {
return err
}
if rows, _ := result.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessUpdate) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin user access update: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, update := range updates {
result, err := tx.ExecContext(ctx, `
UPDATE users
SET role = ?, disabled = ?
WHERE id = ?
`, string(update.Role), boolInt(update.Disabled), update.ID)
if err != nil {
return fmt.Errorf("update user access: %w", err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
}
var admins int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&admins); err != nil {
return fmt.Errorf("count enabled admins: %w", err)
}
if admins == 0 {
return fmt.Errorf("cannot disable or demote the last admin account")
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit user access update: %w", err)
}
return nil
}
func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
if err != nil {
return err
}
if rows, _ := result.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *Repository) SaveCredential(ctx context.Context, userID string, credential webauthn.Credential) error {
now := time.Now().UTC().Format(time.RFC3339)
payload, err := json.Marshal(credential)
if err != nil {
return fmt.Errorf("marshal credential: %w", err)
}
if _, err := r.db.ExecContext(ctx, `
INSERT INTO webauthn_credentials (id, user_id, credential_id, credential_json, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(credential_id) DO UPDATE SET
credential_json = excluded.credential_json,
last_used_at = excluded.last_used_at
`, "cred:"+hashSecret(string(credential.ID))[:24], userID, credential.ID, string(payload), now, now); err != nil {
return fmt.Errorf("save credential: %w", err)
}
return nil
}
func (r *Repository) ListCredentials(ctx context.Context, userID string) ([]webauthn.Credential, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT credential_json
FROM webauthn_credentials
WHERE user_id = ?
ORDER BY created_at ASC
`, userID)
if err != nil {
return nil, fmt.Errorf("list credentials: %w", err)
}
defer rows.Close()
var credentials []webauthn.Credential
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return nil, fmt.Errorf("scan credential: %w", err)
}
var credential webauthn.Credential
if err := json.Unmarshal([]byte(raw), &credential); err != nil {
return nil, fmt.Errorf("unmarshal credential: %w", err)
}
credentials = append(credentials, credential)
}
return credentials, rows.Err()
}
func (r *Repository) SaveChallenge(ctx context.Context, userID, challengeType string, session webauthn.SessionData, ttl time.Duration) (string, error) {
idPart, err := randomToken(18)
if err != nil {
return "", err
}
payload, err := json.Marshal(session)
if err != nil {
return "", fmt.Errorf("marshal challenge: %w", err)
}
now := time.Now().UTC()
id := "chal:" + idPart
if _, err := r.db.ExecContext(ctx, `
INSERT INTO auth_challenges (id, user_id, type, session_json, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`, id, userID, challengeType, string(payload), now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339)); err != nil {
return "", fmt.Errorf("save challenge: %w", err)
}
return id, nil
}
func (r *Repository) ConsumeChallenge(ctx context.Context, id, challengeType string) (webauthn.SessionData, string, error) {
var raw, userID, expires, used string
err := r.db.QueryRowContext(ctx, `
SELECT session_json, user_id, expires_at, COALESCE(used_at, '')
FROM auth_challenges
WHERE id = ? AND type = ?
`, id, challengeType).Scan(&raw, &userID, &expires, &used)
if err != nil {
return webauthn.SessionData{}, "", err
}
if used != "" {
return webauthn.SessionData{}, "", fmt.Errorf("challenge already used")
}
expiresAt, err := time.Parse(time.RFC3339, expires)
if err != nil {
return webauthn.SessionData{}, "", fmt.Errorf("parse challenge expiry: %w", err)
}
if time.Now().UTC().After(expiresAt) {
return webauthn.SessionData{}, "", fmt.Errorf("challenge expired")
}
var session webauthn.SessionData
if err := json.Unmarshal([]byte(raw), &session); err != nil {
return webauthn.SessionData{}, "", fmt.Errorf("unmarshal challenge: %w", err)
}
if _, err := r.db.ExecContext(ctx, `UPDATE auth_challenges SET used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id); err != nil {
return webauthn.SessionData{}, "", fmt.Errorf("consume challenge: %w", err)
}
return session, userID, nil
}
func (r *Repository) CreateSession(ctx context.Context, userID, tokenHash, ip, userAgent string, ttl time.Duration) (Session, error) {
idPart, err := randomToken(18)
if err != nil {
return Session{}, err
}
now := time.Now().UTC()
session := Session{
ID: "sess:" + idPart,
UserID: userID,
TokenHash: tokenHash,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
if _, err := r.db.ExecContext(ctx, `
INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at, last_seen_at, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, session.ID, session.UserID, session.TokenHash, session.CreatedAt.Format(time.RFC3339), session.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339), ip, userAgent); err != nil {
return Session{}, fmt.Errorf("create session: %w", err)
}
return session, nil
}
func (r *Repository) ValidateSession(ctx context.Context, tokenHash string) (Session, User, error) {
var session Session
var created, expires, revoked string
err := r.db.QueryRowContext(ctx, `
SELECT id, user_id, token_hash, created_at, expires_at, COALESCE(revoked_at, '')
FROM sessions
WHERE token_hash = ?
`, tokenHash).Scan(&session.ID, &session.UserID, &session.TokenHash, &created, &expires, &revoked)
if err != nil {
return Session{}, User{}, err
}
session.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return Session{}, User{}, err
}
session.ExpiresAt, err = time.Parse(time.RFC3339, expires)
if err != nil {
return Session{}, User{}, err
}
if revoked != "" {
return Session{}, User{}, fmt.Errorf("session revoked")
}
if time.Now().UTC().After(session.ExpiresAt) {
return Session{}, User{}, fmt.Errorf("session expired")
}
user, err := r.GetUserByID(ctx, session.UserID)
if err != nil {
return Session{}, User{}, err
}
_, _ = r.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), session.ID)
return session, user, nil
}
func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error {
_, err := r.db.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), sessionID)
return err
}
func (r *Repository) CreatePasswordResetToken(ctx context.Context, token PasswordResetToken) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin password reset token: %w", err)
}
defer func() { _ = tx.Rollback() }()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := tx.ExecContext(ctx, `
UPDATE password_reset_tokens
SET used_at = ?
WHERE user_id = ? AND used_at IS NULL
`, now, token.UserID); err != nil {
return fmt.Errorf("expire prior password reset tokens: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO password_reset_tokens (id, user_id, token_hash, created_at, expires_at, initiated_by)
VALUES (?, ?, ?, ?, ?, ?)
`, token.ID, token.UserID, token.TokenHash, token.CreatedAt.Format(time.RFC3339), token.ExpiresAt.Format(time.RFC3339), nullString(token.InitiatedBy)); err != nil {
return fmt.Errorf("create password reset token: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit password reset token: %w", err)
}
return nil
}
func (r *Repository) ExpirePasswordResetToken(ctx context.Context, tokenHash string) {
_, _ = r.db.ExecContext(ctx, `
UPDATE password_reset_tokens
SET used_at = ?
WHERE token_hash = ? AND used_at IS NULL
`, time.Now().UTC().Format(time.RFC3339), tokenHash)
}
func (r *Repository) CompletePasswordReset(ctx context.Context, tokenHash, passwordHash string) (string, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("begin password reset: %w", err)
}
defer func() { _ = tx.Rollback() }()
var id, userID, expires, used string
if err := tx.QueryRowContext(ctx, `
SELECT id, user_id, expires_at, COALESCE(used_at, '')
FROM password_reset_tokens
WHERE token_hash = ?
`, tokenHash).Scan(&id, &userID, &expires, &used); err != nil {
return "", err
}
if used != "" {
return "", fmt.Errorf("password reset link has already been used")
}
expiresAt, err := time.Parse(time.RFC3339, expires)
if err != nil {
return "", fmt.Errorf("parse password reset expiry: %w", err)
}
if time.Now().UTC().After(expiresAt) {
return "", fmt.Errorf("password reset link has expired")
}
now := time.Now().UTC().Format(time.RFC3339)
result, err := tx.ExecContext(ctx, `UPDATE password_reset_tokens SET used_at = ? WHERE id = ? AND used_at IS NULL`, now, id)
if err != nil {
return "", fmt.Errorf("consume password reset token: %w", err)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return "", fmt.Errorf("password reset link has already been used")
}
if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID); err != nil {
return "", fmt.Errorf("update reset password: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, now, userID); err != nil {
return "", fmt.Errorf("revoke password reset sessions: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit password reset: %w", err)
}
return userID, nil
}
func (r *Repository) CreateAPIKey(ctx context.Context, key APIKey) error {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO api_keys (id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at, revoked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, key.ID, key.UserID, key.Name, key.KeyHash, scopesToString(key.Scopes), key.CreatedAt.Format(time.RFC3339), timePtrString(key.ExpiresAt), timePtrString(key.LastUsedAt), timePtrString(key.RevokedAt)); err != nil {
return fmt.Errorf("create api key: %w", err)
}
return nil
}
func (r *Repository) ValidateAPIKey(ctx context.Context, keyID, keyHash string) (APIKey, User, error) {
var key APIKey
var scopesRaw, created, expires, lastUsed, revoked string
err := r.db.QueryRowContext(ctx, `
SELECT id, user_id, name, key_hash, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '')
FROM api_keys
WHERE id = ?
`, keyID).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyHash, &scopesRaw, &created, &expires, &lastUsed, &revoked)
if err != nil {
return APIKey{}, User{}, err
}
if !constantTimeEqualHex(key.KeyHash, keyHash) {
return APIKey{}, User{}, sql.ErrNoRows
}
key.Scopes = scopesFromString(scopesRaw)
key.CreatedAt, err = time.Parse(time.RFC3339, created)
if err != nil {
return APIKey{}, User{}, err
}
if expires != "" {
parsed, err := time.Parse(time.RFC3339, expires)
if err != nil {
return APIKey{}, User{}, err
}
if time.Now().UTC().After(parsed) {
return APIKey{}, User{}, fmt.Errorf("api key expired")
}
key.ExpiresAt = &parsed
}
if revoked != "" {
return APIKey{}, User{}, fmt.Errorf("api key revoked")
}
if lastUsed != "" {
parsed, err := time.Parse(time.RFC3339, lastUsed)
if err == nil {
key.LastUsedAt = &parsed
}
}
user, err := r.GetUserByID(ctx, key.UserID)
if err != nil {
return APIKey{}, User{}, err
}
_, _ = r.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), key.ID)
return key, user, nil
}
func (r *Repository) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, user_id, name, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '')
FROM api_keys
WHERE user_id = ?
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []APIKey
for rows.Next() {
var key APIKey
var scopesRaw, created, expires, lastUsed, revoked string
if err := rows.Scan(&key.ID, &key.UserID, &key.Name, &scopesRaw, &created, &expires, &lastUsed, &revoked); err != nil {
return nil, err
}
key.Scopes = scopesFromString(scopesRaw)
key.CreatedAt, _ = time.Parse(time.RFC3339, created)
if expires != "" {
parsed, _ := time.Parse(time.RFC3339, expires)
key.ExpiresAt = &parsed
}
if lastUsed != "" {
parsed, _ := time.Parse(time.RFC3339, lastUsed)
key.LastUsedAt = &parsed
}
if revoked != "" {
parsed, _ := time.Parse(time.RFC3339, revoked)
key.RevokedAt = &parsed
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func (r *Repository) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
result, err := r.db.ExecContext(ctx, `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ?`, time.Now().UTC().Format(time.RFC3339), keyID, userID)
if err != nil {
return err
}
if rows, _ := result.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
return nil
}
func (r *Repository) CreateDeviceCode(ctx context.Context, device DeviceCode) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO device_codes (id, device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, device.ID, device.DeviceCodeHash, device.UserCodeHash, device.UserCodeDisplay, device.Name, scopesToString(device.Scopes), device.Status, device.CreatedAt.Format(time.RFC3339), device.ExpiresAt.Format(time.RFC3339))
return err
}
func (r *Repository) GetDeviceCodeByUserCode(ctx context.Context, userCodeHash string) (DeviceCode, error) {
return r.getDeviceCode(ctx, `user_code_hash = ?`, userCodeHash)
}
func (r *Repository) GetDeviceCodeByDeviceCode(ctx context.Context, deviceCodeHash string) (DeviceCode, error) {
return r.getDeviceCode(ctx, `device_code_hash = ?`, deviceCodeHash)
}
func (r *Repository) getDeviceCode(ctx context.Context, predicate string, value string) (DeviceCode, error) {
var device DeviceCode
var scopesRaw, created, expires, approved, lastPolled, userID string
row := r.db.QueryRowContext(ctx, `
SELECT id, COALESCE(user_id, ''), device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at, COALESCE(approved_at, ''), COALESCE(last_polled_at, '')
FROM device_codes
WHERE `+predicate, value)
if err := row.Scan(&device.ID, &userID, &device.DeviceCodeHash, &device.UserCodeHash, &device.UserCodeDisplay, &device.Name, &scopesRaw, &device.Status, &created, &expires, &approved, &lastPolled); err != nil {
return DeviceCode{}, err
}
device.UserID = userID
device.Scopes = scopesFromString(scopesRaw)
device.CreatedAt, _ = time.Parse(time.RFC3339, created)
device.ExpiresAt, _ = time.Parse(time.RFC3339, expires)
if approved != "" {
parsed, _ := time.Parse(time.RFC3339, approved)
device.ApprovedAt = &parsed
}
if lastPolled != "" {
parsed, _ := time.Parse(time.RFC3339, lastPolled)
device.LastPolledAt = &parsed
}
if time.Now().UTC().After(device.ExpiresAt) && device.Status == "pending" {
device.Status = "expired"
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired' WHERE id = ?`, device.ID)
}
return device, nil
}
func (r *Repository) ApproveDeviceCode(ctx context.Context, userCodeHash, userID string) (DeviceCode, error) {
now := time.Now().UTC().Format(time.RFC3339)
result, err := r.db.ExecContext(ctx, `
UPDATE device_codes
SET status = 'approved', user_id = ?, approved_at = ?
WHERE user_code_hash = ? AND status = 'pending'
`, userID, now, userCodeHash)
if err != nil {
return DeviceCode{}, err
}
if rows, _ := result.RowsAffected(); rows == 0 {
return DeviceCode{}, sql.ErrNoRows
}
return r.GetDeviceCodeByUserCode(ctx, userCodeHash)
}
func (r *Repository) TouchDevicePoll(ctx context.Context, id string) {
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id)
}
func (r *Repository) FinishDeviceCode(ctx context.Context, id string) {
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired', last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id)
}
func (r *Repository) Audit(ctx context.Context, actorUserID, eventType, resourceType, resourceID, ip, userAgent string, metadata map[string]any) {
idPart, err := randomToken(18)
if err != nil {
return
}
if metadata == nil {
metadata = map[string]any{}
}
payload, err := json.Marshal(metadata)
if err != nil {
payload = []byte("{}")
}
_, _ = r.db.ExecContext(ctx, `
INSERT INTO audit_log (id, actor_user_id, event_type, resource_type, resource_id, ip_address, user_agent, created_at, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "audit:"+idPart, nullString(actorUserID), eventType, nullString(resourceType), nullString(resourceID), ip, userAgent, time.Now().UTC().Format(time.RFC3339), string(payload))
}
func nullString(value string) any {
if value == "" {
return nil
}
return value
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func timePtrString(value *time.Time) any {
if value == nil {
return nil
}
return value.UTC().Format(time.RFC3339)
}
func isNoRows(err error) bool {
return errors.Is(err, sql.ErrNoRows)
}

View File

@@ -0,0 +1,726 @@
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) 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) 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 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,345 @@
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 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)
}
}

View File

@@ -0,0 +1,139 @@
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 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 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,275 @@
package collaboration
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/realtime"
)
type EmailSender interface {
Send(ctx context.Context, to []string, subject, body string) 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
hub *realtime.Hub
logger *slog.Logger
}
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
if email == nil {
email = &NoOpEmailSender{}
}
return &Service{
repo: repo,
email: email,
hub: hub,
logger: logger,
}
}
// 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) 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
}
}
// 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)
}
}
return nil
}
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,14 @@ 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 {
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
From string `json:"from"`
}
func Default() Config {
@@ -47,9 +55,13 @@ 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",
},
LogLevel: "INFO",
}
@@ -65,14 +77,30 @@ 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")
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

@@ -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,32 @@ 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) 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 +135,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 +151,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 +161,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 +187,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 +211,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 +324,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 +343,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 +358,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 +395,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)

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,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

@@ -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,734 @@
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 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()
created, err := s.auth.CreateAPIKey(context.Background(), s.userID, "test token", scopes, nil)
if err != nil {
t.Fatalf("create api token: %v", err)
}
return created.Token
}
func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, 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 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 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.ScopeDocsWrite)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
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 TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, 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())
}
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
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.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())
}
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
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,645 @@
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) 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,262 @@
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
}
anchorHash := r.URL.Query().Get("anchor_hash")
var comments []collaboration.Comment
var err error
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
}
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")
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,7 @@ type documentData struct {
Hash string
HTML template.HTML
Breadcrumbs []breadcrumb
CanWrite bool
}
type documentEditData struct {
@@ -51,6 +56,7 @@ type documentEditData struct {
Hash string
Source string
Breadcrumbs []breadcrumb
CanWrite bool
}
type breadcrumb struct {
@@ -96,9 +102,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
WebEnabled: s.webEnabled,
BodyClass: "page-index",
BodyTemplate: "index_content",
Data: indexData{
@@ -116,9 +121,8 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
s.logger.Warn("search failed", "error", err)
}
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 {
@@ -137,6 +141,10 @@ func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
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 +152,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 {
@@ -161,9 +227,10 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
return
}
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
canWrite := s.canWriteDocuments(r)
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{
@@ -174,6 +241,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
Hash: page.Hash,
Source: page.Content,
Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: canWrite,
},
})
}
@@ -195,9 +263,10 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
return
}
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
canWrite := s.canWriteDocuments(r)
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
@@ -208,11 +277,20 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: 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 +304,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) {
@@ -285,6 +370,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 +390,131 @@ 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
}
if existing != nil {
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
}
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
}
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
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 +540,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 +555,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 +574,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,11 +584,83 @@ 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 != "" {
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 {
@@ -403,8 +680,30 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
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
@@ -459,6 +758,50 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload)
}
func (s *Server) canWriteDocuments(r *http.Request) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
return auth.Allows(principal, auth.ScopeDocsWrite)
}
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
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
}
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 +812,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))

View File

@@ -1,10 +1,24 @@
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/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 +77,472 @@ 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)
}
listRecorder := httptest.NewRecorder()
server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil))
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)
}
})
}
}
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())
return &Server{
documents: documents,
repository: repository,
contentStore: contentStore,
}, 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())
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,145 @@ 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 {
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if !auth.Allows(principal, required) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
next.ServeHTTP(w, r)
})
}
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 strings.HasPrefix(path, "/api/auth/"):
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
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 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 strings.HasSuffix(path, "/edit"):
return auth.ScopeDocsWrite, true
default:
return "", false
}
}
type statusWriter struct {
http.ResponseWriter
status int

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,75 @@ 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("/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/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.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 +175,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,467 @@
(() => {
// 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-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();
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -0,0 +1,123 @@
(function () {
'use strict';
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
if (!documentId) return;
// Hash a paragraph's text content for anchoring
function hashParagraph(el) {
const text = (el.textContent || '').trim();
if (!text) return null;
// Simple hash: first 16 chars of base64 of char codes (deterministic)
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'h' + Math.abs(hash).toString(36);
}
function addCommentButton(el, hash) {
const btn = document.createElement('button');
btn.className = 'comment-anchor-btn';
btn.title = 'Add comment';
btn.innerHTML = '+';
btn.addEventListener('click', () => promptComment(el, hash));
el.appendChild(btn);
}
async function promptComment(el, anchorHash) {
const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return;
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId,
versionHash: documentHash,
content: text.trim(),
anchorHash,
}),
});
if (!res.ok) throw new Error(await res.text());
// Refresh comments display
await loadCommentsForAnchor(el, anchorHash);
} catch (err) {
window.alert('Failed to post comment: ' + err.message);
}
}
async function loadCommentsForAnchor(el, anchorHash) {
try {
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`);
if (!res.ok) return;
const data = await res.json();
renderComments(el, data.comments || []);
} catch (err) {
console.error('load comments', err);
}
}
function renderComments(el, comments) {
// Remove existing comment list
const existing = el.querySelector('.comment-list');
if (existing) existing.remove();
if (!comments.length) return;
const list = document.createElement('div');
list.className = 'comment-list';
comments.forEach(c => {
const item = document.createElement('div');
item.className = 'comment-item';
item.innerHTML = `
<div class="comment-meta">
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
<time>${formatDate(c.createdAt)}</time>
</div>
<div class="comment-body">${escapeHtml(c.content)}</div>
`;
list.appendChild(item);
});
el.appendChild(list);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString();
}
// Initialize: hash all paragraphs in markdown body
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
paragraphs.forEach(el => {
const hash = hashParagraph(el);
if (!hash) return;
el.dataset.anchorHash = hash;
el.classList.add('commentable');
addCommentButton(el, hash);
loadCommentsForAnchor(el, hash);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,140 @@
(function () {
'use strict';
const bell = document.querySelector('[data-notification-bell]');
if (!bell) return;
const trigger = bell.querySelector('.notification-bell__trigger');
const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]');
let isOpen = false;
trigger.addEventListener('click', (e) => {
e.stopPropagation();
isOpen = !isOpen;
dropdown.hidden = !isOpen;
trigger.setAttribute('aria-expanded', String(isOpen));
if (isOpen) loadNotifications();
});
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
document.addEventListener('click', () => {
if (isOpen) {
isOpen = false;
dropdown.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
}
});
async function updateBell() {
try {
const res = await fetch('/api/notifications/bell');
if (!res.ok) return;
const data = await res.json();
const count = data.unreadCount || 0;
countEl.textContent = count > 99 ? '99+' : String(count);
countEl.hidden = count === 0;
bell.classList.toggle('has-unread', count > 0);
} catch (err) {
console.error('bell update', err);
}
}
async function loadNotifications() {
try {
const res = await fetch('/api/notifications?limit=20');
if (!res.ok) return;
const data = await res.json();
renderNotifications(data.notifications || []);
} catch (err) {
console.error('load notifications', err);
}
}
function renderNotifications(notifications) {
const list = dropdown.querySelector('ul');
if (!list) return;
list.innerHTML = '';
// Header
const header = document.createElement('li');
header.className = 'notification-header';
header.textContent = 'Notifications';
list.appendChild(header);
if (!notifications.length) {
const empty = document.createElement('li');
empty.className = 'notification-empty';
empty.textContent = 'No notifications';
list.appendChild(empty);
return;
}
notifications.forEach(n => {
const li = document.createElement('li');
li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
li.innerHTML = `
<div class="notification-message">${escapeHtml(n.message)}</div>
<time class="notification-time">${formatDate(n.createdAt)}</time>
`;
if (!n.readAt) {
li.addEventListener('click', () => markRead(n.id));
}
list.appendChild(li);
});
// Add "Mark all read" button
const footer = document.createElement('li');
footer.className = 'notification-footer';
const btn = document.createElement('button');
btn.textContent = 'Mark all read';
btn.addEventListener('click', markAllRead);
footer.appendChild(btn);
list.appendChild(footer);
}
async function markRead(id) {
try {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
updateBell();
loadNotifications();
} catch (err) {
console.error('mark read', err);
}
}
async function markAllRead() {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
updateBell();
loadNotifications();
} catch (err) {
console.error('mark all read', err);
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString();
}
// Listen for WebSocket notification events
if (window.__cairnquireRealtime) {
window.__cairnquireRealtime.on('notification', () => {
updateBell();
});
}
// Poll every 60s as fallback
setInterval(updateBell, 60000);
updateBell();
})();

View File

@@ -1,4 +1,23 @@
(function () {
const realtimeCallbacks = {
notification: [],
};
window.__cairnquireRealtime = {
on: function (event, cb) {
if (realtimeCallbacks[event]) {
realtimeCallbacks[event].push(cb);
}
},
off: function (event, cb) {
if (realtimeCallbacks[event]) {
realtimeCallbacks[event] = realtimeCallbacks[event].filter(function (c) {
return c !== cb;
});
}
},
};
const notice = document.querySelector("[data-version-notice]");
const reload = document.querySelector("[data-version-reload]");
const offlineNotice = document.querySelector("[data-offline-notice]");
@@ -300,6 +319,13 @@
return;
}
if (payload.type === "notification") {
realtimeCallbacks.notification.forEach(function (cb) {
cb(payload.data);
});
return;
}
if (payload.type !== "document_version" || !payload.data) {
return;
}

View File

@@ -10,7 +10,9 @@
pre.replaceWith(container);
});
mermaid.initialize({ startOnLoad: false });
mermaid.run({ querySelector: ".mermaid" });
mermaid.run({ querySelector: ".mermaid:not([data-processed])" }).catch(function (error) {
console.warn("Mermaid render failed:", error);
});
}
function renderMath() {
@@ -66,8 +68,8 @@
}
function init() {
renderMermaid();
renderMath();
renderMermaid();
}
window.renderMermaid = renderMermaid;

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
const STATIC_CACHE = "md-hub-static-v1";
const STATIC_CACHE = "md-hub-static-v2";
const DB_NAME = "md-hub-cache";
const DB_VERSION = 3;
const STORE_PAGES = "pages";

View File

@@ -3,11 +3,107 @@
return;
}
const syncQueue = document.querySelector("[data-sync-queue]");
const syncQueueTitle = document.querySelector("[data-sync-queue-title]");
const syncQueueSummary = document.querySelector("[data-sync-queue-summary]");
const syncQueueList = document.querySelector("[data-sync-queue-list]");
const syncNow = document.querySelector("[data-sync-now]");
function normalizeDocPath(path) {
if (!path) return "";
return path.replace(/^\/+/, "");
}
function formatPath(path) {
return (path || "").replace(/^\/docs\//, "") || "/";
}
function pluralize(count, singular, plural) {
return count === 1 ? singular : plural;
}
function emitSyncState(detail) {
window.dispatchEvent(new CustomEvent("mdhub:sync-state", { detail: detail }));
}
function renderPendingEdits(pending, state) {
if (!syncQueue || !syncQueueTitle || !syncQueueSummary || !syncQueueList) {
return;
}
const conflicts = pending.filter(function (edit) {
return Boolean(edit.conflict);
});
const clean = pending.filter(function (edit) {
return !edit.conflict;
});
syncQueue.hidden = pending.length === 0;
if (pending.length === 0) {
syncQueueList.replaceChildren();
syncQueueTitle.textContent = "Synced";
syncQueueSummary.textContent = "No pending changes.";
if (syncNow) {
syncNow.disabled = true;
}
return;
}
const countLabel = pending.length + " pending " + pluralize(pending.length, "change", "changes");
syncQueueTitle.textContent = conflicts.length > 0 ? countLabel + " with conflicts" : countLabel;
if (!navigator.onLine) {
syncQueueSummary.textContent = "Offline. Changes will sync when the connection returns.";
} else if (state === "syncing") {
syncQueueSummary.textContent = "Syncing queued edits...";
} else if (conflicts.length > 0) {
syncQueueSummary.textContent = conflicts.length + " " + pluralize(conflicts.length, "file needs", "files need") + " conflict resolution.";
} else {
syncQueueSummary.textContent = "Queued edits are ready to sync.";
}
syncQueueList.replaceChildren();
pending.slice(0, 5).forEach(function (edit) {
const item = document.createElement("li");
item.dataset.state = edit.conflict ? "conflict" : "queued";
const path = document.createElement("span");
path.textContent = formatPath(edit.path);
item.appendChild(path);
const status = document.createElement("span");
status.textContent = edit.conflict ? "Conflict" : "Queued";
item.appendChild(status);
syncQueueList.appendChild(item);
});
if (pending.length > 5) {
const overflow = document.createElement("li");
overflow.textContent = "+" + (pending.length - 5) + " more";
syncQueueList.appendChild(overflow);
}
if (syncNow) {
syncNow.disabled = !navigator.onLine || clean.length === 0 || state === "syncing";
}
}
async function refreshSyncQueue(state) {
const pending = await window.MDHubCache.getPendingEdits();
pending.sort(function (a, b) {
return (b.updatedAt || 0) - (a.updatedAt || 0);
});
renderPendingEdits(pending, state || "idle");
emitSyncState({
state: state || "idle",
pending: pending.length,
conflicts: pending.filter(function (edit) {
return Boolean(edit.conflict);
}).length,
});
return pending;
}
async function postDocument(path, content, baseHash) {
const response = await fetch("/api/documents/" + normalizeDocPath(path), {
method: "POST",
@@ -40,6 +136,7 @@
try {
const result = await postDocument(path, content, baseHash);
await window.MDHubCache.deletePendingEdit(normalizedPath);
await refreshSyncQueue("saved");
return {
status: "saved",
result: result,
@@ -48,12 +145,14 @@
if (error.name === "DocumentConflictError") {
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details);
await refreshSyncQueue("conflict");
return {
status: "conflict",
conflict: error.details,
};
}
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
await refreshSyncQueue("queued");
return {
status: "queued",
};
@@ -62,15 +161,19 @@
async function syncPending() {
if (!navigator.onLine) {
await refreshSyncQueue("offline");
return;
}
const pending = await window.MDHubCache.getPendingEdits();
const pending = await refreshSyncQueue("syncing");
pending.sort(function (a, b) {
return (a.updatedAt || 0) - (b.updatedAt || 0);
});
for (const edit of pending) {
if (edit.conflict) {
continue;
}
try {
await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash);
await window.MDHubCache.deletePendingEdit(edit.path);
@@ -81,16 +184,31 @@
break;
}
}
await refreshSyncQueue("idle");
}
window.addEventListener("online", function () {
syncPending().catch(function () {});
});
window.addEventListener("offline", function () {
refreshSyncQueue("offline").catch(function () {});
});
if (syncNow) {
syncNow.addEventListener("click", function () {
syncPending().catch(function () {
refreshSyncQueue("queued").catch(function () {});
});
});
}
syncPending().catch(function () {});
refreshSyncQueue("idle").catch(function () {});
window.MDHubSync = {
saveDocument: saveDocument,
syncPending: syncPending,
refreshSyncQueue: refreshSyncQueue,
};
})();

View File

@@ -1,11 +1,10 @@
package httpserver
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"github.com/go-chi/chi/v5"
@@ -24,9 +23,13 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
return
}
userID := r.Context().Value("userID").(string)
principal, ok := requirePrincipal(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, userID)
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, principal.UserID)
if err != nil {
s.logger.Error("sync init failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
@@ -51,16 +54,31 @@ func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
return
}
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, sync.Delta{Changes: req.ClientDelta})
principal, ok := requirePrincipal(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta})
if err != nil {
if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
if errors.Is(err, sync.ErrInvalidPath) || errors.Is(err, sync.ErrInvalidHash) || errors.Is(err, sync.ErrContentRequired) || errors.Is(err, sync.ErrHashMismatch) || errors.Is(err, sync.ErrContentNotFound) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
s.logger.Error("sync delta failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to apply delta"})
return
}
writeJSON(w, http.StatusOK, sync.DeltaResponse{
ServerDelta: result.ServerDelta,
Conflicts: result.Conflicts,
ServerDelta: result.ServerDelta,
Conflicts: result.Conflicts,
NewSnapshotID: result.NewSnapshotID,
})
}
@@ -76,8 +94,22 @@ func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
return
}
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, req.Resolutions)
principal, ok := requirePrincipal(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions)
if err != nil {
if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
if errors.Is(err, sync.ErrInvalidPath) || errors.Is(err, sync.ErrInvalidHash) || errors.Is(err, sync.ErrContentRequired) || errors.Is(err, sync.ErrHashMismatch) || errors.Is(err, sync.ErrContentNotFound) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
s.logger.Error("sync resolve failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflicts"})
return
@@ -97,6 +129,10 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
content, err := s.syncService.GetContent(hash)
if err != nil {
if errors.Is(err, sync.ErrInvalidHash) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid content hash"})
return
}
if os.IsNotExist(err) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "content not found"})
return
@@ -112,30 +148,3 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(content)
}
func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip for non-sync routes
if !strings.HasPrefix(r.URL.Path, "/api/sync/") && !strings.HasPrefix(r.URL.Path, "/api/content/") {
next.ServeHTTP(w, r)
return
}
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing API key"})
return
}
// Validate API key against database
record, err := s.syncRepo.ValidateAPIKey(r.Context(), apiKey)
if err != nil {
s.logger.Warn("invalid api key", "error", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid API key"})
return
}
ctx := context.WithValue(r.Context(), "userID", record.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,132 @@
{{ define "account.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "account_content" }}
<section class="account-shell">
<header class="account-header">
<div class="account-identity">
<span class="account-avatar" aria-hidden="true">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.5" />
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
</svg>
</span>
<div>
<p class="eyebrow">Account center</p>
<h1>{{ .DisplayName }}</h1>
<div class="account-meta">
<span>{{ .Email }}</span>
<span class="account-role">{{ .Role }}</span>
</div>
</div>
</div>
<button class="account-signout" type="button" data-auth-logout>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10 17l5-5-5-5" />
<path d="M15 12H3" />
<path d="M21 19V5a2 2 0 0 0-2-2h-6" />
</svg>
Sign out
</button>
</header>
<div class="account-layout">
<div class="account-main">
<section class="account-section account-section--tokens">
<header class="account-section__header account-section__header--action">
<div>
<p class="account-section__kicker">Integrations</p>
<h2>Access tokens</h2>
<p>Manage access for the macOS app, CLI, or another trusted client.</p>
</div>
<a class="account-action" href="/account/tokens/new">Create token</a>
</header>
<div class="account-list-heading">
<h3>Issued tokens</h3>
<p>Tokens can be revoked immediately. New secrets are shown only once.</p>
</div>
<ul class="token-list" data-token-list></ul>
</section>
<section class="account-section account-section--users" data-admin-users-section hidden>
<header class="account-section__header">
<div>
<p class="account-section__kicker">Administration</p>
<h2>People and roles</h2>
</div>
<p>Control access levels for everyone with an account on this server.</p>
</header>
<form class="user-management" data-admin-users>
<div class="user-list" data-admin-user-list></div>
<div class="account-form-footer">
<button type="submit">Save changes</button>
<p class="form-status" data-admin-users-status></p>
</div>
</form>
</section>
</div>
<aside class="account-sidebar">
<section class="account-section">
<header class="account-section__header">
<div>
<p class="account-section__kicker">Security</p>
<h2>Change password</h2>
</div>
<p>Use at least 12 characters for your new password.</p>
</header>
<form class="auth-form" data-password-change>
<label>
Current password
<input type="password" name="currentPassword" autocomplete="current-password" />
</label>
<label>
New password
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit">Update password</button>
<p class="form-status" data-password-status></p>
</form>
</section>
<section class="account-section" data-admin-settings-section hidden>
<header class="account-section__header">
<div>
<p class="account-section__kicker">Administration</p>
<h2>Registration</h2>
</div>
<p>Choose whether new visitors can create their own accounts.</p>
</header>
<form class="auth-form" data-admin-settings>
<label class="auth-choice account-toggle">
<input type="checkbox" name="signupsEnabled" />
<span>
<strong>Public signups</strong>
<small>Allow visitors to register without an invitation.</small>
</span>
</label>
<button type="submit">Save setting</button>
<p class="form-status" data-admin-settings-status></p>
</form>
</section>
<section class="account-section account-section--danger">
<header class="account-section__header">
<div>
<p class="account-section__kicker">Danger zone</p>
<h2>Delete account</h2>
</div>
<p>This permanently removes your account and cannot be undone.</p>
</header>
<form class="auth-form" data-account-delete>
<label>
Current password
<input type="password" name="currentPassword" autocomplete="current-password" />
</label>
<button class="account-danger-button" type="submit">Delete account</button>
<p class="form-status" data-delete-status></p>
</form>
</section>
</aside>
</div>
</section>
{{ end }}

View File

@@ -19,16 +19,37 @@
<header class="site-header">
<div class="site-header__inner">
<a class="site-brand" href="/" aria-label="Cairnquire home">
<img src="/static/cairnquire%20logo%402x.webp" alt="" width="160" height="40" decoding="async" />
<img src="/static/favicon.png" alt="" width="64" height="64" decoding="async" />
<span class="sr-only">Cairnquire</span>
</a>
<form class="site-search" action="/" method="get">
<input type="search" name="q" placeholder="Search..." aria-label="Search documents" />
<button type="submit" aria-label="Search">🔍</button>
<form class="site-search" action="/" method="get" role="search">
<svg class="site-search__icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" name="q" placeholder="Search documents…" aria-label="Search documents" />
</form>
<nav class="site-nav">
<a href="/">Docs</a>
{{ if .WebEnabled }}<a href="/app/">Admin</a>{{ end }}
{{ if .Principal }}
<div class="notification-bell" data-notification-bell>
<button class="notification-bell__trigger" type="button" aria-label="Notifications" aria-haspopup="true" aria-expanded="false">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
<span class="notification-bell__count" data-unread-count hidden>0</span>
</button>
<div class="notification-bell__dropdown" data-notification-dropdown role="menu" hidden>
<ul class="notification-list" aria-live="polite"></ul>
</div>
</div>
{{ end }}
<a href="/help" aria-label="Help" title="Help">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</a>
<a href="/settings" aria-label="Settings" title="Settings">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</a>
<a href="/account" aria-label="Account" title="Account">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</a>
</nav>
</div>
</header>
@@ -41,6 +62,18 @@
{{ template "document_edit_content" .Data }}
{{ else if eq .BodyTemplate "search_content" }}
{{ template "search_content" .Data }}
{{ else if eq .BodyTemplate "login_content" }}
{{ template "login_content" .Data }}
{{ else if eq .BodyTemplate "setup_content" }}
{{ template "setup_content" .Data }}
{{ else if eq .BodyTemplate "password_reset_content" }}
{{ template "password_reset_content" .Data }}
{{ else if eq .BodyTemplate "account_content" }}
{{ template "account_content" .Data }}
{{ else if eq .BodyTemplate "token_create_content" }}
{{ template "token_create_content" .Data }}
{{ else if eq .BodyTemplate "device_verify_content" }}
{{ template "device_verify_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }}
{{ template "error_content" .Data }}
{{ end }}
@@ -53,15 +86,28 @@
<span aria-hidden="true" class="cached-icon"></span>
<p>Viewing cached offline content.</p>
</div>
<section class="sync-queue" data-sync-queue hidden aria-live="polite">
<div class="sync-queue__header">
<div>
<strong data-sync-queue-title>Pending changes</strong>
<p data-sync-queue-summary>Waiting to sync.</p>
</div>
<button type="button" data-sync-now>Sync now</button>
</div>
<ul data-sync-queue-list></ul>
</section>
<div class="version-notice" data-version-notice hidden>
<p>A newer version is available.</p>
<p>Document changes are available.</p>
<button type="button" data-version-reload>Reload</button>
</div>
<script src="/static/cache.js" defer></script>
<script src="/static/sync.js" defer></script>
<script src="/static/realtime.js" defer></script>
<script src="/static/editor.js" defer></script>
<script src="/static/auth.js" defer></script>
<script src="/static/render.js" defer></script>
<script src="/static/comments.js" defer></script>
<script src="/static/archive.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
{{ define "device_verify.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "device_verify_content" }}
<section class="auth-shell">
<div class="auth-panel">
<div class="auth-panel__header">
<p class="eyebrow">Device authorization</p>
<h1>Approve device</h1>
</div>
<form class="auth-form" data-device-approve>
<label>
User code
<input type="text" name="userCode" value="{{ .UserCode }}" autocomplete="one-time-code" required />
</label>
<button type="submit">Approve</button>
<p class="form-status" data-device-status></p>
</form>
<p class="auth-note">You must be signed in with a browser session before approving a device.</p>
</div>
</section>
{{ end }}

View File

@@ -21,7 +21,12 @@
<div class="document-meta">
<div class="document-meta__header">
<h1>{{ .Title }}</h1>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
{{ if .CanWrite }}
<div class="document-actions">
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
</div>
{{ end }}
</div>
<details class="document-meta-panel">
<summary>

View File

@@ -0,0 +1,105 @@
{{ define "login.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "login_content" }}
<section class="auth-shell">
<input type="hidden" data-auth-next value="{{ .Next }}" />
<div class="auth-panel">
<div class="auth-panel__header">
<p class="eyebrow">Cairnquire</p>
<h1 data-auth-heading>Log In</h1>
</div>
<div class="auth-login-section" data-login-section>
<div class="auth-tabs">
<div class="auth-tab-list" role="tablist">
<button type="button" class="auth-tab is-active" role="tab" aria-selected="true" data-tab="passkey">Passkey</button>
<button type="button" class="auth-tab" role="tab" aria-selected="false" data-tab="password">Password</button>
</div>
<div class="auth-tab-panel is-active" role="tabpanel" data-tab-panel="passkey">
<form class="auth-form" data-passkey-login>
<label>
Email
<input type="email" name="email" autocomplete="username webauthn" required />
</label>
<button type="submit" class="btn-primary">Use passkey</button>
<p class="form-status" data-passkey-login-status></p>
</form>
</div>
<div class="auth-tab-panel" role="tabpanel" data-tab-panel="password" hidden>
<form class="auth-form" data-auth-login>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Password
<input type="password" name="password" autocomplete="current-password" required />
</label>
<button type="submit" class="btn-primary">Sign in</button>
<p class="form-status" data-login-status></p>
</form>
</div>
</div>
{{ if .DevMode }}<div class="auth-dev-login">
<p class="auth-note">Development mode is enabled. Use the local development admin without a password.</p>
<button type="button" class="btn-secondary" data-dev-login>Continue as development admin</button>
<p class="form-status" data-dev-login-status></p>
</div>{{ end }}
{{ if .SignupsEnabled }}<div class="auth-register-cta">
<button type="button" class="btn-cta" data-register-toggle>Create an Account</button>
</div>{{ end }}
</div>
{{ if .SignupsEnabled }}<div class="auth-register-panel" data-register-panel hidden>
<div class="auth-tabs">
<div class="auth-tab-list" role="tablist">
<button type="button" class="auth-tab is-active" role="tab" aria-selected="true" data-tab="passkey-register">Passkey</button>
<button type="button" class="auth-tab" role="tab" aria-selected="false" data-tab="password-register">Password</button>
</div>
<div class="auth-tab-panel is-active" role="tabpanel" data-tab-panel="passkey-register">
<form class="auth-form" data-passkey-register>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Display name
<input type="text" name="displayName" autocomplete="name" />
</label>
<button type="submit" class="btn-primary">Create passkey</button>
<p class="form-status" data-passkey-register-status></p>
</form>
</div>
<div class="auth-tab-panel" role="tabpanel" data-tab-panel="password-register" hidden>
<form class="auth-form" data-auth-register>
<label>
Email
<input type="email" name="email" autocomplete="email" required />
</label>
<label>
Display name
<input type="text" name="displayName" autocomplete="name" />
</label>
<label>
Password
<input type="password" name="password" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit" class="btn-primary">Register</button>
<p class="form-status" data-register-status></p>
</form>
</div>
</div>
<div class="auth-register-cancel">
<button type="button" class="btn-secondary" data-register-cancel>Cancel — Back to Log In</button>
</div>
</div>{{ end }}
</div>
</section>
{{ end }}

View File

@@ -0,0 +1,23 @@
{{ define "password_reset.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "password_reset_content" }}
<section class="auth-shell">
<div class="auth-panel">
<div class="auth-panel__header">
<p class="eyebrow">Cairnquire</p>
<h1>Reset password</h1>
<p class="auth-note">Choose a new password with at least 12 characters.</p>
</div>
<form class="auth-form" data-password-reset>
<input type="hidden" name="token" value="{{ .Token }}" />
<label>
New password
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
</label>
<button type="submit" class="btn-primary">Set new password</button>
<p class="form-status" data-password-reset-status></p>
</form>
</div>
</section>
{{ end }}

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