Merge pull request 'master' (#1) from master into main
Reviewed-on: https://codeberg.org/sirtimbly/cairnquire/pulls/1
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.DS_Store
|
||||
|
||||
data
|
||||
tmp
|
||||
|
||||
coverage
|
||||
*.log
|
||||
.build
|
||||
11
.env.example
Normal file
11
.env.example
Normal 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
11
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
70
.project/macos-app-api-gaps.md
Normal file
70
.project/macos-app-api-gaps.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| Build | `vite` | Fast HMR, modern output, minimal config |
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -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"]
|
||||
|
||||
|
||||
93
README.md
93
README.md
@@ -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.
|
||||
|
||||
34
apps/macos-sync/Package.swift
Normal file
34
apps/macos-sync/Package.swift
Normal 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
224
apps/macos-sync/README.md
Normal 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.
|
||||
263
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
263
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal 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 {}
|
||||
185
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
185
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal 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
|
||||
}
|
||||
164
apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift
Normal file
164
apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
103
apps/macos-sync/Sources/CairnquireSync/Sync/FolderWatcher.swift
Normal file
103
apps/macos-sync/Sources/CairnquireSync/Sync/FolderWatcher.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
269
apps/macos-sync/Sources/CairnquireSync/Sync/SyncEngine.swift
Normal file
269
apps/macos-sync/Sources/CairnquireSync/Sync/SyncEngine.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
141
apps/macos-sync/Sources/CairnquireSync/UI/DropZoneView.swift
Normal file
141
apps/macos-sync/Sources/CairnquireSync/UI/DropZoneView.swift
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
262
apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
Normal file
262
apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
Normal 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 }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
244
apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
Normal file
244
apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
374
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
374
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal 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
|
||||
}
|
||||
}
|
||||
406
apps/macos-sync/Sources/cairnquire-cli/main.swift
Normal file
406
apps/macos-sync/Sources/cairnquire-cli/main.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
63
apps/macos-sync/scripts/sign-dev.sh
Executable file
63
apps/macos-sync/scripts/sign-dev.sh
Executable 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."
|
||||
@@ -7,12 +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"
|
||||
@@ -52,10 +55,6 @@ 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)
|
||||
}
|
||||
@@ -68,6 +67,23 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
||||
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,
|
||||
@@ -78,6 +94,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
||||
SyncService: syncService,
|
||||
SyncRepo: syncRepo,
|
||||
Auth: authService,
|
||||
Collaboration: collabService,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build http handler: %w", err)
|
||||
@@ -158,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
|
||||
}
|
||||
|
||||
@@ -49,16 +49,18 @@ func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) {
|
||||
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'), created_at, COALESCE(last_seen_at, '')
|
||||
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, &created, &lastSeen)
|
||||
`, 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)
|
||||
@@ -94,15 +96,102 @@ func (r *Repository) CountUsers(ctx context.Context) (int, error) {
|
||||
|
||||
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'`).Scan(&count); err != nil {
|
||||
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'), created_at, COALESCE(last_seen_at, '')
|
||||
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
|
||||
`)
|
||||
@@ -115,10 +204,12 @@ func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
|
||||
for rows.Next() {
|
||||
var user User
|
||||
var created, lastSeen, role string
|
||||
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &created, &lastSeen); err != nil {
|
||||
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)
|
||||
@@ -158,6 +249,41 @@ func (r *Repository) UpdateUserRole(ctx context.Context, userID string, role Rol
|
||||
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 {
|
||||
@@ -324,6 +450,87 @@ func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error
|
||||
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)
|
||||
@@ -522,6 +729,13 @@ func nullString(value string) any {
|
||||
return value
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func timePtrString(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -18,13 +18,19 @@ const (
|
||||
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) {
|
||||
@@ -55,7 +61,38 @@ func NewService(repo *Repository, publicOrigin string) (*Service, error) {
|
||||
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")
|
||||
@@ -72,21 +109,11 @@ func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName,
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
userCount, err := s.repo.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
normalizedRole := RoleViewer
|
||||
if userCount == 0 {
|
||||
normalizedRole = RoleAdmin
|
||||
} else if strings.EqualFold(role, string(RoleViewer)) {
|
||||
normalizedRole = RoleViewer
|
||||
}
|
||||
return s.repo.UpsertUser(ctx, User{
|
||||
Email: email,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
PasswordHash: passwordHash,
|
||||
Role: normalizedRole,
|
||||
Role: RoleViewer,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,7 +129,7 @@ func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAg
|
||||
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
ok, err := verifyPassword(password, user.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
if err != nil || !ok || user.Disabled {
|
||||
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
principal, token, err := s.createSession(ctx, user, ip, userAgent)
|
||||
@@ -113,23 +140,18 @@ func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAg
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
userCount, err := s.repo.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
normalizedRole := RoleViewer
|
||||
if userCount == 0 {
|
||||
normalizedRole = RoleAdmin
|
||||
}
|
||||
user, err = s.repo.UpsertUser(ctx, User{
|
||||
Email: normalizeEmail(email),
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
Role: normalizedRole,
|
||||
Role: RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -173,6 +195,9 @@ func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, str
|
||||
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
|
||||
@@ -193,6 +218,9 @@ func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r
|
||||
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
|
||||
@@ -212,6 +240,9 @@ func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Princ
|
||||
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
|
||||
}
|
||||
|
||||
@@ -278,34 +309,163 @@ func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
|
||||
return s.repo.ListUsers(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
|
||||
func (s *Service) UpdateSignupsEnabled(ctx context.Context, actor Principal, enabled bool) (InstanceSettings, error) {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return User{}, fmt.Errorf("admin role required")
|
||||
return InstanceSettings{}, fmt.Errorf("admin role required")
|
||||
}
|
||||
normalizedRole := Role(strings.ToLower(strings.TrimSpace(role)))
|
||||
switch normalizedRole {
|
||||
case RoleViewer, RoleEditor, RoleAdmin:
|
||||
default:
|
||||
return User{}, fmt.Errorf("invalid role")
|
||||
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
|
||||
}
|
||||
if user.Role == RoleAdmin && normalizedRole != RoleAdmin {
|
||||
admins, err := s.repo.CountAdmins(ctx)
|
||||
users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{
|
||||
ID: userID,
|
||||
Role: Role(role),
|
||||
Disabled: user.Disabled,
|
||||
}})
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if admins <= 1 {
|
||||
return User{}, fmt.Errorf("cannot demote the last admin account")
|
||||
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 err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil {
|
||||
return User{}, err
|
||||
if len(updates) == 0 {
|
||||
return nil, fmt.Errorf("at least one user update is required")
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole})
|
||||
return s.repo.GetUserByID(ctx, user.ID)
|
||||
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) {
|
||||
@@ -351,6 +511,9 @@ func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Princi
|
||||
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
|
||||
@@ -442,6 +605,9 @@ func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (Create
|
||||
}
|
||||
|
||||
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
|
||||
@@ -453,6 +619,20 @@ func (s *Service) createSession(ctx context.Context, user User, ip, userAgent st
|
||||
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,
|
||||
|
||||
@@ -3,6 +3,8 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +12,20 @@ import (
|
||||
"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()
|
||||
|
||||
@@ -28,13 +44,23 @@ func setupAuthTestService(t *testing.T) *Service {
|
||||
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.RegisterPasswordUser(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", "editor")
|
||||
user, err := service.CompleteInitialSetup(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
t.Fatalf("CompleteInitialSetup() error = %v", err)
|
||||
}
|
||||
if user.Email != "dev@example.com" {
|
||||
t.Fatalf("email = %q, want normalized", user.Email)
|
||||
@@ -48,7 +74,7 @@ func TestPasswordLoginCreatesValidSession(t *testing.T) {
|
||||
t.Fatal("expected session token")
|
||||
}
|
||||
if principal.Role != RoleAdmin {
|
||||
t.Fatalf("role = %s, want first registered user to bootstrap as admin", principal.Role)
|
||||
t.Fatalf("role = %s, want initial setup user to be admin", principal.Role)
|
||||
}
|
||||
|
||||
validated, err := service.ValidateSessionToken(ctx, token)
|
||||
@@ -64,10 +90,7 @@ func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
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 {
|
||||
@@ -99,10 +122,7 @@ func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
user := setupInitialAdmin(t, service, true)
|
||||
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
|
||||
if err != nil {
|
||||
t.Fatalf("StartDeviceFlow() error = %v", err)
|
||||
@@ -132,10 +152,7 @@ func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
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)
|
||||
@@ -152,23 +169,141 @@ func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -179,3 +314,32 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,12 +41,29 @@ type User struct {
|
||||
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)
|
||||
}
|
||||
@@ -92,6 +109,11 @@ type CreatedAPIKey struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type InstanceSettings struct {
|
||||
SetupComplete bool `json:"setupComplete"`
|
||||
SignupsEnabled bool `json:"signupsEnabled"`
|
||||
}
|
||||
|
||||
type DeviceCode struct {
|
||||
ID string
|
||||
UserID string
|
||||
|
||||
304
apps/server/internal/collaboration/repository.go
Normal file
304
apps/server/internal/collaboration/repository.go
Normal 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()
|
||||
}
|
||||
275
apps/server/internal/collaboration/service.go
Normal file
275
apps/server/internal/collaboration/service.go
Normal 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
|
||||
}
|
||||
}
|
||||
51
apps/server/internal/collaboration/types.go
Normal file
51
apps/server/internal/collaboration/types.go
Normal 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"`
|
||||
}
|
||||
71
apps/server/internal/collaboration/util.go
Normal file
71
apps/server/internal/collaboration/util.go
Normal 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:")
|
||||
}
|
||||
@@ -5,15 +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 {
|
||||
@@ -31,15 +33,16 @@ 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 {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
@@ -52,13 +55,14 @@ func Default() Config {
|
||||
SourceDir: filepath.Join("..", "..", "content"),
|
||||
StoreDir: filepath.Join("..", "..", "data", "files"),
|
||||
},
|
||||
Web: WebConfig{
|
||||
DistDir: filepath.Join("..", "web", "dist"),
|
||||
DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"),
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
PublicOrigin: "http://localhost:8080",
|
||||
},
|
||||
Email: EmailConfig{
|
||||
SMTPHost: "localhost",
|
||||
SMTPPort: 1025,
|
||||
From: "notifications@cairnquire.local",
|
||||
},
|
||||
LogLevel: "INFO",
|
||||
}
|
||||
}
|
||||
@@ -73,15 +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")
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS instance_settings;
|
||||
@@ -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');
|
||||
@@ -0,0 +1 @@
|
||||
-- SQLite column removal requires rebuilding the attachments table.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE attachments ADD COLUMN document_path TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS password_reset_tokens;
|
||||
ALTER TABLE users DROP COLUMN disabled;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE documents DROP COLUMN archived_at;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE documents ADD COLUMN archived_at TEXT;
|
||||
@@ -21,6 +21,7 @@ type DocumentRecord struct {
|
||||
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)
|
||||
|
||||
@@ -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,8 +237,18 @@ func (s *Service) SaveSourcePageWithBaseHash(ctx context.Context, requestPath st
|
||||
|
||||
record, _, err := s.resolveRecord(ctx, requestPath)
|
||||
if err != nil {
|
||||
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 {
|
||||
currentContent, readErr := s.store.Read(record.CurrentHash)
|
||||
@@ -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,
|
||||
|
||||
@@ -150,6 +150,71 @@ func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
67
apps/server/internal/email/sender.go
Normal file
67
apps/server/internal/email/sender.go
Normal 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
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
734
apps/server/internal/httpserver/api_handlers_test.go
Normal file
734
apps/server/internal/httpserver/api_handlers_test.go
Normal 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
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,13 @@ type passwordLoginRequest struct {
|
||||
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"`
|
||||
@@ -50,6 +57,11 @@ type changePasswordRequest struct {
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type passwordResetRequest struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type deleteAccountRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
}
|
||||
@@ -67,32 +79,101 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
|
||||
nextPath = "/account"
|
||||
}
|
||||
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
|
||||
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",
|
||||
WebEnabled: s.webEnabled,
|
||||
BodyClass: "page-auth",
|
||||
BodyTemplate: "login_content",
|
||||
Data: struct {
|
||||
Next string
|
||||
}{Next: nextPath},
|
||||
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, http.StatusOK, "account.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{
|
||||
Title: "Account",
|
||||
WebEnabled: s.webEnabled,
|
||||
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 {
|
||||
@@ -143,9 +224,26 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = 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) {
|
||||
@@ -163,6 +261,22 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
@@ -375,9 +489,8 @@ func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
|
||||
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
|
||||
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
|
||||
s.renderTemplate(w, r, http.StatusOK, "device_verify.gohtml", layoutData{
|
||||
Title: "Approve device",
|
||||
WebEnabled: s.webEnabled,
|
||||
BodyClass: "page-auth",
|
||||
BodyTemplate: "device_verify_content",
|
||||
Data: struct {
|
||||
@@ -415,6 +528,36 @@ func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -424,6 +567,7 @@ func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -435,6 +579,29 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -452,6 +619,7 @@ func publicUser(user auth.User) map[string]any {
|
||||
"email": user.Email,
|
||||
"displayName": user.DisplayName,
|
||||
"role": user.Role,
|
||||
"disabled": user.Disabled,
|
||||
"createdAt": user.CreatedAt,
|
||||
"lastSeenAt": user.LastSeenAt,
|
||||
}
|
||||
|
||||
262
apps/server/internal/httpserver/collab_handlers.go
Normal file
262
apps/server/internal/httpserver/collab_handlers.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -223,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{
|
||||
@@ -236,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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -257,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{
|
||||
@@ -270,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()
|
||||
|
||||
@@ -288,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) {
|
||||
@@ -347,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) {
|
||||
@@ -360,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)
|
||||
@@ -403,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 {
|
||||
@@ -412,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{
|
||||
@@ -432,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) {
|
||||
@@ -441,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 {
|
||||
@@ -465,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
|
||||
@@ -521,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
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -9,7 +15,10 @@ import (
|
||||
"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) {
|
||||
@@ -121,3 +130,419 @@ func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"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 {
|
||||
@@ -76,9 +78,49 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
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 ") {
|
||||
principal, err := s.auth.ValidateBearerToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(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
|
||||
}
|
||||
@@ -87,8 +129,12 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||
}
|
||||
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
|
||||
@@ -96,6 +142,11 @@ func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||
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
|
||||
@@ -120,6 +171,8 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
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
|
||||
@@ -127,6 +180,12 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
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:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"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"
|
||||
@@ -33,6 +35,7 @@ type Dependencies struct {
|
||||
SyncService *sync.Service
|
||||
SyncRepo *sync.Repository
|
||||
Auth *auth.Service
|
||||
Collaboration *collaboration.Service
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -45,9 +48,10 @@ type Server struct {
|
||||
syncService *sync.Service
|
||||
syncRepo *sync.Repository
|
||||
auth *auth.Service
|
||||
collaboration *collaboration.Service
|
||||
authLimiter *rateLimiter
|
||||
templates *template.Template
|
||||
webEnabled bool
|
||||
devUserID string
|
||||
}
|
||||
|
||||
func New(deps Dependencies) (http.Handler, error) {
|
||||
@@ -73,12 +77,18 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
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()
|
||||
@@ -88,18 +98,27 @@ 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.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)
|
||||
@@ -119,16 +138,35 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
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)
|
||||
@@ -137,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 {
|
||||
|
||||
45
apps/server/internal/httpserver/static/archive.js
Normal file
45
apps/server/internal/httpserver/static/archive.js
Normal 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';
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,4 +1,62 @@
|
||||
(() => {
|
||||
// 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 = {}) => {
|
||||
@@ -26,6 +84,22 @@
|
||||
|
||||
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);
|
||||
@@ -84,6 +158,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -95,52 +178,111 @@
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
if (!window.PublicKeyCredential) {
|
||||
setStatus("[data-passkey-register-status]", "Passkeys are not available in this browser.", true);
|
||||
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]", error.message, true);
|
||||
setStatus("[data-passkey-register-status]", webAuthnErrorMessage(error), true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!window.PublicKeyCredential) {
|
||||
setStatus("[data-passkey-login-status]", "Passkeys are not available in this browser.", true);
|
||||
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]", error.message, true);
|
||||
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) => {
|
||||
...data.tokens.map((token) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = `token-row${token.revokedAt ? " is-revoked" : ""}`;
|
||||
const meta = document.createElement("span");
|
||||
meta.textContent = `${token.name} - ${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
|
||||
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";
|
||||
@@ -188,6 +330,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -215,7 +368,7 @@
|
||||
|
||||
const loadAdminUsers = async () => {
|
||||
const section = document.querySelector("[data-admin-users-section]");
|
||||
const list = document.querySelector("[data-admin-users]");
|
||||
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;
|
||||
@@ -223,8 +376,9 @@
|
||||
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("form");
|
||||
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;
|
||||
@@ -239,24 +393,75 @@
|
||||
option.selected = user.role === role;
|
||||
select.append(option);
|
||||
}
|
||||
const button = document.createElement("button");
|
||||
button.type = "submit";
|
||||
button.textContent = "Save";
|
||||
row.append(identity, select, button);
|
||||
row.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
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/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" });
|
||||
setStatus("[data-admin-users-status]", "Role updated.");
|
||||
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(() => {});
|
||||
})();
|
||||
|
||||
123
apps/server/internal/httpserver/static/comments.js
Normal file
123
apps/server/internal/httpserver/static/comments.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
140
apps/server/internal/httpserver/static/notification-bell.js
Normal file
140
apps/server/internal/httpserver/static/notification-bell.js
Normal 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();
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -106,45 +106,203 @@ code {
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.site-nav a:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.site-nav a svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notification bell */
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-bell__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notification-bell__trigger:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.notification-bell__trigger svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notification-bell__count {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-bell__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: var(--panel-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.notification-bell__dropdown[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.notification-item.is-read {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notification-footer {
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-footer button {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.notification-footer button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.site-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.site-search__icon {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
z-index: 1;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.site-search input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.75rem;
|
||||
padding: 0.4rem 0.75rem 0.4rem 2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: var(--panel-strong);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.825rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.site-search input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.site-search input:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.site-search button {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.site-main {
|
||||
@@ -224,6 +382,16 @@ code {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-form .auth-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.auth-choice input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.auth-form input,
|
||||
.auth-form select,
|
||||
.user-row select {
|
||||
@@ -250,11 +418,30 @@ code {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.auth-form button[type="submit"],
|
||||
.account-header button {
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 0.85rem 1.75rem;
|
||||
min-height: 3rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.auth-form button[type="submit"]:hover {
|
||||
background: color-mix(in srgb, var(--accent) 85%, black);
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.btn-primary:active,
|
||||
.auth-form button[type="submit"]:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.auth-details {
|
||||
@@ -289,36 +476,414 @@ code {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Auth tabs */
|
||||
.auth-tabs {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-tab-list {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.auth-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-tab.is-active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-tab-panel.is-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.auth-dev-login {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.auth-dev-login .btn-secondary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Register CTA */
|
||||
.auth-register-cta {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 1rem 2rem;
|
||||
min-height: 3.5rem;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font: inherit;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.btn-cta:hover {
|
||||
background: color-mix(in srgb, var(--accent) 85%, black);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.btn-cta:active {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.auth-register-panel {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.auth-register-cancel {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
min-height: 2.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.account-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: min(100%, 1120px);
|
||||
width: min(100%, 1180px);
|
||||
margin: 0 auto 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.account-header p {
|
||||
margin: 0.35rem 0 0;
|
||||
.account-identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
display: inline-flex;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.account-header h1 {
|
||||
margin-top: 0.15rem;
|
||||
font-size: clamp(1.7rem, 3vw, 2.4rem);
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.account-grid {
|
||||
width: min(100%, 1120px);
|
||||
.account-role {
|
||||
padding: 0.18rem 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-header .account-signout {
|
||||
display: inline-flex;
|
||||
min-height: 2.45rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-color: var(--border);
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.account-header .account-signout:hover {
|
||||
border-color: var(--muted);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.account-layout {
|
||||
display: grid;
|
||||
width: min(100%, 1180px);
|
||||
margin: 0 auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.account-main,
|
||||
.account-sidebar {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account-section {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
padding: clamp(1rem, 2vw, 1.35rem);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.account-section__header {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.account-section__header--action {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.account-section__header h2,
|
||||
.account-list-heading h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-section__header h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.account-section__header p,
|
||||
.account-list-heading p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.account-section__kicker {
|
||||
margin: 0 0 0.25rem !important;
|
||||
color: var(--accent) !important;
|
||||
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace !important;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-section__divider {
|
||||
height: 1px;
|
||||
margin: 1.25rem 0 1rem;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.account-list-heading {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.account-list-heading h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.account-action,
|
||||
.account-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.account-action {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.account-action:hover {
|
||||
background: color-mix(in srgb, var(--accent) 85%, black);
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.account-action:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.account-page-header,
|
||||
.token-create-layout {
|
||||
width: min(100%, 920px);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.account-page-header {
|
||||
margin-bottom: 1rem;
|
||||
padding: clamp(1rem, 2vw, 1.35rem);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.account-page-header h1 {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.account-page-header p:last-child {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.account-back {
|
||||
width: fit-content;
|
||||
margin-bottom: 1.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.account-back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.token-create-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(15rem, 18rem);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.token-create-note h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.token-create-note p:last-child {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.account-shell .auth-form button[type="submit"] {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.account-form-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.account-form-footer .form-status {
|
||||
flex: 1 1 14rem;
|
||||
}
|
||||
|
||||
.account-section--danger {
|
||||
border-color: color-mix(in srgb, #b42318 40%, var(--border));
|
||||
background: color-mix(in srgb, #b42318 4%, var(--panel));
|
||||
}
|
||||
|
||||
.account-section--danger .account-section__kicker {
|
||||
color: #b42318 !important;
|
||||
}
|
||||
|
||||
.account-shell .account-danger-button[type="submit"] {
|
||||
border-color: color-mix(in srgb, #b42318 70%, var(--border));
|
||||
background: #b42318;
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, #b42318 22%, transparent);
|
||||
}
|
||||
|
||||
.account-shell .account-danger-button[type="submit"]:hover {
|
||||
background: #8f1c13;
|
||||
}
|
||||
|
||||
.scope-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent) 3%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.scope-grid legend {
|
||||
@@ -332,11 +897,47 @@ code {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.scope-grid input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.auth-form .auth-choice.account-toggle {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.auth-form .account-toggle input {
|
||||
width: auto;
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
|
||||
.account-toggle span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.account-toggle strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.account-toggle small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.token-output {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -359,14 +960,40 @@ code {
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.user-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto;
|
||||
.token-list {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.user-row span {
|
||||
.token-meta {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.token-meta small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.token-row.is-revoked {
|
||||
opacity: 0.64;
|
||||
}
|
||||
|
||||
.token-empty {
|
||||
display: block !important;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto auto;
|
||||
}
|
||||
|
||||
.user-row > span {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
@@ -375,6 +1002,84 @@ code {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.user-management {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-management .account-form-footer {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.user-management button[type="submit"] {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-disable-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-disable-toggle input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.account-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.token-create-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-sidebar {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.account-shell {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.account-header {
|
||||
align-items: flex-start;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
|
||||
.account-header .account-signout {
|
||||
min-height: 2.2rem;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.account-signout svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace layout - adaptive grid */
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
@@ -390,9 +1095,9 @@ code {
|
||||
grid-template-columns: minmax(260px, 0.38fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Empty state: more balanced 50/50 feel */
|
||||
/* Empty state: browser capped at 36% max */
|
||||
.workspace-shell--empty {
|
||||
grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr);
|
||||
grid-template-columns: minmax(300px, 0.5625fr) minmax(300px, 1fr);
|
||||
}
|
||||
|
||||
/* Miller browser - flex layout so all columns are visible as vertical slices */
|
||||
@@ -1004,6 +1709,12 @@ code {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.document-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.document-edit-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1016,6 +1727,25 @@ code {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.document-archive-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.4rem;
|
||||
padding: 0 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, #b42318 40%, var(--border));
|
||||
background: transparent;
|
||||
color: #b42318;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.document-archive-btn:hover {
|
||||
background: color-mix(in srgb, #b42318 8%, transparent);
|
||||
}
|
||||
|
||||
.document-shell--editor {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
@@ -1433,6 +2163,10 @@ code {
|
||||
background: oklch(0.16 0.014 55 / 0.92);
|
||||
}
|
||||
|
||||
.notification-bell__trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.miller-column h2 {
|
||||
background: oklch(0.20 0.015 55 / 0.72);
|
||||
}
|
||||
@@ -1595,3 +2329,72 @@ code {
|
||||
background: oklch(0.705 0.165 55 / 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.commentable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.commentable:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.comment-anchor-btn {
|
||||
position: absolute;
|
||||
right: -1.5rem;
|
||||
top: 0.25rem;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--panel-strong);
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.commentable:hover .comment-anchor-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.comment-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.comment-meta strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -53,8 +54,22 @@ 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
|
||||
@@ -63,6 +78,7 @@ func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, sync.DeltaResponse{
|
||||
ServerDelta: result.ServerDelta,
|
||||
Conflicts: result.Conflicts,
|
||||
NewSnapshotID: result.NewSnapshotID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,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
|
||||
@@ -99,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
|
||||
|
||||
@@ -3,39 +3,77 @@
|
||||
{{ 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</p>
|
||||
<p class="eyebrow">Account center</p>
|
||||
<h1>{{ .DisplayName }}</h1>
|
||||
<p>{{ .Email }} · {{ .Role }}</p>
|
||||
<div class="account-meta">
|
||||
<span>{{ .Email }}</span>
|
||||
<span class="account-role">{{ .Role }}</span>
|
||||
</div>
|
||||
<button type="button" data-auth-logout>Sign out</button>
|
||||
</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-grid">
|
||||
<section class="account-section">
|
||||
<h2>API Tokens</h2>
|
||||
<form class="auth-form" data-token-create>
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" placeholder="CLI on laptop" required />
|
||||
</label>
|
||||
<fieldset class="scope-grid">
|
||||
<legend>Scopes</legend>
|
||||
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
|
||||
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
|
||||
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
|
||||
</fieldset>
|
||||
<button type="submit">Create token</button>
|
||||
<p class="form-status" data-token-status></p>
|
||||
<pre class="token-output" data-token-output hidden></pre>
|
||||
</form>
|
||||
<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">
|
||||
<h2>Password</h2>
|
||||
<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
|
||||
@@ -45,28 +83,50 @@
|
||||
New password
|
||||
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
|
||||
</label>
|
||||
<button type="submit">Change password</button>
|
||||
<button type="submit">Update password</button>
|
||||
<p class="form-status" data-password-status></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="account-section" data-admin-users-section hidden>
|
||||
<h2>Users</h2>
|
||||
<div class="user-list" data-admin-users></div>
|
||||
<p class="form-status" data-admin-users-status></p>
|
||||
<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">
|
||||
<h2>Delete Account</h2>
|
||||
<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 type="submit">Delete account</button>
|
||||
<button class="account-danger-button" type="submit">Delete account</button>
|
||||
<p class="form-status" data-delete-status></p>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
@@ -22,14 +22,34 @@
|
||||
<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>
|
||||
<a href="/account">Account</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>
|
||||
@@ -44,8 +64,14 @@
|
||||
{{ 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" }}
|
||||
@@ -71,7 +97,7 @@
|
||||
<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>
|
||||
@@ -80,6 +106,8 @@
|
||||
<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>
|
||||
|
||||
@@ -21,7 +21,12 @@
|
||||
<div class="document-meta">
|
||||
<div class="document-meta__header">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ if .CanWrite }}
|
||||
<div class="document-actions">
|
||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
||||
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<details class="document-meta-panel">
|
||||
<summary>
|
||||
|
||||
@@ -6,12 +6,29 @@
|
||||
<div class="auth-panel">
|
||||
<div class="auth-panel__header">
|
||||
<p class="eyebrow">Cairnquire</p>
|
||||
<h1>Sign in</h1>
|
||||
<h1 data-auth-heading>Log In</h1>
|
||||
</div>
|
||||
|
||||
<div class="auth-grid">
|
||||
<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>
|
||||
<h2>Password</h2>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" autocomplete="email" required />
|
||||
@@ -20,26 +37,47 @@
|
||||
Password
|
||||
<input type="password" name="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<button type="submit">Sign in</button>
|
||||
<button type="submit" class="btn-primary">Sign in</button>
|
||||
<p class="form-status" data-login-status></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" data-passkey-login>
|
||||
<h2>Passkey</h2>
|
||||
{{ 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="username webauthn" required />
|
||||
<input type="email" name="email" autocomplete="email" required />
|
||||
</label>
|
||||
<button type="submit">Use passkey</button>
|
||||
<p class="form-status" data-passkey-login-status></p>
|
||||
<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>
|
||||
|
||||
<details class="auth-details">
|
||||
<summary>Create an account</summary>
|
||||
<div class="auth-grid">
|
||||
<div class="auth-tab-panel" role="tabpanel" data-tab-panel="password-register" hidden>
|
||||
<form class="auth-form" data-auth-register>
|
||||
<h2>Password account</h2>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" autocomplete="email" required />
|
||||
@@ -52,25 +90,16 @@
|
||||
Password
|
||||
<input type="password" name="password" autocomplete="new-password" minlength="12" required />
|
||||
</label>
|
||||
<button type="submit">Register</button>
|
||||
<button type="submit" class="btn-primary">Register</button>
|
||||
<p class="form-status" data-register-status></p>
|
||||
</form>
|
||||
|
||||
<form class="auth-form" data-passkey-register>
|
||||
<h2>Passkey account</h2>
|
||||
<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">Create passkey</button>
|
||||
<p class="form-status" data-passkey-register-status></p>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</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 }}
|
||||
|
||||
@@ -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 }}
|
||||
34
apps/server/internal/httpserver/templates/setup.gohtml
Normal file
34
apps/server/internal/httpserver/templates/setup.gohtml
Normal file
@@ -0,0 +1,34 @@
|
||||
{{ define "setup.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "setup_content" }}
|
||||
<section class="auth-shell">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-panel__header">
|
||||
<p class="eyebrow">Cairnquire</p>
|
||||
<h1>Initial setup</h1>
|
||||
<p class="auth-note">Create the first administrator account and choose whether visitors can register their own accounts.</p>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" data-initial-setup>
|
||||
<label>
|
||||
Admin 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>
|
||||
<label class="auth-choice">
|
||||
<input type="checkbox" name="signupsEnabled" />
|
||||
Allow visitors to create accounts
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Finish setup</button>
|
||||
<p class="form-status" data-setup-status></p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,49 @@
|
||||
{{ define "token_create.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "token_create_content" }}
|
||||
<section class="account-shell">
|
||||
<header class="account-page-header">
|
||||
<a class="account-back" href="/account">
|
||||
<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="M19 12H5" />
|
||||
<path d="M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to account
|
||||
</a>
|
||||
<p class="account-section__kicker">Integrations</p>
|
||||
<h1>Create access token</h1>
|
||||
<p>Give a trusted client the smallest set of permissions it needs.</p>
|
||||
</header>
|
||||
|
||||
<div class="token-create-layout">
|
||||
<section class="account-section">
|
||||
<form class="auth-form token-create-form" data-token-create>
|
||||
<label>
|
||||
Token name
|
||||
<input type="text" name="name" placeholder="MacBook sync app" required />
|
||||
</label>
|
||||
<fieldset class="scope-grid">
|
||||
<legend>Permissions</legend>
|
||||
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
|
||||
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
|
||||
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
|
||||
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
|
||||
</fieldset>
|
||||
<div class="account-form-footer">
|
||||
<button type="submit">Create token</button>
|
||||
<a class="btn-secondary" href="/account">Cancel</a>
|
||||
</div>
|
||||
<p class="form-status" data-token-status></p>
|
||||
<pre class="token-output" data-token-output hidden></pre>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="account-section token-create-note">
|
||||
<p class="account-section__kicker">Before you create</p>
|
||||
<h2>Keep the secret somewhere safe</h2>
|
||||
<p>The token is shown once after creation. Store it in the client that needs access, then return to your account to revoke it later.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
@@ -46,6 +46,7 @@ type Change struct {
|
||||
Path string `json:"path"`
|
||||
OldPath string `json:"oldPath,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Modified time.Time `json:"modified,omitempty"`
|
||||
}
|
||||
@@ -69,6 +70,7 @@ type Conflict struct {
|
||||
type DeltaResult struct {
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
NewSnapshotID string `json:"newSnapshotId,omitempty"`
|
||||
}
|
||||
|
||||
// Resolution is a user's choice for resolving a conflict.
|
||||
@@ -76,6 +78,8 @@ type Resolution struct {
|
||||
Path string `json:"path"`
|
||||
Strategy ResolutionStrategy `json:"strategy"`
|
||||
NewPath string `json:"newPath,omitempty"` // for rename-both
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// InitRequest starts a new sync session.
|
||||
@@ -100,6 +104,7 @@ type DeltaRequest struct {
|
||||
type DeltaResponse struct {
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
NewSnapshotID string `json:"newSnapshotId,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveRequest uploads conflict resolutions.
|
||||
|
||||
@@ -2,15 +2,26 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbidden = errors.New("sync snapshot does not belong to user")
|
||||
ErrContentRequired = errors.New("sync content is required")
|
||||
ErrHashMismatch = errors.New("sync content hash mismatch")
|
||||
ErrInvalidPath = errors.New("invalid sync path")
|
||||
ErrInvalidHash = errors.New("invalid sync content hash")
|
||||
ErrContentNotFound = errors.New("sync content hash not found")
|
||||
)
|
||||
|
||||
// Service handles sync protocol business logic.
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
@@ -48,11 +59,14 @@ func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snaps
|
||||
}
|
||||
|
||||
// ApplyDelta processes client changes and computes server delta + conflicts.
|
||||
func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta Delta) (*DeltaResult, error) {
|
||||
func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, clientDelta Delta) (*DeltaResult, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
}
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
serverFiles := make(map[string]FileEntry)
|
||||
for _, f := range snap.Files {
|
||||
@@ -83,8 +97,8 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
currentMap[f.Path] = f
|
||||
}
|
||||
|
||||
var serverDelta []Change
|
||||
var conflicts []Conflict
|
||||
serverDelta := make([]Change, 0)
|
||||
conflicts := make([]Conflict, 0)
|
||||
|
||||
// Detect server changes since snapshot
|
||||
for path, current := range currentMap {
|
||||
@@ -121,13 +135,15 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
}
|
||||
|
||||
// Check for conflicts: both client and server changed same file
|
||||
conflictPaths := make(map[string]struct{})
|
||||
for _, clientChange := range clientDelta.Changes {
|
||||
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate {
|
||||
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate || clientChange.Type == ChangeDelete {
|
||||
serverCurrent, serverHas := currentMap[clientChange.Path]
|
||||
serverOld, serverHad := serverFiles[clientChange.Path]
|
||||
|
||||
if serverHad && serverHas && serverOld.Hash != serverCurrent.Hash &&
|
||||
serverCurrent.Hash != clientChange.Hash {
|
||||
conflictPaths[clientChange.Path] = struct{}{}
|
||||
conflicts = append(conflicts, Conflict{
|
||||
Path: clientChange.Path,
|
||||
ServerHash: serverCurrent.Hash,
|
||||
@@ -137,73 +153,107 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
Strategy: ResolutionLastWriteWins,
|
||||
})
|
||||
}
|
||||
if !serverHad && serverHas && serverCurrent.Hash != clientChange.Hash {
|
||||
conflictPaths[clientChange.Path] = struct{}{}
|
||||
conflicts = append(conflicts, Conflict{
|
||||
Path: clientChange.Path,
|
||||
ServerHash: serverCurrent.Hash,
|
||||
ClientHash: clientChange.Hash,
|
||||
ServerModified: serverCurrent.Modified,
|
||||
ClientModified: clientChange.Modified,
|
||||
Strategy: ResolutionManualMerge,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, clientChange := range clientDelta.Changes {
|
||||
if _, conflicted := conflictPaths[clientChange.Path]; conflicted {
|
||||
continue
|
||||
}
|
||||
if err := s.applyClientChange(ctx, clientChange); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(clientDelta.Changes) > 0 {
|
||||
if _, err := s.docService.SyncSourceDir(ctx); err != nil {
|
||||
return nil, fmt.Errorf("sync documents after client delta: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
latestFiles, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-delta snapshot: %w", err)
|
||||
}
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, latestFiles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create post-delta snapshot: %w", err)
|
||||
}
|
||||
|
||||
return &DeltaResult{
|
||||
ServerDelta: serverDelta,
|
||||
Conflicts: conflicts,
|
||||
NewSnapshotID: newSnap.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveConflicts applies resolved changes and creates a new snapshot.
|
||||
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resolutions []Resolution) (*Snapshot, error) {
|
||||
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID string, resolutions []Resolution) (*Snapshot, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of resolutions by path
|
||||
resMap := make(map[string]Resolution)
|
||||
for _, r := range resolutions {
|
||||
resMap[r.Path] = r
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
// Get current server state
|
||||
currentFiles, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
if _, err := s.buildSnapshotFromDisk(ctx); err != nil {
|
||||
return nil, fmt.Errorf("build current snapshot: %w", err)
|
||||
}
|
||||
|
||||
currentMap := make(map[string]FileEntry)
|
||||
for _, f := range currentFiles {
|
||||
currentMap[f.Path] = f
|
||||
}
|
||||
|
||||
// Apply resolutions to create the merged state
|
||||
merged := make(map[string]FileEntry)
|
||||
for path, f := range currentMap {
|
||||
merged[path] = f
|
||||
}
|
||||
|
||||
for _, res := range resolutions {
|
||||
switch res.Strategy {
|
||||
case ResolutionClientWins:
|
||||
// In a real implementation, we'd apply the client's content here.
|
||||
// For now, we keep the server state and mark it resolved.
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Debug("conflict resolved: client wins", "path", res.Path)
|
||||
case ResolutionServerWins:
|
||||
// Keep server state (already in merged)
|
||||
s.logger.Debug("conflict resolved: server wins", "path", res.Path)
|
||||
case ResolutionRenameBoth:
|
||||
if existing, ok := merged[res.Path]; ok {
|
||||
merged[res.NewPath] = existing
|
||||
delete(merged, res.Path)
|
||||
if res.NewPath == "" {
|
||||
res.NewPath = conflictPath(res.Path)
|
||||
}
|
||||
if err := s.applyResolvedContent(res.NewPath, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResolutionLastWriteWins:
|
||||
// Keep whichever is newer - in practice server state since
|
||||
// we haven't received client content yet
|
||||
s.logger.Debug("conflict resolved: last-write-wins", "path", res.Path)
|
||||
if res.Content != "" {
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case ResolutionManualMerge:
|
||||
// Flag for later UI resolution
|
||||
if res.Content != "" {
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.logger.Debug("conflict deferred: manual merge", "path", res.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new snapshot from merged state
|
||||
var files []FileEntry
|
||||
for _, f := range merged {
|
||||
files = append(files, f)
|
||||
if len(resolutions) > 0 {
|
||||
if _, err := s.docService.SyncSourceDir(ctx); err != nil {
|
||||
return nil, fmt.Errorf("sync documents after resolutions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
files, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build resolved snapshot: %w", err)
|
||||
}
|
||||
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
|
||||
@@ -217,12 +267,137 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resol
|
||||
|
||||
// GetContent returns raw file content by hash.
|
||||
func (s *Service) GetContent(hash string) ([]byte, error) {
|
||||
if !isSHA256Hex(hash) {
|
||||
return nil, ErrInvalidHash
|
||||
}
|
||||
return s.contentStore.Read(hash)
|
||||
}
|
||||
|
||||
func (s *Service) applyClientChange(_ context.Context, change Change) error {
|
||||
switch change.Type {
|
||||
case ChangeCreate, ChangeUpdate:
|
||||
return s.applyResolvedContent(change.Path, change.Hash, change.Content)
|
||||
case ChangeDelete:
|
||||
path, err := s.safeContentPath(change.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("delete synced file %s: %w", change.Path, err)
|
||||
}
|
||||
return nil
|
||||
case ChangeRename:
|
||||
oldPath, err := s.safeContentPath(change.OldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newPath, err := s.safeContentPath(change.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create rename directory %s: %w", change.Path, err)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("rename synced file %s to %s: %w", change.OldPath, change.Path, err)
|
||||
}
|
||||
if change.Content != "" || change.Hash != "" {
|
||||
return s.applyResolvedContent(change.Path, change.Hash, change.Content)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyResolvedContent(requestPath, expectedHash, content string) error {
|
||||
path, err := s.safeContentPath(requestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
if content != "" {
|
||||
record, err := s.contentStore.PutBytes([]byte(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store synced content %s: %w", requestPath, err)
|
||||
}
|
||||
if expectedHash != "" && record.Hash != expectedHash {
|
||||
return fmt.Errorf("%w: %s", ErrHashMismatch, requestPath)
|
||||
}
|
||||
bytes = []byte(content)
|
||||
} else {
|
||||
if expectedHash == "" {
|
||||
return fmt.Errorf("%w: %s", ErrContentRequired, requestPath)
|
||||
}
|
||||
if !isSHA256Hex(expectedHash) {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidHash, expectedHash)
|
||||
}
|
||||
bytes, err = s.contentStore.Read(expectedHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: %s", ErrContentNotFound, expectedHash)
|
||||
}
|
||||
return fmt.Errorf("read synced content %s: %w", expectedHash, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create synced file directory %s: %w", requestPath, err)
|
||||
}
|
||||
if err := os.WriteFile(path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write synced file %s: %w", requestPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) safeContentPath(requestPath string) (string, error) {
|
||||
if requestPath == "" {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.Trim(requestPath, "/"))))
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(clean) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
sourceRoot, err := filepath.Abs(s.sourceDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve source root: %w", err)
|
||||
}
|
||||
target, err := filepath.Abs(filepath.Join(sourceRoot, filepath.FromSlash(clean)))
|
||||
if err != nil {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
relative, err := filepath.Rel(sourceRoot, target)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func conflictPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
stem := strings.TrimSuffix(path, ext)
|
||||
return stem + " (conflict)" + ext
|
||||
}
|
||||
|
||||
func isSHA256Hex(hash string) bool {
|
||||
if len(hash) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range hash {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildSnapshotFromDisk walks the source directory and builds a file list.
|
||||
func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error) {
|
||||
var files []FileEntry
|
||||
files := make([]FileEntry, 0)
|
||||
|
||||
err := filepath.WalkDir(s.sourceDir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -231,7 +406,7 @@ func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".md" {
|
||||
if strings.ToLower(filepath.Ext(path)) != ".md" {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -112,7 +116,7 @@ func TestApplyDeltaDetectsServerChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
// Client sends empty delta
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: nil})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: nil})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -133,6 +137,31 @@ func TestApplyDeltaDetectsServerChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaMarshalsEmptyCollectionsAsArrays(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal delta result: %v", err)
|
||||
}
|
||||
for _, expected := range []string{`"serverDelta":[]`, `"conflicts":[]`} {
|
||||
if !strings.Contains(string(payload), expected) {
|
||||
t.Fatalf("delta JSON = %s, want %s", payload, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaDetectsConflicts(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -157,7 +186,7 @@ func TestApplyDeltaDetectsConflicts(t *testing.T) {
|
||||
Modified: time.Now().UTC(),
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{clientChange}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -189,15 +218,17 @@ func TestApplyDeltaAllowsDifferentFileEditsWithoutConflict(t *testing.T) {
|
||||
t.Fatalf("update guide.md: %v", err)
|
||||
}
|
||||
|
||||
clientContent := "# Hello\n\nClient-side hello update"
|
||||
clientChange := Change{
|
||||
Type: ChangeUpdate,
|
||||
Path: "hello.md",
|
||||
Hash: "clienthash-different-file",
|
||||
Size: 128,
|
||||
Hash: sha256Hex(clientContent),
|
||||
Content: clientContent,
|
||||
Size: int64(len(clientContent)),
|
||||
Modified: time.Now().UTC(),
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{clientChange}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -213,6 +244,71 @@ func TestApplyDeltaAllowsDifferentFileEditsWithoutConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaAppliesClientCreateWithContent(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
content := "# Inbox\n\nCreated from macOS"
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{{
|
||||
Type: ChangeCreate,
|
||||
Path: "inbox/from-mac.md",
|
||||
Hash: sha256Hex(content),
|
||||
Content: content,
|
||||
Size: int64(len(content)),
|
||||
Modified: time.Now().UTC(),
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
if len(result.Conflicts) != 0 {
|
||||
t.Fatalf("expected no conflicts, got %#v", result.Conflicts)
|
||||
}
|
||||
if result.NewSnapshotID == "" || result.NewSnapshotID == snap.ID {
|
||||
t.Fatalf("new snapshot id = %q, old = %q", result.NewSnapshotID, snap.ID)
|
||||
}
|
||||
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "from-mac.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read synced file: %v", err)
|
||||
}
|
||||
if string(written) != content {
|
||||
t.Fatalf("synced content = %q, want %q", string(written), content)
|
||||
}
|
||||
|
||||
stored, err := service.GetContent(sha256Hex(content))
|
||||
if err != nil {
|
||||
t.Fatalf("GetContent() error = %v", err)
|
||||
}
|
||||
if string(stored) != content {
|
||||
t.Fatalf("stored content = %q, want %q", string(stored), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaRejectsHashMismatch(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{{
|
||||
Type: ChangeCreate,
|
||||
Path: "bad.md",
|
||||
Hash: "not-the-content-hash",
|
||||
Content: "# Bad\n",
|
||||
}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected hash mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflictsPreservesServerContent(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -227,7 +323,7 @@ func TestResolveConflictsPreservesServerContent(t *testing.T) {
|
||||
t.Fatalf("update hello.md: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.ResolveConflicts(ctx, snap.ID, []Resolution{{
|
||||
_, err = service.ResolveConflicts(ctx, snap.ID, "user:test", []Resolution{{
|
||||
Path: "hello.md",
|
||||
Strategy: ResolutionServerWins,
|
||||
}})
|
||||
@@ -260,7 +356,7 @@ func TestResolveConflictsCreatesNewSnapshot(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
newSnap, err := service.ResolveConflicts(ctx, snap.ID, resolutions)
|
||||
newSnap, err := service.ResolveConflicts(ctx, snap.ID, "user:test", resolutions)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConflicts() error = %v", err)
|
||||
}
|
||||
@@ -300,6 +396,14 @@ func TestGetContentReturnsFileBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContentRejectsInvalidHash(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
|
||||
if _, err := service.GetContent("short"); err == nil {
|
||||
t.Fatal("expected invalid hash error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -329,7 +433,7 @@ func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
start = time.Now()
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -343,3 +447,8 @@ func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
t.Fatalf("server delta path = %q, want note-050.md", result.ServerDelta[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
dist/
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cairnquire App</title>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "cairnquire-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"format": "prettier --check .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"katex": "^0.16.45",
|
||||
"lucide-preact": "^1.14.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"preact": "^10.27.2",
|
||||
"preact-iso": "^2.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.1.12"
|
||||
}
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
import {
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Database,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
LogIn,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Role = "admin" | "editor" | "viewer";
|
||||
type View = "workspace" | "users";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
createdAt: string;
|
||||
lastSeenAt?: string;
|
||||
};
|
||||
|
||||
type DocumentRecord = {
|
||||
path: string;
|
||||
title: string;
|
||||
currentHash: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Workspace = {
|
||||
sourceDir: string;
|
||||
storeDir: string;
|
||||
databasePath: string;
|
||||
documentCount: number;
|
||||
userCount: number;
|
||||
webDistDir: string;
|
||||
};
|
||||
|
||||
type WorkspaceResponse = {
|
||||
workspace: Workspace;
|
||||
documents: DocumentRecord[];
|
||||
};
|
||||
|
||||
type Session = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
type BrowserItem = {
|
||||
type: "folder" | "document";
|
||||
name: string;
|
||||
path: string;
|
||||
document?: DocumentRecord;
|
||||
indexDocument?: DocumentRecord;
|
||||
};
|
||||
|
||||
type BrowserColumn = {
|
||||
title: string;
|
||||
prefix: string;
|
||||
items: BrowserItem[];
|
||||
};
|
||||
|
||||
const sessionKey = "mdhub.admin.session";
|
||||
|
||||
export function App() {
|
||||
const [session, setSession] = useState<Session | null>(() => readSession());
|
||||
const [activeView, setActiveView] = useState<View>("workspace");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [workspace, setWorkspace] = useState<WorkspaceResponse | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [lastSyncedAt, setLastSyncedAt] = useState("");
|
||||
const [activePath, setActivePath] = useState("");
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
|
||||
async function refresh() {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const [usersResponse, workspaceResponse] = await Promise.all([
|
||||
api<{ users: User[] }>("/api/admin/users"),
|
||||
api<WorkspaceResponse>("/api/admin/workspace"),
|
||||
]);
|
||||
setUsers(usersResponse.users);
|
||||
setWorkspace(workspaceResponse);
|
||||
setLastSyncedAt(new Date().toISOString());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [session?.user.email]);
|
||||
|
||||
useEffect(() => {
|
||||
const documents = workspace?.documents ?? [];
|
||||
if (
|
||||
documents.length > 0 &&
|
||||
!isKnownDocumentLocation(documents, activePath)
|
||||
) {
|
||||
setActivePath(documents[0].path);
|
||||
}
|
||||
}, [workspace?.documents, activePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (users.length > 0 && !users.some((user) => user.id === selectedUserId)) {
|
||||
setSelectedUserId(users[0].id);
|
||||
}
|
||||
}, [users, selectedUserId]);
|
||||
|
||||
async function handleLogin(email: string, displayName: string) {
|
||||
const response = await api<{ user: User }>("/api/admin/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, displayName, role: "admin" }),
|
||||
});
|
||||
const next = { user: response.user };
|
||||
localStorage.setItem(sessionKey, JSON.stringify(next));
|
||||
setSession(next);
|
||||
}
|
||||
|
||||
async function handleCreateUser(input: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
}) {
|
||||
const response = await api<{ user: User }>("/api/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
await refresh();
|
||||
setSelectedUserId(response.user.id);
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await api("/api/admin/workspace/sync", { method: "POST" });
|
||||
await refresh();
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem(sessionKey);
|
||||
setSession(null);
|
||||
setUsers([]);
|
||||
setWorkspace(null);
|
||||
setActivePath("");
|
||||
setSelectedUserId("");
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <LoginScreen onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
const selectedDocument = selectDocument(
|
||||
workspace?.documents ?? [],
|
||||
activePath,
|
||||
);
|
||||
const selectedUser =
|
||||
users.find((user) => user.id === selectedUserId) ?? users[0];
|
||||
|
||||
return (
|
||||
<main class={`admin-shell ${sidebarOpen ? "" : "is-collapsed"}`}>
|
||||
<aside class="admin-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a class="brand" href="/docs" title="Open public docs">
|
||||
<Shield size={18} />
|
||||
<span>MD Hub</span>
|
||||
</a>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen((open) => !open)}
|
||||
aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<PanelLeftClose size={17} />
|
||||
) : (
|
||||
<PanelLeftOpen size={17} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="nav-list" aria-label="Admin sections">
|
||||
<button
|
||||
class={activeView === "workspace" ? "is-active" : ""}
|
||||
type="button"
|
||||
onClick={() => setActiveView("workspace")}
|
||||
title="Workspace"
|
||||
>
|
||||
<FolderGit2 size={17} />
|
||||
<span>Workspace</span>
|
||||
</button>
|
||||
<button
|
||||
class={activeView === "users" ? "is-active" : ""}
|
||||
type="button"
|
||||
onClick={() => setActiveView("users")}
|
||||
title="Users"
|
||||
>
|
||||
<Users size={17} />
|
||||
<span>Users</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
class="nav-button"
|
||||
type="button"
|
||||
onClick={logout}
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section class="admin-main">
|
||||
<header class="compact-topbar">
|
||||
<div>
|
||||
<span class="section-label">{activeView}</span>
|
||||
<strong>
|
||||
{activeView === "workspace" ? "Documents" : "Accounts"}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
{activeView === "workspace" && (
|
||||
<button
|
||||
type="button"
|
||||
class="primary-button sync-button"
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
aria-label="Sync workspace"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
<span>Sync</span>
|
||||
</button>
|
||||
)}
|
||||
<span class="sync-label">
|
||||
<Clock3 size={14} />
|
||||
{syncing
|
||||
? "Syncing"
|
||||
: lastSyncedAt
|
||||
? `Synced ${formatTime(lastSyncedAt)}`
|
||||
: "Not synced"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{activeView === "workspace" ? (
|
||||
<WorkspaceView
|
||||
data={workspace}
|
||||
activePath={activePath}
|
||||
selectedDocument={selectedDocument}
|
||||
onSelectPath={setActivePath}
|
||||
/>
|
||||
) : (
|
||||
<UsersView
|
||||
users={users}
|
||||
selectedUser={selectedUser}
|
||||
onSelectUser={setSelectedUserId}
|
||||
onCreateUser={handleCreateUser}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginScreen(props: {
|
||||
onLogin: (email: string, displayName: string) => Promise<void>;
|
||||
}) {
|
||||
const [email, setEmail] = useState("admin@example.com");
|
||||
const [displayName, setDisplayName] = useState("Admin");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(event: Event) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await props.onLogin(email, displayName);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Login failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main class="login-shell">
|
||||
<form class="login-panel" onSubmit={submit}>
|
||||
<div class="login-mark">
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<h1>Admin login</h1>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onInput={(event) => setEmail(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Display name
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onInput={(event) => setDisplayName(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p class="form-error">{error}</p>}
|
||||
<button type="submit" class="primary-button">
|
||||
<LogIn size={15} />
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceView(props: {
|
||||
data: WorkspaceResponse | null;
|
||||
activePath: string;
|
||||
selectedDocument?: DocumentRecord;
|
||||
onSelectPath: (path: string) => void;
|
||||
}) {
|
||||
const documents = props.data?.documents ?? [];
|
||||
const columns = buildDocumentColumns(documents, props.activePath);
|
||||
|
||||
return (
|
||||
<div class="workspace-grid">
|
||||
<section class="column-browser" aria-label="Workspace files">
|
||||
{columns.map((column) => (
|
||||
<div class="browser-column" key={column.prefix || "root"}>
|
||||
<header>
|
||||
<Folder size={15} />
|
||||
{column.title}
|
||||
</header>
|
||||
<div class="item-list">
|
||||
{column.items.map((item) => (
|
||||
<button
|
||||
class={
|
||||
isActiveItem(item, props.activePath)
|
||||
? "list-item is-active"
|
||||
: "list-item"
|
||||
}
|
||||
type="button"
|
||||
key={`${item.type}:${item.path}`}
|
||||
onClick={() =>
|
||||
props.onSelectPath(item.indexDocument?.path ?? item.path)
|
||||
}
|
||||
>
|
||||
{item.type === "folder" ? (
|
||||
isActiveItem(item, props.activePath) ? (
|
||||
<FolderOpen size={15} />
|
||||
) : (
|
||||
<Folder size={15} />
|
||||
)
|
||||
) : (
|
||||
<FileText size={15} />
|
||||
)}
|
||||
<span>{item.name}</span>
|
||||
{item.type === "folder" && <ChevronRight size={14} />}
|
||||
</button>
|
||||
))}
|
||||
{column.items.length === 0 && (
|
||||
<p class="empty-state">No documents</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<WorkspaceDetails
|
||||
workspace={props.data?.workspace}
|
||||
document={props.selectedDocument}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDetails(props: {
|
||||
workspace?: Workspace;
|
||||
document?: DocumentRecord;
|
||||
}) {
|
||||
return (
|
||||
<aside class="detail-panel">
|
||||
<header class="detail-header">
|
||||
<Database size={17} />
|
||||
<span>Details</span>
|
||||
</header>
|
||||
|
||||
{props.document ? (
|
||||
<>
|
||||
<h2>{props.document.title}</h2>
|
||||
<a class="open-link" href={documentHref(props.document.path)}>
|
||||
Open document
|
||||
</a>
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>File</dt>
|
||||
<dd>{props.document.path}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Hash</dt>
|
||||
<dd>
|
||||
<code>{props.document.currentHash.slice(0, 12)}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{formatDate(props.document.updatedAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
) : (
|
||||
<p class="empty-state">Select a document.</p>
|
||||
)}
|
||||
|
||||
{props.workspace && (
|
||||
<dl class="detail-list compact">
|
||||
<div>
|
||||
<dt>Documents</dt>
|
||||
<dd>{props.workspace.documentCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Content</dt>
|
||||
<dd>{props.workspace.sourceDir}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Storage</dt>
|
||||
<dd>{props.workspace.storeDir}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Database</dt>
|
||||
<dd>{props.workspace.databasePath}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersView(props: {
|
||||
users: User[];
|
||||
selectedUser?: User;
|
||||
onSelectUser: (id: string) => void;
|
||||
onCreateUser: (input: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div class="workspace-grid">
|
||||
<section class="browser-column user-column" aria-label="Users">
|
||||
<header>Users</header>
|
||||
<div class="item-list">
|
||||
{props.users.map((user) => (
|
||||
<button
|
||||
class={
|
||||
user.id === props.selectedUser?.id
|
||||
? "list-item is-active"
|
||||
: "list-item"
|
||||
}
|
||||
type="button"
|
||||
key={user.id}
|
||||
onClick={() => props.onSelectUser(user.id)}
|
||||
>
|
||||
<Users size={15} />
|
||||
<span>{user.displayName}</span>
|
||||
<small>{user.role}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UserDetails
|
||||
selectedUser={props.selectedUser}
|
||||
onCreateUser={props.onCreateUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetails(props: {
|
||||
selectedUser?: User;
|
||||
onCreateUser: (input: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [role, setRole] = useState<Role>("viewer");
|
||||
|
||||
async function submit(event: Event) {
|
||||
event.preventDefault();
|
||||
await props.onCreateUser({ email, displayName, role });
|
||||
setEmail("");
|
||||
setDisplayName("");
|
||||
setRole("viewer");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside class="detail-panel">
|
||||
<header class="detail-header">
|
||||
<UserPlus size={17} />
|
||||
<span>Add user</span>
|
||||
</header>
|
||||
|
||||
<form class="stack-form" onSubmit={submit}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onInput={(event) => setEmail(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
onInput={(event) => setDisplayName(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.currentTarget.value as Role)}
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button type="submit" class="primary-button">
|
||||
<UserPlus size={15} />
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{props.selectedUser && (
|
||||
<>
|
||||
<h2>{props.selectedUser.displayName}</h2>
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>Email</dt>
|
||||
<dd>{props.selectedUser.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Role</dt>
|
||||
<dd>{props.selectedUser.role}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{formatDate(props.selectedUser.createdAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDocumentColumns(
|
||||
documents: DocumentRecord[],
|
||||
activePath: string,
|
||||
): BrowserColumn[] {
|
||||
const activeSegments = activePath.split("/").filter(Boolean);
|
||||
const prefixes = [""];
|
||||
const maxFolderDepth = activePath.endsWith(".md")
|
||||
? activeSegments.length - 1
|
||||
: activeSegments.length;
|
||||
|
||||
for (let index = 0; index < maxFolderDepth; index += 1) {
|
||||
prefixes.push(activeSegments.slice(0, index + 1).join("/"));
|
||||
}
|
||||
|
||||
return prefixes.map((prefix) => ({
|
||||
title: prefix ? folderLabel(prefix) : "Documents",
|
||||
prefix,
|
||||
items: collectItems(documents, prefix),
|
||||
}));
|
||||
}
|
||||
|
||||
function collectItems(
|
||||
documents: DocumentRecord[],
|
||||
prefix: string,
|
||||
): BrowserItem[] {
|
||||
const items = new Map<string, BrowserItem>();
|
||||
const prefixWithSlash = prefix ? `${prefix}/` : "";
|
||||
|
||||
for (const document of documents) {
|
||||
if (!document.path.startsWith(prefixWithSlash)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainder = document.path.slice(prefixWithSlash.length);
|
||||
if (!remainder || remainder === "index.md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [nextSegment, ...rest] = remainder.split("/");
|
||||
if (rest.length > 0) {
|
||||
const folderPath = `${prefixWithSlash}${nextSegment}`;
|
||||
const indexDocument = documents.find(
|
||||
(candidate) => candidate.path === `${folderPath}/index.md`,
|
||||
);
|
||||
items.set(folderPath, {
|
||||
type: "folder",
|
||||
name: folderLabel(nextSegment),
|
||||
path: folderPath,
|
||||
indexDocument,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
items.set(document.path, {
|
||||
type: "document",
|
||||
name: document.title || fileLabel(document.path),
|
||||
path: document.path,
|
||||
document,
|
||||
});
|
||||
}
|
||||
|
||||
return [...items.values()].sort((left, right) => {
|
||||
if (left.type !== right.type) {
|
||||
return left.type === "folder" ? -1 : 1;
|
||||
}
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}
|
||||
|
||||
function selectDocument(documents: DocumentRecord[], activePath: string) {
|
||||
if (!activePath) {
|
||||
return documents[0];
|
||||
}
|
||||
return (
|
||||
documents.find((document) => document.path === activePath) ??
|
||||
documents.find((document) => document.path === `${activePath}/index.md`)
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownDocumentLocation(documents: DocumentRecord[], path: string) {
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
return documents.some(
|
||||
(document) =>
|
||||
document.path === path ||
|
||||
document.path === `${path}/index.md` ||
|
||||
document.path.startsWith(`${path}/`),
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveItem(item: BrowserItem, activePath: string) {
|
||||
return (
|
||||
activePath === item.path ||
|
||||
activePath === item.indexDocument?.path ||
|
||||
activePath.startsWith(`${item.path}/`)
|
||||
);
|
||||
}
|
||||
|
||||
function documentHref(path: string) {
|
||||
return `/docs/${path.replace(/(^|\/)index\.md$/, "").replace(/\.md$/, "")}`;
|
||||
}
|
||||
|
||||
function folderLabel(path: string) {
|
||||
return fileLabel(path.split("/").filter(Boolean).at(-1) ?? path);
|
||||
}
|
||||
|
||||
function fileLabel(path: string) {
|
||||
return path
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || response.statusText);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function readSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(sessionKey);
|
||||
return raw ? (JSON.parse(raw) as Session) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { render } from "preact";
|
||||
import { LocationProvider, Route, Router } from "preact-iso";
|
||||
import { App } from "./app";
|
||||
import "./styles.css";
|
||||
|
||||
render(
|
||||
<LocationProvider scope="/app">
|
||||
<Router>{[<Route path="/*" component={App} />]}</Router>
|
||||
</LocationProvider>,
|
||||
document.getElementById("app")!,
|
||||
);
|
||||
@@ -1,504 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f1ea;
|
||||
--surface: rgba(255, 255, 255, 0.76);
|
||||
--surface-strong: #ffffff;
|
||||
--text: #202227;
|
||||
--muted: #626775;
|
||||
--accent: #1947e5;
|
||||
--accent-soft: rgba(25, 71, 229, 0.1);
|
||||
--border: rgba(32, 34, 39, 0.12);
|
||||
--shadow: 0 18px 54px rgba(25, 71, 229, 0.1);
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgba(25, 71, 229, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at bottom right,
|
||||
rgba(15, 138, 108, 0.14),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(180deg, #fbf8f2 0%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
transition: grid-template-columns 160ms ease;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed {
|
||||
grid-template-columns: 4rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem;
|
||||
border-right: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.sidebar-header,
|
||||
.compact-topbar,
|
||||
.topbar-actions,
|
||||
.detail-header,
|
||||
.brand,
|
||||
.nav-list button,
|
||||
.nav-button,
|
||||
.icon-button,
|
||||
.primary-button,
|
||||
.sync-label,
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
min-width: 0;
|
||||
gap: 0.5rem;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand span,
|
||||
.nav-list span,
|
||||
.nav-button span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.nav-list button,
|
||||
.nav-button {
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0.45rem;
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.nav-list button:hover,
|
||||
.nav-button:hover,
|
||||
.nav-list button.is-active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-list button,
|
||||
.nav-button {
|
||||
min-width: 0;
|
||||
min-height: 2.25rem;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed .brand span,
|
||||
.admin-shell.is-collapsed .nav-list span,
|
||||
.admin-shell.is-collapsed .nav-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed .sidebar-header,
|
||||
.admin-shell.is-collapsed .nav-list button,
|
||||
.admin-shell.is-collapsed .nav-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
min-width: 0;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.compact-topbar {
|
||||
min-height: 3rem;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.compact-topbar > div:first-child {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
color: var(--accent);
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.sync-label {
|
||||
gap: 0.4rem;
|
||||
min-height: 2.1rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.sync-button {
|
||||
width: 2.25rem;
|
||||
padding-inline: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sync-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 1fr) clamp(11.5rem, 20vw, 15rem);
|
||||
gap: 0.65rem;
|
||||
min-height: calc(100vh - 4.8rem);
|
||||
}
|
||||
|
||||
.column-browser {
|
||||
display: grid;
|
||||
grid-auto-columns: minmax(11rem, 1fr);
|
||||
grid-auto-flow: column;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.browser-column,
|
||||
.detail-panel,
|
||||
.login-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.browser-column,
|
||||
.detail-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-column > header,
|
||||
.detail-header {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.item-list {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.35rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.42rem;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.list-item:hover,
|
||||
.list-item.is-active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.list-item span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-item small {
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-column {
|
||||
min-height: 22rem;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
padding-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-panel h2 {
|
||||
margin: 0.85rem 0.85rem 0.35rem;
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
display: inline-flex;
|
||||
margin: 0 0.85rem 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-list.compact {
|
||||
margin-top: 0.35rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-list div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-list dt {
|
||||
margin-bottom: 0.16rem;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stack-form input,
|
||||
.stack-form select,
|
||||
.login-panel input {
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.45rem;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0;
|
||||
padding: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
width: min(23rem, 100%);
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.login-panel label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login-mark {
|
||||
display: inline-flex;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
color: #a43131;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.admin-shell,
|
||||
.admin-shell.is-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.admin-shell.is-collapsed .brand span,
|
||||
.admin-shell.is-collapsed .nav-list span,
|
||||
.admin-shell.is-collapsed .nav-button span {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sync-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.column-browser {
|
||||
min-height: 18rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-sidebar,
|
||||
.compact-topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header,
|
||||
.topbar-actions,
|
||||
.nav-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-list button,
|
||||
.nav-button,
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.column-browser {
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: auto;
|
||||
}
|
||||
}
|
||||
6
apps/web/src/vite-env.d.ts
vendored
6
apps/web/src/vite-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.css" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import preact from "@preact/preset-vite";
|
||||
|
||||
const serverTarget = "http://localhost:8080";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/app/",
|
||||
plugins: [preact()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: serverTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/attachments": {
|
||||
target: serverTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/docs": {
|
||||
target: serverTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/health": {
|
||||
target: serverTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/static": {
|
||||
target: serverTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/sw.js": {
|
||||
target: serverTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: "ws://localhost:8080",
|
||||
ws: true,
|
||||
},
|
||||
"^/app$": {
|
||||
target: serverTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
BIN
cairnquire
Executable file
BIN
cairnquire
Executable file
Binary file not shown.
@@ -5,14 +5,20 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- mailpit
|
||||
environment:
|
||||
CAIRNQUIRE_SERVER_ADDR: ":8080"
|
||||
CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite"
|
||||
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
|
||||
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
|
||||
CAIRNQUIRE_WEB_DIST_DIR: "/workspace/web-dist"
|
||||
CAIRNQUIRE_PUBLIC_ORIGIN: "${CAIRNQUIRE_PUBLIC_ORIGIN:?Set CAIRNQUIRE_PUBLIC_ORIGIN in .env}"
|
||||
CAIRNQUIRE_EMAIL_SMTP_HOST: "${CAIRNQUIRE_EMAIL_SMTP_HOST:-mailpit}"
|
||||
CAIRNQUIRE_EMAIL_SMTP_PORT: "${CAIRNQUIRE_EMAIL_SMTP_PORT:-1025}"
|
||||
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
|
||||
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
|
||||
volumes:
|
||||
- ./content:/workspace/content:ro
|
||||
- ./content:/workspace/content
|
||||
- ./data:/workspace/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:8080/health"]
|
||||
@@ -21,3 +27,11 @@ services:
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
environment:
|
||||
MP_UI_BIND_ADDR: "0.0.0.0:8025"
|
||||
MP_SMTP_BIND_ADDR: "0.0.0.0:1025"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user