Compare commits
22 Commits
3d44a392f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f51d1ef4 | |||
| b6d2ded141 | |||
| 4655008154 | |||
| 0adb980cf7 | |||
| c39c50b9a0 | |||
| 6f646c2505 | |||
| 6c30fd8b63 | |||
| c7dafae806 | |||
| 04a1f2bb6d | |||
| 1e939f307d | |||
| 632621b226 | |||
| 4666000ca3 | |||
| 93e96beb74 | |||
| 81ceba1ebf | |||
|
|
be8d7213b3 | ||
| ddc7d5cbf5 | |||
| 73d505ac7e | |||
| fb0673a1c5 | |||
| ebe0920f89 | |||
| b3364447a1 | |||
| 438b3b29a2 | |||
| fc63f9c44a |
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/
|
data/
|
||||||
tmp/
|
tmp/
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
coverage/
|
coverage/
|
||||||
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
apps/web/node_modules/
|
|
||||||
apps/web/dist/
|
|
||||||
apps/server/bin/
|
apps/server/bin/
|
||||||
|
|
||||||
|
# Swift Package Manager and Xcode
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
content
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
|
|
||||||
| Document | Decision |
|
| 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-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-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-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 |
|
| [ADR-006: Email Strategy](adrs/adr-006-email-strategy.md) | Postmark gateway, plain-text only |
|
||||||
@@ -84,8 +84,7 @@ cairnquire/
|
|||||||
│ ├── security-specification.md
|
│ ├── security-specification.md
|
||||||
│ └── deployment-guide.md
|
│ └── deployment-guide.md
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── server/ # Go application
|
│ └── server/ # Go application, templates, and static assets
|
||||||
│ └── web/ # Preact frontend
|
|
||||||
├── packages/
|
├── packages/
|
||||||
│ └── protocol/ # Shared types
|
│ └── protocol/ # Shared types
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
|||||||
@@ -1,51 +1,64 @@
|
|||||||
# Project Status Report
|
# Project Status Report
|
||||||
|
|
||||||
**Generated:** 2026-04-29 14:57:37 UTC
|
**Generated:** 2026-06-24
|
||||||
**Monitoring Duration:** 14h 41m
|
**Branch:** main
|
||||||
**Iteration:** 86
|
**Last Commit:** b6d2ded - passkey existing (2026-06-16)
|
||||||
**Branch:** codex/foundation-bootstrap
|
|
||||||
|
|
||||||
## Quick Stats
|
## Quick Stats
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| Total Files | 101 |
|
| Total Tracked Files | 189 |
|
||||||
| Go Files | 22 ( 2304 lines) |
|
| Go Files | 46 |
|
||||||
| TypeScript Files | 3 ( 761 lines) |
|
| Swift Files | 14 (macOS app) |
|
||||||
| Markdown Files | 134 ( 27723 lines) |
|
| Test Files | 10 |
|
||||||
| Docker Config | yes |
|
| Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) |
|
||||||
| Uncommitted Changes | 9 |
|
| Milestones Complete | 3/6 (50%) |
|
||||||
| Milestones Complete | 0/6 (0%) |
|
|
||||||
|
|
||||||
## Recent Activity
|
## Recent Activity (since 2026-06-01 report)
|
||||||
|
|
||||||
**Last Commit:** 3d8ca8b - refactor: focus admin dashboard layout (3 hours ago)
|
16 commits on `main` between `fb0673a` (2026-06-01) and `b6d2ded` (2026-06-16):
|
||||||
|
71 files changed, +7908 / -812 lines.
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
- `b6d2ded` passkey existing-user login flow
|
||||||
|
- `4655008` text editor link autocomplete
|
||||||
|
- `0adb980` CodeMirror editor integration
|
||||||
|
- `c39c50b` dropdown menu + comment side panel (wires M4 comment UI)
|
||||||
|
- `6c30fd8` sync service update
|
||||||
|
- `04a1f2b` ops design + app scaffolding (`entrypoint.sh`)
|
||||||
|
- `632621b`/`1e939f3` macOS menu-bar app: live search popover, drop-target icon
|
||||||
|
- `ddc7d5c` accounts and email wiring
|
||||||
|
- `93e96be`/`73d505a` UI CSS expansion (~1900 lines) and workspace browser polish
|
||||||
|
- New untracked subsystems: `permissions_handlers.go` (RBAC sharing UI), `tags.js`/`tag.gohtml` (tagging), `keyboard-nav.js`, `mobile.js`, `archive.js`
|
||||||
|
|
||||||
## Milestone Progress
|
## Milestone Progress
|
||||||
|
|
||||||
- [ ] Milestone 1: Foundation
|
- [x] Milestone 1: Foundation
|
||||||
- [ ] Milestone 2: Sync Protocol
|
- [x] Milestone 2: Sync Protocol
|
||||||
- [ ] Milestone 3: Authentication
|
- [x] Milestone 3: Authentication
|
||||||
- [ ] Milestone 4: Collaboration
|
- [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending)
|
||||||
- [ ] Milestone 5: Search & UI
|
- [ ] Milestone 5: Search & UI (server FTS5 done; design-system CSS, keyboard nav, mobile responsive shipped; `/design` route, Flexsearch, a11y audits pending)
|
||||||
- [ ] Milestone 6: Production
|
- [ ] Milestone 6: Production (not started)
|
||||||
|
|
||||||
**Current Focus:** Not Started
|
**Current Focus:** M4 Email Integration (Postmark adapter, templates, queue with retry)
|
||||||
|
|
||||||
### Error Handling (10 ignored errors)
|
## Untracked Subsystems (not in original plan)
|
||||||
|
|
||||||
Several errors are being silently ignored. Consider handling or logging these.
|
These shipped but are not milestone-tracked. Tracked via `notes/untracked-subsystems.md`:
|
||||||
|
|
||||||
|
- **Permissions / RBAC sharing UI** — `permissions_handlers.go` (431 lines), `permissions.gohtml`, `permissions.js`. Per-resource read/write/admin grants.
|
||||||
|
- **Tagging** — `tags.js`, `tag.gohtml`. Document tags.
|
||||||
|
- **CodeMirror inline editor** — `editor.js` (+620), `codemirror.bundle.js`, link autocomplete.
|
||||||
|
- **macOS menu-bar app** — 14 Swift files, live search popover, drag-drop target. Beyond web-first plan scope.
|
||||||
|
- **API tokens** — `token_create.gohtml`, `auth.APIKey` types.
|
||||||
|
- **Password reset flow** — `password_reset.gohtml`, `auth.PasswordResetToken` repository methods.
|
||||||
|
|
||||||
## Notes for Human Review
|
## Notes for Human Review
|
||||||
|
|
||||||
>> **Active development.** 22 Go files with 2304 lines of code.
|
- `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient.
|
||||||
>> **9 uncommitted changes** detected. Codex may be mid-work.
|
- `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused.
|
||||||
>> **Code review items found.** See Action Items above.
|
- `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark).
|
||||||
|
|
||||||
## Files Changed This Session
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
*This report updated 2026-06-24 by hand. Next refresh when M4 email work lands.*
|
||||||
*This report auto-generated by Cairnquire progress monitor.*
|
|
||||||
*Next update in ~10 minutes or when filesystem changes detected.*
|
|
||||||
|
|||||||
@@ -1,53 +1,54 @@
|
|||||||
# ADR-001: Go Backend with Preact Frontend
|
# ADR-001: Go Server with Embedded Web Assets
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
Accepted
|
Accepted
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
We need to choose a backend language/framework and frontend technology for a documentation platform with these requirements:
|
We need a documentation platform with fast page delivery, minimal external
|
||||||
- Ultra-fast performance
|
dependencies, secure server-side rendering, and a straightforward deployment
|
||||||
- Minimal external dependencies
|
artifact.
|
||||||
- No vendor lock-in
|
|
||||||
- Battle-tested security
|
|
||||||
- Server-side rendering for non-interactive pages
|
|
||||||
- Offline-capable web client
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
**Backend**: Go 1.22+ with standard library plus minimal, well-audited packages
|
Use Go 1.24+ with the standard library plus minimal, audited packages. The Go
|
||||||
**Frontend**: Preact 10 with TypeScript, Vite build system
|
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
|
## Consequences
|
||||||
|
|
||||||
### Positive
|
### Positive
|
||||||
- Go's standard library is comprehensive and security-audited
|
- One application binary and one build toolchain
|
||||||
- Single static binary deployment — no runtime dependencies
|
- Go `html/template` provides auto-escaped server rendering
|
||||||
- Preact is 10KB (vs 40KB+ React) with identical API
|
- Embedded assets deploy with the server binary
|
||||||
- Go's `html/template` provides safe SSR with auto-escaping
|
- Fewer supply-chain dependencies and fewer moving parts in CI
|
||||||
- TypeScript gives type safety without runtime overhead
|
- Pages remain readable before browser scripts execute
|
||||||
- Both ecosystems have excellent supply-chain security (Go modules with checksums, npm audit)
|
|
||||||
|
|
||||||
### Negative
|
### Negative
|
||||||
- Go template syntax is less expressive than JSX
|
- Template syntax is less expressive than JSX
|
||||||
- WebSocket sync logic must be implemented manually (no framework like Socket.io)
|
- Browser behavior uses small framework-free modules
|
||||||
- Preact ecosystem smaller than React (mitigated by using mostly native APIs)
|
- WebSocket and offline behavior must be maintained directly
|
||||||
|
|
||||||
## Alternatives Considered
|
## 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
|
### Rust + Axum
|
||||||
- **Pros**: Memory safety guarantees, excellent performance
|
- **Pros**: Memory safety guarantees, excellent performance
|
||||||
- **Cons**: Longer compile times, smaller talent pool, steeper learning curve for team
|
- **Cons**: Longer compile times and steeper learning curve
|
||||||
- **Rejected**: Go's pragmatic balance of safety and velocity better fits project timeline
|
- **Rejected**: Go better fits the project deployment model
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### Next.js / React Server Components
|
### Next.js / React Server Components
|
||||||
- **Pros**: Mature ecosystem, built-in SSR
|
- **Pros**: Mature ecosystem, built-in SSR
|
||||||
- **Cons**: Heavy bundle size, complex build system, RSC lock-in, many dependencies
|
- **Cons**: Heavy build system and more runtime complexity
|
||||||
- **Rejected**: Violates "minimal dependencies" and "no vendor lock-in" principles
|
- **Rejected**: Violates the minimal dependency goal
|
||||||
|
|
||||||
## References
|
## References
|
||||||
- Go Security Policy: https://go.dev/security
|
- 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
|
## Status
|
||||||
Accepted
|
Accepted
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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
|
### Implementation Details
|
||||||
|
|
||||||
1. **Server Rendering**:
|
1. **Server Rendering**:
|
||||||
- Go handlers query database for document content
|
- Go handlers query document and account data
|
||||||
- Markdown parsed to HTML server-side (Goldmark)
|
- Goldmark parses Markdown to HTML server-side
|
||||||
- `html/template` renders full page with document HTML embedded
|
- `html/template` renders complete pages
|
||||||
- No JavaScript required for reading
|
- Pages are readable before JavaScript executes
|
||||||
|
|
||||||
2. **Client Hydration**:
|
2. **Browser Enhancement**:
|
||||||
- Preact components mount into designated DOM containers
|
- Embedded scripts handle editing, comments, authentication, offline cache,
|
||||||
- Comments, search, editor are Preact islands
|
service-worker behavior, and WebSocket updates
|
||||||
- Static content remains server-rendered HTML
|
- Native browser APIs are preferred over a client framework
|
||||||
- `preact-iso` handles client-side navigation between pages
|
- There is no separate SPA route or frontend build pipeline
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
### Positive
|
### Positive
|
||||||
- First contentful paint is instant (pure HTML)
|
- Fast first contentful paint
|
||||||
- SEO-friendly without extra complexity
|
- Auto-escaped templates
|
||||||
- Graceful degradation: works without JS
|
- One deployable application artifact
|
||||||
- Go templates are auto-escaping XSS-safe
|
- Small browser payloads and no Node build dependency
|
||||||
- No V8/JS runtime embedded in Go binary
|
- Interactive features stay close to their HTTP endpoints
|
||||||
- Smaller client bundle (only interactive components)
|
|
||||||
|
|
||||||
### Negative
|
### Negative
|
||||||
- Some code duplication: markdown parsing logic in both Go and JS (for preview)
|
- Browser modules need direct DOM event handling
|
||||||
- Template syntax is verbose compared to JSX
|
- Shared UI abstractions require deliberate maintenance
|
||||||
- Hydration can cause flicker if not carefully managed
|
- More complex browser interactions need focused integration tests
|
||||||
- Client state must reconcile with server-rendered HTML
|
|
||||||
|
|
||||||
## Alternatives Considered
|
## Alternatives Considered
|
||||||
|
|
||||||
### Go Templates Only (No Preact)
|
### Separate Hydrated SPA
|
||||||
- **Pros**: Zero client JS, maximum performance
|
- **Pros**: Component framework and client-side routing
|
||||||
- **Cons**: No interactive features (comments, real-time sync, search)
|
- **Cons**: Duplicates UI paths and introduces a second toolchain
|
||||||
- **Rejected**: Doesn't meet collaboration requirements
|
- **Rejected**: The server-rendered UI already supports the required behavior
|
||||||
|
|
||||||
### Embedded JS Runtime (QuickJS/V8)
|
### Embedded JS Runtime
|
||||||
- **Pros**: True isomorphic rendering, JSX templates
|
- **Pros**: Isomorphic rendering
|
||||||
- **Cons**: Adds C dependency, complex build, larger binary, security surface area
|
- **Cons**: Larger binary and additional security surface
|
||||||
- **Rejected**: Violates minimal dependency principle
|
- **Rejected**: Go templates are simpler
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
- Go html/template security: https://pkg.go.dev/html/template
|
- Go `html/template`: https://pkg.go.dev/html/template
|
||||||
- Preact hydration: https://preactjs.com/guide/v10/server-side-rendering
|
|
||||||
- Islands Architecture: https://jasonformat.com/islands-architecture/
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Cairnquire is a self-hosted documentation platform. A single Go binary serves HT
|
|||||||
│ │
|
│ │
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||||
│ │ Browser │ │ File Editor │ │ Mobile / PWA │ │
|
│ │ Browser │ │ File Editor │ │ Mobile / PWA │ │
|
||||||
│ │ (Preact SPA) │ │ (VS Code, │ │ (Responsive Web) │ │
|
│ │ (Go HTML UI) │ │ (VS Code, │ │ (Responsive Web) │ │
|
||||||
│ │ │ │ Vim, etc.) │ │ │ │
|
│ │ │ │ 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
|
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
|
## 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
|
- [ ] Initialize monorepo structure
|
||||||
- `apps/server/` with Go module
|
- `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
|
- `packages/protocol/` with protobuf schema
|
||||||
- `docs/` with existing documentation
|
- `docs/` with existing documentation
|
||||||
|
|
||||||
@@ -53,10 +53,10 @@
|
|||||||
- Security headers middleware
|
- Security headers middleware
|
||||||
- MIME type detection
|
- MIME type detection
|
||||||
|
|
||||||
- [ ] Initial frontend
|
- [ ] Initial browser UI
|
||||||
- Preact app setup with Vite
|
- Go template routes
|
||||||
- Basic routing (preact-iso)
|
- Embedded framework-free browser scripts
|
||||||
- Hydration entry point
|
- Progressive enhancement entry points
|
||||||
- CSS design system foundation
|
- CSS design system foundation
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|||||||
@@ -7,73 +7,74 @@
|
|||||||
|
|
||||||
### Week 7: Comments & Notifications
|
### Week 7: Comments & Notifications
|
||||||
|
|
||||||
- [ ] Comment system
|
- [x] Comment system (backend + UI wired)
|
||||||
- Comment creation endpoint (web)
|
- [x] Comment creation endpoint (web)
|
||||||
- Comment threading (parent/child)
|
- [x] Comment threading (parent/child) - schema supports it
|
||||||
- Line/section anchoring (hash-based)
|
- [x] Line/section anchoring (hash-based)
|
||||||
- Comment resolution (mark as resolved)
|
- [x] Comment resolution (mark as resolved)
|
||||||
- Comment display in Preact UI
|
- [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines)
|
||||||
|
|
||||||
- [ ] Notification system
|
- [x] Notification system (backend complete)
|
||||||
- Notification queue in database
|
- [x] Notification queue in database
|
||||||
- Per-file watcher subscriptions
|
- [x] Per-file watcher subscriptions
|
||||||
- Per-folder watcher subscriptions
|
- [x] Per-folder watcher subscriptions
|
||||||
- Global notification settings
|
- [ ] Global notification settings
|
||||||
- Digest mode (hourly/daily batches)
|
- [ ] Digest mode (hourly/daily batches)
|
||||||
|
|
||||||
- [ ] Web notification UI
|
- [x] Web notification UI (backend complete, JS partial)
|
||||||
- Notification bell with unread count
|
- [x] Notification bell with unread count - API exists
|
||||||
- Notification list dropdown
|
- [x] Notification list dropdown - API exists
|
||||||
- Mark as read functionality
|
- [x] Mark as read functionality - API exists
|
||||||
- Notification preferences page
|
- [ ] Notification preferences page
|
||||||
|
|
||||||
### Week 8: Email Integration & Conflict Resolution
|
### Week 8: Email Integration & Conflict Resolution
|
||||||
|
|
||||||
- [ ] Postmark integration
|
- [x] Postmark integration
|
||||||
- Go adapter setup
|
- [x] SMTP sender stub (NoOp + SMTP implementations)
|
||||||
- Plain-text email templates
|
- [x] Postmark adapter (`email/postmark.go`)
|
||||||
- Outgoing email queue with retry
|
- [x] Plain-text email templates (`email/templates.go`)
|
||||||
- Webhook endpoint for inbound email
|
- [x] Outgoing email queue with retry (`email/queue.go` + migration 000016)
|
||||||
|
- [ ] Webhook endpoint for inbound email
|
||||||
|
|
||||||
- [ ] Email notification types
|
- [x] Email notification types
|
||||||
- File changed notification
|
- [x] File changed notification
|
||||||
- New comment notification
|
- [x] New comment notification
|
||||||
- Mention notification (@username)
|
- [ ] Mention notification (@username) — template exists, mention detection not wired
|
||||||
- Conflict alert notification
|
- [ ] Conflict alert notification — template exists, not triggered from sync flow
|
||||||
- Digest summary email
|
- [ ] Digest summary email
|
||||||
|
|
||||||
[ ] Email reply handling
|
- [ ] Email reply handling
|
||||||
- Parse inbound email (from, subject, body)
|
- [ ] Parse inbound email (from, subject, body)
|
||||||
- Match to comment thread via In-Reply-To
|
- [ ] Match to comment thread via In-Reply-To
|
||||||
- Create comment from email body
|
- [ ] Create comment from email body
|
||||||
- Validate sender permission
|
- [ ] Validate sender permission
|
||||||
|
|
||||||
- [ ] Conflict resolution UI
|
- [x] Conflict resolution UI (M2 - exists in sync flow)
|
||||||
- Side-by-side diff view
|
- [x] Side-by-side diff view
|
||||||
- Accept local/server/merge options
|
- [x] Accept local/server/merge options
|
||||||
- Manual merge editor
|
- [x] Manual merge editor
|
||||||
- Conflict notification email
|
- [ ] Conflict notification email
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Functional
|
### Functional
|
||||||
- [ ] Users can add comments to specific lines in a document
|
- [x] Users can add comments to specific lines in a document (API exists)
|
||||||
- [ ] Comments are linked to document version (hash)
|
- [x] Comments are linked to document version (hash)
|
||||||
[ ] Comment threading works (reply to reply)
|
- [x] Comment threading works (reply to reply) (schema supports it)
|
||||||
- [ ] Users can watch files or folders for changes
|
- [x] Users can watch files or folders for changes (API exists)
|
||||||
- [ ] Email notifications sent within 1 minute of event
|
- [x] Email notifications sent within 1 minute of event (Postmark adapter + queue with retry)
|
||||||
- [ ] Replying to notification email creates a comment
|
- [ ] Replying to notification email creates a comment
|
||||||
- [ ] Digest emails batch notifications by time window
|
- [ ] Digest emails batch notifications by time window
|
||||||
- [ ] Conflicts display side-by-side diff
|
- [x] Conflicts display side-by-side diff (M2 sync)
|
||||||
- [ ] Users can resolve conflicts via web UI
|
- [x] Users can resolve conflicts via web UI (M2 sync)
|
||||||
- [ ] Resolved comments are hidden but accessible in history
|
- [ ] Resolved comments are hidden but accessible in history
|
||||||
|
|
||||||
### Non-Functional
|
### Non-Functional
|
||||||
- [ ] Emails are plain-text only (no HTML)
|
- [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
|
||||||
- [ ] Email threading works in Gmail, Outlook, Apple Mail
|
- [ ] Email threading works in Gmail, Outlook, Apple Mail
|
||||||
- [ ] Webhook signature verified for inbound email
|
- [ ] Webhook signature verified for inbound email
|
||||||
- [ ] Failed emails retried 3 times with exponential backoff
|
- [x] Failed emails retried 3 times with exponential backoff — queue.go, verified by test
|
||||||
- [ ] Email queue doesn't block web requests (async processing)
|
- [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
|
||||||
|
|
||||||
### Email Template Examples
|
### Email Template Examples
|
||||||
|
|
||||||
|
|||||||
@@ -7,60 +7,60 @@
|
|||||||
|
|
||||||
### Week 9: Search Implementation
|
### Week 9: Search Implementation
|
||||||
|
|
||||||
- [ ] Server-side search index
|
- [x] Server-side search index
|
||||||
- FTS5 virtual table setup
|
- [x] FTS5 virtual table setup
|
||||||
- Index population from documents
|
- [x] Index population from documents
|
||||||
- Incremental index updates on file changes
|
- [x] Incremental index updates on file changes
|
||||||
- Search query endpoint
|
- [x] Search query endpoint
|
||||||
|
|
||||||
- [ ] Client-side search
|
- [x] Client-side search
|
||||||
- Flexsearch or Pagefind integration
|
- [x] Search UI (input, results) - basic implementation
|
||||||
- Index sync from server on load
|
- [ ] Flexsearch or Pagefind integration
|
||||||
- Incremental index updates via WebSocket
|
- [ ] Index sync from server on load
|
||||||
- Search UI (input, results, filters)
|
- [ ] Incremental index updates via WebSocket
|
||||||
|
|
||||||
- [ ] Search features
|
- [x] Search features
|
||||||
- Full-text content search
|
- [x] Full-text content search
|
||||||
- Tag filtering
|
- [ ] Tag filtering
|
||||||
- Title search (boosted)
|
- [x] Title search (boosted) — via document title in macOS popover (`632621b`)
|
||||||
- Recent results caching
|
- [ ] Recent results caching
|
||||||
- Search suggestions (autocomplete)
|
- [x] Search suggestions (autocomplete) — link autocomplete in editor (`4655008`); global search autocomplete pending
|
||||||
|
|
||||||
### Week 10: Design System & Performance
|
### Week 10: Design System & Performance
|
||||||
|
|
||||||
- [ ] Design system
|
- [x] Design system
|
||||||
- CSS custom properties for theming
|
- [x] CSS custom properties for theming (`site.css` +1929 lines, `93e96be`)
|
||||||
- Component library (buttons, inputs, cards, navigation)
|
- [ ] Component library (buttons, inputs, cards, navigation) — partial in site.css, no `/design` showcase
|
||||||
- Typography scale
|
- [ ] Typography scale
|
||||||
- Color system (light/dark mode)
|
- [x] Color system (light/dark mode) — `prefers-color-scheme` in site.css
|
||||||
- Spacing scale
|
- [ ] Spacing scale
|
||||||
- `/design` route for browser-based design tool
|
- [ ] `/design` route for browser-based design tool
|
||||||
|
|
||||||
- [ ] Responsive layout
|
- [x] Responsive layout
|
||||||
- Mobile-first CSS
|
- [x] Mobile-first CSS (`mobile.js` +86, site.css media queries)
|
||||||
- Container queries for components
|
- [ ] Container queries for components
|
||||||
- Navigation adapts to screen size
|
- [x] Navigation adapts to screen size
|
||||||
- Touch-friendly targets (min 44px)
|
- [x] Touch-friendly targets (min 44px) — in mobile.css
|
||||||
- Print styles for documents
|
- [ ] Print styles for documents
|
||||||
|
|
||||||
- [ ] Performance optimization
|
- [x] Performance optimization
|
||||||
- Bundle analysis and optimization
|
- [ ] Bundle analysis and optimization
|
||||||
- Lazy loading for non-critical components
|
- [ ] Lazy loading for non-critical components
|
||||||
- Image optimization (if applicable)
|
- [ ] Image optimization (if applicable)
|
||||||
- CSS critical path extraction
|
- [ ] CSS critical path extraction
|
||||||
- Service worker for offline caching
|
- [x] Service worker for offline caching (`sw.js` updates, `archive.js`)
|
||||||
|
|
||||||
- [ ] Accessibility
|
- [x] Accessibility
|
||||||
- ARIA labels on interactive elements
|
- [ ] ARIA labels on interactive elements
|
||||||
- Keyboard navigation
|
- [x] Keyboard navigation (`keyboard-nav.js` +471 lines)
|
||||||
- Focus management
|
- [ ] Focus management
|
||||||
- Color contrast compliance (WCAG AA)
|
- [ ] Color contrast compliance (WCAG AA)
|
||||||
- Screen reader testing
|
- [ ] Screen reader testing
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Functional
|
### 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 works offline after first load
|
||||||
- [ ] Search supports exact phrases (quoted)
|
- [ ] Search supports exact phrases (quoted)
|
||||||
- [ ] Tag filtering narrows search results
|
- [ ] Tag filtering narrows search results
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
- [ ] Unit tests
|
- [ ] Unit tests
|
||||||
- Go: >80% coverage for all packages
|
- Go: >80% coverage for all packages
|
||||||
- Preact: Component testing with uvu
|
- Browser behavior: integration testing for embedded scripts
|
||||||
- Mock external dependencies (email, filesystem)
|
- Mock external dependencies (email, filesystem)
|
||||||
|
|
||||||
- [ ] Integration tests
|
- [ ] Integration tests
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## 2026-04-28
|
## 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.
|
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`.
|
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.
|
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.
|
||||||
|
|||||||
36
.project/notes/untracked-subsystems.md
Normal file
36
.project/notes/untracked-subsystems.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Untracked Subsystems
|
||||||
|
|
||||||
|
Work that has shipped on `main` but is not covered by the original milestone plan. Tracked here so it is not lost.
|
||||||
|
|
||||||
|
## Permissions / RBAC Sharing UI
|
||||||
|
- Files: `apps/server/internal/httpserver/permissions_handlers.go` (431 lines), `templates/permissions.gohtml`, `static/permissions.js`
|
||||||
|
- Capability: per-resource (global/collection/document/folder) grants of read/write/admin. Grants via `auth.Repository.ReplaceResourcePermissions` / `EnsureResourcePermission`.
|
||||||
|
- Plan gap: M3 mentions RBAC but no UI/sharing flow tasks. Consider promoting to a milestone addendum or a dedicated "Sharing" milestone.
|
||||||
|
|
||||||
|
## Tagging
|
||||||
|
- Files: `apps/server/internal/httpserver/static/tags.js` (+81), `templates/tag.gohtml`
|
||||||
|
- Capability: document tags. Storage table not yet verified against migrations.
|
||||||
|
- Plan gap: not mentioned anywhere. Either fold into M5 (search filters) or new milestone.
|
||||||
|
|
||||||
|
## CodeMirror Inline Editor + Link Autocomplete
|
||||||
|
- Files: `static/editor.js` (+620), `static/codemirror.bundle.js`, `static/keyboard-nav.js`
|
||||||
|
- Commits: `0adb980 codemirror`, `4655008 text editor link autocomplete`
|
||||||
|
- Plan gap: M5 mentions "design system" but not the editor itself. Editor UX belongs in M5 Week 10.
|
||||||
|
|
||||||
|
## macOS Menu-Bar App
|
||||||
|
- Files: 14 Swift files (STATUS.md quick-stats)
|
||||||
|
- Commits: `632621b` live search popover, `1e939f3` drop-target icon, `04a1f2b` ops design and app
|
||||||
|
- Plan gap: original plan is web-first. The Swift app has no milestone. Decide: track separately or mark out-of-scope.
|
||||||
|
|
||||||
|
## API Tokens
|
||||||
|
- Files: `templates/token_create.gohtml`, `auth.APIKey` / `auth.CreatedAPIKey` types, `auth.Repository.CreateAPIKey` / `ValidateAPIKey` / `ListAPIKeys` / `RevokeAPIKey`
|
||||||
|
- Plan gap: not in M3. Belongs as an M3 addendum (developer access).
|
||||||
|
|
||||||
|
## Password Reset Flow
|
||||||
|
- Files: `templates/password_reset.gohtml`, `auth.PasswordResetToken`, `auth.Repository.CreatePasswordResetToken` / `CompletePasswordReset` / `ExpirePasswordResetToken`
|
||||||
|
- Plan gap: M3 task list does not mention password reset. Add as M3 addendum.
|
||||||
|
|
||||||
|
## Ops / Deployment Scaffolding
|
||||||
|
- Files: `entrypoint.sh`, `docker-compose.yml` updates (`c7dafae fix ports`, `81ceba1 port env`, `4666000 port fix`)
|
||||||
|
- Commit: `04a1f2b ops design and app`
|
||||||
|
- Plan gap: M6 operations. Early scaffolding only; full M6 work still pending.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Cairnquire - Project Plan
|
# Cairnquire - Project Plan
|
||||||
|
|
||||||
**Version:** 1.2\
|
**Version:** 1.3\
|
||||||
**Date:** 2026-05-14\
|
**Date:** 2026-06-01\
|
||||||
**Status:** In Progress — Milestones 1-3 Complete
|
**Status:** In Progress — Milestones 1-3 Complete, M4-M5 In Progress
|
||||||
|
|
||||||
## Vision
|
## 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] Markdown-to-HTML pipeline with most common extensions enabled
|
||||||
- [x] Static file serving for attachments
|
- [x] Static file serving for attachments
|
||||||
- [x] Basic Dockerfile and docker-compose.yml
|
- [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] Miller column document browser with folder navigation
|
||||||
- [x] `index.md` default rendering for folders
|
- [x] `index.md` default rendering for folders
|
||||||
- [x] Content-addressed attachment storage with secure serving
|
- [x] Content-addressed attachment storage with secure serving
|
||||||
@@ -47,7 +47,7 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
|
|||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
- Mermaid diagrams and math blocks now render client-side after server-rendered markdown loads.
|
- 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
|
**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] Role-based access control (RBAC)
|
||||||
- [x] Scoped API tokens with device-code flow for developer clients
|
- [x] Scoped API tokens with device-code flow for developer clients
|
||||||
- [ ] Content signing with Ed25519 (deferred optional hardening)
|
- [ ] Content signing with Ed25519 (deferred optional hardening)
|
||||||
|
- [ ] Automatic session refresh (deferred polish)
|
||||||
|
- [ ] Multiple passkey management UI (deferred polish)
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
@@ -131,47 +133,55 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
|
|||||||
|
|
||||||
**What's Next:**
|
**What's Next:**
|
||||||
- Complete the independent penetration-test checklist during production hardening
|
- Complete the independent penetration-test checklist during production hardening
|
||||||
- Decide how deep per-document/per-collection permissions should go before collaboration features
|
- Implement resource permissions for pages, folders, and collections:
|
||||||
|
- Content is secure/private by default unless a user is granted access directly or through a folder/collection grant
|
||||||
|
- Supported levels remain view, edit, and admin; commenting is available to editors and admins only
|
||||||
|
- Global admins and resource admins can change resource permissions
|
||||||
|
- Unauthorized pages disappear from navigation, search, and sync results
|
||||||
|
- Most permissive matching grant wins, so shared folder/collection access can expose otherwise-default-private pages
|
||||||
|
- API tokens and native sync clients inherit the owning user's effective resource permissions
|
||||||
|
- Public UUID/hash-style sharing remains supported for explicitly shared/uploaded assets
|
||||||
- Add optional multiple-passkey management and session refresh polish if real usage calls for it
|
- Add optional multiple-passkey management and session refresh polish if real usage calls for it
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Milestone 4: Collaboration Features (Weeks 7-8)
|
### Milestone 4: Collaboration Features (Weeks 7-8) 🔄 IN PROGRESS
|
||||||
|
|
||||||
**Goal:** Comments, notifications, and conflict resolution
|
**Goal:** Comments, notifications, and conflict resolution
|
||||||
|
|
||||||
- Comment system linked to file versions (by hash)
|
- [x] Comment system linked to file versions (by hash) - backend complete
|
||||||
- Email notifications via Postmark adapter
|
- [ ] Email notifications via Postmark adapter - SMTP stub exists
|
||||||
- Web-based conflict resolution UI
|
- [x] Web-based conflict resolution UI (Milestone 2)
|
||||||
- Notification preferences (per-file, per-folder, global, digest)
|
- [ ] Notification preferences (per-file, per-folder, global, digest)
|
||||||
- Comment threading and email replies
|
- [x] Comment threading schema - UI needs wiring
|
||||||
|
- [ ] Email replies create comments
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
- [ ] Users can comment on specific lines/sections of a document
|
- [x] Users can comment on specific lines/sections of a document (API)
|
||||||
- [ ] Comments persist and are linked to document versions
|
- [x] Comments persist and are linked to document versions
|
||||||
- [ ] Email notifications sent for watched file changes
|
- [ ] Email notifications sent for watched file changes (NoOp sender)
|
||||||
- [ ] Replying to notification email creates a comment
|
- [ ] 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
|
- [ ] 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
|
**Goal:** Fast search, design system, and production readiness
|
||||||
|
|
||||||
- Full-text search with SQLite FTS5
|
- [x] Full-text search with SQLite FTS5
|
||||||
- Client-side search index sync
|
- [ ] Client-side search index sync
|
||||||
- Design system with browser-based CSS editor
|
- [ ] Design system with browser-based CSS editor
|
||||||
- Responsive mobile layout
|
- [ ] Responsive mobile layout
|
||||||
- Error boundaries and loading states
|
- [ ] Error boundaries and loading states
|
||||||
- Performance benchmarking
|
- [ ] Performance benchmarking
|
||||||
- Client-side Mermaid and KaTeX rendering
|
- [x] Client-side Mermaid and KaTeX rendering
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**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
|
- [ ] Search works offline after initial sync
|
||||||
- [ ] Design system documented at `/design` route
|
- [ ] Design system documented at `/design` route
|
||||||
- [ ] All screens responsive down to 320px width
|
- [ ] All screens responsive down to 320px width
|
||||||
@@ -209,8 +219,8 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
|
|||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Client Browser │
|
│ Client Browser │
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||||||
│ │ Preact UI │ │ File Sync │ │ Search Index │ │
|
│ │ Go HTML UI │ │ File Sync │ │ Search Index │ │
|
||||||
│ │ (hydrated) │ │ (IndexedDB) │ │ (FTS5) │ │
|
│ │ + scripts │ │ (IndexedDB) │ │ (FTS5) │ │
|
||||||
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
|
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
@@ -259,17 +269,14 @@ See [architecture-overview.md](architecture-overview.md) for detailed component
|
|||||||
| Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps |
|
| Crypto | `crypto/ed25519`, `crypto/sha256` | Standard library, no external deps |
|
||||||
| Templates | `html/template` | Stdlib, auto-escaping, SSR-safe |
|
| Templates | `html/template` | Stdlib, auto-escaping, SSR-safe |
|
||||||
|
|
||||||
### Frontend (Preact + TypeScript)
|
### Browser UI
|
||||||
|
|
||||||
| Component | Library | Justification |
|
| Component | Technology | Justification |
|
||||||
| --------- | ------------------------------ | --------------------------------------- |
|
| ----------- | ------------------------------ | -------------------------------------- |
|
||||||
| Framework | `preact` (10KB) | React API, tiny bundle, fast hydration |
|
| Rendering | Go `html/template` | Server-rendered, auto-escaped HTML |
|
||||||
| Routing | `preact-iso` | Lightweight isomorphic routing |
|
| Interaction | Embedded browser scripts | Small progressive-enhancement modules |
|
||||||
| Markdown | `marked` + `micromark` | Server+client compatible |
|
| Styling | Native CSS + custom properties | No build-time CSS processing needed |
|
||||||
| Search | `flexsearch` | Client-side full-text, zero server load |
|
| Icons | Inline SVG | No icon font dependency |
|
||||||
| 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
|
### Infrastructure
|
||||||
|
|
||||||
@@ -347,12 +354,8 @@ cairnquire/
|
|||||||
│ │ │ ├── markdown/ # Markdown rendering pipeline
|
│ │ │ ├── markdown/ # Markdown rendering pipeline
|
||||||
│ │ │ ├── realtime/ # WebSocket hub
|
│ │ │ ├── realtime/ # WebSocket hub
|
||||||
│ │ │ └── store/ # Content-addressed filesystem
|
│ │ │ └── store/ # Content-addressed filesystem
|
||||||
│ │ ├── static/ # Static assets
|
│ │ ├── static/ # Embedded static assets
|
||||||
│ │ └── templates/ # Go HTML templates
|
│ │ └── templates/ # Go HTML templates
|
||||||
│ └── web/ # Preact frontend application
|
|
||||||
│ ├── src/
|
|
||||||
│ ├── index.html
|
|
||||||
│ └── vite.config.ts
|
|
||||||
├── content/ # Markdown source files
|
├── content/ # Markdown source files
|
||||||
├── data/ # SQLite database & attachments
|
├── data/ # SQLite database & attachments
|
||||||
└── packages/
|
└── packages/
|
||||||
@@ -378,11 +381,25 @@ cairnquire/
|
|||||||
|
|
||||||
1. ✅ Complete Milestone 1: Foundation
|
1. ✅ Complete Milestone 1: Foundation
|
||||||
2. ✅ Complete Milestone 2: Sync Protocol
|
2. ✅ Complete Milestone 2: Sync Protocol
|
||||||
3. 🔄 Finish Milestone 3 hardening
|
3. ✅ Complete Milestone 3: Authentication
|
||||||
- Add browser login/account management screens
|
4. 🔄 Finish Milestone 4: Collaboration
|
||||||
- Complete security review checklist
|
- Wire up comment UI in browser (JS exists, needs integration)
|
||||||
4. Implement SQLite FTS5 search (Milestone 5)
|
- Wire up notification bell UI
|
||||||
5. Add Renovate with dependency update cooldown policies
|
- 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:
|
Endpoints:
|
||||||
|
|
||||||
|
- `GET /setup`
|
||||||
|
- `POST /api/setup`
|
||||||
- `GET /login`
|
- `GET /login`
|
||||||
|
- `GET /password-reset?token=...`
|
||||||
- `GET /account`
|
- `GET /account`
|
||||||
|
- `GET /account/tokens/new`
|
||||||
- `POST /api/auth/register/password`
|
- `POST /api/auth/register/password`
|
||||||
- `POST /api/auth/login/password`
|
- `POST /api/auth/login/password`
|
||||||
|
- `POST /api/auth/password/reset`
|
||||||
- `POST /api/auth/logout`
|
- `POST /api/auth/logout`
|
||||||
- `GET /api/auth/me`
|
- `GET /api/auth/me`
|
||||||
- `POST /api/auth/password/change`
|
- `POST /api/auth/password/change`
|
||||||
@@ -21,7 +26,12 @@ Endpoints:
|
|||||||
- `POST /api/auth/passkeys/login/begin`
|
- `POST /api/auth/passkeys/login/begin`
|
||||||
- `POST /api/auth/passkeys/login/finish?challengeId=...`
|
- `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
|
## API Tokens
|
||||||
|
|
||||||
@@ -31,7 +41,7 @@ Developer clients use bearer tokens:
|
|||||||
Authorization: Bearer cq_pat_<id>_<secret>
|
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:
|
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.
|
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
|
## Device Flow
|
||||||
|
|
||||||
CLI clients can avoid handling browser cookies by using the first-party 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
|
## 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.
|
- 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.
|
- Admin role required: admin APIs and API token management.
|
||||||
|
|
||||||
|
|||||||
@@ -4,269 +4,124 @@
|
|||||||
|
|
||||||
- Docker 24.0+
|
- Docker 24.0+
|
||||||
- Docker Compose 2.20+
|
- Docker Compose 2.20+
|
||||||
- 1GB RAM minimum (2GB recommended)
|
- A reverse proxy that terminates TLS for the public hostname
|
||||||
- 10GB disk space
|
- A writable checkout directory for `./data` and `./content`
|
||||||
- TLS certificate (Let's Encrypt or custom)
|
|
||||||
|
|
||||||
## Quick Start
|
## Fresh Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
|
||||||
git clone https://github.com/your-org/cairnquire.git
|
git clone https://github.com/your-org/cairnquire.git
|
||||||
cd cairnquire
|
cd cairnquire
|
||||||
|
|
||||||
# Copy and edit configuration
|
|
||||||
cp .env.example .env
|
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
|
```dotenv
|
||||||
|
CAIRNQUIRE_PUBLIC_ORIGIN=https://cairnquire.example.com
|
||||||
| 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production Checklist
|
Start the application and verify the local container health endpoint:
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
docker compose up -d --build
|
||||||
# backup.sh
|
docker compose ps
|
||||||
|
curl http://127.0.0.1:8080/health
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**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
|
```bash
|
||||||
# Stop application
|
docker compose stop app
|
||||||
docker-compose stop app
|
tar czf cairnquire-backup.tgz data content
|
||||||
|
docker compose up -d 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
docker-compose pull
|
docker compose stop app
|
||||||
docker-compose up -d
|
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
|
Database migrations run automatically on startup.
|
||||||
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
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Application Won't Start
|
|
||||||
```bash
|
```bash
|
||||||
# Check logs
|
docker compose logs -f app
|
||||||
docker-compose logs -f app
|
docker compose ps
|
||||||
|
curl http://127.0.0.1:8080/health
|
||||||
# Verify configuration
|
|
||||||
docker-compose exec app /server -check-config
|
|
||||||
|
|
||||||
# Test database
|
|
||||||
docker-compose exec app sqlite3 /data/db.sqlite ".tables"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sync Not Working
|
If passkey creation fails after deployment, confirm that the browser URL and
|
||||||
```bash
|
`CAIRNQUIRE_PUBLIC_ORIGIN` are the same HTTPS origin.
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -408,7 +408,6 @@ RUN addgroup -g 1000 appgroup && \
|
|||||||
|
|
||||||
# Read-only root filesystem
|
# Read-only root filesystem
|
||||||
COPY --from=builder /app/server /server
|
COPY --from=builder /app/server /server
|
||||||
COPY --from=builder /app/web/dist /web
|
|
||||||
|
|
||||||
# Data directory (writable)
|
# Data directory (writable)
|
||||||
RUN mkdir /data && chown appuser:appgroup /data
|
RUN mkdir /data && chown appuser:appgroup /data
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ Response:
|
|||||||
"type": "update",
|
"type": "update",
|
||||||
"path": "guide.md",
|
"path": "guide.md",
|
||||||
"hash": "client-hash",
|
"hash": "client-hash",
|
||||||
|
"content": "# Guide\n\nClient edit\n",
|
||||||
"size": 58,
|
"size": 58,
|
||||||
"modified": "2026-05-14T12:05:00Z"
|
"modified": "2026-05-14T12:05:00Z"
|
||||||
}
|
}
|
||||||
@@ -212,6 +213,7 @@ Response:
|
|||||||
"modified": "2026-05-14T12:03:00Z"
|
"modified": "2026-05-14T12:03:00Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"newSnapshotId": "snap:device:new-timestamp",
|
||||||
"conflicts": [
|
"conflicts": [
|
||||||
{
|
{
|
||||||
"path": "guide.md",
|
"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
|
### Resolve
|
||||||
|
|
||||||
`POST /api/sync/resolve`
|
`POST /api/sync/resolve`
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ Upload client changes and receive server changes + conflicts.
|
|||||||
"type": "update",
|
"type": "update",
|
||||||
"path": "hello.md",
|
"path": "hello.md",
|
||||||
"hash": "d4e5f6...",
|
"hash": "d4e5f6...",
|
||||||
|
"content": "# Hello\n\nUpdated content\n",
|
||||||
"size": 50,
|
"size": 50,
|
||||||
"modified": "2024-01-15T10:05:00Z"
|
"modified": "2024-01-15T10:05:00Z"
|
||||||
}
|
}
|
||||||
@@ -131,6 +132,7 @@ Upload client changes and receive server changes + conflicts.
|
|||||||
"modified": "2024-01-15T10:02:00Z"
|
"modified": "2024-01-15T10:02:00Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"newSnapshotId": "snap:macbook-pro-1:1234567999",
|
||||||
"conflicts": [
|
"conflicts": [
|
||||||
{
|
{
|
||||||
"path": "hello.md",
|
"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
|
### POST /api/sync/resolve
|
||||||
|
|
||||||
Resolve conflicts and finalize the sync.
|
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)
|
### Current State (Already Built)
|
||||||
- Server: Go HTTP + SQLite + file watcher + WebSocket hub
|
- 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
|
- Documents served at `/:path*` with `index.md` default
|
||||||
- Real-time updates pushed via WebSocket when files change on disk
|
- Real-time updates pushed via WebSocket when files change on disk
|
||||||
|
|
||||||
|
|||||||
21
Dockerfile
21
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
|
FROM golang:1.24-bookworm AS server-build
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends build-essential ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends build-essential ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -16,20 +9,18 @@ COPY apps/server/ ./
|
|||||||
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire
|
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gosu && rm -rf /var/lib/apt/lists/*
|
||||||
RUN useradd --create-home --shell /usr/sbin/nologin appuser
|
RUN useradd --create-home --shell /usr/sbin/nologin appuser
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire
|
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
|
COPY content /workspace/content
|
||||||
RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
USER appuser
|
RUN mkdir -p /workspace/data/files && chmod +x /usr/local/bin/entrypoint.sh
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENV CAIRNQUIRE_SERVER_ADDR=:8080
|
ENV CAIRNQUIRE_SERVER_ADDR=:8080
|
||||||
ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite
|
ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite
|
||||||
ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content
|
ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content
|
||||||
ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files
|
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/healthz || exit 1
|
||||||
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/entrypoint.sh"]
|
||||||
ENTRYPOINT ["/usr/local/bin/cairnquire"]
|
CMD ["/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
|
- SQLite/libsql-oriented data layer with startup migrations
|
||||||
- Markdown rendering pipeline with wiki-link rewriting, tag extraction, and SSR templates
|
- Markdown rendering pipeline with wiki-link rewriting, tag extraction, and SSR templates
|
||||||
- Content-addressed attachment storage with secure serving
|
- 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
|
- Docker, Compose, CI, OpenAPI, and local development targets
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -21,6 +21,7 @@ This repository now contains the Milestone 1 foundation scaffold:
|
|||||||
- Docker (optional)
|
- Docker (optional)
|
||||||
- CGO-capable toolchain for `go-libsql`
|
- CGO-capable toolchain for `go-libsql`
|
||||||
- `air` for Go live reload (`go install github.com/air-verse/air@v1.65.1`)
|
- `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:
|
Install the pinned toolchain with:
|
||||||
|
|
||||||
@@ -30,39 +31,64 @@ mise install
|
|||||||
|
|
||||||
### Development Mode (with live reload)
|
### 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
|
```bash
|
||||||
mise run dev
|
mise run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts:
|
This starts the Go server with `air` live reload on `http://localhost:8080`.
|
||||||
- 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Mode
|
### Production Mode
|
||||||
|
|
||||||
Build the web app and run the Go server without Vite proxy:
|
Run the Go server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run web-install
|
|
||||||
mise run web-build
|
|
||||||
mise run server-run
|
mise run server-run
|
||||||
```
|
```
|
||||||
|
|
||||||
The server listens on `http://localhost:8080` by default.
|
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
|
### Common commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -82,18 +108,27 @@ Configuration can come from:
|
|||||||
|
|
||||||
Supported variables:
|
Supported variables:
|
||||||
|
|
||||||
- `CAIRNQUIRE_SERVER_ADDR`
|
- `CAIRNQUIRE_PUBLIC_ORIGIN` - Required for deployment. Exact public HTTPS origin used for passkeys and device-flow URLs, for example `https://cairnquire.example.com`.
|
||||||
- `CAIRNQUIRE_DATABASE_PATH`
|
- `CAIRNQUIRE_SERVER_ADDR` - Listen address. Defaults to `:8080`.
|
||||||
- `CAIRNQUIRE_DATABASE_PRIMARY_URL`
|
- `PORT` - Optional shorthand for the listen port, for example `PORT=9000`.
|
||||||
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN`
|
- `CAIRNQUIRE_DATABASE_PATH` - Local SQLite/libsql path. Defaults to `../../data/db.sqlite` when running from `apps/server`.
|
||||||
- `CAIRNQUIRE_CONTENT_SOURCE_DIR`
|
- `CAIRNQUIRE_DATABASE_PRIMARY_URL` - Optional remote libsql primary URL for an embedded replica.
|
||||||
- `CAIRNQUIRE_CONTENT_STORE_DIR`
|
- `CAIRNQUIRE_DATABASE_AUTH_TOKEN` - Optional auth token for the remote libsql primary.
|
||||||
- `CAIRNQUIRE_WEB_DIST_DIR`
|
- `CAIRNQUIRE_CONTENT_SOURCE_DIR` - Editable Markdown source directory.
|
||||||
- `CAIRNQUIRE_DEV_VITE_URL` - Set to proxy `/app/*` to Vite dev server (e.g. `http://localhost:5173`)
|
- `CAIRNQUIRE_CONTENT_STORE_DIR` - Content-addressed attachment store.
|
||||||
- `CAIRNQUIRE_LOG_LEVEL`
|
- `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
|
## 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 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.
|
- 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.
|
||||||
281
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
281
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public typealias UploadProgressHandler = @Sendable (Double) -> Void
|
||||||
|
|
||||||
|
private final class UploadProgressTaskDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
|
private let onProgress: UploadProgressHandler
|
||||||
|
|
||||||
|
init(onProgress: @escaping UploadProgressHandler) {
|
||||||
|
self.onProgress = onProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didSendBodyData bytesSent: Int64,
|
||||||
|
totalBytesSent: Int64,
|
||||||
|
totalBytesExpectedToSend: Int64
|
||||||
|
) {
|
||||||
|
guard totalBytesExpectedToSend > 0 else { return }
|
||||||
|
let progress = min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1)
|
||||||
|
onProgress(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor APIClient {
|
||||||
|
public let baseURL: URL
|
||||||
|
public var apiToken: String?
|
||||||
|
|
||||||
|
private let session: URLSession
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
|
||||||
|
public init(baseURL: URL, apiToken: String? = nil) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.apiToken = apiToken
|
||||||
|
if self.apiToken == nil && Self.isLocalhost(baseURL) {
|
||||||
|
self.apiToken = "dev"
|
||||||
|
}
|
||||||
|
self.session = URLSession(configuration: .default)
|
||||||
|
|
||||||
|
self.decoder = JSONDecoder()
|
||||||
|
self.decoder.dateDecodingStrategy = .iso8601
|
||||||
|
|
||||||
|
self.encoder = JSONEncoder()
|
||||||
|
self.encoder.dateEncodingStrategy = .iso8601
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLocalhost(_ url: URL) -> Bool {
|
||||||
|
guard let host = url.host else { return false }
|
||||||
|
return host == "localhost" || host == "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setAPIToken(_ token: String?) {
|
||||||
|
self.apiToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(for path: String) -> URLRequest {
|
||||||
|
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||||
|
if let token = apiToken {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeResponse<T: Decodable>(data: Data, response: URLResponse) throws -> T {
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw APIError(error: "invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode >= 400 {
|
||||||
|
if let error = try? decoder.decode(APIError.self, from: data) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw APIError(error: "HTTP \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if T.self == EmptyResponse.self {
|
||||||
|
return EmptyResponse() as! T
|
||||||
|
}
|
||||||
|
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
return try decodeResponse(data: data, response: response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performUpload<T: Decodable>(
|
||||||
|
_ request: URLRequest,
|
||||||
|
body: Data,
|
||||||
|
onProgress: UploadProgressHandler?
|
||||||
|
) async throws -> T {
|
||||||
|
onProgress?(0)
|
||||||
|
let delegate = onProgress.map { UploadProgressTaskDelegate(onProgress: $0) }
|
||||||
|
let (data, response) = try await session.upload(for: request, from: body, delegate: delegate)
|
||||||
|
onProgress?(1)
|
||||||
|
return try decodeResponse(data: data, response: response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uploads
|
||||||
|
|
||||||
|
public func uploadFile(
|
||||||
|
url: URL,
|
||||||
|
folder: String? = nil,
|
||||||
|
duplicateAction: UploadDuplicateAction? = nil,
|
||||||
|
onProgress: UploadProgressHandler? = nil
|
||||||
|
) async throws -> UploadResponse {
|
||||||
|
guard let token = apiToken else {
|
||||||
|
throw APIError(error: "no API token configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundary = UUID().uuidString
|
||||||
|
var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let filename = url.lastPathComponent
|
||||||
|
|
||||||
|
var body = Data()
|
||||||
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
if let folder, !folder.isEmpty {
|
||||||
|
body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(folder.data(using: .utf8)!)
|
||||||
|
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
if let duplicateAction {
|
||||||
|
body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(duplicateAction.rawValue.data(using: .utf8)!)
|
||||||
|
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||||
|
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(data)
|
||||||
|
body.append("\r\n".data(using: .utf8)!)
|
||||||
|
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
|
||||||
|
return try await performUpload(request, body: body, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func uploadData(
|
||||||
|
_ data: Data,
|
||||||
|
filename: String,
|
||||||
|
folder: String? = nil,
|
||||||
|
duplicateAction: UploadDuplicateAction? = nil,
|
||||||
|
onProgress: UploadProgressHandler? = nil
|
||||||
|
) async throws -> UploadResponse {
|
||||||
|
guard let token = apiToken else {
|
||||||
|
throw APIError(error: "no API token configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundary = UUID().uuidString
|
||||||
|
var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var body = Data()
|
||||||
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
if let folder, !folder.isEmpty {
|
||||||
|
body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(folder.data(using: .utf8)!)
|
||||||
|
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
if let duplicateAction {
|
||||||
|
body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(duplicateAction.rawValue.data(using: .utf8)!)
|
||||||
|
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||||
|
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(data)
|
||||||
|
body.append("\r\n".data(using: .utf8)!)
|
||||||
|
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
|
||||||
|
return try await performUpload(request, body: body, onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Attachments
|
||||||
|
|
||||||
|
public func listAttachments() async throws -> [AttachmentRecord] {
|
||||||
|
let request = self.request(for: "api/attachments")
|
||||||
|
let response: AttachmentsListResponse = try await perform(request)
|
||||||
|
return response.attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
public func attachmentURL(hash: String) -> URL {
|
||||||
|
baseURL.appendingPathComponent("attachments/\(hash)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Documents
|
||||||
|
|
||||||
|
public func listDocuments() async throws -> [DocumentRecord] {
|
||||||
|
let request = self.request(for: "api/documents")
|
||||||
|
let response: DocumentsListResponse = try await perform(request)
|
||||||
|
return response.documents
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveDocument(path: String, content: String, baseHash: String? = nil) async throws -> DocumentSaveResponse {
|
||||||
|
var request = self.request(for: "api/documents/\(path)")
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let body = DocumentSaveRequest(content: content, baseHash: baseHash)
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
|
||||||
|
return try await perform(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func searchDocuments(query: String) async throws -> [SearchResult] {
|
||||||
|
var components = URLComponents(url: baseURL.appendingPathComponent("api/search"), resolvingAgainstBaseURL: false)
|
||||||
|
components?.queryItems = [URLQueryItem(name: "q", value: query)]
|
||||||
|
|
||||||
|
guard let url = components?.url else {
|
||||||
|
throw APIError(error: "invalid search URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
if let token = apiToken {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let response: SearchResponse = try await perform(request)
|
||||||
|
return response.results
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync
|
||||||
|
|
||||||
|
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {
|
||||||
|
var request = self.request(for: "api/sync/init")
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let body = SyncInitRequest(deviceId: deviceId, rootPath: rootPath)
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
|
||||||
|
return try await perform(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func syncDelta(snapshotId: String, clientDelta: [Change]) async throws -> DeltaResponse {
|
||||||
|
var request = self.request(for: "api/sync/delta")
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let body = DeltaRequest(snapshotId: snapshotId, clientDelta: clientDelta)
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
|
||||||
|
return try await perform(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fetchContent(hash: String) async throws -> Data {
|
||||||
|
let request = self.request(for: "api/content/\(hash)")
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw APIError(error: "invalid response")
|
||||||
|
}
|
||||||
|
if httpResponse.statusCode >= 400 {
|
||||||
|
if let error = try? decoder.decode(APIError.self, from: data) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw APIError(error: "HTTP \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health
|
||||||
|
|
||||||
|
public func healthCheck() async throws -> Bool {
|
||||||
|
let request = self.request(for: "health")
|
||||||
|
let (_, response) = try await session.data(for: request)
|
||||||
|
return (response as? HTTPURLResponse)?.statusCode == 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EmptyResponse: Decodable {}
|
||||||
195
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
195
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct AttachmentRecord: Codable, Identifiable {
|
||||||
|
public var id: String { hash }
|
||||||
|
public let hash: String
|
||||||
|
public let originalName: String
|
||||||
|
public let contentType: String
|
||||||
|
public let sizeBytes: Int
|
||||||
|
public let createdAt: Date
|
||||||
|
public let url: String
|
||||||
|
public let kind: String?
|
||||||
|
public let documentPath: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case hash
|
||||||
|
case originalName
|
||||||
|
case contentType
|
||||||
|
case sizeBytes
|
||||||
|
case createdAt
|
||||||
|
case url
|
||||||
|
case kind
|
||||||
|
case documentPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UploadResponse: Codable {
|
||||||
|
public let hash: String
|
||||||
|
public let contentType: String
|
||||||
|
public let attachmentUrl: String
|
||||||
|
public let url: String?
|
||||||
|
public let kind: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DocumentRecord: Codable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let path: String
|
||||||
|
public let title: String
|
||||||
|
public let hash: String
|
||||||
|
public let createdAt: Date?
|
||||||
|
public let updatedAt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DocumentSaveRequest: Codable {
|
||||||
|
public let content: String
|
||||||
|
public let baseHash: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DocumentSaveResponse: Codable {
|
||||||
|
public let status: String
|
||||||
|
public let path: String
|
||||||
|
public let title: String
|
||||||
|
public let hash: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchResult: Codable, Identifiable {
|
||||||
|
public var id: String { path }
|
||||||
|
public let path: String
|
||||||
|
public let title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SyncInitRequest: Codable {
|
||||||
|
public let deviceId: String
|
||||||
|
public let rootPath: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SyncInitResponse: Codable {
|
||||||
|
public let snapshotId: String
|
||||||
|
public let serverSnapshot: [FileEntry]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case snapshotId
|
||||||
|
case serverSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
snapshotId = try container.decode(String.self, forKey: .snapshotId)
|
||||||
|
serverSnapshot = try container.decodeIfPresent([FileEntry].self, forKey: .serverSnapshot) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FileEntry: Codable {
|
||||||
|
public let path: String
|
||||||
|
public let hash: String
|
||||||
|
public let size: Int64
|
||||||
|
public let modified: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DeltaRequest: Codable {
|
||||||
|
public let snapshotId: String
|
||||||
|
public let clientDelta: [Change]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DeltaResponse: Codable {
|
||||||
|
public let serverDelta: [Change]
|
||||||
|
public let conflicts: [Conflict]
|
||||||
|
public let newSnapshotId: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case serverDelta
|
||||||
|
case conflicts
|
||||||
|
case newSnapshotId
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
serverDelta = try container.decodeIfPresent([Change].self, forKey: .serverDelta) ?? []
|
||||||
|
conflicts = try container.decodeIfPresent([Conflict].self, forKey: .conflicts) ?? []
|
||||||
|
newSnapshotId = try container.decodeIfPresent(String.self, forKey: .newSnapshotId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Change: Codable {
|
||||||
|
public let type: String
|
||||||
|
public let path: String
|
||||||
|
public let oldPath: String?
|
||||||
|
public let hash: String?
|
||||||
|
public let content: String?
|
||||||
|
public let size: Int64?
|
||||||
|
public let modified: Date?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
type: String,
|
||||||
|
path: String,
|
||||||
|
oldPath: String?,
|
||||||
|
hash: String?,
|
||||||
|
content: String? = nil,
|
||||||
|
size: Int64?,
|
||||||
|
modified: Date?
|
||||||
|
) {
|
||||||
|
self.type = type
|
||||||
|
self.path = path
|
||||||
|
self.oldPath = oldPath
|
||||||
|
self.hash = hash
|
||||||
|
self.content = content
|
||||||
|
self.size = size
|
||||||
|
self.modified = modified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Conflict: Codable {
|
||||||
|
public let path: String
|
||||||
|
public let serverHash: String
|
||||||
|
public let clientHash: String
|
||||||
|
public let serverModified: Date
|
||||||
|
public let clientModified: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct APIError: Error, Codable {
|
||||||
|
public let error: String
|
||||||
|
public let conflictType: String?
|
||||||
|
public let existingHash: String?
|
||||||
|
public let existingPath: String?
|
||||||
|
public let existingUrl: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
error: String,
|
||||||
|
conflictType: String? = nil,
|
||||||
|
existingHash: String? = nil,
|
||||||
|
existingPath: String? = nil,
|
||||||
|
existingUrl: String? = nil
|
||||||
|
) {
|
||||||
|
self.error = error
|
||||||
|
self.conflictType = conflictType
|
||||||
|
self.existingHash = existingHash
|
||||||
|
self.existingPath = existingPath
|
||||||
|
self.existingUrl = existingUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isUploadConflict: Bool {
|
||||||
|
conflictType == "document" || conflictType == "attachment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIError: LocalizedError {
|
||||||
|
public var errorDescription: String? { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AttachmentsListResponse: Codable {
|
||||||
|
public let attachments: [AttachmentRecord]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DocumentsListResponse: Codable {
|
||||||
|
public let documents: [DocumentRecord]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchResponse: Codable {
|
||||||
|
public let results: [SearchResult]
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UploadDuplicateAction: String {
|
||||||
|
case overwrite
|
||||||
|
case rename
|
||||||
|
case reuse
|
||||||
|
}
|
||||||
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,547 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
public struct ClipboardNotice: Identifiable, Equatable {
|
||||||
|
public let id = UUID()
|
||||||
|
public let filename: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||||
|
public static let minimumSearchCharacters = 3
|
||||||
|
|
||||||
|
@Published public var config: SyncConfig
|
||||||
|
@Published public var isSyncing = false
|
||||||
|
@Published public var lastSyncTime: Date?
|
||||||
|
@Published public var syncStatus: String = "Ready"
|
||||||
|
@Published public var pendingChanges = 0
|
||||||
|
@Published public var uploadProgress: Double? = nil
|
||||||
|
@Published public private(set) var recentUploads: [AttachmentRecord] = []
|
||||||
|
@Published public private(set) var isLoadingRecentUploads = false
|
||||||
|
@Published public private(set) var recentUploadsError: String?
|
||||||
|
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
||||||
|
@Published public var searchQuery = ""
|
||||||
|
@Published public private(set) var searchResults: [SearchResult] = []
|
||||||
|
@Published public private(set) var isSearching = false
|
||||||
|
@Published public private(set) var searchError: String?
|
||||||
|
@Published public var selectedSearchIndex = 0
|
||||||
|
|
||||||
|
public var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private let configStore = ConfigStore.shared
|
||||||
|
private var apiClient: APIClient
|
||||||
|
private var syncEngine: SyncEngine?
|
||||||
|
private var uploadManager: UploadManager
|
||||||
|
private var folderWatcher: FolderWatcher?
|
||||||
|
private var syncTimer: Timer?
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private var settingsWindow: NSWindow?
|
||||||
|
private var uploadsWindow: NSWindow?
|
||||||
|
private var dropZoneWindow: NSWindow?
|
||||||
|
|
||||||
|
public override init() {
|
||||||
|
let config = ConfigStore.shared.load()
|
||||||
|
self.config = config
|
||||||
|
self.apiClient = APIClient(baseURL: config.serverBaseURL ?? URL(string: "http://localhost:8080")!, apiToken: config.apiToken)
|
||||||
|
self.uploadManager = UploadManager(client: apiClient, config: config)
|
||||||
|
self.syncEngine = SyncEngine(client: apiClient, config: config)
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await setupSync()
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await refreshRecentUploads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateConfig(_ newConfig: SyncConfig) {
|
||||||
|
config = newConfig
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await configStore.save(newConfig)
|
||||||
|
|
||||||
|
if let url = newConfig.serverBaseURL {
|
||||||
|
await apiClient.setAPIToken(newConfig.apiToken)
|
||||||
|
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
||||||
|
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
||||||
|
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
||||||
|
resetSearch()
|
||||||
|
await refreshRecentUploads()
|
||||||
|
await setupSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSync() async {
|
||||||
|
// Stop existing watcher
|
||||||
|
folderWatcher?.stop()
|
||||||
|
syncTimer?.invalidate()
|
||||||
|
|
||||||
|
guard config.autoSync, let syncFolder = config.syncFolder else {
|
||||||
|
syncStatus = "Auto-sync disabled"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup folder watcher
|
||||||
|
let watcher = FolderWatcher(folderPath: syncFolder)
|
||||||
|
watcher.delegate = self
|
||||||
|
watcher.start()
|
||||||
|
self.folderWatcher = watcher
|
||||||
|
|
||||||
|
// Setup periodic sync
|
||||||
|
syncTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(config.syncInterval), repeats: true) { [weak self] _ in
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.performSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial sync
|
||||||
|
await performSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func performSync() async {
|
||||||
|
guard !isSyncing else { return }
|
||||||
|
guard config.syncFolder != nil else {
|
||||||
|
syncStatus = "No sync folder configured"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncing = true
|
||||||
|
syncStatus = "Syncing..."
|
||||||
|
|
||||||
|
do {
|
||||||
|
let engine = syncEngine ?? SyncEngine(client: apiClient, config: config)
|
||||||
|
syncEngine = engine
|
||||||
|
try await engine.performSync()
|
||||||
|
|
||||||
|
lastSyncTime = Date()
|
||||||
|
syncStatus = "Synced"
|
||||||
|
} catch {
|
||||||
|
syncStatus = "Sync failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window Management
|
||||||
|
|
||||||
|
public func showSettings() {
|
||||||
|
if let window = settingsWindow {
|
||||||
|
present(window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
|
||||||
|
styleMask: [.titled, .closable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "Cairnquire Settings"
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.delegate = self
|
||||||
|
window.contentView = NSHostingView(rootView: SettingsView(config: config, onSave: { [weak self, weak window] newConfig in
|
||||||
|
self?.updateConfig(newConfig)
|
||||||
|
window?.close()
|
||||||
|
}))
|
||||||
|
window.center()
|
||||||
|
|
||||||
|
settingsWindow = window
|
||||||
|
present(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func showUploads() {
|
||||||
|
if let window = uploadsWindow {
|
||||||
|
present(window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "Uploaded Files"
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.delegate = self
|
||||||
|
window.contentView = NSHostingView(rootView: UploadsListView(client: apiClient))
|
||||||
|
window.center()
|
||||||
|
|
||||||
|
uploadsWindow = window
|
||||||
|
present(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func showDropZone() {
|
||||||
|
if let window = dropZoneWindow {
|
||||||
|
present(window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 300, height: 250),
|
||||||
|
styleMask: [.titled, .closable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "Upload File"
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.delegate = self
|
||||||
|
window.contentView = NSHostingView(rootView: DropZoneView(uploadManager: uploadManager))
|
||||||
|
window.center()
|
||||||
|
|
||||||
|
dropZoneWindow = window
|
||||||
|
present(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func windowWillClose(_ notification: Notification) {
|
||||||
|
guard let window = notification.object as? NSWindow else { return }
|
||||||
|
|
||||||
|
if window === settingsWindow {
|
||||||
|
settingsWindow = nil
|
||||||
|
} else if window === uploadsWindow {
|
||||||
|
uploadsWindow = nil
|
||||||
|
} else if window === dropZoneWindow {
|
||||||
|
dropZoneWindow = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func present(_ window: NSWindow) {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func quickUpload() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.allowsMultipleSelection = true
|
||||||
|
|
||||||
|
guard panel.runModal() == .OK else { return }
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await uploadURLList(panel.urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func uploadFiles(_ urls: [URL]) {
|
||||||
|
Task {
|
||||||
|
await uploadURLList(urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadURLList(_ urls: [URL]) async {
|
||||||
|
guard !urls.isEmpty else { return }
|
||||||
|
|
||||||
|
let total = Double(urls.count)
|
||||||
|
for (index, url) in urls.enumerated() {
|
||||||
|
let baseProgress = Double(index) / total
|
||||||
|
let fileWeight = 1 / total
|
||||||
|
uploadProgress = baseProgress
|
||||||
|
syncStatus = "Uploading \(url.lastPathComponent)"
|
||||||
|
|
||||||
|
let onProgress: UploadProgressHandler = { [weak self] fileProgress in
|
||||||
|
let combinedProgress = baseProgress + min(max(fileProgress, 0), 1) * fileWeight
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.uploadProgress = combinedProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await uploadFileResolvingDuplicates(url, onProgress: onProgress)
|
||||||
|
uploadProgress = Double(index + 1) / total
|
||||||
|
syncStatus = "Uploaded \(result.filename)"
|
||||||
|
clipboardNotice = ClipboardNotice(filename: result.filename)
|
||||||
|
await refreshRecentUploads()
|
||||||
|
} catch {
|
||||||
|
syncStatus = "Upload failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploadProgress = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadFileResolvingDuplicates(
|
||||||
|
_ url: URL,
|
||||||
|
onProgress: UploadProgressHandler?
|
||||||
|
) async throws -> UploadResult {
|
||||||
|
var duplicateAction: UploadDuplicateAction?
|
||||||
|
|
||||||
|
while true {
|
||||||
|
do {
|
||||||
|
return try await uploadManager.uploadFile(
|
||||||
|
url,
|
||||||
|
duplicateAction: duplicateAction,
|
||||||
|
onProgress: onProgress
|
||||||
|
)
|
||||||
|
} catch let error as APIError where error.isUploadConflict {
|
||||||
|
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
||||||
|
throw UploadCancelledError()
|
||||||
|
}
|
||||||
|
duplicateAction = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadDataResolvingDuplicates(_ data: Data, filename: String) async throws -> UploadResult {
|
||||||
|
var duplicateAction: UploadDuplicateAction?
|
||||||
|
|
||||||
|
while true {
|
||||||
|
do {
|
||||||
|
return try await uploadManager.uploadData(
|
||||||
|
data,
|
||||||
|
filename: filename,
|
||||||
|
duplicateAction: duplicateAction
|
||||||
|
)
|
||||||
|
} catch let error as APIError where error.isUploadConflict {
|
||||||
|
guard let action = promptForUploadDuplicate(error, filename: filename) else {
|
||||||
|
throw UploadCancelledError()
|
||||||
|
}
|
||||||
|
duplicateAction = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func refreshRecentUploads() async {
|
||||||
|
guard !isLoadingRecentUploads else { return }
|
||||||
|
|
||||||
|
isLoadingRecentUploads = true
|
||||||
|
defer { isLoadingRecentUploads = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
recentUploads = Array(try await apiClient.listAttachments().prefix(3))
|
||||||
|
recentUploadsError = nil
|
||||||
|
} catch {
|
||||||
|
recentUploadsError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateSearchQuery(_ query: String) {
|
||||||
|
searchQuery = query
|
||||||
|
selectedSearchIndex = 0
|
||||||
|
searchTask?.cancel()
|
||||||
|
|
||||||
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count >= Self.minimumSearchCharacters else {
|
||||||
|
searchResults = []
|
||||||
|
isSearching = false
|
||||||
|
searchError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching = true
|
||||||
|
searchError = nil
|
||||||
|
searchTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .milliseconds(300))
|
||||||
|
await self?.performSearch(for: trimmed)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
if self?.isCurrentSearchQuery(trimmed) == true {
|
||||||
|
self?.isSearching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearSearch() {
|
||||||
|
resetSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func moveSearchSelection(by delta: Int) {
|
||||||
|
guard !searchResults.isEmpty else { return }
|
||||||
|
selectedSearchIndex = min(max(selectedSearchIndex + delta, 0), searchResults.count - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func openSelectedSearchResult() {
|
||||||
|
guard searchResults.indices.contains(selectedSearchIndex) else { return }
|
||||||
|
openSearchResult(searchResults[selectedSearchIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func openSearchResult(_ result: SearchResult) {
|
||||||
|
guard let url = documentURL(for: result) else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSearch(for query: String) async {
|
||||||
|
do {
|
||||||
|
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
guard isCurrentSearchQuery(query) else { return }
|
||||||
|
searchResults = Array(results.prefix(8))
|
||||||
|
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
|
||||||
|
searchError = nil
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
guard isCurrentSearchQuery(query) else { return }
|
||||||
|
searchResults = []
|
||||||
|
searchError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isSearching = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCurrentSearchQuery(_ query: String) -> Bool {
|
||||||
|
let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed == query && trimmed.count >= Self.minimumSearchCharacters
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetSearch() {
|
||||||
|
searchTask?.cancel()
|
||||||
|
searchQuery = ""
|
||||||
|
searchResults = []
|
||||||
|
isSearching = false
|
||||||
|
searchError = nil
|
||||||
|
selectedSearchIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func liveSearchQuery(for query: String) -> String {
|
||||||
|
let terms = query
|
||||||
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
guard !terms.isEmpty else { return query }
|
||||||
|
return terms.map { "\($0)*" }.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func documentURL(for result: SearchResult) -> URL? {
|
||||||
|
guard let baseURL = config.serverBaseURL else { return nil }
|
||||||
|
let trimmedPath = result.path.hasSuffix(".md") ? String(result.path.dropLast(3)) : result.path
|
||||||
|
var allowed = CharacterSet.urlPathAllowed
|
||||||
|
allowed.remove(charactersIn: "?#")
|
||||||
|
guard let encodedPath = trimmedPath.addingPercentEncoding(withAllowedCharacters: allowed) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return URL(string: "docs/\(encodedPath)", relativeTo: baseURL)?.absoluteURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createNote() {
|
||||||
|
switch config.effectiveNewNoteAction {
|
||||||
|
case .clipboard:
|
||||||
|
createNoteFromClipboard()
|
||||||
|
case .editor:
|
||||||
|
createNoteInEditor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createNoteFromClipboard() {
|
||||||
|
guard let text = NSPasteboard.general.string(forType: .string) else {
|
||||||
|
syncStatus = "Clipboard is empty"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await uploadManager.createNote(from: text)
|
||||||
|
syncStatus = "Created note: \(result.title)"
|
||||||
|
} catch {
|
||||||
|
syncStatus = "Failed to create note: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createNoteInEditor() {
|
||||||
|
let draftURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("cairnquire-\(UUID().uuidString)")
|
||||||
|
.appendingPathExtension("md")
|
||||||
|
let completionURL = draftURL.appendingPathExtension("done")
|
||||||
|
|
||||||
|
do {
|
||||||
|
try Data().write(to: draftURL, options: .atomic)
|
||||||
|
try launchTerminalEditor(draftURL: draftURL, completionURL: completionURL)
|
||||||
|
syncStatus = "Waiting for $EDITOR to exit"
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: draftURL)
|
||||||
|
syncStatus = "Failed to open $EDITOR: \(error.localizedDescription)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.uploadEditorDraft(draftURL: draftURL, completionURL: completionURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadEditorDraft(draftURL: URL, completionURL: URL) async {
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: draftURL)
|
||||||
|
try? FileManager.default.removeItem(at: completionURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
while !FileManager.default.fileExists(atPath: completionURL.path) {
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: draftURL)
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
syncStatus = "Editor note was empty; nothing uploaded"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = Self.editorNoteFilename()
|
||||||
|
let result = try await uploadDataResolvingDuplicates(data, filename: filename)
|
||||||
|
syncStatus = "Uploaded \(result.filename)"
|
||||||
|
clipboardNotice = ClipboardNotice(filename: result.filename)
|
||||||
|
await refreshRecentUploads()
|
||||||
|
} catch {
|
||||||
|
syncStatus = "Failed to upload editor note: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchTerminalEditor(draftURL: URL, completionURL: URL) throws {
|
||||||
|
let draftPath = Self.shellQuote(draftURL.path)
|
||||||
|
let completionPath = Self.shellQuote(completionURL.path)
|
||||||
|
let shellCommand = "trap '/usr/bin/touch \(completionPath)' EXIT; editor=\"${EDITOR:-${VISUAL:-vi}}\"; eval \"$editor \(draftPath)\""
|
||||||
|
let script = "tell application \"Terminal\" to do script \"\(Self.appleScriptEscape(shellCommand))\""
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
let errorPipe = Pipe()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||||
|
process.arguments = ["-e", "tell application \"Terminal\" to activate", "-e", script]
|
||||||
|
process.standardError = errorPipe
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let message = String(data: data, encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
throw NSError(
|
||||||
|
domain: "CairnquireSync",
|
||||||
|
code: Int(process.terminationStatus),
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: message ?? "Terminal could not be opened"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shellQuote(_ value: String) -> String {
|
||||||
|
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appleScriptEscape(_ value: String) -> String {
|
||||||
|
value
|
||||||
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func editorNoteFilename() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
|
||||||
|
return "Note \(formatter.string(from: Date())).md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MenuBarController: FolderWatcherDelegate {
|
||||||
|
public nonisolated func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String]) {
|
||||||
|
Task { @MainActor in
|
||||||
|
pendingChanges += paths.count
|
||||||
|
syncStatus = "\(pendingChanges) pending changes"
|
||||||
|
await performSync()
|
||||||
|
pendingChanges = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,436 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct StatusPopoverView: View {
|
||||||
|
@ObservedObject public var controller: MenuBarController
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
|
public init(controller: MenuBarController) {
|
||||||
|
self.controller = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header with status
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
searchSection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Quick actions
|
||||||
|
actionsSection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Recent uploads preview (last 3)
|
||||||
|
recentUploadsSection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footerSection
|
||||||
|
}
|
||||||
|
.frame(width: 360)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.onAppear {
|
||||||
|
Task { @MainActor in
|
||||||
|
searchFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onMoveCommand { direction in
|
||||||
|
switch direction {
|
||||||
|
case .up:
|
||||||
|
controller.moveSearchSelection(by: -1)
|
||||||
|
case .down:
|
||||||
|
controller.moveSearchSelection(by: 1)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
statusIndicator
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(controller.syncStatus)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
|
||||||
|
if let lastSync = controller.lastSyncTime {
|
||||||
|
Text("Last sync: \(timeAgo(lastSync))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Never synced")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if controller.isSyncing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if controller.pendingChanges > 0 {
|
||||||
|
Text("\(controller.pendingChanges) pending changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIndicator: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(statusColor)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(statusColor.opacity(0.3), lineWidth: 2)
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
if controller.isSyncing { return .blue }
|
||||||
|
if controller.syncStatus.contains("fail") || controller.syncStatus.contains("error") {
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
if controller.pendingChanges > 0 { return .orange }
|
||||||
|
return .green
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionsSection: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ActionButton(
|
||||||
|
icon: "arrow.clockwise",
|
||||||
|
label: "Sync",
|
||||||
|
color: .blue
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
await controller.performSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionButton(
|
||||||
|
icon: "arrow.up.doc",
|
||||||
|
label: "Upload",
|
||||||
|
color: .purple
|
||||||
|
) {
|
||||||
|
controller.quickUpload()
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionButton(
|
||||||
|
icon: "doc.badge.plus",
|
||||||
|
label: "Note",
|
||||||
|
color: .green
|
||||||
|
) {
|
||||||
|
controller.createNote()
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionButton(
|
||||||
|
icon: "square.and.arrow.down",
|
||||||
|
label: "Drop",
|
||||||
|
color: .orange
|
||||||
|
) {
|
||||||
|
controller.showDropZone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var searchSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
"Search documents",
|
||||||
|
text: Binding(
|
||||||
|
get: { controller.searchQuery },
|
||||||
|
set: { controller.updateSearchQuery($0) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.focused($searchFocused)
|
||||||
|
.onSubmit {
|
||||||
|
controller.openSelectedSearchResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
if controller.isSearching {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.scaleEffect(0.65)
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(NSColor.controlBackgroundColor))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
if controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).count >= MenuBarController.minimumSearchCharacters {
|
||||||
|
searchResultsList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var searchResultsList: some View {
|
||||||
|
if controller.searchResults.isEmpty {
|
||||||
|
Text(controller.isSearching ? "Searching..." : (controller.searchError == nil ? "No results" : "Search unavailable"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
ForEach(Array(controller.searchResults.enumerated()), id: \.element.id) { index, result in
|
||||||
|
SearchResultRow(
|
||||||
|
result: result,
|
||||||
|
isSelected: index == controller.selectedSearchIndex
|
||||||
|
) {
|
||||||
|
controller.selectedSearchIndex = index
|
||||||
|
controller.openSearchResult(result)
|
||||||
|
}
|
||||||
|
.onHover { isHovered in
|
||||||
|
if isHovered {
|
||||||
|
controller.selectedSearchIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var recentUploadsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Recent Uploads")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("View All") {
|
||||||
|
controller.showUploads()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if controller.isLoadingRecentUploads && controller.recentUploads.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} else if controller.recentUploads.isEmpty {
|
||||||
|
Text(controller.recentUploadsError == nil ? "No recent uploads" : "Unable to load recent uploads")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} else {
|
||||||
|
ForEach(controller.recentUploads) { upload in
|
||||||
|
RecentUploadRow(upload: upload, baseURL: controller.config.serverBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerSection: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
controller.showSettings()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "gear")
|
||||||
|
Text("Settings")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "power")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("Quit")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let url = controller.config.serverBaseURL {
|
||||||
|
Button {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("Open Server")
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func timeAgo(_ date: Date) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RecentUploadRow: View {
|
||||||
|
let upload: AttachmentRecord
|
||||||
|
let baseURL: URL?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: openUpload) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: upload.kind == "document" ? "doc.text" : "paperclip")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(upload.originalName)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(timeAgo(upload.createdAt))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openUpload() {
|
||||||
|
guard let baseURL,
|
||||||
|
let url = URL(string: upload.url, relativeTo: baseURL)?.absoluteURL else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeAgo(_ date: Date) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SearchResultRow: View {
|
||||||
|
let result: SearchResult
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(isSelected ? .white : .accentColor)
|
||||||
|
.frame(width: 18)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(result.title.isEmpty ? result.path : result.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(isSelected ? .white : .primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(result.path)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(isSelected ? .white.opacity(0.82) : .secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Image(systemName: "return")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(isSelected ? .white.opacity(0.75) : .secondary)
|
||||||
|
.opacity(isSelected ? 1 : 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(isSelected ? Color.accentColor : Color.clear)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Action Button
|
||||||
|
|
||||||
|
struct ActionButton: View {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
let color: Color
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundColor(color)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(color.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
// Preview won't work without a real controller
|
||||||
|
Text("StatusPopoverView Preview")
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
382
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
382
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import CairnquireSync
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CairnquireSyncApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
Settings {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
var statusItem: NSStatusItem!
|
||||||
|
var controller: MenuBarController!
|
||||||
|
var popover: NSPopover!
|
||||||
|
var contextMenu: NSMenu!
|
||||||
|
var dropButton: DropStatusButton!
|
||||||
|
private var clipboardHUDPanel: NSPanel?
|
||||||
|
private var clipboardHUDDismissWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
controller = MenuBarController()
|
||||||
|
|
||||||
|
setupStatusItem()
|
||||||
|
setupPopover()
|
||||||
|
setupContextMenu()
|
||||||
|
|
||||||
|
// Observe config changes to update icon
|
||||||
|
controller.$config
|
||||||
|
.sink { [weak self] config in
|
||||||
|
self?.updateIcon(config: config)
|
||||||
|
}
|
||||||
|
.store(in: &controller.cancellables)
|
||||||
|
|
||||||
|
// Observe upload progress to animate icon
|
||||||
|
controller.$uploadProgress
|
||||||
|
.sink { [weak self] progress in
|
||||||
|
self?.updateIconForUpload(progress: progress)
|
||||||
|
}
|
||||||
|
.store(in: &controller.cancellables)
|
||||||
|
|
||||||
|
controller.$clipboardNotice
|
||||||
|
.compactMap { $0 }
|
||||||
|
.sink { [weak self] notice in
|
||||||
|
self?.showClipboardHUD(notice)
|
||||||
|
}
|
||||||
|
.store(in: &controller.cancellables)
|
||||||
|
|
||||||
|
// Hide dock icon
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupStatusItem() {
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
|
|
||||||
|
// Create custom drop-aware button
|
||||||
|
let button = DropStatusButton()
|
||||||
|
button.imagePosition = .imageOnly
|
||||||
|
button.target = self
|
||||||
|
button.action = #selector(statusItemButtonClicked(_:))
|
||||||
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||||
|
button.appDelegate = self
|
||||||
|
|
||||||
|
// Replace the default button with our custom one
|
||||||
|
if let oldButton = statusItem.button {
|
||||||
|
oldButton.superview?.addSubview(button)
|
||||||
|
button.frame = oldButton.frame
|
||||||
|
button.autoresizingMask = [.width, .height]
|
||||||
|
oldButton.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dropButton = button
|
||||||
|
updateIcon(config: controller.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIcon(config: SyncConfig) {
|
||||||
|
guard controller.uploadProgress == nil else { return }
|
||||||
|
let iconName = config.iconName
|
||||||
|
dropButton.image = makeMenuBarIcon(named: iconName, accessibilityDescription: "Cairnquire")
|
||||||
|
dropButton.setDropTargetActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateIconForUpload(progress: Double?) {
|
||||||
|
if progress != nil {
|
||||||
|
// During upload: show tray.circle (outline)
|
||||||
|
dropButton.image = makeMenuBarIcon(named: "tray.circle", accessibilityDescription: "Uploading")
|
||||||
|
dropButton.setDropTargetActive(false)
|
||||||
|
} else {
|
||||||
|
// Upload complete: restore normal icon
|
||||||
|
updateIcon(config: controller.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeMenuBarIcon(named iconName: String, accessibilityDescription: String) -> NSImage? {
|
||||||
|
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: accessibilityDescription)
|
||||||
|
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: accessibilityDescription)
|
||||||
|
image?.isTemplate = true
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
func bounceIcon() {
|
||||||
|
guard let button = dropButton else { return }
|
||||||
|
|
||||||
|
let originalOrigin = button.frame.origin
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.12
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
button.animator().setFrameOrigin(NSPoint(
|
||||||
|
x: originalOrigin.x,
|
||||||
|
y: originalOrigin.y - 6
|
||||||
|
))
|
||||||
|
} completionHandler: {
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.15
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
button.animator().setFrameOrigin(originalOrigin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showClipboardHUD(_ notice: ClipboardNotice) {
|
||||||
|
clipboardHUDDismissWorkItem?.cancel()
|
||||||
|
|
||||||
|
let panel = clipboardHUDPanel ?? makeClipboardHUDPanel()
|
||||||
|
panel.contentView = NSHostingView(rootView: ClipboardHUDView(filename: notice.filename))
|
||||||
|
positionClipboardHUD(panel)
|
||||||
|
panel.alphaValue = 0
|
||||||
|
panel.orderFrontRegardless()
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.15
|
||||||
|
panel.animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let dismissWorkItem = DispatchWorkItem { [weak self, weak panel] in
|
||||||
|
guard let self, let panel else { return }
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.2
|
||||||
|
panel.animator().alphaValue = 0
|
||||||
|
} completionHandler: {
|
||||||
|
panel.orderOut(nil)
|
||||||
|
}
|
||||||
|
self.clipboardHUDDismissWorkItem = nil
|
||||||
|
}
|
||||||
|
clipboardHUDDismissWorkItem = dismissWorkItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: dismissWorkItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeClipboardHUDPanel() -> NSPanel {
|
||||||
|
let panel = NSPanel(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 280, height: 72),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
panel.level = .floating
|
||||||
|
panel.isOpaque = false
|
||||||
|
panel.backgroundColor = .clear
|
||||||
|
panel.hasShadow = true
|
||||||
|
panel.hidesOnDeactivate = false
|
||||||
|
panel.collectionBehavior = [.canJoinAllSpaces, .transient, .ignoresCycle]
|
||||||
|
clipboardHUDPanel = panel
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
private func positionClipboardHUD(_ panel: NSPanel) {
|
||||||
|
guard let buttonWindow = dropButton.window else { return }
|
||||||
|
let buttonFrame = buttonWindow.convertToScreen(dropButton.convert(dropButton.bounds, to: nil))
|
||||||
|
let panelSize = panel.frame.size
|
||||||
|
let visibleFrame = buttonWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero
|
||||||
|
let centeredX = buttonFrame.midX - panelSize.width / 2
|
||||||
|
let origin = NSPoint(
|
||||||
|
x: min(max(centeredX, visibleFrame.minX + 8), visibleFrame.maxX - panelSize.width - 8),
|
||||||
|
y: buttonFrame.minY - panelSize.height - 8
|
||||||
|
)
|
||||||
|
panel.setFrameOrigin(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPopover() {
|
||||||
|
popover = NSPopover()
|
||||||
|
popover.behavior = .transient
|
||||||
|
popover.contentViewController = NSHostingController(
|
||||||
|
rootView: StatusPopoverView(controller: controller)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupContextMenu() {
|
||||||
|
contextMenu = NSMenu()
|
||||||
|
|
||||||
|
contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s"))
|
||||||
|
contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u"))
|
||||||
|
contextMenu.addItem(NSMenuItem(title: "New Note", action: #selector(createNote), keyEquivalent: "n"))
|
||||||
|
contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d"))
|
||||||
|
contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v"))
|
||||||
|
|
||||||
|
contextMenu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
contextMenu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
|
||||||
|
contextMenu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func statusItemButtonClicked(_ sender: AnyObject?) {
|
||||||
|
guard let event = NSApp.currentEvent else { return }
|
||||||
|
|
||||||
|
switch event.type {
|
||||||
|
case .rightMouseUp:
|
||||||
|
statusItem.menu = contextMenu
|
||||||
|
statusItem.button?.performClick(nil)
|
||||||
|
statusItem.menu = nil
|
||||||
|
default:
|
||||||
|
togglePopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func togglePopover() {
|
||||||
|
if popover.isShown {
|
||||||
|
closePopover()
|
||||||
|
} else {
|
||||||
|
showPopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPopover() {
|
||||||
|
guard let button = dropButton else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
controller.clearSearch()
|
||||||
|
await controller.refreshRecentUploads()
|
||||||
|
}
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
|
|
||||||
|
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
||||||
|
guard let self = self, self.popover.isShown else { return }
|
||||||
|
self.closePopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closePopover() {
|
||||||
|
popover.performClose(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc private func syncNow() {
|
||||||
|
Task { @MainActor in
|
||||||
|
await controller.performSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func uploadFile() {
|
||||||
|
Task { @MainActor in
|
||||||
|
controller.quickUpload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func createNote() {
|
||||||
|
Task { @MainActor in
|
||||||
|
controller.createNote()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showDropZone() {
|
||||||
|
Task { @MainActor in
|
||||||
|
controller.showDropZone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showUploads() {
|
||||||
|
Task { @MainActor in
|
||||||
|
controller.showUploads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showSettings() {
|
||||||
|
Task { @MainActor in
|
||||||
|
controller.showSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func quit() {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ClipboardHUDView: View {
|
||||||
|
let filename: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 26))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("URL Copied to Clipboard")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(filename)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(width: 280, height: 72)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Drop Status Button
|
||||||
|
|
||||||
|
class DropStatusButton: NSButton {
|
||||||
|
weak var appDelegate: AppDelegate?
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setup() {
|
||||||
|
isBordered = false
|
||||||
|
bezelStyle = .regularSquare
|
||||||
|
imageScaling = .scaleProportionallyDown
|
||||||
|
registerForDraggedTypes([.fileURL])
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDropTargetActive(_ isActive: Bool) {
|
||||||
|
isBordered = isActive
|
||||||
|
contentTintColor = isActive ? .systemBlue : nil
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||||
|
let pasteboard = sender.draggingPasteboard
|
||||||
|
guard pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) else {
|
||||||
|
setDropTargetActive(false)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the button frame only while the menu bar icon is a valid drop target.
|
||||||
|
setDropTargetActive(true)
|
||||||
|
return .copy
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||||
|
setDropTargetActive(false)
|
||||||
|
if let appDelegate = appDelegate {
|
||||||
|
appDelegate.updateIcon(config: appDelegate.controller.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||||
|
let pasteboard = sender.draggingPasteboard
|
||||||
|
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
|
||||||
|
!urls.isEmpty else {
|
||||||
|
setDropTargetActive(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setDropTargetActive(false)
|
||||||
|
appDelegate?.bounceIcon()
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
appDelegate?.controller.uploadFiles(urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
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."
|
||||||
BIN
apps/server/cairnquire
Executable file
BIN
apps/server/cairnquire
Executable file
Binary file not shown.
@@ -3,16 +3,25 @@ module github.com/tim/cairnquire/apps/server
|
|||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
github.com/go-webauthn/webauthn v0.15.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04
|
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04
|
||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.2
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.1.26 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.6 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +1,54 @@
|
|||||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||||
|
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||||
|
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||||
|
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||||
|
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
||||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc=
|
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc=
|
||||||
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"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/config"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
"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/httpserver"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||||
@@ -20,12 +24,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
db database.DB
|
db database.DB
|
||||||
docs *docs.Service
|
docs *docs.Service
|
||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
server *http.Server
|
server *http.Server
|
||||||
|
emailQueue *email.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
|
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
|
||||||
@@ -51,26 +56,66 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
|||||||
repo := docs.NewRepository(db.SQL())
|
repo := docs.NewRepository(db.SQL())
|
||||||
service := docs.NewService(cfg.Content.SourceDir, contentStore, renderer, repo, logger)
|
service := docs.NewService(cfg.Content.SourceDir, contentStore, renderer, repo, logger)
|
||||||
hub := realtime.NewHub(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 {
|
if _, err := service.SyncSourceDir(ctx); err != nil {
|
||||||
logger.Warn("initial content sync failed", "error", err)
|
logger.Warn("initial content sync failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
syncRepo := sync.NewRepository(db.SQL())
|
syncRepo := sync.NewRepository(db.SQL())
|
||||||
syncService := sync.NewService(syncRepo, service, contentStore, cfg.Content.SourceDir, logger)
|
syncService := sync.NewService(syncRepo, service, contentStore, cfg.Content.SourceDir, logger)
|
||||||
|
authRepo := auth.NewRepository(db.SQL())
|
||||||
|
authService, err := auth.NewService(authRepo, cfg.Auth.PublicOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build auth service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collabRepo := collaboration.NewRepository(db.SQL())
|
||||||
|
var emailSender collaboration.EmailSender
|
||||||
|
var emailQueue *email.Queue
|
||||||
|
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
|
||||||
|
switch cfg.Email.ResolvedProvider() {
|
||||||
|
case "postmark":
|
||||||
|
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
|
||||||
|
emailSender = pm
|
||||||
|
authService.SetEmailSender(pm)
|
||||||
|
case "smtp":
|
||||||
|
sm := email.NewSMTPSender(cfg.Email, logger)
|
||||||
|
emailSender = sm
|
||||||
|
authService.SetEmailSender(sm)
|
||||||
|
default:
|
||||||
|
noOp := email.NewNoOpSender(logger)
|
||||||
|
emailSender = noOp
|
||||||
|
authService.SetEmailSender(noOp)
|
||||||
|
}
|
||||||
|
// The durable queue wraps whichever sender is configured so retries
|
||||||
|
// survive process restarts. It is nil-safe in the service: if absent,
|
||||||
|
// notifications stay in-app only.
|
||||||
|
emailQueue = email.NewQueue(db.SQL(), emailSender, logger)
|
||||||
|
collabService := collaboration.NewServiceWithOptions(collabRepo, hub, logger, collaboration.Options{
|
||||||
|
EmailSender: emailSender,
|
||||||
|
EmailQueue: emailQueue,
|
||||||
|
EmailRenderer: emailRenderer,
|
||||||
|
UserLookup: authRepo,
|
||||||
|
PublicOrigin: cfg.Auth.PublicOrigin,
|
||||||
|
})
|
||||||
|
|
||||||
|
service.OnChange(func(change docs.DocumentChange) {
|
||||||
|
hub.Broadcast(realtime.Event{Type: "document_version", Data: change})
|
||||||
|
if err := collabService.NotifyDocumentChanged(ctx, formatDocumentID(change.Path), change.Path, "system"); err != nil {
|
||||||
|
logger.Warn("notify document changed", "error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
handler, err := httpserver.New(httpserver.Dependencies{
|
handler, err := httpserver.New(httpserver.Dependencies{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Documents: service,
|
Documents: service,
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
ContentStore: contentStore,
|
ContentStore: contentStore,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
SyncService: syncService,
|
SyncService: syncService,
|
||||||
SyncRepo: syncRepo,
|
SyncRepo: syncRepo,
|
||||||
|
Auth: authService,
|
||||||
|
Collaboration: collabService,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build http handler: %w", err)
|
return nil, fmt.Errorf("build http handler: %w", err)
|
||||||
@@ -86,18 +131,22 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
db: db,
|
db: db,
|
||||||
docs: service,
|
docs: service,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
server: server,
|
server: server,
|
||||||
|
emailQueue: emailQueue,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Run(ctx context.Context) error {
|
func (a *App) Run(ctx context.Context) error {
|
||||||
go a.watchDocuments(ctx)
|
go a.watchDocuments(ctx)
|
||||||
go a.syncPoll(ctx)
|
go a.syncPoll(ctx)
|
||||||
|
if a.emailQueue != nil {
|
||||||
|
go a.emailQueue.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
@@ -151,3 +200,9 @@ func (a *App) syncPoll(ctx context.Context) {
|
|||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
return a.db.Close()
|
return a.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatDocumentID(path string) string {
|
||||||
|
clean := strings.TrimPrefix(path, "/")
|
||||||
|
clean = strings.TrimSuffix(clean, ".md")
|
||||||
|
return "doc:" + clean
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fileWatcherDebounce = 500 * time.Millisecond
|
||||||
|
|
||||||
type FileWatcher struct {
|
type FileWatcher struct {
|
||||||
watcher *fsnotify.Watcher
|
watcher *fsnotify.Watcher
|
||||||
rootDir string
|
rootDir string
|
||||||
onChange func(path string)
|
onChange func(path string)
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
debounce time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
||||||
@@ -25,9 +29,10 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fw := &FileWatcher{
|
fw := &FileWatcher{
|
||||||
watcher: watcher,
|
watcher: watcher,
|
||||||
rootDir: rootDir,
|
rootDir: rootDir,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
debounce: fileWatcherDebounce,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fw.addWatches(); err != nil {
|
if err := fw.addWatches(); err != nil {
|
||||||
@@ -39,19 +44,150 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (fw *FileWatcher) addWatches() error {
|
func (fw *FileWatcher) addWatches() error {
|
||||||
return filepath.Walk(fw.rootDir, func(path string, info os.FileInfo, err error) error {
|
return filepath.WalkDir(fw.rootDir, func(path string, entry os.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
if !entry.IsDir() {
|
||||||
if err := fw.watcher.Add(path); err != nil {
|
return nil
|
||||||
fw.logger.Warn("watch directory", "path", path, "error", err)
|
}
|
||||||
}
|
if path != fw.rootDir && fw.shouldIgnorePath(path) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
if err := fw.watcher.Add(path); err != nil {
|
||||||
|
fw.logger.Warn("watch directory", "path", path, "error", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) addWatchIfDirectory(path string) {
|
||||||
|
if fw.shouldIgnorePath(path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := fw.watcher.Add(path); err != nil {
|
||||||
|
fw.logger.Warn("watch new directory", "path", path, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) flushPending(pending map[string]struct{}) {
|
||||||
|
if len(pending) == 0 || fw.onChange == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var changedPath string
|
||||||
|
for path := range pending {
|
||||||
|
changedPath = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
clear(pending)
|
||||||
|
fw.onChange(changedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) scheduleSync(path string, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
|
||||||
|
pending[path] = struct{}{}
|
||||||
|
|
||||||
|
if *timer == nil {
|
||||||
|
*timer = time.NewTimer(fw.debounce)
|
||||||
|
*timerC = (*timer).C
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(*timer).Stop() {
|
||||||
|
select {
|
||||||
|
case <-(*timer).C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(*timer).Reset(fw.debounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) stopTimer(timer *time.Timer) {
|
||||||
|
if timer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) isMarkdownPath(path string) bool {
|
||||||
|
return strings.EqualFold(filepath.Ext(path), ".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) shouldIgnorePath(path string) bool {
|
||||||
|
rel, err := filepath.Rel(fw.rootDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(part, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if isTempFileName(part) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTempFileName(name string) bool {
|
||||||
|
if strings.HasSuffix(name, "~") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(filepath.Ext(name)) {
|
||||||
|
case ".swp", ".swo", ".tmp", ".temp", ".bak", ".orig", ".part", ".crdownload":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case ".DS_Store", "Thumbs.db":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) isProcessableEvent(event fsnotify.Event) bool {
|
||||||
|
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fw.shouldIgnorePath(event.Name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return fw.isMarkdownPath(event.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) handleEvent(event fsnotify.Event, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
|
||||||
|
if event.Op&(fsnotify.Create|fsnotify.Rename) != 0 {
|
||||||
|
fw.addWatchIfDirectory(event.Name)
|
||||||
|
}
|
||||||
|
if fw.isProcessableEvent(event) {
|
||||||
|
fw.scheduleSync(event.Name, pending, timer, timerC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
||||||
fw.onChange = fn
|
fw.onChange = fn
|
||||||
}
|
}
|
||||||
@@ -59,24 +195,24 @@ func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
|||||||
func (fw *FileWatcher) Run(ctx context.Context) {
|
func (fw *FileWatcher) Run(ctx context.Context) {
|
||||||
defer fw.watcher.Close()
|
defer fw.watcher.Close()
|
||||||
|
|
||||||
|
pending := make(map[string]struct{})
|
||||||
|
var timer *time.Timer
|
||||||
|
var timerC <-chan time.Time
|
||||||
|
defer fw.stopTimer(timer)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
case <-timerC:
|
||||||
|
fw.flushPending(pending)
|
||||||
|
timer = nil
|
||||||
|
timerC = nil
|
||||||
case event, ok := <-fw.watcher.Events:
|
case event, ok := <-fw.watcher.Events:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if fw.shouldProcess(event) {
|
fw.handleEvent(event, pending, &timer, &timerC)
|
||||||
if event.Op&fsnotify.Create == fsnotify.Create {
|
|
||||||
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
|
|
||||||
fw.watcher.Add(event.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fw.onChange != nil {
|
|
||||||
fw.onChange(event.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case err, ok := <-fw.watcher.Errors:
|
case err, ok := <-fw.watcher.Errors:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -85,17 +221,3 @@ func (fw *FileWatcher) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fw *FileWatcher) shouldProcess(event fsnotify.Event) bool {
|
|
||||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
name := filepath.Base(event.Name)
|
|
||||||
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, "~") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if filepath.Ext(event.Name) == ".md" || event.Op&fsnotify.Remove != 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
84
apps/server/internal/app/watcher_test.go
Normal file
84
apps/server/internal/app/watcher_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileWatcherFiltersProcessableEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
watcher := &FileWatcher{rootDir: root, logger: slog.Default()}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
op fsnotify.Op
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "markdown write", path: "guide.md", op: fsnotify.Write, want: true},
|
||||||
|
{name: "markdown create", path: "nested/guide.md", op: fsnotify.Create, want: true},
|
||||||
|
{name: "case insensitive markdown", path: "guide.MD", op: fsnotify.Write, want: true},
|
||||||
|
{name: "markdown delete", path: "guide.md", op: fsnotify.Remove, want: true},
|
||||||
|
{name: "non markdown write", path: "logo.webp", op: fsnotify.Write, want: false},
|
||||||
|
{name: "non markdown delete", path: "logo.webp", op: fsnotify.Remove, want: false},
|
||||||
|
{name: "hidden markdown file", path: ".guide.md", op: fsnotify.Write, want: false},
|
||||||
|
{name: "hidden directory", path: ".cache/guide.md", op: fsnotify.Write, want: false},
|
||||||
|
{name: "backup file", path: "guide.md~", op: fsnotify.Write, want: false},
|
||||||
|
{name: "vim swap", path: "guide.md.swp", op: fsnotify.Write, want: false},
|
||||||
|
{name: "emacs lock file", path: "#guide.md#", op: fsnotify.Write, want: false},
|
||||||
|
{name: "chmod only", path: "guide.md", op: fsnotify.Chmod, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
event := fsnotify.Event{Name: filepath.Join(root, tt.path), Op: tt.op}
|
||||||
|
if got := watcher.isProcessableEvent(event); got != tt.want {
|
||||||
|
t.Fatalf("isProcessableEvent() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileWatcherDebouncesPendingSyncs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
watcher := &FileWatcher{
|
||||||
|
rootDir: root,
|
||||||
|
logger: slog.Default(),
|
||||||
|
debounce: 10 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed []string
|
||||||
|
watcher.SetOnChange(func(path string) {
|
||||||
|
changed = append(changed, path)
|
||||||
|
})
|
||||||
|
|
||||||
|
pending := make(map[string]struct{})
|
||||||
|
var timer *time.Timer
|
||||||
|
var timerC <-chan time.Time
|
||||||
|
defer watcher.stopTimer(timer)
|
||||||
|
|
||||||
|
watcher.scheduleSync(filepath.Join(root, "one.md"), pending, &timer, &timerC)
|
||||||
|
watcher.scheduleSync(filepath.Join(root, "two.md"), pending, &timer, &timerC)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timerC:
|
||||||
|
watcher.flushPending(pending)
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
t.Fatal("debounce timer did not fire")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changed) != 1 {
|
||||||
|
t.Fatalf("onChange calls = %d, want 1", len(changed))
|
||||||
|
}
|
||||||
|
if len(pending) != 0 {
|
||||||
|
t.Fatalf("pending changes = %d, want 0", len(pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
175
apps/server/internal/auth/crypto.go
Normal file
175
apps/server/internal/auth/crypto.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
passwordTime uint32 = 3
|
||||||
|
passwordMemory uint32 = 64 * 1024
|
||||||
|
passwordThreads uint8 = 4
|
||||||
|
passwordKeyLen uint32 = 32
|
||||||
|
passwordSaltLen = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
func randomBytes(length int) ([]byte, error) {
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken(length int) (string, error) {
|
||||||
|
buf, err := randomBytes(length)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(length int) (string, error) {
|
||||||
|
buf, err := randomBytes(length)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecret(secret string) string {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func constantTimeEqualHex(a, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
salt, err := randomBytes(passwordSaltLen)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, passwordTime, passwordMemory, passwordThreads, passwordKeyLen)
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
passwordMemory,
|
||||||
|
passwordTime,
|
||||||
|
passwordThreads,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(hash),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyPassword(password, encoded string) (bool, error) {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return false, fmt.Errorf("invalid password hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
var memory uint64
|
||||||
|
var timeCost uint64
|
||||||
|
var threads uint64
|
||||||
|
for _, param := range strings.Split(parts[3], ",") {
|
||||||
|
keyValue := strings.SplitN(param, "=", 2)
|
||||||
|
if len(keyValue) != 2 {
|
||||||
|
return false, fmt.Errorf("invalid password hash parameters")
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(keyValue[1], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("parse password hash parameter: %w", err)
|
||||||
|
}
|
||||||
|
switch keyValue[0] {
|
||||||
|
case "m":
|
||||||
|
memory = value
|
||||||
|
case "t":
|
||||||
|
timeCost = value
|
||||||
|
case "p":
|
||||||
|
threads = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memory == 0 || timeCost == 0 || threads == 0 {
|
||||||
|
return false, fmt.Errorf("missing password hash parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode password salt: %w", err)
|
||||||
|
}
|
||||||
|
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode password hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := argon2.IDKey([]byte(password), salt, uint32(timeCost), uint32(memory), uint8(threads), uint32(len(expected)))
|
||||||
|
return subtle.ConstantTimeCompare(actual, expected) == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeScopes(scopes []Scope) []Scope {
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
return []Scope{ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite}
|
||||||
|
}
|
||||||
|
seen := make(map[Scope]struct{}, len(scopes))
|
||||||
|
var normalized []Scope
|
||||||
|
for _, scope := range scopes {
|
||||||
|
switch scope {
|
||||||
|
case ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite, ScopeAdmin:
|
||||||
|
if _, ok := seen[scope]; !ok {
|
||||||
|
seen[scope] = struct{}{}
|
||||||
|
normalized = append(normalized, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
return []Scope{ScopeDocsRead}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopesToString(scopes []Scope) string {
|
||||||
|
normalized := normalizeScopes(scopes)
|
||||||
|
parts := make([]string, 0, len(normalized))
|
||||||
|
for _, scope := range normalized {
|
||||||
|
parts = append(parts, string(scope))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopesFromString(raw string) []Scope {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
scopes := make([]Scope, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
if trimmed != "" {
|
||||||
|
scopes = append(scopes, Scope(trimmed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeScopes(scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasScope(scopes []Scope, required Scope) bool {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == ScopeAdmin || scope == required {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasScope(scopes []Scope, required Scope) bool {
|
||||||
|
return hasScope(scopes, required)
|
||||||
|
}
|
||||||
1093
apps/server/internal/auth/repository.go
Normal file
1093
apps/server/internal/auth/repository.go
Normal file
File diff suppressed because it is too large
Load Diff
884
apps/server/internal/auth/service.go
Normal file
884
apps/server/internal/auth/service.go
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionCookieName = "cairnquire_session"
|
||||||
|
sessionTTL = 24 * time.Hour
|
||||||
|
challengeTTL = 5 * time.Minute
|
||||||
|
deviceCodeTTL = 15 * time.Minute
|
||||||
|
passwordResetTTL = time.Hour
|
||||||
|
devicePollSeconds = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailSender interface {
|
||||||
|
Send(ctx context.Context, to []string, subject, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *Repository
|
||||||
|
webauthn *webauthn.WebAuthn
|
||||||
|
publicOrigin string
|
||||||
|
email EmailSender
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo *Repository, publicOrigin string) (*Service, error) {
|
||||||
|
if publicOrigin == "" {
|
||||||
|
publicOrigin = "http://localhost:8080"
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(publicOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse public origin: %w", err)
|
||||||
|
}
|
||||||
|
rpID := parsed.Hostname()
|
||||||
|
if rpID == "" {
|
||||||
|
return nil, fmt.Errorf("public origin must include host")
|
||||||
|
}
|
||||||
|
web, err := webauthn.New(&webauthn.Config{
|
||||||
|
RPID: rpID,
|
||||||
|
RPDisplayName: "Cairnquire",
|
||||||
|
RPOrigins: []string{publicOrigin},
|
||||||
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
||||||
|
UserVerification: protocol.VerificationPreferred,
|
||||||
|
},
|
||||||
|
AttestationPreference: protocol.PreferNoAttestation,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetEmailSender(sender EmailSender) {
|
||||||
|
s.email = sender
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
|
||||||
|
return s.repo.GetInstanceSettings(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CompleteInitialSetup(ctx context.Context, email, displayName, password string, signupsEnabled bool) (User, error) {
|
||||||
|
email = normalizeEmail(email)
|
||||||
|
if email == "" {
|
||||||
|
return User{}, fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
if len(password) < 12 {
|
||||||
|
return User{}, fmt.Errorf("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
passwordHash, err := hashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return s.repo.CompleteInitialSetup(ctx, User{
|
||||||
|
Email: email,
|
||||||
|
DisplayName: strings.TrimSpace(displayName),
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Role: RoleAdmin,
|
||||||
|
}, signupsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (User, error) {
|
||||||
|
if err := s.requirePublicSignups(ctx); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
email = normalizeEmail(email)
|
||||||
|
if email == "" {
|
||||||
|
return User{}, fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
if len(password) < 12 {
|
||||||
|
return User{}, fmt.Errorf("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
if _, err := s.repo.GetUserByEmail(ctx, email); err == nil {
|
||||||
|
return User{}, fmt.Errorf("user already exists")
|
||||||
|
} else if err != nil && !isNoRows(err) {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
passwordHash, err := hashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return s.repo.UpsertUser(ctx, User{
|
||||||
|
Email: email,
|
||||||
|
DisplayName: strings.TrimSpace(displayName),
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Role: RoleViewer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAgent string) (Principal, string, error) {
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||||
|
if err != nil {
|
||||||
|
if isNoRows(err) {
|
||||||
|
_, _ = verifyPassword(password, mustDummyPasswordHash())
|
||||||
|
}
|
||||||
|
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
if user.PasswordHash == "" {
|
||||||
|
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
ok, err := verifyPassword(password, user.PasswordHash)
|
||||||
|
if err != nil || !ok || user.Disabled {
|
||||||
|
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
principal, token, err := s.createSession(ctx, user, ip, userAgent)
|
||||||
|
if err == nil {
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.password.login", "user", user.ID, ip, userAgent, nil)
|
||||||
|
}
|
||||||
|
return principal, token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BeginPasskeyRegistration(ctx context.Context, email, displayName, role string) (any, string, error) {
|
||||||
|
if err := s.requirePublicSignups(ctx); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||||
|
if err != nil {
|
||||||
|
if !isNoRows(err) {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
user, err = s.repo.UpsertUser(ctx, User{
|
||||||
|
Email: normalizeEmail(email),
|
||||||
|
DisplayName: strings.TrimSpace(displayName),
|
||||||
|
Role: RoleViewer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, "", fmt.Errorf("user already exists")
|
||||||
|
}
|
||||||
|
creation, session, err := s.webauthn.BeginRegistration(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return creation, challengeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID string, r *http.Request) (User, error) {
|
||||||
|
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
credential, err := s.webauthn.FinishRegistration(user, session, r)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.passkey.register", "user", user.ID, clientIP(r), r.UserAgent(), nil)
|
||||||
|
return s.repo.GetUserByID(ctx, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BeginCurrentUserPasskeyRegistration(ctx context.Context, principal Principal) (any, string, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return nil, "", fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return nil, "", fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
creation, session, err := s.webauthn.BeginRegistration(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return creation, challengeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FinishCurrentUserPasskeyRegistration(ctx context.Context, principal Principal, challengeID string, r *http.Request) (User, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return User{}, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if userID != principal.UserID {
|
||||||
|
return User{}, fmt.Errorf("challenge does not belong to the current user")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return User{}, fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
credential, err := s.webauthn.FinishRegistration(user, session, r)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.passkey.add", "user", user.ID, clientIP(r), r.UserAgent(), nil)
|
||||||
|
return s.repo.GetUserByID(ctx, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return nil, "", fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
assertion, session, err := s.webauthn.BeginLogin(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_login", *session, challengeTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return assertion, challengeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r *http.Request) (Principal, string, error) {
|
||||||
|
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_login")
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
credential, err := s.webauthn.FinishLogin(user, session, r)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
principal, token, err := s.createSession(ctx, user, clientIP(r), r.UserAgent())
|
||||||
|
if err == nil {
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.passkey.login", "user", user.ID, clientIP(r), r.UserAgent(), nil)
|
||||||
|
}
|
||||||
|
return principal, token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Principal, error) {
|
||||||
|
session, user, err := s.repo.ValidateSession(ctx, hashSecret(token))
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return Principal{}, fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RevokeSession(ctx context.Context, sessionID string) error {
|
||||||
|
return s.repo.RevokeSession(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ChangePassword(ctx context.Context, principal Principal, currentPassword, newPassword string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
if len(newPassword) < 12 {
|
||||||
|
return fmt.Errorf("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user.PasswordHash != "" {
|
||||||
|
ok, err := verifyPassword(currentPassword, user.PasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passwordHash, err := hashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.UpdatePasswordHash(ctx, user.ID, passwordHash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.password.change", "user", user.ID, "", "", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteAccount(ctx context.Context, principal Principal, currentPassword string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
admins, err := s.repo.CountAdmins(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if admins <= 1 {
|
||||||
|
return fmt.Errorf("cannot delete the last admin account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.PasswordHash != "" {
|
||||||
|
ok, err := verifyPassword(currentPassword, user.PasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.account.delete", "user", user.ID, "", "", nil)
|
||||||
|
return s.repo.DeleteUser(ctx, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
|
||||||
|
return s.repo.ListUsers(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateSignupsEnabled(ctx context.Context, actor Principal, enabled bool) (InstanceSettings, error) {
|
||||||
|
if !Allows(actor, ScopeAdmin) {
|
||||||
|
return InstanceSettings{}, fmt.Errorf("admin role required")
|
||||||
|
}
|
||||||
|
if err := s.repo.UpdateSignupsEnabled(ctx, enabled); err != nil {
|
||||||
|
return InstanceSettings{}, err
|
||||||
|
}
|
||||||
|
return s.repo.GetInstanceSettings(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureDevUser(ctx context.Context) (User, error) {
|
||||||
|
const email = "dev@localhost"
|
||||||
|
settings, err := s.repo.GetInstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if !settings.SetupComplete {
|
||||||
|
return s.CompleteInitialSetup(ctx, email, "Development Admin", "development-only-password", true)
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||||
|
if err == nil {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
if !isNoRows(err) {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return s.repo.UpsertUser(ctx, User{Email: email, DisplayName: "Development Admin", Role: RoleAdmin})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principal, error) {
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return Principal{}, fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{
|
||||||
|
ID: userID,
|
||||||
|
Role: Role(role),
|
||||||
|
Disabled: user.Disabled,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return users[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates []UserAccessUpdate) ([]User, error) {
|
||||||
|
if !Allows(actor, ScopeAdmin) {
|
||||||
|
return nil, fmt.Errorf("admin role required")
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one user update is required")
|
||||||
|
}
|
||||||
|
normalized := make([]UserAccessUpdate, 0, len(updates))
|
||||||
|
seen := make(map[string]struct{}, len(updates))
|
||||||
|
for _, update := range updates {
|
||||||
|
update.ID = strings.TrimSpace(update.ID)
|
||||||
|
if update.ID == "" {
|
||||||
|
return nil, fmt.Errorf("user id is required")
|
||||||
|
}
|
||||||
|
if _, ok := seen[update.ID]; ok {
|
||||||
|
return nil, fmt.Errorf("duplicate user update")
|
||||||
|
}
|
||||||
|
seen[update.ID] = struct{}{}
|
||||||
|
update.Role = Role(strings.ToLower(strings.TrimSpace(string(update.Role))))
|
||||||
|
switch update.Role {
|
||||||
|
case RoleViewer, RoleEditor, RoleAdmin:
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid role")
|
||||||
|
}
|
||||||
|
normalized = append(normalized, update)
|
||||||
|
}
|
||||||
|
if err := s.repo.UpdateUserAccess(ctx, normalized); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users := make([]User, 0, len(normalized))
|
||||||
|
for _, update := range normalized {
|
||||||
|
user, err := s.repo.GetUserByID(ctx, update.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, actor.UserID, "auth.user.access.update", "user", user.ID, "", "", map[string]any{
|
||||||
|
"role": user.Role,
|
||||||
|
"disabled": user.Disabled,
|
||||||
|
})
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) {
|
||||||
|
if actor.UserID == "" {
|
||||||
|
return nil, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
if actor.Role != RoleAdmin {
|
||||||
|
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !PermissionAllows(permission, PermissionAdmin) {
|
||||||
|
return nil, fmt.Errorf("admin permission required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.repo.ListResourcePermissions(ctx, resourceType, resourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, updates []ResourcePermissionUpdate) ([]ResourcePermission, error) {
|
||||||
|
if actor.UserID == "" {
|
||||||
|
return nil, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
if actor.Role != RoleAdmin {
|
||||||
|
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !PermissionAllows(permission, PermissionAdmin) {
|
||||||
|
return nil, fmt.Errorf("admin permission required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized := make([]ResourcePermissionUpdate, 0, len(updates))
|
||||||
|
seen := make(map[string]struct{}, len(updates))
|
||||||
|
for _, update := range updates {
|
||||||
|
update.UserID = strings.TrimSpace(update.UserID)
|
||||||
|
if update.UserID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[update.UserID]; ok {
|
||||||
|
return nil, fmt.Errorf("duplicate user permission")
|
||||||
|
}
|
||||||
|
seen[update.UserID] = struct{}{}
|
||||||
|
update.Permission = normalizePermission(update.Permission)
|
||||||
|
if !validPermission(update.Permission) {
|
||||||
|
return nil, fmt.Errorf("invalid permission")
|
||||||
|
}
|
||||||
|
normalized = append(normalized, update)
|
||||||
|
}
|
||||||
|
if err := s.repo.ReplaceResourcePermissions(ctx, resourceType, resourceID, actor.UserID, normalized); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.update", string(resourceType), resourceID, "", "", map[string]any{
|
||||||
|
"updates": len(normalized),
|
||||||
|
})
|
||||||
|
return s.repo.ListResourcePermissions(ctx, resourceType, resourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureResourcePermission(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, update ResourcePermissionUpdate) error {
|
||||||
|
if actor.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
update.UserID = strings.TrimSpace(update.UserID)
|
||||||
|
if update.UserID == "" {
|
||||||
|
return fmt.Errorf("user id is required")
|
||||||
|
}
|
||||||
|
update.Permission = normalizePermission(update.Permission)
|
||||||
|
if !validPermission(update.Permission) || update.Permission == PermissionNone {
|
||||||
|
return fmt.Errorf("invalid permission")
|
||||||
|
}
|
||||||
|
if update.UserID != actor.UserID && actor.Role != RoleAdmin {
|
||||||
|
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !PermissionAllows(permission, PermissionAdmin) {
|
||||||
|
return fmt.Errorf("admin permission required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.repo.EnsureResourcePermission(ctx, resourceType, resourceID, actor.UserID, update); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.ensure", string(resourceType), resourceID, "", "", map[string]any{
|
||||||
|
"userID": update.UserID,
|
||||||
|
"permission": update.Permission,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) {
|
||||||
|
return s.repo.EffectiveResourcePermission(ctx, principal, resourceType, resourceID, collectionIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
|
||||||
|
if !Allows(actor, ScopeAdmin) {
|
||||||
|
return fmt.Errorf("admin role required")
|
||||||
|
}
|
||||||
|
if s.email == nil {
|
||||||
|
return fmt.Errorf("email sender is not configured")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
secret, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := randomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := s.repo.CreatePasswordResetToken(ctx, PasswordResetToken{
|
||||||
|
ID: "reset:" + id,
|
||||||
|
UserID: user.ID,
|
||||||
|
TokenHash: hashSecret(secret),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(passwordResetTTL),
|
||||||
|
InitiatedBy: actor.UserID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link := s.publicOrigin + "/password-reset?token=" + url.QueryEscape(secret)
|
||||||
|
body := fmt.Sprintf("A Cairnquire administrator requested a password reset for your account.\n\nSet a new password within one hour:\n%s\n\nIf you did not expect this message, contact your administrator.", link)
|
||||||
|
if err := s.email.Send(ctx, []string{user.Email}, "Reset your Cairnquire password", body); err != nil {
|
||||||
|
s.repo.ExpirePasswordResetToken(ctx, hashSecret(secret))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, actor.UserID, "auth.password.reset.request", "user", user.ID, "", "", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
|
||||||
|
if len(newPassword) < 12 {
|
||||||
|
return fmt.Errorf("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(token) == "" {
|
||||||
|
return fmt.Errorf("password reset token is required")
|
||||||
|
}
|
||||||
|
passwordHash, err := hashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userID, err := s.repo.CompletePasswordReset(ctx, hashSecret(token), passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, userID, "auth.password.reset.complete", "user", userID, "", "", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) {
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
name = "API token"
|
||||||
|
}
|
||||||
|
tokenID, err := randomHex(12)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
secret, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
keyID := "api:" + tokenID
|
||||||
|
token := "cq_pat_" + tokenID + "_" + secret
|
||||||
|
record := APIKey{
|
||||||
|
ID: keyID,
|
||||||
|
UserID: user.ID,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
KeyHash: hashSecret(secret),
|
||||||
|
Scopes: normalizeScopes(scopes),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
if err := s.repo.CreateAPIKey(ctx, record); err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
return CreatedAPIKey{Record: record, Token: token}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Principal, error) {
|
||||||
|
keyID, secret, err := parseAPIToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
key, user, err := s.repo.ValidateAPIKey(ctx, keyID, hashSecret(secret))
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return Principal{}, fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
expiresAt := time.Time{}
|
||||||
|
if key.ExpiresAt != nil {
|
||||||
|
expiresAt = *key.ExpiresAt
|
||||||
|
}
|
||||||
|
return principalFromUser(user, "api_token", "", key.ID, key.Scopes, expiresAt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
|
||||||
|
return s.repo.ListAPIKeys(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
|
||||||
|
return s.repo.RevokeAPIKey(ctx, userID, keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) StartDeviceFlow(ctx context.Context, name string, scopes []Scope) (DeviceStart, error) {
|
||||||
|
deviceSecret, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return DeviceStart{}, err
|
||||||
|
}
|
||||||
|
rawUserCode, err := randomToken(5)
|
||||||
|
if err != nil {
|
||||||
|
return DeviceStart{}, err
|
||||||
|
}
|
||||||
|
userCode := formatUserCode(rawUserCode)
|
||||||
|
idPart, err := randomToken(12)
|
||||||
|
if err != nil {
|
||||||
|
return DeviceStart{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
device := DeviceCode{
|
||||||
|
ID: "dev:" + idPart,
|
||||||
|
DeviceCodeHash: hashSecret(deviceSecret),
|
||||||
|
UserCodeHash: hashSecret(normalizeUserCode(userCode)),
|
||||||
|
UserCodeDisplay: userCode,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
Scopes: normalizeScopes(scopes),
|
||||||
|
Status: "pending",
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(deviceCodeTTL),
|
||||||
|
}
|
||||||
|
if device.Name == "" {
|
||||||
|
device.Name = "Device"
|
||||||
|
}
|
||||||
|
if err := s.repo.CreateDeviceCode(ctx, device); err != nil {
|
||||||
|
return DeviceStart{}, err
|
||||||
|
}
|
||||||
|
verifyURI := strings.TrimRight(s.publicOrigin, "/") + "/device/verify"
|
||||||
|
return DeviceStart{
|
||||||
|
DeviceCode: deviceSecret,
|
||||||
|
UserCode: userCode,
|
||||||
|
VerificationURI: verifyURI,
|
||||||
|
VerificationURIComplete: verifyURI + "?user_code=" + url.QueryEscape(userCode),
|
||||||
|
ExpiresIn: int(deviceCodeTTL.Seconds()),
|
||||||
|
Interval: devicePollSeconds,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ApproveDeviceFlow(ctx context.Context, principal Principal, userCode string) (DeviceCode, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return DeviceCode{}, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.ApproveDeviceCode(ctx, hashSecret(normalizeUserCode(userCode)), principal.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (CreatedAPIKey, string, error) {
|
||||||
|
device, err := s.repo.GetDeviceCodeByDeviceCode(ctx, hashSecret(deviceCode))
|
||||||
|
if err != nil {
|
||||||
|
if errorsIsNoRows(err) {
|
||||||
|
return CreatedAPIKey{}, "invalid_device_code", err
|
||||||
|
}
|
||||||
|
return CreatedAPIKey{}, "", err
|
||||||
|
}
|
||||||
|
s.repo.TouchDevicePoll(ctx, device.ID)
|
||||||
|
switch device.Status {
|
||||||
|
case "pending":
|
||||||
|
return CreatedAPIKey{}, "authorization_pending", sql.ErrNoRows
|
||||||
|
case "denied", "expired":
|
||||||
|
return CreatedAPIKey{}, device.Status, fmt.Errorf("device flow %s", device.Status)
|
||||||
|
case "approved":
|
||||||
|
created, err := s.CreateAPIKey(ctx, device.UserID, device.Name, device.Scopes, nil)
|
||||||
|
if err == nil {
|
||||||
|
s.repo.FinishDeviceCode(ctx, device.ID)
|
||||||
|
}
|
||||||
|
return created, "", err
|
||||||
|
default:
|
||||||
|
return CreatedAPIKey{}, "invalid_state", fmt.Errorf("invalid device status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) {
|
||||||
|
if user.Disabled {
|
||||||
|
return Principal{}, "", fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
token, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
session, err := s.repo.CreateSession(ctx, user.ID, hashSecret(token), ip, userAgent, sessionTTL)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requirePublicSignups(ctx context.Context) error {
|
||||||
|
settings, err := s.repo.GetInstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !settings.SetupComplete {
|
||||||
|
return fmt.Errorf("initial setup is required")
|
||||||
|
}
|
||||||
|
if !settings.SignupsEnabled {
|
||||||
|
return fmt.Errorf("signups are disabled")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal {
|
||||||
|
return Principal{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Role: user.Role,
|
||||||
|
Scopes: scopes,
|
||||||
|
Method: method,
|
||||||
|
SessionID: sessionID,
|
||||||
|
APIKeyID: apiKeyID,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPIToken(token string) (string, string, error) {
|
||||||
|
parts := strings.SplitN(token, "_", 4)
|
||||||
|
if len(parts) != 4 || parts[0] != "cq" || parts[1] != "pat" {
|
||||||
|
return "", "", fmt.Errorf("invalid api token")
|
||||||
|
}
|
||||||
|
return "api:" + parts[2], parts[3], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEmail(email string) string {
|
||||||
|
return strings.TrimSpace(strings.ToLower(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUserCode(raw string) string {
|
||||||
|
clean := strings.ToUpper(strings.NewReplacer("-", "", "_", "").Replace(raw))
|
||||||
|
if len(clean) > 8 {
|
||||||
|
clean = clean[:8]
|
||||||
|
}
|
||||||
|
if len(clean) > 4 {
|
||||||
|
return clean[:4] + "-" + clean[4:]
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserCode(code string) string {
|
||||||
|
return strings.ToUpper(strings.NewReplacer("-", "", " ", "", "_", "").Replace(strings.TrimSpace(code)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorsIsNoRows(err error) bool {
|
||||||
|
return err == sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
var dummyPasswordHash string
|
||||||
|
|
||||||
|
func mustDummyPasswordHash() string {
|
||||||
|
if dummyPasswordHash == "" {
|
||||||
|
hash, err := hashPassword("not-the-password")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
dummyPasswordHash = hash
|
||||||
|
}
|
||||||
|
return dummyPasswordHash
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
parts := strings.Split(forwarded, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func RoleAllows(role Role, required Scope) bool {
|
||||||
|
switch required {
|
||||||
|
case ScopeDocsRead, ScopeSyncRead:
|
||||||
|
return role == RoleViewer || role == RoleEditor || role == RoleAdmin
|
||||||
|
case ScopeDocsWrite, ScopeSyncWrite:
|
||||||
|
return role == RoleEditor || role == RoleAdmin
|
||||||
|
case ScopeAdmin:
|
||||||
|
return role == RoleAdmin
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PermissionAllows(actual Permission, required Permission) bool {
|
||||||
|
return permissionRank(actual) >= permissionRank(required)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePermission(permission Permission) Permission {
|
||||||
|
switch Permission(strings.ToLower(strings.TrimSpace(string(permission)))) {
|
||||||
|
case "view":
|
||||||
|
return PermissionRead
|
||||||
|
case "edit":
|
||||||
|
return PermissionWrite
|
||||||
|
case PermissionRead, PermissionWrite, PermissionAdmin, PermissionNone:
|
||||||
|
return Permission(strings.ToLower(strings.TrimSpace(string(permission))))
|
||||||
|
default:
|
||||||
|
return Permission(strings.ToLower(strings.TrimSpace(string(permission))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Allows(principal Principal, required Scope) bool {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(principal.Scopes) > 0 && !hasScope(principal.Scopes, required) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return RoleAllows(principal.Role, required)
|
||||||
|
}
|
||||||
439
apps/server/internal/auth/service_test.go
Normal file
439
apps/server/internal/auth/service_test.go
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testEmailSender struct {
|
||||||
|
to []string
|
||||||
|
subject string
|
||||||
|
body string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
s.to = append([]string(nil), to...)
|
||||||
|
s.subject = subject
|
||||||
|
s.body = body
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAuthTestService(t *testing.T) *Service {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("libsql", "file:"+t.TempDir()+"/auth.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
service, err := NewService(NewRepository(db), "http://localhost:8080")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new auth service: %v", err)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupInitialAdmin(t *testing.T, service *Service, signupsEnabled bool) User {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
user, err := service.CompleteInitialSetup(context.Background(), "admin@example.com", "Admin", "correct horse battery staple", signupsEnabled)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteInitialSetup() error = %v", err)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordLoginCreatesValidSession(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user, err := service.CompleteInitialSetup(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteInitialSetup() error = %v", err)
|
||||||
|
}
|
||||||
|
if user.Email != "dev@example.com" {
|
||||||
|
t.Fatalf("email = %q, want normalized", user.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, token, err := service.LoginPassword(ctx, "dev@example.com", "correct horse battery staple", "127.0.0.1", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoginPassword() error = %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("expected session token")
|
||||||
|
}
|
||||||
|
if principal.Role != RoleAdmin {
|
||||||
|
t.Fatalf("role = %s, want initial setup user to be admin", principal.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
validated, err := service.ValidateSessionToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateSessionToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if validated.UserID != principal.UserID {
|
||||||
|
t.Fatalf("validated user = %q, want %q", validated.UserID, principal.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := setupInitialAdmin(t, service, true)
|
||||||
|
expires := time.Now().UTC().Add(time.Hour)
|
||||||
|
created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAPIKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(created.Token, "cq_pat_") {
|
||||||
|
t.Fatalf("token prefix = %q, want cq_pat_", created.Token)
|
||||||
|
}
|
||||||
|
if created.Record.KeyHash != "" && strings.Contains(created.Record.KeyHash, created.Token) {
|
||||||
|
t.Fatal("record contains raw token")
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, err := service.ValidateBearerToken(ctx, created.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateBearerToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if principal.APIKeyID != created.Record.ID {
|
||||||
|
t.Fatalf("api key id = %q, want %q", principal.APIKeyID, created.Record.ID)
|
||||||
|
}
|
||||||
|
if !Allows(principal, ScopeDocsRead) {
|
||||||
|
t.Fatal("expected docs:read to be allowed")
|
||||||
|
}
|
||||||
|
if Allows(principal, ScopeAdmin) {
|
||||||
|
t.Fatal("did not expect admin scope to be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := setupInitialAdmin(t, service, true)
|
||||||
|
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartDeviceFlow() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "authorization_pending" || err == nil {
|
||||||
|
t.Fatalf("PollDeviceFlow() before approval code=%q err=%v, want authorization_pending", code, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.ApproveDeviceFlow(ctx, principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)), start.UserCode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApproveDeviceFlow() error = %v", err)
|
||||||
|
}
|
||||||
|
created, code, err := service.PollDeviceFlow(ctx, start.DeviceCode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PollDeviceFlow() after approval code=%q err=%v", code, err)
|
||||||
|
}
|
||||||
|
if created.Token == "" {
|
||||||
|
t.Fatal("expected device flow to mint api token")
|
||||||
|
}
|
||||||
|
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "expired" || err == nil {
|
||||||
|
t.Fatalf("PollDeviceFlow() after token issuance code=%q err=%v, want expired", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := setupInitialAdmin(t, service, true)
|
||||||
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||||
|
if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("ChangePassword() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||||
|
t.Fatal("old password still works")
|
||||||
|
}
|
||||||
|
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
|
||||||
|
t.Fatalf("new password login error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := setupInitialAdmin(t, service, true)
|
||||||
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||||
|
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
|
||||||
|
t.Fatal("expected demoting last admin to fail")
|
||||||
|
}
|
||||||
|
if _, err := service.UpdateUserAccess(ctx, principal, []UserAccessUpdate{{ID: user.ID, Role: RoleAdmin, Disabled: true}}); err == nil {
|
||||||
|
t.Fatal("expected disabling last admin to fail")
|
||||||
|
}
|
||||||
|
if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil {
|
||||||
|
t.Fatal("expected deleting last admin to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledUserCannotAuthenticateWithPasswordSessionOrAPIKey(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := setupInitialAdmin(t, service, true)
|
||||||
|
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoginPassword() before disable error = %v", err)
|
||||||
|
}
|
||||||
|
apiKey, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAPIKey() error = %v", err)
|
||||||
|
}
|
||||||
|
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||||
|
if _, err := service.UpdateUserAccess(ctx, adminPrincipal, []UserAccessUpdate{{ID: user.ID, Role: RoleViewer, Disabled: true}}); err != nil {
|
||||||
|
t.Fatalf("UpdateUserAccess() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||||
|
t.Fatal("disabled user password login succeeded")
|
||||||
|
}
|
||||||
|
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
|
||||||
|
t.Fatal("disabled user session remained valid")
|
||||||
|
}
|
||||||
|
if _, err := service.ValidateBearerToken(ctx, apiKey.Token); err == nil {
|
||||||
|
t.Fatal("disabled user api token remained valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetEmailReplacesPriorLinkAndRevokesSessions(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := setupInitialAdmin(t, service, true)
|
||||||
|
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
_, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoginPassword() before reset error = %v", err)
|
||||||
|
}
|
||||||
|
sender := &testEmailSender{}
|
||||||
|
service.SetEmailSender(sender)
|
||||||
|
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
|
||||||
|
t.Fatalf("SendPasswordReset() first error = %v", err)
|
||||||
|
}
|
||||||
|
firstToken := passwordResetTokenFromBody(t, sender.body)
|
||||||
|
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil {
|
||||||
|
t.Fatalf("SendPasswordReset() second error = %v", err)
|
||||||
|
}
|
||||||
|
secondToken := passwordResetTokenFromBody(t, sender.body)
|
||||||
|
if firstToken == secondToken {
|
||||||
|
t.Fatal("expected each password reset email to contain a fresh token")
|
||||||
|
}
|
||||||
|
if err := service.ResetPassword(ctx, firstToken, "new correct horse battery staple"); err == nil {
|
||||||
|
t.Fatal("first password reset link remained valid after requesting a new one")
|
||||||
|
}
|
||||||
|
if err := service.ResetPassword(ctx, secondToken, "new correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("ResetPassword() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := service.ResetPassword(ctx, secondToken, "another correct horse battery staple"); err == nil {
|
||||||
|
t.Fatal("password reset link was reusable")
|
||||||
|
}
|
||||||
|
if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil {
|
||||||
|
t.Fatal("password reset did not revoke existing sessions")
|
||||||
|
}
|
||||||
|
if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||||
|
t.Fatal("old password still works after reset")
|
||||||
|
}
|
||||||
|
if _, _, err := service.LoginPassword(ctx, user.Email, "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
|
||||||
|
t.Fatalf("new password login error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetEmailFailureInvalidatesLink(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := setupInitialAdmin(t, service, true)
|
||||||
|
user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
sender := &testEmailSender{err: errors.New("smtp unavailable")}
|
||||||
|
service.SetEmailSender(sender)
|
||||||
|
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err == nil {
|
||||||
|
t.Fatal("expected password reset email delivery failure")
|
||||||
|
}
|
||||||
|
token := passwordResetTokenFromBody(t, sender.body)
|
||||||
|
if err := service.ResetPassword(ctx, token, "new correct horse battery staple"); err == nil {
|
||||||
|
t.Fatal("password reset link remained valid after email delivery failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwordResetTokenFromBody(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
if !strings.HasPrefix(line, "http") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse password reset URL: %v", err)
|
||||||
|
}
|
||||||
|
if token := parsed.Query().Get("token"); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("password reset body does not contain a link: %q", body)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
setupInitialAdmin(t, service, true)
|
||||||
|
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "another correct horse", "viewer"); err == nil {
|
||||||
|
t.Fatal("expected duplicate password registration to fail")
|
||||||
|
}
|
||||||
|
if _, _, err := service.BeginPasskeyRegistration(ctx, "dev@example.com", "Dev", "viewer"); err == nil {
|
||||||
|
t.Fatal("expected public passkey registration for existing account to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentUserCanBeginPasskeyRegistrationForExistingAccount(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := setupInitialAdmin(t, service, false)
|
||||||
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||||
|
options, challengeID, err := service.BeginCurrentUserPasskeyRegistration(ctx, principal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginCurrentUserPasskeyRegistration() error = %v", err)
|
||||||
|
}
|
||||||
|
if options == nil || challengeID == "" {
|
||||||
|
t.Fatalf("options = %#v, challengeID = %q; want registration options and challenge", options, challengeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := setupInitialAdmin(t, service, false)
|
||||||
|
|
||||||
|
if admin.Role != RoleAdmin {
|
||||||
|
t.Fatalf("initial role = %s, want admin", admin.Role)
|
||||||
|
}
|
||||||
|
if _, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer"); err == nil || err.Error() != "signups are disabled" {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v, want signups are disabled", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
principal := principalFromUser(admin, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||||
|
settings, err := service.UpdateSignupsEnabled(ctx, principal, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateSignupsEnabled() error = %v", err)
|
||||||
|
}
|
||||||
|
if !settings.SignupsEnabled {
|
||||||
|
t.Fatal("expected signups to be enabled")
|
||||||
|
}
|
||||||
|
viewer, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() after enabling signups error = %v", err)
|
||||||
|
}
|
||||||
|
if viewer.Role != RoleViewer {
|
||||||
|
t.Fatalf("public signup role = %s, want viewer", viewer.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResourcePermissionsArePrivateByDefault(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := setupInitialAdmin(t, service, true)
|
||||||
|
viewer, err := service.repo.UpsertUser(ctx, User{
|
||||||
|
Email: "viewer@example.com",
|
||||||
|
DisplayName: "Viewer",
|
||||||
|
Role: RoleViewer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create viewer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
|
||||||
|
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "private/page.md", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EffectiveResourcePermission() error = %v", err)
|
||||||
|
}
|
||||||
|
if permission != PermissionNone {
|
||||||
|
t.Fatalf("viewer permission = %q, want no access", permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||||
|
permission, err = service.EffectiveResourcePermission(ctx, adminPrincipal, ResourceDocument, "private/page.md", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EffectiveResourcePermission() admin error = %v", err)
|
||||||
|
}
|
||||||
|
if permission != PermissionAdmin {
|
||||||
|
t.Fatalf("admin permission = %q, want admin", permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResourcePermissionsUseMostPermissiveGrant(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := setupInitialAdmin(t, service, true)
|
||||||
|
viewer, err := service.repo.UpsertUser(ctx, User{
|
||||||
|
Email: "viewer@example.com",
|
||||||
|
DisplayName: "Viewer",
|
||||||
|
Role: RoleViewer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create viewer: %v", err)
|
||||||
|
}
|
||||||
|
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||||
|
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceDocument, "shared/page.md", []ResourcePermissionUpdate{
|
||||||
|
{UserID: viewer.ID, Permission: PermissionRead},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("grant document read: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceFolder, "shared", []ResourcePermissionUpdate{
|
||||||
|
{UserID: viewer.ID, Permission: PermissionWrite},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("grant folder write: %v", err)
|
||||||
|
}
|
||||||
|
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "shared/page.md", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EffectiveResourcePermission() folder error = %v", err)
|
||||||
|
}
|
||||||
|
if permission != PermissionWrite {
|
||||||
|
t.Fatalf("folder/document combined permission = %q, want write", permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceCollection, "team", []ResourcePermissionUpdate{
|
||||||
|
{UserID: viewer.ID, Permission: PermissionAdmin},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("grant collection admin: %v", err)
|
||||||
|
}
|
||||||
|
permission, err = service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "other/page.md", []string{"team"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EffectiveResourcePermission() collection error = %v", err)
|
||||||
|
}
|
||||||
|
if permission != PermissionAdmin {
|
||||||
|
t.Fatalf("collection permission = %q, want admin", permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
apps/server/internal/auth/types.go
Normal file
174
apps/server/internal/auth/types.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleViewer Role = "viewer"
|
||||||
|
RoleEditor Role = "editor"
|
||||||
|
RoleAdmin Role = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScopeDocsRead Scope = "docs:read"
|
||||||
|
ScopeDocsWrite Scope = "docs:write"
|
||||||
|
ScopeSyncRead Scope = "sync:read"
|
||||||
|
ScopeSyncWrite Scope = "sync:write"
|
||||||
|
ScopeAdmin Scope = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Permission string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermissionNone Permission = ""
|
||||||
|
PermissionRead Permission = "read"
|
||||||
|
PermissionWrite Permission = "write"
|
||||||
|
PermissionAdmin Permission = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResourceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResourceGlobal ResourceType = "global"
|
||||||
|
ResourceCollection ResourceType = "collection"
|
||||||
|
ResourceDocument ResourceType = "document"
|
||||||
|
ResourceFolder ResourceType = "folder"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Scopes []Scope `json:"scopes,omitempty"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
|
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
DisplayName string
|
||||||
|
Role Role
|
||||||
|
Disabled bool
|
||||||
|
PasswordHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastSeenAt time.Time
|
||||||
|
Credentials []webauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAccessUpdate struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourcePermission struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
UserEmail string `json:"userEmail,omitempty"`
|
||||||
|
UserName string `json:"userName,omitempty"`
|
||||||
|
ResourceType ResourceType `json:"resourceType"`
|
||||||
|
ResourceID string `json:"resourceId"`
|
||||||
|
Permission Permission `json:"permission"`
|
||||||
|
GrantedBy string `json:"grantedBy,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourcePermissionUpdate struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Permission Permission `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordResetToken struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
TokenHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
UsedAt *time.Time
|
||||||
|
InitiatedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) WebAuthnID() []byte {
|
||||||
|
return []byte(u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) WebAuthnName() string {
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) WebAuthnDisplayName() string {
|
||||||
|
if u.DisplayName != "" {
|
||||||
|
return u.DisplayName
|
||||||
|
}
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) WebAuthnCredentials() []webauthn.Credential {
|
||||||
|
return u.Credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
TokenHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyHash string `json:"-"`
|
||||||
|
Scopes []Scope `json:"scopes"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||||
|
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatedAPIKey struct {
|
||||||
|
Record APIKey `json:"record"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceSettings struct {
|
||||||
|
SetupComplete bool `json:"setupComplete"`
|
||||||
|
SignupsEnabled bool `json:"signupsEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceCode struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
DeviceCodeHash string
|
||||||
|
UserCodeHash string
|
||||||
|
UserCodeDisplay string
|
||||||
|
Name string
|
||||||
|
Scopes []Scope
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
ApprovedAt *time.Time
|
||||||
|
LastPolledAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceStart struct {
|
||||||
|
DeviceCode string `json:"deviceCode"`
|
||||||
|
UserCode string `json:"userCode"`
|
||||||
|
VerificationURI string `json:"verificationUri"`
|
||||||
|
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||||
|
ExpiresIn int `json:"expiresIn"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
385
apps/server/internal/collaboration/service.go
Normal file
385
apps/server/internal/collaboration/service.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package collaboration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmailSender mirrors email.Sender without taking a direct dependency on the
|
||||||
|
// email package (which would create a cycle once the queue imports from
|
||||||
|
// collaboration's repository types).
|
||||||
|
type EmailSender interface {
|
||||||
|
Send(ctx context.Context, to []string, subject, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailQueue is the minimal contract the collaboration service needs to
|
||||||
|
// enqueue an outgoing email. The email.Queue type satisfies this.
|
||||||
|
type EmailQueue interface {
|
||||||
|
Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailRenderer renders a plain-text email for a notification kind. The
|
||||||
|
// email.Render function satisfies this.
|
||||||
|
type EmailRenderer interface {
|
||||||
|
Render(kind string, data EmailData) (subject, body string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailData is the email-package TemplateData projected through the
|
||||||
|
// collaboration boundary to avoid an import cycle.
|
||||||
|
type EmailData struct {
|
||||||
|
ActorName string
|
||||||
|
ActorEmail string
|
||||||
|
DocumentPath string
|
||||||
|
DocumentTitle string
|
||||||
|
CommentBody string
|
||||||
|
MentionText string
|
||||||
|
ConflictDesc string
|
||||||
|
ViewURL string
|
||||||
|
UnsubURL string
|
||||||
|
InstanceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserLookup resolves a user id to an email address and display name. The
|
||||||
|
// auth.Repository satisfies this.
|
||||||
|
type UserLookup interface {
|
||||||
|
GetUserByID(ctx context.Context, userID string) (auth.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoOpEmailSender struct{}
|
||||||
|
|
||||||
|
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
slog.Info("email stub", "to", to, "subject", subject, "body_len", len(body))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *Repository
|
||||||
|
email EmailSender
|
||||||
|
queue EmailQueue
|
||||||
|
renderer EmailRenderer
|
||||||
|
users UserLookup
|
||||||
|
hub *realtime.Hub
|
||||||
|
logger *slog.Logger
|
||||||
|
publicOrigin string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
EmailSender EmailSender
|
||||||
|
EmailQueue EmailQueue
|
||||||
|
EmailRenderer EmailRenderer
|
||||||
|
UserLookup UserLookup
|
||||||
|
PublicOrigin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
|
||||||
|
return NewServiceWithOptions(repo, hub, logger, Options{EmailSender: email})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceWithOptions constructs the service with email queue + renderer +
|
||||||
|
// user lookup wiring. Callers that want email delivery should pass all of
|
||||||
|
// EmailQueue, EmailRenderer, UserLookup; otherwise notifications stay
|
||||||
|
// in-app only.
|
||||||
|
func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Logger, opts Options) *Service {
|
||||||
|
if opts.EmailSender == nil {
|
||||||
|
opts.EmailSender = &NoOpEmailSender{}
|
||||||
|
}
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
email: opts.EmailSender,
|
||||||
|
queue: opts.EmailQueue,
|
||||||
|
renderer: opts.EmailRenderer,
|
||||||
|
users: opts.UserLookup,
|
||||||
|
hub: hub,
|
||||||
|
logger: logger,
|
||||||
|
publicOrigin: opts.PublicOrigin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
|
||||||
|
func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, input CreateCommentInput) (*Comment, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return nil, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
if input.Content == "" {
|
||||||
|
return nil, fmt.Errorf("content is required")
|
||||||
|
}
|
||||||
|
if input.DocumentID == "" {
|
||||||
|
return nil, fmt.Errorf("document_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := Comment{
|
||||||
|
ID: randomID("comment"),
|
||||||
|
DocumentID: input.DocumentID,
|
||||||
|
VersionHash: input.VersionHash,
|
||||||
|
AuthorID: principal.UserID,
|
||||||
|
Content: input.Content,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if input.ParentID != nil && *input.ParentID != "" {
|
||||||
|
comment.ParentID = input.ParentID
|
||||||
|
}
|
||||||
|
if input.AnchorHash != nil && *input.AnchorHash != "" {
|
||||||
|
comment.AnchorHash = input.AnchorHash
|
||||||
|
}
|
||||||
|
if input.AnchorLine != nil && *input.AnchorLine > 0 {
|
||||||
|
comment.AnchorLine = input.AnchorLine
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.CreateComment(ctx, comment); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notifications for watchers
|
||||||
|
if err := s.notifyWatchersOfComment(ctx, comment); err != nil {
|
||||||
|
s.logger.Warn("failed to notify watchers of comment", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast to connected clients
|
||||||
|
s.broadcastNotification(principal.UserID)
|
||||||
|
|
||||||
|
return s.repo.GetComment(ctx, comment.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) {
|
||||||
|
return s.repo.ListCommentsByDocument(ctx, documentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
|
||||||
|
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) {
|
||||||
|
return s.repo.GetComment(ctx, commentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.ResolveComment(ctx, commentID, principal.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
|
||||||
|
func (s *Service) ListNotifications(ctx context.Context, principal auth.Principal, unreadOnly bool, limit int) ([]Notification, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return nil, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.ListNotifications(ctx, principal.UserID, unreadOnly, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetBellStatus(ctx context.Context, principal auth.Principal) (*NotificationBell, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return &NotificationBell{UnreadCount: 0, Items: []Notification{}}, nil
|
||||||
|
}
|
||||||
|
count, err := s.repo.CountUnreadNotifications(ctx, principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := s.repo.ListNotifications(ctx, principal.UserID, false, 20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &NotificationBell{
|
||||||
|
UnreadCount: count,
|
||||||
|
Items: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MarkNotificationRead(ctx context.Context, principal auth.Principal, notificationID string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.MarkNotificationRead(ctx, notificationID, principal.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MarkAllNotificationsRead(ctx context.Context, principal auth.Principal) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.MarkAllNotificationsRead(ctx, principal.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
|
||||||
|
func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, documentID string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.AddWatcher(ctx, Watcher{
|
||||||
|
UserID: principal.UserID,
|
||||||
|
DocumentID: &documentID,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal, documentID string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.AddWatcher(ctx, Watcher{
|
||||||
|
UserID: principal.UserID,
|
||||||
|
FolderPath: &folderPath,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) IsWatching(ctx context.Context, principal auth.Principal, documentID string) (bool, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return s.repo.IsWatching(ctx, principal.UserID, documentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document change notifications
|
||||||
|
|
||||||
|
func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, documentPath string, actorName string) error {
|
||||||
|
watchers, err := s.repo.ListWatchersByDocument(ctx, documentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list document watchers: %w", err)
|
||||||
|
}
|
||||||
|
folderWatchers, err := s.repo.ListWatchersByFolder(ctx, documentPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list folder watchers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, w := range append(watchers, folderWatchers...) {
|
||||||
|
if seen[w.UserID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[w.UserID] = true
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
|
||||||
|
notification := Notification{
|
||||||
|
ID: randomID("notif"),
|
||||||
|
UserID: w.UserID,
|
||||||
|
Type: "file_changed",
|
||||||
|
ResourceType: "document",
|
||||||
|
ResourceID: documentID,
|
||||||
|
Message: msg,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := s.repo.CreateNotification(ctx, notification); err != nil {
|
||||||
|
s.logger.Warn("create notification", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{
|
||||||
|
ActorName: actorName,
|
||||||
|
DocumentPath: documentPath,
|
||||||
|
ViewURL: s.documentURL(documentPath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast bell update to all connected clients
|
||||||
|
s.broadcastNotification("")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
|
||||||
|
watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range watchers {
|
||||||
|
if w.UserID == comment.AuthorID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("New comment on document")
|
||||||
|
if comment.AnchorHash != nil {
|
||||||
|
msg = fmt.Sprintf("New comment on a section of the document")
|
||||||
|
}
|
||||||
|
notification := Notification{
|
||||||
|
ID: randomID("notif"),
|
||||||
|
UserID: w.UserID,
|
||||||
|
Type: "comment",
|
||||||
|
ResourceType: "document",
|
||||||
|
ResourceID: comment.DocumentID,
|
||||||
|
Message: msg,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := s.repo.CreateNotification(ctx, notification); err != nil {
|
||||||
|
s.logger.Warn("create comment notification", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
|
||||||
|
ActorName: comment.AuthorName,
|
||||||
|
CommentBody: comment.Content,
|
||||||
|
ViewURL: s.documentURL(comment.DocumentID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueNotificationEmail resolves the watcher's email address, renders the
|
||||||
|
// template, and enqueues a durable email. Failures are logged and swallowed
|
||||||
|
// so a bad email config never blocks the in-app notification path.
|
||||||
|
func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID, userID, kind string, data EmailData) {
|
||||||
|
if s.queue == nil || s.renderer == nil || s.users == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.users.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("email: lookup user", "user_id", userID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Email == "" || user.Disabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data.ActorName == "" {
|
||||||
|
data.ActorName = user.DisplayName
|
||||||
|
}
|
||||||
|
subject, body, err := s.renderer.Render(kind, data)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("email: render", "kind", kind, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.queue.Enqueue(ctx, notificationID, user.Email, subject, body); err != nil {
|
||||||
|
s.logger.Warn("email: enqueue", "user_id", userID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) documentURL(documentPath string) string {
|
||||||
|
if s.publicOrigin == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.publicOrigin + "/" + documentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) broadcastNotification(userID string) {
|
||||||
|
if s.hub == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.hub.Broadcast(realtime.Event{
|
||||||
|
Type: "notification",
|
||||||
|
Data: map[string]string{"userId": userID},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetEmailSender(email EmailSender) {
|
||||||
|
if email != nil {
|
||||||
|
s.email = email
|
||||||
|
}
|
||||||
|
}
|
||||||
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,14 +5,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `json:"server"`
|
Server ServerConfig `json:"server"`
|
||||||
Database DatabaseConfig `json:"database"`
|
Database DatabaseConfig `json:"database"`
|
||||||
Content ContentConfig `json:"content"`
|
Content ContentConfig `json:"content"`
|
||||||
Web WebConfig `json:"web"`
|
Auth AuthConfig `json:"auth"`
|
||||||
|
Email EmailConfig `json:"email"`
|
||||||
LogLevel string `json:"logLevel"`
|
LogLevel string `json:"logLevel"`
|
||||||
|
DevMode bool `json:"devMode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -30,9 +33,36 @@ type ContentConfig struct {
|
|||||||
StoreDir string `json:"storeDir"`
|
StoreDir string `json:"storeDir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebConfig struct {
|
type AuthConfig struct {
|
||||||
DistDir string `json:"distDir"`
|
PublicOrigin string `json:"publicOrigin"`
|
||||||
DevViteURL string `json:"devViteUrl"`
|
}
|
||||||
|
|
||||||
|
type EmailConfig struct {
|
||||||
|
// Provider selects the sender implementation. Valid values: "noop",
|
||||||
|
// "smtp", "postmark". When empty, the loader infers from the other
|
||||||
|
// fields: PostmarkToken wins, then SMTPHost, then NoOp.
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
SMTPHost string `json:"smtpHost"`
|
||||||
|
SMTPPort int `json:"smtpPort"`
|
||||||
|
PostmarkToken string `json:"postmarkToken"`
|
||||||
|
PostmarkAPIURL string `json:"postmarkApiUrl"`
|
||||||
|
From string `json:"from"`
|
||||||
|
InstanceName string `json:"instanceName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedProvider returns the concrete provider choice after applying the
|
||||||
|
// inference rule. Operators can set Provider explicitly to force a choice.
|
||||||
|
func (e EmailConfig) ResolvedProvider() string {
|
||||||
|
if e.Provider != "" {
|
||||||
|
return e.Provider
|
||||||
|
}
|
||||||
|
if e.PostmarkToken != "" {
|
||||||
|
return "postmark"
|
||||||
|
}
|
||||||
|
if e.SMTPHost != "" {
|
||||||
|
return "smtp"
|
||||||
|
}
|
||||||
|
return "noop"
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -47,9 +77,14 @@ func Default() Config {
|
|||||||
SourceDir: filepath.Join("..", "..", "content"),
|
SourceDir: filepath.Join("..", "..", "content"),
|
||||||
StoreDir: filepath.Join("..", "..", "data", "files"),
|
StoreDir: filepath.Join("..", "..", "data", "files"),
|
||||||
},
|
},
|
||||||
Web: WebConfig{
|
Auth: AuthConfig{
|
||||||
DistDir: filepath.Join("..", "web", "dist"),
|
PublicOrigin: "http://localhost:8080",
|
||||||
DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"),
|
},
|
||||||
|
Email: EmailConfig{
|
||||||
|
SMTPHost: "localhost",
|
||||||
|
SMTPPort: 1025,
|
||||||
|
From: "notifications@cairnquire.local",
|
||||||
|
InstanceName: "Cairnquire",
|
||||||
},
|
},
|
||||||
LogLevel: "INFO",
|
LogLevel: "INFO",
|
||||||
}
|
}
|
||||||
@@ -65,14 +100,34 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
overrideString(&cfg.Server.Addr, "CAIRNQUIRE_SERVER_ADDR")
|
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.Path, "CAIRNQUIRE_DATABASE_PATH")
|
||||||
overrideString(&cfg.Database.PrimaryURL, "CAIRNQUIRE_DATABASE_PRIMARY_URL")
|
overrideString(&cfg.Database.PrimaryURL, "CAIRNQUIRE_DATABASE_PRIMARY_URL")
|
||||||
overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN")
|
overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN")
|
||||||
overrideString(&cfg.Content.SourceDir, "CAIRNQUIRE_CONTENT_SOURCE_DIR")
|
overrideString(&cfg.Content.SourceDir, "CAIRNQUIRE_CONTENT_SOURCE_DIR")
|
||||||
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
|
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
|
||||||
overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR")
|
overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN")
|
||||||
overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL")
|
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
|
||||||
|
overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN")
|
||||||
|
overrideString(&cfg.Email.PostmarkAPIURL, "CAIRNQUIRE_EMAIL_POSTMARK_API_URL")
|
||||||
|
overrideString(&cfg.Email.Provider, "CAIRNQUIRE_EMAIL_PROVIDER")
|
||||||
|
overrideString(&cfg.Email.InstanceName, "CAIRNQUIRE_INSTANCE_NAME")
|
||||||
|
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
|
||||||
|
if p, err := strconv.Atoi(port); err == nil {
|
||||||
|
cfg.Email.SMTPPort = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overrideString(&cfg.Email.From, "CAIRNQUIRE_EMAIL_FROM")
|
||||||
overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL")
|
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 == "" {
|
if cfg.Server.Addr == "" {
|
||||||
return Config{}, fmt.Errorf("server.addr must not be empty")
|
return Config{}, fmt.Errorf("server.addr must not be empty")
|
||||||
|
|||||||
90
apps/server/internal/database/migrations/000009_auth.up.sql
Normal file
90
apps/server/internal/database/migrations/000009_auth.up.sql
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
revoked_at TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
credential_id BLOB NOT NULL UNIQUE,
|
||||||
|
credential_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_challenges (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('webauthn_registration', 'webauthn_login')),
|
||||||
|
session_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_challenges_user_type ON auth_challenges(user_id, type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_codes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
device_code_hash TEXT NOT NULL UNIQUE,
|
||||||
|
user_code_hash TEXT NOT NULL UNIQUE,
|
||||||
|
user_code_display TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scopes TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'approved', 'denied', 'expired')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
approved_at TEXT,
|
||||||
|
last_polled_at TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_codes_user_code_hash ON device_codes(user_code_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_codes_device_code_hash ON device_codes(device_code_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document')),
|
||||||
|
resource_id TEXT,
|
||||||
|
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')),
|
||||||
|
granted_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
FOREIGN KEY(actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||||
|
|
||||||
|
ALTER TABLE api_keys ADD COLUMN revoked_at TEXT;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS permissions_previous (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document')),
|
||||||
|
resource_id TEXT,
|
||||||
|
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')),
|
||||||
|
granted_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO permissions_previous (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
|
||||||
|
SELECT id, user_id, resource_type, resource_id, permission, granted_by, created_at
|
||||||
|
FROM permissions
|
||||||
|
WHERE resource_type IN ('global', 'collection', 'document');
|
||||||
|
|
||||||
|
DROP TABLE permissions;
|
||||||
|
ALTER TABLE permissions_previous RENAME TO permissions;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS permissions_next (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document', 'folder')),
|
||||||
|
resource_id TEXT,
|
||||||
|
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')),
|
||||||
|
granted_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO permissions_next (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
|
||||||
|
SELECT id, user_id, resource_type, resource_id, permission, granted_by, created_at
|
||||||
|
FROM permissions;
|
||||||
|
|
||||||
|
DROP TABLE permissions;
|
||||||
|
ALTER TABLE permissions_next RENAME TO permissions;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource_type, resource_id);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_email_queue_notification;
|
||||||
|
DROP INDEX IF EXISTS idx_email_queue_status;
|
||||||
|
DROP TABLE IF EXISTS email_queue;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS email_queue (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
notification_id TEXT,
|
||||||
|
to_address TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'failed', 'skipped')),
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||||
|
last_error TEXT,
|
||||||
|
next_attempt_at TEXT NOT NULL,
|
||||||
|
sent_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(notification_id) REFERENCES notifications(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_queue(status, next_attempt_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_queue_notification ON email_queue(notification_id);
|
||||||
@@ -14,13 +14,14 @@ type Repository struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DocumentRecord struct {
|
type DocumentRecord struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
CurrentHash string `json:"currentHash"`
|
CurrentHash string `json:"currentHash"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRecord struct {
|
type UserRecord struct {
|
||||||
@@ -38,6 +39,7 @@ type AttachmentRecord struct {
|
|||||||
ContentType string
|
ContentType string
|
||||||
SizeBytes int64
|
SizeBytes int64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
DocumentPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PersistDocumentInput struct {
|
type PersistDocumentInput struct {
|
||||||
@@ -100,18 +102,64 @@ func (r *Repository) PersistDocument(ctx context.Context, input PersistDocumentI
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) {
|
func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) {
|
||||||
|
return r.getDocumentByPath(ctx, path, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, path string) (*DocumentRecord, error) {
|
||||||
|
return r.getDocumentByPath(ctx, path, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) {
|
||||||
var (
|
var (
|
||||||
record DocumentRecord
|
record DocumentRecord
|
||||||
tagsJSON string
|
tagsJSON string
|
||||||
created string
|
created string
|
||||||
updated string
|
updated string
|
||||||
|
archived sql.NullString
|
||||||
|
)
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
|
||||||
|
FROM documents
|
||||||
|
WHERE id = ? AND archived_at IS NULL
|
||||||
|
`, id).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if archived.Valid && archived.String != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, archived.String)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse document archived_at: %w", err)
|
||||||
|
}
|
||||||
|
record.ArchivedAt = &t
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
||||||
|
}
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) {
|
||||||
|
var (
|
||||||
|
record DocumentRecord
|
||||||
|
tagsJSON string
|
||||||
|
created string
|
||||||
|
updated string
|
||||||
|
archived sql.NullString
|
||||||
)
|
)
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
query := `
|
||||||
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
|
FROM documents
|
||||||
WHERE path = ?
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -119,6 +167,13 @@ func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*Docum
|
|||||||
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
||||||
return nil, err
|
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 {
|
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
||||||
}
|
}
|
||||||
@@ -128,8 +183,9 @@ func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*Docum
|
|||||||
|
|
||||||
func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
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
|
FROM documents
|
||||||
|
WHERE archived_at IS NULL
|
||||||
ORDER BY path ASC
|
ORDER BY path ASC
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -137,6 +193,25 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
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
|
var records []DocumentRecord
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
@@ -144,13 +219,21 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
|
|||||||
tagsJSON string
|
tagsJSON string
|
||||||
created string
|
created string
|
||||||
updated 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)
|
return nil, fmt.Errorf("scan document: %w", err)
|
||||||
}
|
}
|
||||||
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
||||||
return nil, err
|
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 {
|
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
||||||
}
|
}
|
||||||
@@ -160,6 +243,41 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
|
|||||||
return records, rows.Err()
|
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) {
|
func (r *Repository) ListUsers(ctx context.Context) ([]UserRecord, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
||||||
@@ -238,13 +356,14 @@ func (r *Repository) UpsertUser(ctx context.Context, user UserRecord) (UserRecor
|
|||||||
|
|
||||||
func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
|
func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
|
||||||
if _, err := r.db.ExecContext(ctx, `
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)
|
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at, document_path)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(hash) DO UPDATE SET
|
ON CONFLICT(hash) DO UPDATE SET
|
||||||
original_name = excluded.original_name,
|
original_name = excluded.original_name,
|
||||||
content_type = excluded.content_type,
|
content_type = excluded.content_type,
|
||||||
size_bytes = excluded.size_bytes
|
size_bytes = excluded.size_bytes,
|
||||||
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339)); err != nil {
|
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 fmt.Errorf("save attachment: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -256,10 +375,10 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
|
|||||||
created string
|
created string
|
||||||
)
|
)
|
||||||
err := r.db.QueryRowContext(ctx, `
|
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
|
FROM attachments
|
||||||
WHERE hash = ?
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -271,6 +390,33 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
|
|||||||
return &record, nil
|
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 {
|
type SearchResult struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -281,7 +427,7 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
|
|||||||
SELECT d.path, d.title
|
SELECT d.path, d.title
|
||||||
FROM document_search ds
|
FROM document_search ds
|
||||||
JOIN documents d ON d.rowid = ds.rowid
|
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
|
ORDER BY rank
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`, query)
|
`, query)
|
||||||
@@ -302,6 +448,32 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
|
|||||||
return results, rows.Err()
|
return results, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SearchDocumentsByTag(ctx context.Context, tag string) ([]SearchResult, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT d.path, d.title
|
||||||
|
FROM documents d
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM json_each(d.tags) WHERE json_each.value = ?
|
||||||
|
) AND d.archived_at IS NULL
|
||||||
|
ORDER BY d.path
|
||||||
|
`, tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search documents by tag: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
for rows.Next() {
|
||||||
|
var result SearchResult
|
||||||
|
if err := rows.Scan(&result.Path, &result.Title); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan tag search result: %w", err)
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error {
|
func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error {
|
||||||
if _, err := r.db.ExecContext(ctx, `
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE document_search SET content = ?
|
UPDATE document_search SET content = ?
|
||||||
|
|||||||
@@ -123,6 +123,53 @@ func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
|
|||||||
return s.repo.ListDocuments(ctx)
|
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) {
|
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
|
||||||
if _, err := s.SyncSourceDir(ctx); err != nil {
|
if _, err := s.SyncSourceDir(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -190,7 +237,17 @@ func (s *Service) SaveSourcePageWithBaseHash(ctx context.Context, requestPath st
|
|||||||
|
|
||||||
record, _, err := s.resolveRecord(ctx, requestPath)
|
record, _, err := s.resolveRecord(ctx, requestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if baseHash != "" {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path, pathErr := normalizeWritableDocumentPath(requestPath)
|
||||||
|
if pathErr != nil {
|
||||||
|
return nil, pathErr
|
||||||
|
}
|
||||||
|
record = &DocumentRecord{Path: path}
|
||||||
}
|
}
|
||||||
|
|
||||||
if baseHash != "" && record.CurrentHash != baseHash {
|
if baseHash != "" && record.CurrentHash != baseHash {
|
||||||
@@ -270,6 +327,21 @@ func normalizeRequestPathCandidates(path string) []string {
|
|||||||
return []string{path}
|
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) {
|
func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
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)
|
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) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, err
|
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)
|
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)
|
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
|
||||||
return &DocumentChange{
|
return &DocumentChange{
|
||||||
Path: relative,
|
Path: relative,
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||||
@@ -110,6 +112,134 @@ func TestSaveSourcePageWithBaseHashAcceptsMatchingHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(t *testing.T) {
|
||||||
|
service, sourceDir := setupDocsTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
page, err := service.LoadSourcePage(ctx, "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSourcePage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverContent := "# Hello\n\nServer edit"
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
|
||||||
|
t.Fatalf("write server edit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nQueued offline edit", page.Hash)
|
||||||
|
var conflict *DocumentConflictError
|
||||||
|
if !errors.As(err, &conflict) {
|
||||||
|
t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedContent := conflict.CurrentContent + "\n\nResolved local addition"
|
||||||
|
updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", resolvedContent, conflict.CurrentHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveSourcePageWithBaseHash() latest hash error = %v", err)
|
||||||
|
}
|
||||||
|
if updated.Hash == conflict.CurrentHash {
|
||||||
|
t.Fatal("expected resolved save to produce a new hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read hello.md: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != resolvedContent {
|
||||||
|
t.Fatalf("resolved content = %q, want %q", string(content), resolvedContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveSourcePageCreatesNewDocument(t *testing.T) {
|
||||||
|
service, sourceDir := setupDocsTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
content := "# Inbox Note\n\nCreated from clipboard"
|
||||||
|
page, err := service.SaveSourcePageWithBaseHash(ctx, "inbox/note", content, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err)
|
||||||
|
}
|
||||||
|
if page.Path != "inbox/note.md" {
|
||||||
|
t.Fatalf("page path = %q, want inbox/note.md", page.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "note.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read new document: %v", err)
|
||||||
|
}
|
||||||
|
if string(written) != content {
|
||||||
|
t.Fatalf("new document content = %q, want %q", string(written), content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
|
||||||
|
service, _ := setupDocsTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := service.SaveSourcePageWithBaseHash(ctx, "../outside", "# Outside\n", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected traversal path to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
|
||||||
|
service, _ := setupDocsTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := service.SyncSourceDir(ctx); err != nil {
|
||||||
|
t.Fatalf("initial SyncSourceDir() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed []DocumentChange
|
||||||
|
service.OnChange(func(change DocumentChange) {
|
||||||
|
changed = append(changed, change)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := service.ArchiveDocument(ctx, "hello.md"); err != nil {
|
||||||
|
t.Fatalf("ArchiveDocument() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(changed) != 1 {
|
||||||
|
t.Fatalf("changes after archive = %d, want 1", len(changed))
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = nil
|
||||||
|
changes, err := service.SyncSourceDir(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SyncSourceDir() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(changes) != 0 {
|
||||||
|
t.Fatalf("sync changes = %#v, want none for archived document", changes)
|
||||||
|
}
|
||||||
|
if len(changed) != 0 {
|
||||||
|
t.Fatalf("broadcast changes = %#v, want none for archived document", changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
|
||||||
|
service, sourceDir := setupDocsTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
name := filepath.Join(sourceDir, fmt.Sprintf("note-%03d.md", i))
|
||||||
|
content := fmt.Sprintf("# Note %03d\n\nDeterministic body %03d\n", i, i)
|
||||||
|
if err := os.WriteFile(name, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("create %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
changes, err := service.SyncSourceDir(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SyncSourceDir() error = %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||||
|
t.Fatalf("SyncSourceDir() took %s, want <= 10s", elapsed)
|
||||||
|
}
|
||||||
|
if len(changes) != 101 {
|
||||||
|
t.Fatalf("changes = %d, want 101", len(changes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupDocsTestService(t *testing.T) (*Service, string) {
|
func setupDocsTestService(t *testing.T) (*Service, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
29
apps/server/internal/email/collaboration_adapter.go
Normal file
29
apps/server/internal/email/collaboration_adapter.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import "github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||||
|
|
||||||
|
// CollaborationRenderer adapts email.Render to the collaboration.EmailRenderer
|
||||||
|
// interface by converting collaboration.EmailData into email.TemplateData.
|
||||||
|
type CollaborationRenderer struct {
|
||||||
|
InstanceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render satisfies collaboration.EmailRenderer.
|
||||||
|
func (c CollaborationRenderer) Render(kind string, data collaboration.EmailData) (string, string, error) {
|
||||||
|
td := TemplateData{
|
||||||
|
ActorName: data.ActorName,
|
||||||
|
ActorEmail: data.ActorEmail,
|
||||||
|
DocumentPath: data.DocumentPath,
|
||||||
|
DocumentTitle: data.DocumentTitle,
|
||||||
|
CommentBody: data.CommentBody,
|
||||||
|
MentionText: data.MentionText,
|
||||||
|
ConflictDesc: data.ConflictDesc,
|
||||||
|
ViewURL: data.ViewURL,
|
||||||
|
UnsubURL: data.UnsubURL,
|
||||||
|
InstanceName: c.InstanceName,
|
||||||
|
}
|
||||||
|
if td.InstanceName == "" {
|
||||||
|
td.InstanceName = "Cairnquire"
|
||||||
|
}
|
||||||
|
return Render(kind, td)
|
||||||
|
}
|
||||||
14
apps/server/internal/email/internal.go
Normal file
14
apps/server/internal/email/internal.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
func randomToken(length int) (string, error) {
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
141
apps/server/internal/email/postmark.go
Normal file
141
apps/server/internal/email/postmark.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPostmarkURL = "https://api.postmarkapp.com"
|
||||||
|
postmarkTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostmarkSender sends email via the Postmark API.
|
||||||
|
type PostmarkSender struct {
|
||||||
|
serverToken string
|
||||||
|
apiURL string
|
||||||
|
from string
|
||||||
|
client *http.Client
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostmarkSender constructs a Postmark sender. apiURL may be empty to use
|
||||||
|
// the public Postmark endpoint; tests can point it at an httptest server.
|
||||||
|
func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *PostmarkSender {
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = defaultPostmarkURL
|
||||||
|
}
|
||||||
|
return &PostmarkSender{
|
||||||
|
serverToken: serverToken,
|
||||||
|
apiURL: strings.TrimRight(apiURL, "/"),
|
||||||
|
from: from,
|
||||||
|
client: &http.Client{Timeout: postmarkTimeout},
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type postmarkRequest struct {
|
||||||
|
From string `json:"From"`
|
||||||
|
To string `json:"To"`
|
||||||
|
Subject string `json:"Subject"`
|
||||||
|
TextBody string `json:"TextBody"`
|
||||||
|
Headers []postmarkHeader `json:"Headers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type postmarkHeader struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Value string `json:"Value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type postmarkResponse struct {
|
||||||
|
ErrorCode int `json:"ErrorCode"`
|
||||||
|
Message string `json:"Message"`
|
||||||
|
To string `json:"To"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithReplyTo sets a Reply-To header on the outgoing message. Returns a copy
|
||||||
|
// of the sender with the reply-to address applied for the next Send call.
|
||||||
|
// Not safe for concurrent use; callers should construct a fresh sender per
|
||||||
|
// send if they need different reply-to addresses.
|
||||||
|
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
if p.serverToken == "" {
|
||||||
|
return fmt.Errorf("postmark server token not configured")
|
||||||
|
}
|
||||||
|
if p.from == "" {
|
||||||
|
return fmt.Errorf("postmark from address not configured")
|
||||||
|
}
|
||||||
|
if len(to) == 0 {
|
||||||
|
return fmt.Errorf("postmark send requires at least one recipient")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := postmarkRequest{
|
||||||
|
From: p.from,
|
||||||
|
To: strings.Join(to, ","),
|
||||||
|
Subject: subject,
|
||||||
|
TextBody: body,
|
||||||
|
}
|
||||||
|
bodyBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal postmark request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiURL+"/email", bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build postmark request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Postmark-Server-Token", p.serverToken)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("postmark request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14))
|
||||||
|
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
return &TemporaryError{Cause: fmt.Errorf("postmark server error: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed postmarkResponse
|
||||||
|
if err := json.Unmarshal(respBytes, &parsed); err != nil {
|
||||||
|
return fmt.Errorf("decode postmark response: %w", err)
|
||||||
|
}
|
||||||
|
if parsed.ErrorCode != 0 {
|
||||||
|
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
|
||||||
|
// 406 as non-retryable; others bubble up as generic failures.
|
||||||
|
return fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string) string {
|
||||||
|
if len(s) > 256 {
|
||||||
|
return s[:256] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemporaryError wraps a failure that callers may retry. The queue worker
|
||||||
|
// uses errors.As to detect it and apply exponential backoff.
|
||||||
|
type TemporaryError struct {
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TemporaryError) Error() string { return t.Cause.Error() }
|
||||||
|
func (t *TemporaryError) Unwrap() error { return t.Cause }
|
||||||
113
apps/server/internal/email/postmark_test.go
Normal file
113
apps/server/internal/email/postmark_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostmarkSender_Send(t *testing.T) {
|
||||||
|
var gotBody postmarkRequest
|
||||||
|
var gotToken string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotToken = r.Header.Get("X-Postmark-Server-Token")
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(body, &gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ErrorCode":0,"Message":"OK","To":"alice@example.com"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sender := NewPostmarkSender("test-token", "noreply@example.com", srv.URL, testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"alice@example.com"}, "Hello", "World"); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if gotToken != "test-token" {
|
||||||
|
t.Errorf("token = %q, want test-token", gotToken)
|
||||||
|
}
|
||||||
|
if gotBody.From != "noreply@example.com" {
|
||||||
|
t.Errorf("from = %q", gotBody.From)
|
||||||
|
}
|
||||||
|
if gotBody.To != "alice@example.com" {
|
||||||
|
t.Errorf("to = %q", gotBody.To)
|
||||||
|
}
|
||||||
|
if gotBody.Subject != "Hello" {
|
||||||
|
t.Errorf("subject = %q", gotBody.Subject)
|
||||||
|
}
|
||||||
|
if gotBody.TextBody != "World" {
|
||||||
|
t.Errorf("body = %q", gotBody.TextBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostmarkSender_ErrorCode(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ErrorCode":406,"Message":"Inactive recipient"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
|
||||||
|
err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "406") {
|
||||||
|
t.Errorf("error should mention code 406: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostmarkSender_ServerError_Temporary(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("boom"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
|
||||||
|
err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
var temp *TemporaryError
|
||||||
|
if !errors.As(err, &temp) {
|
||||||
|
t.Errorf("expected TemporaryError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostmarkSender_Validation(t *testing.T) {
|
||||||
|
sender := NewPostmarkSender("", "noreply@example.com", "", testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for missing token")
|
||||||
|
}
|
||||||
|
sender = NewPostmarkSender("tok", "", "", testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"x@x.com"}, "s", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for missing from")
|
||||||
|
}
|
||||||
|
sender = NewPostmarkSender("tok", "noreply@example.com", "", testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), nil, "s", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for empty recipients")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostmarkSender_MultipleRecipients(t *testing.T) {
|
||||||
|
var gotBody postmarkRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(body, &gotBody)
|
||||||
|
_, _ = w.Write([]byte(`{"ErrorCode":0,"Message":"OK"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sender := NewPostmarkSender("tok", "noreply@example.com", srv.URL, testLogger(t))
|
||||||
|
if err := sender.Send(context.Background(), []string{"a@x.com", "b@x.com"}, "s", "b"); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotBody.To, "a@x.com") || !strings.Contains(gotBody.To, "b@x.com") {
|
||||||
|
t.Errorf("to = %q, expected both recipients", gotBody.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
344
apps/server/internal/email/queue.go
Normal file
344
apps/server/internal/email/queue.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Queue stores outgoing emails durably and dispatches them via a Sender.
|
||||||
|
// It is safe for concurrent use; claim uses an atomic UPDATE ... WHERE
|
||||||
|
// status='pending' to lease rows without races.
|
||||||
|
type Queue struct {
|
||||||
|
db *sql.DB
|
||||||
|
sender Sender
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
pollInterval time.Duration
|
||||||
|
maxBackoff time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueRow is the on-disk representation of a queued email.
|
||||||
|
type QueueRow struct {
|
||||||
|
ID string
|
||||||
|
NotificationID string
|
||||||
|
ToAddress string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
Status string
|
||||||
|
Attempts int
|
||||||
|
MaxAttempts int
|
||||||
|
LastError string
|
||||||
|
NextAttemptAt time.Time
|
||||||
|
SentAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQueue constructs a queue. sender may be nil; in that case emails stay
|
||||||
|
// pending forever (useful for tests that only inspect enqueue state).
|
||||||
|
func NewQueue(db *sql.DB, sender Sender, logger *slog.Logger) *Queue {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Queue{
|
||||||
|
db: db,
|
||||||
|
sender: sender,
|
||||||
|
logger: logger,
|
||||||
|
pollInterval: 10 * time.Second,
|
||||||
|
maxBackoff: 30 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPollInterval overrides the worker poll interval. Must be called before
|
||||||
|
// Run. Useful for tests.
|
||||||
|
func (q *Queue) SetPollInterval(d time.Duration) {
|
||||||
|
if d > 0 {
|
||||||
|
q.pollInterval = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxBackoff overrides the maximum retry backoff.
|
||||||
|
func (q *Queue) SetMaxBackoff(d time.Duration) {
|
||||||
|
if d > 0 {
|
||||||
|
q.maxBackoff = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue inserts a new outgoing email. notificationID may be empty. The
|
||||||
|
// email is immediately eligible for delivery (next_attempt_at = now).
|
||||||
|
func (q *Queue) Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error) {
|
||||||
|
if to == "" {
|
||||||
|
return "", fmt.Errorf("enqueue: to address is required")
|
||||||
|
}
|
||||||
|
if subject == "" {
|
||||||
|
return "", fmt.Errorf("enqueue: subject is required")
|
||||||
|
}
|
||||||
|
id, err := randomQueueID()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if _, err := q.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO email_queue (id, notification_id, to_address, subject, body, status, attempts, max_attempts, next_attempt_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'pending', 0, 3, ?, ?)
|
||||||
|
`, id, nullableString(notificationID), to, subject, body, formatTime(now), formatTime(now)); err != nil {
|
||||||
|
return "", fmt.Errorf("enqueue email: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotificationEmailed stamps notifications.emailed_at for the given
|
||||||
|
// notification id. Called by the worker once the email is dispatched.
|
||||||
|
func (q *Queue) MarkNotificationEmailed(ctx context.Context, notificationID string) error {
|
||||||
|
if notificationID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE notifications SET emailed_at = ? WHERE id = ?
|
||||||
|
`, formatTime(time.Now().UTC()), notificationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark notification emailed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the worker loop. It blocks until ctx is cancelled.
|
||||||
|
func (q *Queue) Run(ctx context.Context) {
|
||||||
|
q.logger.Info("email queue worker started", "poll_interval", q.pollInterval)
|
||||||
|
ticker := time.NewTicker(q.pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
q.logger.Info("email queue worker stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := q.processBatch(ctx); err != nil {
|
||||||
|
q.logger.Warn("email queue batch failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processBatch claims and sends up to 25 pending emails per tick.
|
||||||
|
func (q *Queue) processBatch(ctx context.Context) error {
|
||||||
|
if q.sender == nil {
|
||||||
|
// No sender configured; leave rows pending so a future sender can
|
||||||
|
// pick them up once wired.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := q.claimBatch(ctx, 25)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("claim batch: %w", err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := q.deliver(ctx, row); err != nil {
|
||||||
|
q.logger.Warn("deliver email failed", "id", row.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimBatch atomically leases pending rows by flipping their status to
|
||||||
|
// 'pending' but bumping attempts so a concurrent worker cannot re-claim
|
||||||
|
// them until the next_attempt_at expires. We use a single UPDATE with a
|
||||||
|
// subquery to keep this race-free under SQLite.
|
||||||
|
func (q *Queue) claimBatch(ctx context.Context, limit int) ([]QueueRow, error) {
|
||||||
|
tx, err := q.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin claim: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
|
||||||
|
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
|
||||||
|
FROM email_queue
|
||||||
|
WHERE status = 'pending' AND next_attempt_at <= ?
|
||||||
|
ORDER BY next_attempt_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, formatTime(time.Now().UTC()), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query pending: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claimed []QueueRow
|
||||||
|
for rows.Next() {
|
||||||
|
row, err := scanQueueRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claimed = append(claimed, row)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range claimed {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET attempts = attempts + 1 WHERE id = ?
|
||||||
|
`, row.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("lease row %s: %w", row.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit claim: %w", err)
|
||||||
|
}
|
||||||
|
return claimed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver attempts one email. On success it marks the row sent and stamps
|
||||||
|
// the linked notification. On failure it schedules a retry with exponential
|
||||||
|
// backoff or marks the row failed if attempts are exhausted.
|
||||||
|
func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
|
||||||
|
err := q.sender.Send(ctx, []string{row.ToAddress}, row.Subject, row.Body)
|
||||||
|
if err == nil {
|
||||||
|
if _, err := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET status = 'sent', sent_at = ?, last_error = '' WHERE id = ?
|
||||||
|
`, formatTime(time.Now().UTC()), row.ID); err != nil {
|
||||||
|
return fmt.Errorf("mark sent: %w", err)
|
||||||
|
}
|
||||||
|
if err := q.MarkNotificationEmailed(ctx, row.NotificationID); err != nil {
|
||||||
|
q.logger.Warn("mark notification emailed", "id", row.NotificationID, "error", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure: retry or give up.
|
||||||
|
if row.Attempts >= row.MaxAttempts {
|
||||||
|
if _, ferr := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
|
||||||
|
`, truncateErr(err.Error()), row.ID); ferr != nil {
|
||||||
|
return fmt.Errorf("mark failed: %w", ferr)
|
||||||
|
}
|
||||||
|
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", row.Attempts, "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
backoff := q.backoff(row.Attempts)
|
||||||
|
next := time.Now().UTC().Add(backoff)
|
||||||
|
if _, err := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
|
||||||
|
`, formatTime(next), truncateErr(err.Error()), row.ID); err != nil {
|
||||||
|
return fmt.Errorf("schedule retry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemporaryError (Postmark 5xx) is retried with the same backoff as
|
||||||
|
// other errors; the max-attempts cap still bounds total work.
|
||||||
|
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) backoff(attempts int) time.Duration {
|
||||||
|
// 2^attempts seconds, capped: 2s, 4s, 8s, 16s, ... up to maxBackoff.
|
||||||
|
d := time.Duration(math.Pow(2, float64(attempts))) * time.Second
|
||||||
|
if d > q.maxBackoff {
|
||||||
|
d = q.maxBackoff
|
||||||
|
}
|
||||||
|
if d < time.Second {
|
||||||
|
d = time.Second
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingCount returns the number of rows in pending status, primarily for
|
||||||
|
// tests and operator dashboards.
|
||||||
|
func (q *Queue) PendingCount(ctx context.Context) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM email_queue WHERE status = 'pending'`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFailed returns rows that have exhausted their retries, for operator
|
||||||
|
// inspection.
|
||||||
|
func (q *Queue) ListFailed(ctx context.Context, limit int) ([]QueueRow, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := q.db.QueryContext(ctx, `
|
||||||
|
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
|
||||||
|
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
|
||||||
|
FROM email_queue
|
||||||
|
WHERE status = 'failed'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []QueueRow
|
||||||
|
for rows.Next() {
|
||||||
|
row, err := scanQueueRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanQueueRow(rows *sql.Rows) (QueueRow, error) {
|
||||||
|
var r QueueRow
|
||||||
|
var sentAt, lastError string
|
||||||
|
var nextAttempt, createdAt string
|
||||||
|
if err := rows.Scan(&r.ID, &r.NotificationID, &r.ToAddress, &r.Subject, &r.Body, &r.Status,
|
||||||
|
&r.Attempts, &r.MaxAttempts, &lastError, &nextAttempt, &sentAt, &createdAt); err != nil {
|
||||||
|
return QueueRow{}, fmt.Errorf("scan queue row: %w", err)
|
||||||
|
}
|
||||||
|
r.LastError = lastError
|
||||||
|
var err error
|
||||||
|
r.NextAttemptAt, err = time.Parse(time.RFC3339, nextAttempt)
|
||||||
|
if err != nil {
|
||||||
|
return QueueRow{}, fmt.Errorf("parse next_attempt_at: %w", err)
|
||||||
|
}
|
||||||
|
r.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return QueueRow{}, fmt.Errorf("parse created_at: %w", err)
|
||||||
|
}
|
||||||
|
if sentAt != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, sentAt)
|
||||||
|
if err == nil {
|
||||||
|
r.SentAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(t time.Time) string {
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateErr(s string) string {
|
||||||
|
if len(s) > 512 {
|
||||||
|
return s[:512] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomQueueID produces an id with enough entropy to avoid collisions. We
|
||||||
|
// keep a small helper in-package to avoid an import cycle on auth.
|
||||||
|
func randomQueueID() (string, error) {
|
||||||
|
id, err := randomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "email:" + id, nil
|
||||||
|
}
|
||||||
248
apps/server/internal/email/queue_test.go
Normal file
248
apps/server/internal/email/queue_test.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.DatabaseConfig{Path: t.TempDir() + "/test.sqlite"}
|
||||||
|
handle, err := database.Open(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = handle.Close() })
|
||||||
|
db := handle.SQL()
|
||||||
|
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
// Seed a user + notification so the queue's notification_id FK is
|
||||||
|
// satisfiable and MarkNotificationEmailed can update a real row.
|
||||||
|
_, err = db.Exec(`INSERT INTO users (id, email, display_name, role, created_at, last_seen_at) VALUES ('user:alice@example.com', 'alice@example.com', 'Alice', 'admin', ?, ?)`,
|
||||||
|
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, created_at) VALUES (?, 'user:alice@example.com', 'file_changed', 'document', 'doc:test', 'test', ?)`,
|
||||||
|
"notif:test-1", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed notification: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_EnqueueAndPendingCount(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
q := NewQueue(db, nil, testLogger(t))
|
||||||
|
|
||||||
|
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Subject", "Body")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected non-empty id")
|
||||||
|
}
|
||||||
|
count, err := q.PendingCount(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PendingCount: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("pending = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_EnqueueValidation(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
q := NewQueue(db, nil, testLogger(t))
|
||||||
|
|
||||||
|
// Empty notification_id is allowed (the column is nullable).
|
||||||
|
if _, err := q.Enqueue(context.Background(), "", "alice@example.com", "s", "b"); err != nil {
|
||||||
|
t.Fatalf("empty notification_id should be allowed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "", "s", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for empty to")
|
||||||
|
}
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "", "b"); err == nil {
|
||||||
|
t.Fatal("expected error for empty subject")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingSender struct {
|
||||||
|
sends []recordedSend
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordedSend struct {
|
||||||
|
to []string
|
||||||
|
subject string
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
if r.err != nil {
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
r.sends = append(r.sends, recordedSend{to: to, subject: subject, body: body})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_DeliverSuccess(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
sender := &recordingSender{}
|
||||||
|
q := NewQueue(db, sender, testLogger(t))
|
||||||
|
q.SetPollInterval(20 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Hello", "World"); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go q.Run(ctx)
|
||||||
|
|
||||||
|
if err := waitFor(ctx, func() bool {
|
||||||
|
n, _ := q.PendingCount(ctx)
|
||||||
|
return n == 0
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("email never sent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sender.sends) != 1 {
|
||||||
|
t.Fatalf("sends = %d, want 1", len(sender.sends))
|
||||||
|
}
|
||||||
|
if sender.sends[0].subject != "Hello" {
|
||||||
|
t.Errorf("subject = %q", sender.sends[0].subject)
|
||||||
|
}
|
||||||
|
if sender.sends[0].body != "World" {
|
||||||
|
t.Errorf("body = %q", sender.sends[0].body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The linked notification should now have emailed_at stamped.
|
||||||
|
var emailedAt string
|
||||||
|
if err := db.QueryRow(`SELECT COALESCE(emailed_at, '') FROM notifications WHERE id = ?`, "notif:test-1").Scan(&emailedAt); err != nil {
|
||||||
|
t.Fatalf("query emailed_at: %v", err)
|
||||||
|
}
|
||||||
|
if emailedAt == "" {
|
||||||
|
t.Error("expected notifications.emailed_at to be set after send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_RetryThenSucceed(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
// Fail twice, then succeed.
|
||||||
|
sender := &flakySender{failTimes: 2}
|
||||||
|
q := NewQueue(db, sender, testLogger(t))
|
||||||
|
q.SetPollInterval(15 * time.Millisecond)
|
||||||
|
q.SetMaxBackoff(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Retry", "Body"); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go q.Run(ctx)
|
||||||
|
|
||||||
|
if err := waitFor(ctx, func() bool {
|
||||||
|
return sender.successCount >= 1
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the row is marked sent.
|
||||||
|
var status string
|
||||||
|
if err := db.QueryRow(`SELECT status FROM email_queue WHERE to_address = ?`, "alice@example.com").Scan(&status); err != nil {
|
||||||
|
t.Fatalf("query status: %v", err)
|
||||||
|
}
|
||||||
|
if status != "sent" {
|
||||||
|
t.Errorf("status = %q, want sent", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_PermanentFailure(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
sender := &recordingSender{err: errors.New("permanent Postmark rejection")}
|
||||||
|
q := NewQueue(db, sender, testLogger(t))
|
||||||
|
q.SetPollInterval(15 * time.Millisecond)
|
||||||
|
q.SetMaxBackoff(20 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body"); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go q.Run(ctx)
|
||||||
|
|
||||||
|
if err := waitFor(ctx, func() bool {
|
||||||
|
failed, _ := q.ListFailed(ctx, 10)
|
||||||
|
return len(failed) == 1
|
||||||
|
}); err != nil {
|
||||||
|
failed, _ := q.ListFailed(ctx, 10)
|
||||||
|
t.Fatalf("expected 1 failed row, got %d: %v", len(failed), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failed, _ := q.ListFailed(ctx, 10)
|
||||||
|
if !strings.Contains(failed[0].LastError, "permanent") {
|
||||||
|
t.Errorf("last_error = %q", failed[0].LastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueue_NilSenderLeavesPending(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
q := NewQueue(db, nil, testLogger(t))
|
||||||
|
q.SetPollInterval(15 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Pending", "Body"); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
go q.Run(ctx)
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
count, _ := q.PendingCount(context.Background())
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected row to stay pending with nil sender, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flakySender fails the first N attempts then succeeds.
|
||||||
|
type flakySender struct {
|
||||||
|
failTimes int
|
||||||
|
attempts int
|
||||||
|
successCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
f.attempts++
|
||||||
|
if f.attempts <= f.failTimes {
|
||||||
|
return errors.New("transient failure")
|
||||||
|
}
|
||||||
|
f.successCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(ctx context.Context, cond func() bool) error {
|
||||||
|
ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if cond() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
149
apps/server/internal/email/templates.go
Normal file
149
apps/server/internal/email/templates.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplateData carries the context required to render any notification email.
|
||||||
|
// Fields are optional per template; callers should populate what they have.
|
||||||
|
type TemplateData struct {
|
||||||
|
ActorName string
|
||||||
|
ActorEmail string
|
||||||
|
DocumentPath string
|
||||||
|
DocumentTitle string
|
||||||
|
CommentBody string
|
||||||
|
MentionText string
|
||||||
|
ConflictDesc string
|
||||||
|
ViewURL string
|
||||||
|
UnsubURL string
|
||||||
|
InstanceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render returns a plain-text subject and body for the given notification
|
||||||
|
// type. Subjects are short and prefixed with the document path so email
|
||||||
|
// clients thread per-document.
|
||||||
|
func Render(kind string, data TemplateData) (subject, body string, err error) {
|
||||||
|
if data.InstanceName == "" {
|
||||||
|
data.InstanceName = "Cairnquire"
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "file_changed":
|
||||||
|
return renderFileChanged(data)
|
||||||
|
case "comment":
|
||||||
|
return renderComment(data)
|
||||||
|
case "mention":
|
||||||
|
return renderMention(data)
|
||||||
|
case "conflict":
|
||||||
|
return renderConflict(data)
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("unknown email template kind %q", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subjectPrefix(data TemplateData) string {
|
||||||
|
if data.DocumentPath != "" {
|
||||||
|
return "[" + data.DocumentPath + "]"
|
||||||
|
}
|
||||||
|
return "[" + data.InstanceName + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func footer(data TemplateData) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\n--\n")
|
||||||
|
if data.ViewURL != "" {
|
||||||
|
fmt.Fprintf(&b, "View online: %s\n", data.ViewURL)
|
||||||
|
}
|
||||||
|
if data.UnsubURL != "" {
|
||||||
|
fmt.Fprintf(&b, "Unsubscribe: %s\n", data.UnsubURL)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "You receive this because you watch this document on %s.\n", data.InstanceName)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFileChanged(data TemplateData) (string, string, error) {
|
||||||
|
title := data.DocumentTitle
|
||||||
|
if title == "" {
|
||||||
|
title = data.DocumentPath
|
||||||
|
}
|
||||||
|
actor := data.ActorName
|
||||||
|
if actor == "" {
|
||||||
|
actor = "Someone"
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("%s Updated by %s", subjectPrefix(data), actor)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s updated %q at %s UTC.\n", actor, title, time.Now().UTC().Format("2006-01-02 15:04"))
|
||||||
|
if data.DocumentPath != "" {
|
||||||
|
fmt.Fprintf(&b, "Path: %s\n", data.DocumentPath)
|
||||||
|
}
|
||||||
|
b.WriteString("\nView the changes online to see what is new.\n")
|
||||||
|
b.WriteString(footer(data))
|
||||||
|
return subject, b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderComment(data TemplateData) (string, string, error) {
|
||||||
|
actor := data.ActorName
|
||||||
|
if actor == "" {
|
||||||
|
actor = "Someone"
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("%s Comment from %s", subjectPrefix(data), actor)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s commented on %q:\n", actor, docLabel(data))
|
||||||
|
b.WriteString("\n")
|
||||||
|
if data.CommentBody != "" {
|
||||||
|
// Quote the comment body, line by line, as plain text.
|
||||||
|
for _, line := range strings.Split(data.CommentBody, "\n") {
|
||||||
|
fmt.Fprintf(&b, "> %s\n", line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteString("> (no body)\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\nReply to this email to respond.\n")
|
||||||
|
b.WriteString(footer(data))
|
||||||
|
return subject, b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMention(data TemplateData) (string, string, error) {
|
||||||
|
actor := data.ActorName
|
||||||
|
if actor == "" {
|
||||||
|
actor = "Someone"
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("%s Mention from %s", subjectPrefix(data), actor)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s mentioned you on %q.\n", actor, docLabel(data))
|
||||||
|
if data.MentionText != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, line := range strings.Split(data.MentionText, "\n") {
|
||||||
|
fmt.Fprintf(&b, "> %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(footer(data))
|
||||||
|
return subject, b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderConflict(data TemplateData) (string, string, error) {
|
||||||
|
subject := fmt.Sprintf("%s Sync conflict", subjectPrefix(data))
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "A sync conflict was detected on %q.\n", docLabel(data))
|
||||||
|
if data.ConflictDesc != "" {
|
||||||
|
fmt.Fprintf(&b, "\nDetails: %s\n", data.ConflictDesc)
|
||||||
|
}
|
||||||
|
b.WriteString("\nOpen the document to review and resolve the conflict.\n")
|
||||||
|
b.WriteString(footer(data))
|
||||||
|
return subject, b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func docLabel(data TemplateData) string {
|
||||||
|
if data.DocumentTitle != "" {
|
||||||
|
return data.DocumentTitle
|
||||||
|
}
|
||||||
|
if data.DocumentPath != "" {
|
||||||
|
return data.DocumentPath
|
||||||
|
}
|
||||||
|
return "a document"
|
||||||
|
}
|
||||||
119
apps/server/internal/email/templates_test.go
Normal file
119
apps/server/internal/email/templates_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRender_FileChanged(t *testing.T) {
|
||||||
|
subject, body, err := Render("file_changed", TemplateData{
|
||||||
|
ActorName: "Alice",
|
||||||
|
DocumentPath: "docs/api.md",
|
||||||
|
DocumentTitle: "API Reference",
|
||||||
|
ViewURL: "https://example.com/docs/api.md",
|
||||||
|
UnsubURL: "https://example.com/unsub/123",
|
||||||
|
InstanceName: "Cairnquire",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "docs/api.md") {
|
||||||
|
t.Errorf("subject should include path: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Alice") {
|
||||||
|
t.Errorf("subject should include actor: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Alice") {
|
||||||
|
t.Errorf("body should mention actor: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "API Reference") {
|
||||||
|
t.Errorf("body should mention title: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "https://example.com/docs/api.md") {
|
||||||
|
t.Errorf("body should include view url: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "https://example.com/unsub/123") {
|
||||||
|
t.Errorf("body should include unsub url: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_Comment(t *testing.T) {
|
||||||
|
subject, body, err := Render("comment", TemplateData{
|
||||||
|
ActorName: "Bob",
|
||||||
|
DocumentTitle: "Getting Started",
|
||||||
|
CommentBody: "This step is unclear.\nCan we add an example?",
|
||||||
|
ViewURL: "https://example.com/docs/getting-started",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Bob") {
|
||||||
|
t.Errorf("subject should include actor: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "> This step is unclear.") {
|
||||||
|
t.Errorf("body should quote comment: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "> Can we add an example?") {
|
||||||
|
t.Errorf("body should quote second line: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Reply to this email") {
|
||||||
|
t.Errorf("body should mention reply-to-respond: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_Mention(t *testing.T) {
|
||||||
|
subject, body, err := Render("mention", TemplateData{
|
||||||
|
ActorName: "Carol",
|
||||||
|
DocumentTitle: "Spec",
|
||||||
|
MentionText: "@dan please review",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Mention") {
|
||||||
|
t.Errorf("subject: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "> @dan please review") {
|
||||||
|
t.Errorf("body should quote mention: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_Conflict(t *testing.T) {
|
||||||
|
subject, body, err := Render("conflict", TemplateData{
|
||||||
|
DocumentPath: "notes/2024.md",
|
||||||
|
ConflictDesc: "Local and remote both edited line 12",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "conflict") {
|
||||||
|
t.Errorf("subject: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "sync conflict") {
|
||||||
|
t.Errorf("body should mention conflict: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "line 12") {
|
||||||
|
t.Errorf("body should include conflict desc: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_UnknownKind(t *testing.T) {
|
||||||
|
_, _, err := Render("bogus", TemplateData{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_PlainTextOnly(t *testing.T) {
|
||||||
|
for _, kind := range []string{"file_changed", "comment", "mention", "conflict"} {
|
||||||
|
_, body, err := Render(kind, TemplateData{
|
||||||
|
ActorName: "A", DocumentTitle: "T", CommentBody: "<b>html</b>",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render %s: %v", kind, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "<html>") || strings.Contains(body, "<body>") {
|
||||||
|
t.Errorf("template %s produced HTML: %q", kind, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/server/internal/email/test_helpers_test.go
Normal file
18
apps/server/internal/email/test_helpers_test.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger(t *testing.T) *slog.Logger {
|
||||||
|
t.Helper()
|
||||||
|
return slog.New(slog.NewTextHandler(&testWriter{t: t}, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
}
|
||||||
|
|
||||||
|
type testWriter struct{ t *testing.T }
|
||||||
|
|
||||||
|
func (w *testWriter) Write(p []byte) (int, error) {
|
||||||
|
w.t.Logf("%s", p)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ type adminUserInput struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminSettingsInput struct {
|
||||||
|
SignupsEnabled bool `json:"signupsEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var input adminUserInput
|
var input adminUserInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
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,
|
"databasePath": s.config.Database.Path,
|
||||||
"documentCount": len(documents),
|
"documentCount": len(documents),
|
||||||
"userCount": len(users),
|
"userCount": len(users),
|
||||||
"webDistDir": s.config.Web.DistDir,
|
|
||||||
},
|
},
|
||||||
"documents": documents,
|
"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) {
|
func (s *Server) normalizeAdminUserInput(input adminUserInput) (docs.UserRecord, bool) {
|
||||||
email := strings.TrimSpace(strings.ToLower(input.Email))
|
email := strings.TrimSpace(strings.ToLower(input.Email))
|
||||||
if email == "" {
|
if email == "" {
|
||||||
|
|||||||
954
apps/server/internal/httpserver/api_handlers_test.go
Normal file
954
apps/server/internal/httpserver/api_handlers_test.go
Normal file
@@ -0,0 +1,954 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testEmailSender struct {
|
||||||
|
body string
|
||||||
|
to []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||||
|
s.to = append([]string(nil), to...)
|
||||||
|
s.body = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiTestServer struct {
|
||||||
|
handler http.Handler
|
||||||
|
auth *auth.Service
|
||||||
|
docs *docs.Service
|
||||||
|
userID string
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAPITestServer(t *testing.T) *apiTestServer {
|
||||||
|
return newAPITestServerWithSetup(t, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
|
||||||
|
return newAPITestServerWithSetupAndDevMode(t, setupComplete, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMode bool) *apiTestServer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
sourceDir := filepath.Join(root, "content")
|
||||||
|
storeDir := filepath.Join(root, "files")
|
||||||
|
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("create source dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nInitial"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write source fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(root, "db.sqlite")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open test database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
contentStore, err := store.New(storeDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create content store: %v", err)
|
||||||
|
}
|
||||||
|
docRepo := docs.NewRepository(db.SQL())
|
||||||
|
docService := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), docRepo, logger)
|
||||||
|
if _, err := docService.SyncSourceDir(ctx); err != nil {
|
||||||
|
t.Fatalf("sync source fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authService, err := auth.NewService(auth.NewRepository(db.SQL()), "http://localhost:8080")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create auth service: %v", err)
|
||||||
|
}
|
||||||
|
var user auth.User
|
||||||
|
if setupComplete {
|
||||||
|
user, err = authService.CompleteInitialSetup(ctx, "editor@example.com", "Editor", "very secure password", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create test user: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRepo := sync.NewRepository(db.SQL())
|
||||||
|
handler, err := New(Dependencies{
|
||||||
|
Config: config.Config{
|
||||||
|
Content: config.ContentConfig{
|
||||||
|
SourceDir: sourceDir,
|
||||||
|
StoreDir: storeDir,
|
||||||
|
},
|
||||||
|
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
||||||
|
DevMode: devMode,
|
||||||
|
},
|
||||||
|
Logger: logger,
|
||||||
|
Documents: docService,
|
||||||
|
Repository: docRepo,
|
||||||
|
ContentStore: contentStore,
|
||||||
|
Hub: realtime.NewHub(logger),
|
||||||
|
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
|
||||||
|
SyncRepo: syncRepo,
|
||||||
|
Auth: authService,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create http server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &apiTestServer{
|
||||||
|
handler: handler,
|
||||||
|
auth: authService,
|
||||||
|
docs: docService,
|
||||||
|
userID: user.ID,
|
||||||
|
root: root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevModeLogoutSuppressesAutomaticBrowserPrincipal(t *testing.T) {
|
||||||
|
server := newAPITestServerWithSetupAndDevMode(t, true, true)
|
||||||
|
|
||||||
|
devMe := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(devMe, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||||
|
if devMe.Code != http.StatusOK || !bytes.Contains(devMe.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||||
|
t.Fatalf("dev me response = %d %q, want dev principal", devMe.Code, devMe.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
logout := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(logout, jsonRequest(http.MethodPost, "/api/auth/logout", map[string]any{}))
|
||||||
|
if logout.Code != http.StatusOK {
|
||||||
|
t.Fatalf("logout response = %d %q, want OK", logout.Code, logout.Body.String())
|
||||||
|
}
|
||||||
|
devLogoutCookie := cookieByName(t, logout.Result().Cookies(), devLogoutCookieName)
|
||||||
|
if devLogoutCookie.Value != "1" {
|
||||||
|
t.Fatalf("dev logout cookie = %#v, want opt-out marker", devLogoutCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
loggedOutMeRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||||
|
loggedOutMeRequest.AddCookie(devLogoutCookie)
|
||||||
|
loggedOutMe := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(loggedOutMe, loggedOutMeRequest)
|
||||||
|
if loggedOutMe.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("logged out me response = %d %q, want unauthorized", loggedOutMe.Code, loggedOutMe.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
loginPageRequest.AddCookie(devLogoutCookie)
|
||||||
|
loginPage := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(loginPage, loginPageRequest)
|
||||||
|
if loginPage.Code != http.StatusOK || !bytes.Contains(loginPage.Body.Bytes(), []byte("data-dev-login")) {
|
||||||
|
t.Fatalf("login page response = %d %q, want dev login button", loginPage.Code, loginPage.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
devLoginRequest := jsonRequest(http.MethodPost, "/api/auth/login/dev", map[string]any{})
|
||||||
|
devLoginRequest.AddCookie(devLogoutCookie)
|
||||||
|
devLogin := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(devLogin, devLoginRequest)
|
||||||
|
if devLogin.Code != http.StatusOK || !bytes.Contains(devLogin.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||||
|
t.Fatalf("dev login response = %d %q, want dev principal", devLogin.Code, devLogin.Body.String())
|
||||||
|
}
|
||||||
|
clearedByDevLogin := cookieByName(t, devLogin.Result().Cookies(), devLogoutCookieName)
|
||||||
|
if clearedByDevLogin.MaxAge != -1 {
|
||||||
|
t.Fatalf("dev login cookie = %#v, want MaxAge -1", clearedByDevLogin)
|
||||||
|
}
|
||||||
|
|
||||||
|
devTokenRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||||
|
devTokenRequest.AddCookie(devLogoutCookie)
|
||||||
|
devTokenRequest.Header.Set("Authorization", "Bearer dev")
|
||||||
|
devToken := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(devToken, devTokenRequest)
|
||||||
|
if devToken.Code != http.StatusOK || !bytes.Contains(devToken.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||||
|
t.Fatalf("dev token response = %d %q, want dev principal", devToken.Code, devToken.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
loginRequest := jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||||
|
"email": "editor@example.com",
|
||||||
|
"password": "very secure password",
|
||||||
|
})
|
||||||
|
loginRequest.AddCookie(devLogoutCookie)
|
||||||
|
login := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(login, loginRequest)
|
||||||
|
if login.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login response = %d %q, want OK", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
sessionCookie := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||||
|
if sessionCookie.Value == "" {
|
||||||
|
t.Fatalf("session cookie = %#v, want value", sessionCookie)
|
||||||
|
}
|
||||||
|
clearedDevLogoutCookie := cookieByName(t, login.Result().Cookies(), devLogoutCookieName)
|
||||||
|
if clearedDevLogoutCookie.MaxAge != -1 {
|
||||||
|
t.Fatalf("cleared dev logout cookie = %#v, want MaxAge -1", clearedDevLogoutCookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cookieByName(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == name {
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("response did not include cookie %q; cookies=%#v", name, cookies)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
|
||||||
|
server := newAPITestServerWithSetup(t, false)
|
||||||
|
|
||||||
|
index := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if index.Code != http.StatusSeeOther || index.Header().Get("Location") != "/setup" {
|
||||||
|
t.Fatalf("initial index response = %d %q, want redirect to /setup", index.Code, index.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
setupPage := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(setupPage, httptest.NewRequest(http.MethodGet, "/setup", nil))
|
||||||
|
if setupPage.Code != http.StatusOK || !bytes.Contains(setupPage.Body.Bytes(), []byte("Initial setup")) {
|
||||||
|
t.Fatalf("setup page response = %d %q", setupPage.Code, setupPage.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
setup := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(setup, jsonRequest(http.MethodPost, "/api/setup", map[string]any{
|
||||||
|
"email": "owner@example.com",
|
||||||
|
"displayName": "Owner",
|
||||||
|
"password": "correct horse battery staple",
|
||||||
|
"signupsEnabled": false,
|
||||||
|
}))
|
||||||
|
if setup.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("setup status = %d, want %d; body=%s", setup.Code, http.StatusCreated, setup.Body.String())
|
||||||
|
}
|
||||||
|
if len(setup.Result().Cookies()) == 0 {
|
||||||
|
t.Fatal("expected setup to create a browser session")
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := server.auth.GetInstanceSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInstanceSettings() error = %v", err)
|
||||||
|
}
|
||||||
|
if !settings.SetupComplete || settings.SignupsEnabled {
|
||||||
|
t.Fatalf("settings = %#v, want setup complete with signups disabled", settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
register := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(register, jsonRequest(http.MethodPost, "/api/auth/register/password", map[string]any{
|
||||||
|
"email": "viewer@example.com",
|
||||||
|
"displayName": "Viewer",
|
||||||
|
"password": "correct horse battery staple",
|
||||||
|
}))
|
||||||
|
if register.Code != http.StatusBadRequest || !bytes.Contains(register.Body.Bytes(), []byte("signups are disabled")) {
|
||||||
|
t.Fatalf("register response = %d %q, want disabled signup error", register.Code, register.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
|
||||||
|
login := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||||
|
"email": "editor@example.com",
|
||||||
|
"password": "very secure password",
|
||||||
|
}))
|
||||||
|
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||||
|
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||||
|
|
||||||
|
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||||
|
accountRequest.AddCookie(session)
|
||||||
|
account := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(account, accountRequest)
|
||||||
|
if account.Code != http.StatusOK {
|
||||||
|
t.Fatalf("account status = %d, want %d", account.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if bytes.Contains(account.Body.Bytes(), []byte("data-token-create")) {
|
||||||
|
t.Fatal("account page should not render the token creation form")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(account.Body.Bytes(), []byte(`href="/account/tokens/new"`)) {
|
||||||
|
t.Fatal("account page should link to the token creation page")
|
||||||
|
}
|
||||||
|
|
||||||
|
createRequest := httptest.NewRequest(http.MethodGet, "/account/tokens/new", nil)
|
||||||
|
createRequest.AddCookie(session)
|
||||||
|
create := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(create, createRequest)
|
||||||
|
if create.Code != http.StatusOK || !bytes.Contains(create.Body.Bytes(), []byte("data-token-create")) {
|
||||||
|
t.Fatalf("token creation page response = %d %q", create.Code, create.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordUserCanBeginPasskeyRegistrationFromAccount(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
|
||||||
|
login := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||||
|
"email": "editor@example.com",
|
||||||
|
"password": "very secure password",
|
||||||
|
}))
|
||||||
|
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||||
|
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||||
|
|
||||||
|
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||||
|
accountRequest.AddCookie(session)
|
||||||
|
account := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(account, accountRequest)
|
||||||
|
if account.Code != http.StatusOK || !bytes.Contains(account.Body.Bytes(), []byte("data-passkey-add")) {
|
||||||
|
t.Fatalf("account page response = %d %q, want add-passkey form", account.Code, account.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
beginRequest := jsonRequest(http.MethodPost, "/api/auth/passkeys/me/register/begin", map[string]any{})
|
||||||
|
beginRequest.AddCookie(session)
|
||||||
|
begin := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(begin, beginRequest)
|
||||||
|
if begin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("passkey begin response = %d %q", begin.Code, begin.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ChallengeID string `json:"challengeId"`
|
||||||
|
Options any `json:"options"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(begin.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode passkey begin response: %v", err)
|
||||||
|
}
|
||||||
|
if body.ChallengeID == "" || body.Options == nil {
|
||||||
|
t.Fatalf("passkey begin body = %#v, want challenge and options", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicBegin := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(publicBegin, jsonRequest(http.MethodPost, "/api/auth/passkeys/register/begin", map[string]string{
|
||||||
|
"email": "editor@example.com",
|
||||||
|
}))
|
||||||
|
if publicBegin.Code == http.StatusOK {
|
||||||
|
t.Fatalf("public passkey begin response = %d %q, want failure for existing account", publicBegin.Code, publicBegin.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
sender := &testEmailSender{}
|
||||||
|
server.auth.SetEmailSender(sender)
|
||||||
|
|
||||||
|
login := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||||
|
"email": "editor@example.com",
|
||||||
|
"password": "very secure password",
|
||||||
|
}))
|
||||||
|
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||||
|
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||||
|
}
|
||||||
|
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||||
|
|
||||||
|
updateRequest := jsonRequest(http.MethodPatch, "/api/admin/auth-users", map[string]any{
|
||||||
|
"users": []map[string]any{{
|
||||||
|
"id": server.userID,
|
||||||
|
"role": "admin",
|
||||||
|
"disabled": false,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
updateRequest.AddCookie(session)
|
||||||
|
update := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(update, updateRequest)
|
||||||
|
if update.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bulk user update response = %d %q", update.Code, update.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRequest := jsonRequest(http.MethodPost, "/api/admin/auth-users/"+url.PathEscape(server.userID)+"/password-reset", map[string]any{})
|
||||||
|
sendRequest.AddCookie(session)
|
||||||
|
send := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(send, sendRequest)
|
||||||
|
if send.Code != http.StatusOK {
|
||||||
|
t.Fatalf("send password reset response = %d %q", send.Code, send.Body.String())
|
||||||
|
}
|
||||||
|
token := passwordResetTokenFromEmail(t, sender.body)
|
||||||
|
|
||||||
|
page := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/password-reset?token="+url.QueryEscape(token), nil))
|
||||||
|
if page.Code != http.StatusOK || !bytes.Contains(page.Body.Bytes(), []byte("data-password-reset")) {
|
||||||
|
t.Fatalf("password reset page response = %d %q", page.Code, page.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
reset := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(reset, jsonRequest(http.MethodPost, "/api/auth/password/reset", map[string]string{
|
||||||
|
"token": token,
|
||||||
|
"newPassword": "new very secure password",
|
||||||
|
}))
|
||||||
|
if reset.Code != http.StatusOK {
|
||||||
|
t.Fatalf("password reset response = %d %q", reset.Code, reset.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
newLogin := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(newLogin, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||||
|
"email": "editor@example.com",
|
||||||
|
"password": "new very secure password",
|
||||||
|
}))
|
||||||
|
if newLogin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("new password login response = %d %q", newLogin.Code, newLogin.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwordResetTokenFromEmail(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
if !strings.HasPrefix(line, "http") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse password reset URL: %v", err)
|
||||||
|
}
|
||||||
|
if token := parsed.Query().Get("token"); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("password reset body does not contain a link: %q", body)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
|
||||||
|
t.Helper()
|
||||||
|
return s.tokenForUser(t, s.userID, scopes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *apiTestServer) tokenForUser(t *testing.T, userID string, scopes ...auth.Scope) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
created, err := s.auth.CreateAPIKey(context.Background(), userID, "test token", scopes, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create api token: %v", err)
|
||||||
|
}
|
||||||
|
return created.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *apiTestServer) viewerUser(t *testing.T) auth.User {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := auth.Principal{UserID: s.userID, Role: auth.RoleAdmin}
|
||||||
|
if _, err := s.auth.UpdateSignupsEnabled(ctx, admin, true); err != nil {
|
||||||
|
t.Fatalf("enable signups: %v", err)
|
||||||
|
}
|
||||||
|
user, err := s.auth.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create viewer: %v", err)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||||
|
|
||||||
|
body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
|
||||||
|
request.Header.Set("Content-Type", contentType)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
response := recorder.Result()
|
||||||
|
if response.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("upload status = %d, want %d; body=%s", response.StatusCode, http.StatusCreated, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
AttachmentURL string `json:"attachmentUrl"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode upload response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Hash == "" {
|
||||||
|
t.Fatal("expected upload response hash")
|
||||||
|
}
|
||||||
|
if payload.ContentType != "application/pdf" {
|
||||||
|
t.Fatalf("contentType = %q, want application/pdf", payload.ContentType)
|
||||||
|
}
|
||||||
|
if payload.AttachmentURL != "/attachments/"+payload.Hash {
|
||||||
|
t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
download := httptest.NewRequest(http.MethodGet, payload.AttachmentURL, nil)
|
||||||
|
downloadRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(downloadRecorder, download)
|
||||||
|
|
||||||
|
downloadResponse := downloadRecorder.Result()
|
||||||
|
if downloadResponse.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("download status = %d, want %d", downloadResponse.StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType {
|
||||||
|
t.Fatalf("download content-type = %q, want %q", got, payload.ContentType)
|
||||||
|
}
|
||||||
|
if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.pdf"` {
|
||||||
|
t.Fatalf("content-disposition = %q, want sanitized filename", got)
|
||||||
|
}
|
||||||
|
if downloadRecorder.Body.String() != "%PDF-1.4\n" {
|
||||||
|
t.Fatalf("download body = %q", downloadRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDocumentsArePrivateUntilResourceGrant(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
viewer := server.viewerUser(t)
|
||||||
|
token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead)
|
||||||
|
|
||||||
|
listRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||||
|
listRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
listRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(listRecorder, listRequest)
|
||||||
|
if listRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("documents status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(listRecorder.Body.String(), "hello.md") {
|
||||||
|
t.Fatalf("private document leaked before grant: %s", listRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
|
||||||
|
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
|
||||||
|
{UserID: viewer.ID, Permission: auth.PermissionRead},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("grant root folder read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listRequest = httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||||
|
listRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
listRecorder = httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(listRecorder, listRequest)
|
||||||
|
if listRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("documents after grant status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(listRecorder.Body.String(), "hello.md") {
|
||||||
|
t.Fatalf("document missing after folder grant: %s", listRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||||
|
|
||||||
|
body, contentType := multipartBody(t, "file", "note.txt", []byte("plain attachment\n"))
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
|
||||||
|
request.Header.Set("Content-Type", contentType)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISyncSnapshotInheritsResourcePermissions(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
viewer := server.viewerUser(t)
|
||||||
|
token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||||
|
|
||||||
|
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
|
||||||
|
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
initRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||||
|
if initRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(initRecorder.Body.String(), "hello.md") {
|
||||||
|
t.Fatalf("private sync snapshot leaked before grant: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
|
||||||
|
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
|
||||||
|
{UserID: viewer.ID, Permission: auth.PermissionRead},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("grant root folder read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-2"})
|
||||||
|
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
initRecorder = httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||||
|
if initRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(initRecorder.Body.String(), "hello.md") {
|
||||||
|
t.Fatalf("sync snapshot missing document after folder grant: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISyncSnapshotHandlesUnindexedDiskFilesByFolderPermission(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
viewer := server.viewerUser(t)
|
||||||
|
token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(server.root, "content", "unindexed.md"), []byte("# Unindexed\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write unindexed document: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-1"})
|
||||||
|
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
initRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||||
|
if initRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(initRecorder.Body.String(), "sql: no rows") {
|
||||||
|
t.Fatalf("sync init leaked missing document row: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(initRecorder.Body.String(), "unindexed.md") {
|
||||||
|
t.Fatalf("private unindexed document leaked before grant: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
|
||||||
|
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
|
||||||
|
{UserID: viewer.ID, Permission: auth.PermissionRead},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("grant root folder read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-2"})
|
||||||
|
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
initRecorder = httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||||
|
if initRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(initRecorder.Body.String(), "unindexed.md") {
|
||||||
|
t.Fatalf("sync snapshot missing unindexed document after folder grant: %s", initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||||
|
|
||||||
|
request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync init status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
SnapshotID string `json:"snapshotId"`
|
||||||
|
ServerSnapshot []sync.FileEntry `json:"serverSnapshot"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode sync init response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.SnapshotID == "" {
|
||||||
|
t.Fatal("expected snapshot id")
|
||||||
|
}
|
||||||
|
if len(payload.ServerSnapshot) != 1 || payload.ServerSnapshot[0].Path != "hello.md" {
|
||||||
|
t.Fatalf("server snapshot = %#v, want hello.md entry", payload.ServerSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch := httptest.NewRequest(http.MethodGet, "/api/content/"+payload.ServerSnapshot[0].Hash, nil)
|
||||||
|
fetch.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
fetchRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(fetchRecorder, fetch)
|
||||||
|
|
||||||
|
if fetchRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("content fetch status = %d, want %d", fetchRecorder.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if fetchRecorder.Body.String() != "# Hello\n\nInitial" {
|
||||||
|
t.Fatalf("content fetch body = %q", fetchRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISyncInitRejectsTokenWithoutSyncWriteScope(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||||
|
|
||||||
|
request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||||
|
|
||||||
|
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
|
||||||
|
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
initRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||||
|
if initRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var initPayload struct {
|
||||||
|
SnapshotID string `json:"snapshotId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(initRecorder.Body.Bytes(), &initPayload); err != nil {
|
||||||
|
t.Fatalf("decode sync init response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer update"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write server update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deltaRequest := jsonRequest(http.MethodPost, "/api/sync/delta", map[string]any{
|
||||||
|
"snapshotId": initPayload.SnapshotID,
|
||||||
|
"clientDelta": []sync.Change{},
|
||||||
|
})
|
||||||
|
deltaRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
deltaRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(deltaRecorder, deltaRequest)
|
||||||
|
|
||||||
|
if deltaRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sync delta status = %d, want %d; body=%s", deltaRecorder.Code, http.StatusOK, deltaRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var deltaPayload struct {
|
||||||
|
ServerDelta []sync.Change `json:"serverDelta"`
|
||||||
|
Conflicts []sync.Conflict `json:"conflicts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(deltaRecorder.Body.Bytes(), &deltaPayload); err != nil {
|
||||||
|
t.Fatalf("decode sync delta response: %v", err)
|
||||||
|
}
|
||||||
|
if len(deltaPayload.ServerDelta) != 1 {
|
||||||
|
t.Fatalf("serverDelta = %#v, want one update", deltaPayload.ServerDelta)
|
||||||
|
}
|
||||||
|
change := deltaPayload.ServerDelta[0]
|
||||||
|
if change.Type != sync.ChangeUpdate || change.Path != "hello.md" || change.Hash == "" {
|
||||||
|
t.Fatalf("server delta change = %#v, want update for hello.md with hash", change)
|
||||||
|
}
|
||||||
|
if len(deltaPayload.Conflicts) != 0 {
|
||||||
|
t.Fatalf("conflicts = %#v, want none", deltaPayload.Conflicts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||||
|
|
||||||
|
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||||
|
documentsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
documents := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(documents, documentsRequest)
|
||||||
|
if documents.Code != http.StatusOK {
|
||||||
|
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list struct {
|
||||||
|
Documents []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
} `json:"documents"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatalf("decode documents: %v", err)
|
||||||
|
}
|
||||||
|
if len(list.Documents) != 1 {
|
||||||
|
t.Fatalf("documents = %#v, want one document", list.Documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer edit"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write server edit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
save := jsonRequest(http.MethodPost, "/api/documents/hello", map[string]string{
|
||||||
|
"content": "# Hello\n\nClient edit",
|
||||||
|
"baseHash": list.Documents[0].Hash,
|
||||||
|
})
|
||||||
|
save.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
saveRecorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handler.ServeHTTP(saveRecorder, save)
|
||||||
|
|
||||||
|
if saveRecorder.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusConflict, saveRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflict struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
BaseHash string `json:"baseHash"`
|
||||||
|
CurrentHash string `json:"currentHash"`
|
||||||
|
CurrentContent string `json:"currentContent"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(saveRecorder.Body.Bytes(), &conflict); err != nil {
|
||||||
|
t.Fatalf("decode conflict response: %v", err)
|
||||||
|
}
|
||||||
|
if conflict.Status != "conflict" || conflict.Path != "hello.md" {
|
||||||
|
t.Fatalf("conflict payload = %#v, want conflict for hello.md", conflict)
|
||||||
|
}
|
||||||
|
if conflict.BaseHash != list.Documents[0].Hash {
|
||||||
|
t.Fatalf("baseHash = %q, want %q", conflict.BaseHash, list.Documents[0].Hash)
|
||||||
|
}
|
||||||
|
if conflict.CurrentHash == "" || conflict.CurrentHash == conflict.BaseHash {
|
||||||
|
t.Fatalf("currentHash = %q, baseHash = %q", conflict.CurrentHash, conflict.BaseHash)
|
||||||
|
}
|
||||||
|
if conflict.CurrentContent != "# Hello\n\nServer edit" {
|
||||||
|
t.Fatalf("currentContent = %q", conflict.CurrentContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||||
|
|
||||||
|
save := jsonRequest(http.MethodPost, "/api/documents/inbox/new-note", map[string]string{
|
||||||
|
"content": "# New Note\n\nCreated in browser",
|
||||||
|
})
|
||||||
|
save.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
saveRecorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handler.ServeHTTP(saveRecorder, save)
|
||||||
|
|
||||||
|
if saveRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusOK, saveRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions := httptest.NewRequest(http.MethodGet, "/api/permissions?resourceType=document&resourceId=inbox/new-note.md", nil)
|
||||||
|
permissions.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
permissionsRecorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(permissionsRecorder, permissions)
|
||||||
|
|
||||||
|
if permissionsRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("permissions status = %d, want %d; body=%s", permissionsRecorder.Code, http.StatusOK, permissionsRecorder.Body.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Permissions []auth.ResourcePermission `json:"permissions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(permissionsRecorder.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode permissions: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Permissions) != 1 || payload.Permissions[0].UserID != server.userID || payload.Permissions[0].Permission != auth.PermissionAdmin {
|
||||||
|
t.Fatalf("permissions = %#v, want creator admin grant", payload.Permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
nestedDir := filepath.Join(server.root, "content", "guide")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("create nested source dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(nestedDir, "setup.md"), []byte("# Setup\n\nNested"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write nested source fixture: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
|
||||||
|
t.Fatalf("sync nested source fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/documents/guide/setup.md/archive", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||||
|
documentsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
documents := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(documents, documentsRequest)
|
||||||
|
if documents.Code != http.StatusOK {
|
||||||
|
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if strings.Contains(documents.Body.String(), "guide/setup.md") {
|
||||||
|
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write special source fixture: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
|
||||||
|
t.Fatalf("sync special source fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/documents/"+url.PathEscape("@dani.md")+"/archive", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||||
|
documentsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
documents := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(documents, documentsRequest)
|
||||||
|
if documents.Code != http.StatusOK {
|
||||||
|
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if strings.Contains(documents.Body.String(), "@dani.md") {
|
||||||
|
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile(fieldName, filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(content); err != nil {
|
||||||
|
t.Fatalf("write multipart file: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
return body, writer.FormDataContentType()
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonRequest(method, path string, payload any) *http.Request {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
return request
|
||||||
|
}
|
||||||
@@ -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"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
23
apps/server/internal/httpserver/auth_context.go
Normal file
23
apps/server/internal/httpserver/auth_context.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authContextKey struct{}
|
||||||
|
|
||||||
|
func withPrincipal(ctx context.Context, principal auth.Principal) context.Context {
|
||||||
|
return context.WithValue(ctx, authContextKey{}, principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFromContext(ctx context.Context) (auth.Principal, bool) {
|
||||||
|
principal, ok := ctx.Value(authContextKey{}).(auth.Principal)
|
||||||
|
return principal, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func requirePrincipal(r *http.Request) (auth.Principal, bool) {
|
||||||
|
return principalFromContext(r.Context())
|
||||||
|
}
|
||||||
681
apps/server/internal/httpserver/auth_handlers.go
Normal file
681
apps/server/internal/httpserver/auth_handlers.go
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type passwordRegisterRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwordLoginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type initialSetupRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SignupsEnabled bool `json:"signupsEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passkeyBeginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passkeyFinishRequest struct {
|
||||||
|
ChallengeID string `json:"challengeId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTokenRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scopes []auth.Scope `json:"scopes"`
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceStartRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scopes []auth.Scope `json:"scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type changePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwordResetRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteAccountRequest struct {
|
||||||
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateUserRoleRequest struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := requirePrincipal(r); ok {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextPath := r.URL.Query().Get("next")
|
||||||
|
if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
|
||||||
|
nextPath = "/account"
|
||||||
|
}
|
||||||
|
settings, err := s.auth.GetInstanceSettings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "load registration settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "login.gohtml", layoutData{
|
||||||
|
Title: "Sign in",
|
||||||
|
BodyClass: "page-auth",
|
||||||
|
BodyTemplate: "login_content",
|
||||||
|
Data: struct {
|
||||||
|
Next string
|
||||||
|
SignupsEnabled bool
|
||||||
|
DevMode bool
|
||||||
|
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled, DevMode: s.config.DevMode},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := s.auth.GetInstanceSettings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "load initial setup state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if settings.SetupComplete {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "setup.gohtml", layoutData{
|
||||||
|
Title: "Initial setup",
|
||||||
|
BodyClass: "page-auth",
|
||||||
|
BodyTemplate: "setup_content",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswordResetPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "password_reset.gohtml", layoutData{
|
||||||
|
Title: "Reset password",
|
||||||
|
BodyClass: "page-auth",
|
||||||
|
BodyTemplate: "password_reset_content",
|
||||||
|
Data: struct {
|
||||||
|
Token string
|
||||||
|
}{Token: strings.TrimSpace(r.URL.Query().Get("token"))},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req initialSetupRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.auth.CompleteInitialSetup(r.Context(), req.Email, req.DisplayName, req.Password, req.SignupsEnabled); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "initial admin created; sign in to continue"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, principal.ExpiresAt)
|
||||||
|
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"principal": principal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{
|
||||||
|
Title: "Account",
|
||||||
|
BodyClass: "page-account",
|
||||||
|
BodyTemplate: "account_content",
|
||||||
|
Data: principal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTokenCreatePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "token_create.gohtml", layoutData{
|
||||||
|
Title: "Create access token",
|
||||||
|
BodyClass: "page-account",
|
||||||
|
BodyTemplate: "token_create_content",
|
||||||
|
Data: principal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswordRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passwordRegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.auth.RegisterPasswordUser(r.Context(), req.Email, req.DisplayName, req.Password, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": publicUser(user)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passwordLoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, principal.ExpiresAt)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if principal.SessionID != "" {
|
||||||
|
_ = s.auth.RevokeSession(r.Context(), principal.SessionID)
|
||||||
|
}
|
||||||
|
clearSessionCookie(w)
|
||||||
|
if s.config.DevMode {
|
||||||
|
setDevLogoutCookie(w, r)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDevLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.config.DevMode || s.devUserID == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "dev login is not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearDevLogoutCookie(w)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req changePasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.auth.ChangePassword(r.Context(), principal, req.CurrentPassword, req.NewPassword); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passwordResetRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.auth.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req deleteAccountRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.auth.DeleteAccount(r.Context(), principal, req.CurrentPassword); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearSessionCookie(w)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "account_deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passkeyBeginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, challengeID, err := s.auth.BeginPasskeyRegistration(r.Context(), req.Email, req.DisplayName, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
challengeID := r.URL.Query().Get("challengeId")
|
||||||
|
if challengeID == "" {
|
||||||
|
var req passkeyFinishRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
challengeID = req.ChallengeID
|
||||||
|
}
|
||||||
|
if challengeID == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.auth.FinishPasskeyRegistration(r.Context(), challengeID, r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCurrentUserPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, challengeID, err := s.auth.BeginCurrentUserPasskeyRegistration(r.Context(), principal)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCurrentUserPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
challengeID := r.URL.Query().Get("challengeId")
|
||||||
|
if challengeID == "" {
|
||||||
|
var req passkeyFinishRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
challengeID = req.ChallengeID
|
||||||
|
}
|
||||||
|
if challengeID == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.auth.FinishCurrentUserPasskeyRegistration(r.Context(), principal, challengeID, r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passkeyBeginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, challengeID, err := s.auth.BeginPasskeyLogin(r.Context(), req.Email)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
challengeID := r.URL.Query().Get("challengeId")
|
||||||
|
if challengeID == "" {
|
||||||
|
var req passkeyFinishRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
challengeID = req.ChallengeID
|
||||||
|
}
|
||||||
|
if challengeID == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, token, err := s.auth.FinishPasskeyLogin(r.Context(), challengeID, r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, principal.ExpiresAt)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys, err := s.auth.ListAPIKeys(r.Context(), principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tokens": keys})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := s.auth.CreateAPIKey(r.Context(), principal.UserID, req.Name, req.Scopes, req.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyID := chi.URLParam(r, "id")
|
||||||
|
if !strings.HasPrefix(keyID, "api:") {
|
||||||
|
keyID = "api:" + keyID
|
||||||
|
}
|
||||||
|
if err := s.auth.RevokeAPIKey(r.Context(), principal.UserID, keyID); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "token not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req deviceStartRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start, err := s.auth.StartDeviceFlow(r.Context(), req.Name, req.Scopes)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, start)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserCode string `json:"userCode"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
device, err := s.auth.ApproveDeviceFlow(r.Context(), principal, req.UserCode)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "approved", "device": map[string]any{"name": device.Name, "scopes": device.Scopes}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
DeviceCode string `json:"deviceCode"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, code, err := s.auth.PollDeviceFlow(r.Context(), req.DeviceCode)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if code == "authorization_pending" {
|
||||||
|
status = http.StatusAccepted
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, status, map[string]string{"error": code})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userCode := template.HTMLEscapeString(r.URL.Query().Get("user_code"))
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<!doctype html><html><body><main><h1>Approve Device</h1><p>Sign in, then approve this code with <code>POST /api/device/approve</code>.</p><p>User code: <strong>` + userCode + `</strong></p></main></body></html>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "device_verify.gohtml", layoutData{
|
||||||
|
Title: "Approve device",
|
||||||
|
BodyClass: "page-auth",
|
||||||
|
BodyTemplate: "device_verify_content",
|
||||||
|
Data: struct {
|
||||||
|
UserCode string
|
||||||
|
}{UserCode: userCode},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminUpdateUserRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
userID := chi.URLParam(r, "id")
|
||||||
|
var req updateUserRoleRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.auth.UpdateUserRole(r.Context(), principal, userID, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := s.auth.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
public := make([]map[string]any, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
public = append(public, publicUser(user))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"users": public})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminUpdateAuthUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
var req struct {
|
||||||
|
Users []auth.UserAccessUpdate `json:"users"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := s.auth.UpdateUserAccess(r.Context(), principal, req.Users)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
public := make([]map[string]any, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
public = append(public, publicUser(user))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"users": public})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminSendPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if err := s.auth.SendPasswordReset(r.Context(), principal, chi.URLParam(r, "id")); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset_sent"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if s.authLimiter.Allow(authRateLimitKey(r)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||||
|
clearDevLogoutCookie(w)
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: auth.SessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: expires,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDevLogoutCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: devLogoutCookieName,
|
||||||
|
Value: "1",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int((24 * time.Hour).Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearDevLogoutCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: devLogoutCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: auth.SessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicUser(user auth.User) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"displayName": user.DisplayName,
|
||||||
|
"role": user.Role,
|
||||||
|
"disabled": user.Disabled,
|
||||||
|
"createdAt": user.CreatedAt,
|
||||||
|
"lastSeenAt": user.LastSeenAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireSessionPrincipal(w http.ResponseWriter, principal auth.Principal) bool {
|
||||||
|
if principal.Method == "session" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "browser session required"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestIP(r *http.Request) string {
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSecureRequest(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
308
apps/server/internal/httpserver/collab_handlers.go
Normal file
308
apps/server/internal/httpserver/collab_handlers.go
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
|
||||||
|
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
documentID := r.URL.Query().Get("document_id")
|
||||||
|
if documentID == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canRead, err := s.canReadDocumentRecord(r, *document)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canRead {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
anchorHash := r.URL.Query().Get("anchor_hash")
|
||||||
|
var comments []collaboration.Comment
|
||||||
|
if anchorHash != "" {
|
||||||
|
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
|
||||||
|
} else {
|
||||||
|
comments, err = s.collaboration.ListComments(r.Context(), documentID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("list comments", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"comments": comments})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input collaboration.CreateCommentInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document, err := s.repository.GetDocumentByID(r.Context(), input.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canWrite, err := s.canWriteDocumentRecord(r, *document)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canWrite {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := s.collaboration.CreateComment(r.Context(), principal, input)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("create comment", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusCreated, comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commentID := chi.URLParam(r, "id")
|
||||||
|
comment, err := s.collaboration.GetComment(r.Context(), commentID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document, err := s.repository.GetDocumentByID(r.Context(), comment.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canWrite, err := s.canWriteDocumentRecord(r, *document)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canWrite {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.collaboration.ResolveComment(r.Context(), principal, commentID); err != nil {
|
||||||
|
s.logger.Warn("resolve comment", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
|
||||||
|
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadOnly := r.URL.Query().Get("unread") == "true"
|
||||||
|
limit := 50
|
||||||
|
if r.URL.Query().Get("limit") != "" {
|
||||||
|
// Parse limit if needed, but for now just use default
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("list notifications", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"notifications": notifications})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationID := chi.URLParam(r, "id")
|
||||||
|
if err := s.collaboration.MarkNotificationRead(r.Context(), principal, notificationID); err != nil {
|
||||||
|
s.logger.Warn("mark notification read", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.collaboration.MarkAllNotificationsRead(r.Context(), principal); err != nil {
|
||||||
|
s.logger.Warn("mark all notifications read", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleNotificationBell(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusOK, collaboration.NotificationBell{UnreadCount: 0, Items: []collaboration.Notification{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bell, err := s.collaboration.GetBellStatus(r.Context(), principal)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("notification bell", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, bell)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
|
||||||
|
func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
DocumentID string `json:"documentId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
|
||||||
|
s.logger.Warn("watch document", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "watching"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
DocumentID string `json:"documentId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
|
||||||
|
s.logger.Warn("unwatch document", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "unwatched"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
FolderPath string `json:"folderPath"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil {
|
||||||
|
s.logger.Warn("watch folder", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "watching"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
FolderPath string `json:"folderPath"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil {
|
||||||
|
s.logger.Warn("unwatch folder", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "unwatched"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"watching": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
documentID := r.URL.Query().Get("document_id")
|
||||||
|
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("is watching", "error", err)
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"watching": watching})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document page with comments
|
||||||
|
|
||||||
|
func (s *Server) renderDocumentPageWithComments(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||||
|
// This is a helper that could be used to inject comments into the template
|
||||||
|
// For now, comments are fetched client-side via JS
|
||||||
|
s.renderDocumentPage(w, r, pagePath)
|
||||||
|
}
|
||||||
@@ -10,23 +10,27 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
pathpkg "path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
)
|
)
|
||||||
|
|
||||||
type layoutData struct {
|
type layoutData struct {
|
||||||
Title string
|
Title string
|
||||||
WebEnabled bool
|
|
||||||
BodyClass string
|
BodyClass string
|
||||||
BodyTemplate string
|
BodyTemplate string
|
||||||
Data any
|
Data any
|
||||||
|
Principal *auth.Principal
|
||||||
}
|
}
|
||||||
|
|
||||||
type indexData struct {
|
type indexData struct {
|
||||||
@@ -41,6 +45,10 @@ type documentData struct {
|
|||||||
Hash string
|
Hash string
|
||||||
HTML template.HTML
|
HTML template.HTML
|
||||||
Breadcrumbs []breadcrumb
|
Breadcrumbs []breadcrumb
|
||||||
|
CanRead bool
|
||||||
|
CanWrite bool
|
||||||
|
CanAdmin bool
|
||||||
|
CanComment bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type documentEditData struct {
|
type documentEditData struct {
|
||||||
@@ -51,6 +59,7 @@ type documentEditData struct {
|
|||||||
Hash string
|
Hash string
|
||||||
Source string
|
Source string
|
||||||
Breadcrumbs []breadcrumb
|
Breadcrumbs []breadcrumb
|
||||||
|
CanWrite bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type breadcrumb struct {
|
type breadcrumb struct {
|
||||||
@@ -64,11 +73,13 @@ type errorData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type browserData struct {
|
type browserData struct {
|
||||||
Columns []browserColumn
|
Columns []browserColumn
|
||||||
|
CanWrite bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type browserColumn struct {
|
type browserColumn struct {
|
||||||
Title string
|
Title string
|
||||||
|
Path string
|
||||||
Items []browserItem
|
Items []browserItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,20 +100,32 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tag := r.URL.Query().Get("tag")
|
||||||
|
if tag != "" {
|
||||||
|
s.handleTagPage(w, r, tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
items, err := s.documents.ListDocuments(r.Context())
|
items, err := s.documents.ListDocuments(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items, err = s.filterReadableDocuments(r, items)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
|
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
|
||||||
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
|
browser := buildBrowser(items, activeFolder)
|
||||||
|
browser.CanWrite = s.canWriteFolder(r, activeFolder)
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
|
||||||
Title: "Cairnquire",
|
Title: "Cairnquire",
|
||||||
WebEnabled: s.webEnabled,
|
|
||||||
BodyClass: "page-index",
|
BodyClass: "page-index",
|
||||||
BodyTemplate: "index_content",
|
BodyTemplate: "index_content",
|
||||||
Data: indexData{
|
Data: indexData{
|
||||||
Browser: buildBrowser(items, activeFolder),
|
Browser: browser,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -115,10 +138,14 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("search failed", "error", err)
|
s.logger.Warn("search failed", "error", err)
|
||||||
}
|
}
|
||||||
|
results, err = s.filterReadableSearchResults(r, results)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{
|
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
|
||||||
Title: "Search: " + query,
|
Title: "Search: " + query,
|
||||||
WebEnabled: s.webEnabled,
|
|
||||||
BodyClass: "page-search",
|
BodyClass: "page-search",
|
||||||
BodyTemplate: "search_content",
|
BodyTemplate: "search_content",
|
||||||
Data: struct {
|
Data: struct {
|
||||||
@@ -131,12 +158,49 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTagPage(w http.ResponseWriter, r *http.Request, tag string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results, err := s.repository.SearchDocumentsByTag(ctx, tag)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("tag search failed", "error", err)
|
||||||
|
}
|
||||||
|
results, err = s.filterReadableSearchResults(r, results)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
browser := buildTagBrowser(results, tag)
|
||||||
|
browser.CanWrite = s.canWriteFolder(r, "")
|
||||||
|
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "tag.gohtml", layoutData{
|
||||||
|
Title: "Tag: " + tag,
|
||||||
|
BodyClass: "page-tag",
|
||||||
|
BodyTemplate: "tag_content",
|
||||||
|
Data: struct {
|
||||||
|
Tag string
|
||||||
|
Results []docs.SearchResult
|
||||||
|
Browser browserData
|
||||||
|
}{
|
||||||
|
Tag: tag,
|
||||||
|
Results: results,
|
||||||
|
Browser: browser,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderDocumentPage(w, r, "")
|
s.renderDocumentPage(w, r, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
pagePath := chi.URLParam(r, "*")
|
pagePath := chi.URLParam(r, "*")
|
||||||
|
if isContentAssetPath(pagePath) {
|
||||||
|
s.handleContentAsset(w, r, pagePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
if editPath, ok := trimEditSuffix(pagePath); ok {
|
if editPath, ok := trimEditSuffix(pagePath); ok {
|
||||||
s.renderDocumentEditor(w, r, editPath)
|
s.renderDocumentEditor(w, r, editPath)
|
||||||
return
|
return
|
||||||
@@ -144,6 +208,64 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderDocumentPage(w, r, pagePath)
|
s.renderDocumentPage(w, r, pagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
|
||||||
|
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
|
||||||
|
if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(s.config.Content.SourceDir, cleanPath)
|
||||||
|
sourceRoot, err := filepath.Abs(s.config.Content.SourceDir)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "resolve content root")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assetAbs, err := filepath.Abs(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(sourceRoot, assetAbs)
|
||||||
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(assetAbs)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "read asset")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := make([]byte, 512)
|
||||||
|
n, _ := file.Read(buffer)
|
||||||
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "read asset")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := http.DetectContentType(buffer[:n])
|
||||||
|
if !isAllowedContentType(contentType) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
||||||
|
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
|
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||||
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
|
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,26 +276,47 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
|
|||||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canWrite, err := s.canWriteDocumentRecord(r, *record)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canWrite {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
items, err := s.documents.ListDocuments(r.Context())
|
items, err := s.documents.ListDocuments(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items, err = s.filterReadableDocuments(r, items)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
|
browser := buildBrowser(items, page.Path)
|
||||||
|
browser.CanWrite = canWrite
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
|
||||||
Title: "Edit: " + page.Title,
|
Title: "Edit: " + page.Title,
|
||||||
WebEnabled: s.webEnabled,
|
|
||||||
BodyClass: "page-document-edit",
|
BodyClass: "page-document-edit",
|
||||||
BodyTemplate: "document_edit_content",
|
BodyTemplate: "document_edit_content",
|
||||||
Data: documentEditData{
|
Data: documentEditData{
|
||||||
Browser: buildBrowser(items, page.Path),
|
Browser: browser,
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
Path: page.Path,
|
Path: page.Path,
|
||||||
Tags: page.Tags,
|
Tags: page.Tags,
|
||||||
Hash: page.Hash,
|
Hash: page.Hash,
|
||||||
Source: page.Content,
|
Source: page.Content,
|
||||||
Breadcrumbs: buildBreadcrumbs(page.Path),
|
Breadcrumbs: buildBreadcrumbs(page.Path),
|
||||||
|
CanWrite: canWrite,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -188,31 +331,73 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
|
|||||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canRead, err := s.canReadDocumentRecord(r, *record)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canRead {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canWrite, err := s.canWriteDocumentRecord(r, *record)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canAdmin, err := s.canAdminDocumentRecord(r, *record)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
items, err := s.documents.ListDocuments(r.Context())
|
items, err := s.documents.ListDocuments(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items, err = s.filterReadableDocuments(r, items)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
|
browser := buildBrowser(items, page.Path)
|
||||||
|
browser.CanWrite = canWrite
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
WebEnabled: s.webEnabled,
|
|
||||||
BodyClass: "page-document",
|
BodyClass: "page-document",
|
||||||
BodyTemplate: "document_content",
|
BodyTemplate: "document_content",
|
||||||
Data: documentData{
|
Data: documentData{
|
||||||
Browser: buildBrowser(items, page.Path),
|
Browser: browser,
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
Path: page.Path,
|
Path: page.Path,
|
||||||
Tags: page.Tags,
|
Tags: page.Tags,
|
||||||
Hash: page.Hash,
|
Hash: page.Hash,
|
||||||
HTML: template.HTML(page.HTML),
|
HTML: template.HTML(page.HTML),
|
||||||
Breadcrumbs: buildBreadcrumbs(page.Path),
|
Breadcrumbs: buildBreadcrumbs(page.Path),
|
||||||
|
CanRead: canRead,
|
||||||
|
CanWrite: canWrite,
|
||||||
|
CanAdmin: canAdmin,
|
||||||
|
CanComment: canWrite,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -226,11 +411,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
contentStatus = err.Error()
|
contentStatus = err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
ready := dbStatus == "ok" && contentStatus == "ok"
|
||||||
|
payload := map[string]string{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"database": dbStatus,
|
"database": dbStatus,
|
||||||
"contentStore": contentStatus,
|
"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) {
|
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -248,21 +440,35 @@ func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query().Get("q")
|
|
||||||
if query == "" {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
results, err := s.repository.SearchDocuments(ctx, query)
|
var results []docs.SearchResult
|
||||||
|
var err error
|
||||||
|
|
||||||
|
tag := r.URL.Query().Get("tag")
|
||||||
|
if tag != "" {
|
||||||
|
results, err = s.repository.SearchDocumentsByTag(ctx, tag)
|
||||||
|
} else {
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
if query == "" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err = s.repository.SearchDocuments(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("search failed", "error", err)
|
s.logger.Warn("search failed", "error", err)
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
results, err = s.filterReadableSearchResults(r, results)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("filter search failed", "error", err)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
||||||
}
|
}
|
||||||
@@ -285,6 +491,13 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderError(w, r, http.StatusInternalServerError, "read upload")
|
s.renderError(w, r, http.StatusInternalServerError, "read upload")
|
||||||
return
|
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)
|
contentType := http.DetectContentType(content)
|
||||||
if !isAllowedContentType(contentType) {
|
if !isAllowedContentType(contentType) {
|
||||||
@@ -298,24 +511,151 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingAttachment, err := s.repository.GetAttachment(r.Context(), record.Hash)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "inspect existing attachment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existingAttachment != nil && duplicateAction != "overwrite" && duplicateAction != "rename" {
|
||||||
|
existingURL := attachmentRecordURL(existingAttachment)
|
||||||
|
if duplicateAction == "reuse" {
|
||||||
|
writeUploadSuccess(w, http.StatusOK, existingAttachment.Hash, existingAttachment.ContentType, attachmentRecordKind(existingAttachment), existingURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeUploadConflict(w, "attachment", existingAttachment.Hash, existingAttachment.DocumentPath, existingURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadURL := "/attachments/" + record.Hash
|
||||||
|
uploadKind := "attachment"
|
||||||
|
documentPath := ""
|
||||||
|
if path, readable, err := readableDocumentPath(header.Filename, r.FormValue("folder")); err != nil {
|
||||||
|
s.renderError(w, r, http.StatusBadRequest, "invalid document upload path")
|
||||||
|
return
|
||||||
|
} else if readable && isReadableContentType(contentType) && utf8.Valid(content) {
|
||||||
|
if _, err := s.documents.ListDocuments(r.Context()); err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, err := s.repository.GetDocumentByPath(r.Context(), path)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createdDocument := existing == nil
|
||||||
|
if existing != nil {
|
||||||
|
canWriteExisting, err := s.canWriteDocumentRecord(r, *existing)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "check document permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canWriteExisting {
|
||||||
|
s.renderError(w, r, http.StatusForbidden, "permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch duplicateAction {
|
||||||
|
case "overwrite":
|
||||||
|
case "rename":
|
||||||
|
path, err = s.availableDocumentPath(r.Context(), path)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "choose document name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createdDocument = true
|
||||||
|
case "reuse":
|
||||||
|
writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path))
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
writeUploadConflict(w, "document", existing.CurrentHash, existing.Path, documentPageURL(existing.Path))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if !s.canWriteNewDocument(r, path) {
|
||||||
|
s.renderError(w, r, http.StatusForbidden, "permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if createdDocument {
|
||||||
|
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "grant document owner permission")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentPath = page.Path
|
||||||
|
uploadURL = documentPageURL(page.Path)
|
||||||
|
uploadKind = "document"
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
|
if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
|
||||||
Hash: record.Hash,
|
Hash: record.Hash,
|
||||||
OriginalName: header.Filename,
|
OriginalName: header.Filename,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
SizeBytes: record.Size,
|
SizeBytes: record.Size,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
|
DocumentPath: documentPath,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.renderError(w, r, http.StatusInternalServerError, "persist upload metadata")
|
s.renderError(w, r, http.StatusInternalServerError, "persist upload metadata")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSONWithStatus(w, http.StatusCreated, map[string]string{
|
writeUploadSuccess(w, http.StatusCreated, record.Hash, contentType, uploadKind, uploadURL)
|
||||||
"hash": record.Hash,
|
}
|
||||||
"contentType": contentType,
|
|
||||||
"attachmentUrl": "/attachments/" + record.Hash,
|
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) {
|
func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
hash := chi.URLParam(r, "hash")
|
hash := chi.URLParam(r, "hash")
|
||||||
attachment, err := s.repository.GetAttachment(r.Context(), hash)
|
attachment, err := s.repository.GetAttachment(r.Context(), hash)
|
||||||
@@ -341,7 +681,13 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(content)
|
_, _ = 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.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||||
@@ -350,9 +696,8 @@ func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
|
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),
|
Title: http.StatusText(status),
|
||||||
WebEnabled: s.webEnabled,
|
|
||||||
BodyClass: "page-error",
|
BodyClass: "page-error",
|
||||||
BodyTemplate: "error_content",
|
BodyTemplate: "error_content",
|
||||||
Data: errorData{
|
Data: errorData{
|
||||||
@@ -370,6 +715,7 @@ func isAllowedContentType(contentType string) bool {
|
|||||||
"image/webp",
|
"image/webp",
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
"text/plain; charset=utf-8",
|
"text/plain; charset=utf-8",
|
||||||
|
"text/html; charset=utf-8",
|
||||||
}
|
}
|
||||||
for _, candidate := range allowed {
|
for _, candidate := range allowed {
|
||||||
if strings.EqualFold(candidate, contentType) {
|
if strings.EqualFold(candidate, contentType) {
|
||||||
@@ -379,17 +725,102 @@ func isAllowedContentType(contentType string) bool {
|
|||||||
return false
|
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 {
|
func sanitizeFilename(name string) string {
|
||||||
name = filepath.Base(name)
|
name = filepath.Base(name)
|
||||||
return strings.ReplaceAll(name, "\"", "")
|
return strings.ReplaceAll(name, "\"", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readableDocumentPath(filename string, folder string) (string, bool, error) {
|
||||||
|
extension := strings.ToLower(filepath.Ext(filename))
|
||||||
|
switch extension {
|
||||||
|
case ".md", ".markdown", ".txt", ".html", ".htm":
|
||||||
|
default:
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSuffix(sanitizeFilename(filename), filepath.Ext(filename))
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return "", false, fmt.Errorf("document filename is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
folder = strings.TrimSpace(folder)
|
||||||
|
if filepath.IsAbs(filepath.FromSlash(folder)) {
|
||||||
|
return "", false, fmt.Errorf("upload folder must be relative")
|
||||||
|
}
|
||||||
|
folder = filepath.ToSlash(filepath.Clean(filepath.FromSlash(folder)))
|
||||||
|
if folder == "." {
|
||||||
|
folder = ""
|
||||||
|
}
|
||||||
|
if folder == ".." || strings.HasPrefix(folder, "../") {
|
||||||
|
return "", false, fmt.Errorf("invalid upload folder")
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathpkg.Join(folder, name+".md"), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPageURL(documentPath string) string {
|
||||||
|
documentPath = strings.TrimSuffix(filepath.ToSlash(documentPath), ".md")
|
||||||
|
parts := strings.Split(documentPath, "/")
|
||||||
|
for index, part := range parts {
|
||||||
|
parts[index] = url.PathEscape(part)
|
||||||
|
}
|
||||||
|
return "/docs/" + strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
records, err := s.repository.ListAttachments(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]any, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
uploadURL := "/attachments/" + record.Hash
|
||||||
|
uploadKind := "attachment"
|
||||||
|
if record.DocumentPath != "" {
|
||||||
|
document, err := s.repository.GetDocumentByPath(r.Context(), record.DocumentPath)
|
||||||
|
if err != nil || document == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
canRead, err := s.canReadDocumentRecord(r, *document)
|
||||||
|
if err != nil || !canRead {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uploadURL = documentPageURL(record.DocumentPath)
|
||||||
|
uploadKind = "document"
|
||||||
|
}
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"hash": record.Hash,
|
||||||
|
"originalName": record.OriginalName,
|
||||||
|
"contentType": record.ContentType,
|
||||||
|
"sizeBytes": record.SizeBytes,
|
||||||
|
"createdAt": record.CreatedAt.Format(time.RFC3339),
|
||||||
|
"kind": uploadKind,
|
||||||
|
"documentPath": record.DocumentPath,
|
||||||
|
"url": uploadURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"attachments": results})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
||||||
records, err := s.documents.ListDocuments(r.Context())
|
records, err := s.documents.ListDocuments(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
records, err = s.filterReadableDocuments(r, records)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
results := make([]map[string]any, 0, len(records))
|
results := make([]map[string]any, 0, len(records))
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
@@ -397,14 +828,37 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
|||||||
"path": record.Path,
|
"path": record.Path,
|
||||||
"title": record.Title,
|
"title": record.Title,
|
||||||
"hash": record.CurrentHash,
|
"hash": record.CurrentHash,
|
||||||
|
"tags": record.Tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
|
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) {
|
||||||
pagePath := chi.URLParam(r, "*")
|
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 == "" {
|
if pagePath == "" {
|
||||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
||||||
return
|
return
|
||||||
@@ -418,6 +872,27 @@ func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
createdDocument := false
|
||||||
|
if existing, err := s.documentRecordForRequestPath(r, pagePath); err == nil && existing != nil {
|
||||||
|
canWrite, err := s.canWriteDocumentRecord(r, *existing)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canWrite {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
createdDocument = true
|
||||||
|
if !s.canWriteNewDocument(r, pagePath) {
|
||||||
|
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
|
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -440,6 +915,12 @@ func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if createdDocument {
|
||||||
|
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "grant document owner permission"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSONWithStatus(w, http.StatusOK, map[string]any{
|
writeJSONWithStatus(w, http.StatusOK, map[string]any{
|
||||||
"status": "saved",
|
"status": "saved",
|
||||||
@@ -459,6 +940,65 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
|
|||||||
_ = json.NewEncoder(w).Encode(payload)
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||||
|
if pagePath == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record, err := s.documentRecordForRequestPath(r, pagePath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canWrite, err := s.canWriteDocumentRecord(r, *record)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canWrite {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||||
|
if pagePath == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, _ := principalFromContext(r.Context())
|
||||||
|
if principal.Role != auth.RoleAdmin {
|
||||||
|
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "restored"})
|
||||||
|
}
|
||||||
|
|
||||||
func trimEditSuffix(path string) (string, bool) {
|
func trimEditSuffix(path string) (string, bool) {
|
||||||
if path == "edit" {
|
if path == "edit" {
|
||||||
return "", true
|
return "", true
|
||||||
@@ -469,6 +1009,11 @@ func trimEditSuffix(path string) (string, bool) {
|
|||||||
return strings.TrimSuffix(path, "/edit"), true
|
return strings.TrimSuffix(path, "/edit"), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isContentAssetPath(path string) bool {
|
||||||
|
extension := strings.ToLower(filepath.Ext(path))
|
||||||
|
return extension != "" && extension != ".md"
|
||||||
|
}
|
||||||
|
|
||||||
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
|
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
|
||||||
paths := make([]string, 0, len(records))
|
paths := make([]string, 0, len(records))
|
||||||
titleByPath := make(map[string]string, len(records))
|
titleByPath := make(map[string]string, len(records))
|
||||||
@@ -497,16 +1042,39 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData
|
|||||||
for _, prefix := range prefixes {
|
for _, prefix := range prefixes {
|
||||||
column := browserColumn{
|
column := browserColumn{
|
||||||
Title: columnTitle(prefix),
|
Title: columnTitle(prefix),
|
||||||
|
Path: prefix,
|
||||||
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
|
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
|
||||||
}
|
}
|
||||||
if len(column.Items) > 0 {
|
columns = append(columns, column)
|
||||||
columns = append(columns, column)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return browserData{Columns: columns}
|
return browserData{Columns: columns}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildTagBrowser(results []docs.SearchResult, tag string) browserData {
|
||||||
|
items := make([]browserItem, 0, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
items = append(items, browserItem{
|
||||||
|
Name: displayDocumentName(result.Path, result.Title),
|
||||||
|
Path: result.Path,
|
||||||
|
URL: "/docs/" + strings.TrimSuffix(result.Path, ".md"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return browserData{
|
||||||
|
Columns: []browserColumn{
|
||||||
|
{
|
||||||
|
Title: "#" + tag,
|
||||||
|
Path: "",
|
||||||
|
Items: items,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
|
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
|
||||||
seenFolders := make(map[string]browserItem)
|
seenFolders := make(map[string]browserItem)
|
||||||
files := make([]browserItem, 0)
|
files := make([]browserItem, 0)
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildBrowserUsesFolderIndex(t *testing.T) {
|
func TestBuildBrowserUsesFolderIndex(t *testing.T) {
|
||||||
@@ -63,3 +78,506 @@ func TestTrimEditSuffix(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsContentAssetPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{path: "logo.webp", want: true},
|
||||||
|
{path: "guide/logo.png", want: true},
|
||||||
|
{path: "guide/page", want: false},
|
||||||
|
{path: "guide/page.md", want: false},
|
||||||
|
{path: "guide/page/edit", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
if got := isContentAssetPath(tt.path); got != tt.want {
|
||||||
|
t.Fatalf("isContentAssetPath(%q) = %t, want %t", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
image := []byte{
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||||
|
0x89,
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "logo.png"), image, 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &Server{
|
||||||
|
config: config.Config{
|
||||||
|
Content: config.ContentConfig{SourceDir: root},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/docs/logo.png", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handleContentAsset(recorder, request, "logo.png")
|
||||||
|
|
||||||
|
response := recorder.Result()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := response.Header.Get("Content-Type"); got != "image/png" {
|
||||||
|
t.Fatalf("content-type = %q, want image/png", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
content string
|
||||||
|
wantPath string
|
||||||
|
wantURL string
|
||||||
|
wantHTML string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "markdown",
|
||||||
|
filename: "release notes.md",
|
||||||
|
content: "# Release Notes\n\nReadable markdown",
|
||||||
|
wantPath: "inbox/release notes.md",
|
||||||
|
wantURL: "/docs/inbox/release%20notes",
|
||||||
|
wantHTML: "<p>Readable markdown</p>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "html",
|
||||||
|
filename: "status.html",
|
||||||
|
content: "<p>Readable HTML</p>",
|
||||||
|
wantPath: "inbox/status.md",
|
||||||
|
wantURL: "/docs/inbox/status",
|
||||||
|
wantHTML: "<p>Readable HTML</p>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain text",
|
||||||
|
filename: "summary.txt",
|
||||||
|
content: "Readable plain text",
|
||||||
|
wantPath: "inbox/summary.md",
|
||||||
|
wantURL: "/docs/inbox/summary",
|
||||||
|
wantHTML: "<p>Readable plain text</p>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server, sourceDir := setupUploadTestServer(t)
|
||||||
|
request := multipartUploadRequest(t, tt.filename, []byte(tt.content), "inbox")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handleUpload(recorder, request)
|
||||||
|
|
||||||
|
response := recorder.Result()
|
||||||
|
if response.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
AttachmentURL string `json:"attachmentUrl"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Kind != "document" {
|
||||||
|
t.Fatalf("kind = %q, want document", payload.Kind)
|
||||||
|
}
|
||||||
|
if payload.URL != tt.wantURL || payload.AttachmentURL != tt.wantURL {
|
||||||
|
t.Fatalf("urls = (%q, %q), want %q", payload.URL, payload.AttachmentURL, tt.wantURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
written, err := os.ReadFile(filepath.Join(sourceDir, filepath.FromSlash(tt.wantPath)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read promoted document: %v", err)
|
||||||
|
}
|
||||||
|
if string(written) != tt.content {
|
||||||
|
t.Fatalf("promoted content = %q, want %q", string(written), tt.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment, err := server.repository.GetAttachment(context.Background(), payload.Hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup upload history record: %v", err)
|
||||||
|
}
|
||||||
|
if attachment.DocumentPath != tt.wantPath {
|
||||||
|
t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
listRequest := httptest.NewRequest(http.MethodGet, "/api/attachments", nil)
|
||||||
|
listRequest = listRequest.WithContext(withPrincipal(listRequest.Context(), auth.Principal{
|
||||||
|
UserID: "user:test-admin",
|
||||||
|
Role: auth.RoleAdmin,
|
||||||
|
}))
|
||||||
|
listRecorder := httptest.NewRecorder()
|
||||||
|
server.handleAttachmentsList(listRecorder, listRequest)
|
||||||
|
var listPayload struct {
|
||||||
|
Attachments []struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"attachments"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(listRecorder.Result().Body).Decode(&listPayload); err != nil {
|
||||||
|
t.Fatalf("decode upload history response: %v", err)
|
||||||
|
}
|
||||||
|
if len(listPayload.Attachments) != 1 || listPayload.Attachments[0].Kind != "document" || listPayload.Attachments[0].URL != tt.wantURL {
|
||||||
|
t.Fatalf("upload history = %#v, want rendered document URL %q", listPayload.Attachments, tt.wantURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := server.documents.LoadPage(context.Background(), tt.wantPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load promoted page: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
|
||||||
|
t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
grants, err := server.auth.ListResourcePermissions(context.Background(), auth.Principal{
|
||||||
|
UserID: "user:test-admin",
|
||||||
|
Role: auth.RoleAdmin,
|
||||||
|
}, auth.ResourceDocument, tt.wantPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list promoted document permissions: %v", err)
|
||||||
|
}
|
||||||
|
if len(grants) != 1 || grants[0].UserID != "user:test-admin" || grants[0].Permission != auth.PermissionAdmin {
|
||||||
|
t.Fatalf("promoted document grants = %#v, want creator admin grant", grants)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUploadKeepsPDFAsAttachment(t *testing.T) {
|
||||||
|
for _, filename := range []string{"brief.pdf", "renamed.md"} {
|
||||||
|
t.Run(filename, func(t *testing.T) {
|
||||||
|
server, _ := setupUploadTestServer(t)
|
||||||
|
request := multipartUploadRequest(t, filename, []byte("%PDF-1.4\n"), "inbox")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handleUpload(recorder, request)
|
||||||
|
|
||||||
|
response := recorder.Result()
|
||||||
|
if response.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Kind != "attachment" {
|
||||||
|
t.Fatalf("kind = %q, want attachment", payload.Kind)
|
||||||
|
}
|
||||||
|
if payload.URL != "/attachments/"+payload.Hash {
|
||||||
|
t.Fatalf("url = %q, want hash attachment URL", payload.URL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUploadRequiresResolutionForExistingDocumentName(t *testing.T) {
|
||||||
|
server, sourceDir := setupUploadTestServer(t)
|
||||||
|
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
server.handleUpload(first, multipartUploadRequest(t, "report.md", []byte("# First"), "inbox"))
|
||||||
|
if first.Result().StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
conflict := httptest.NewRecorder()
|
||||||
|
server.handleUpload(conflict, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox"))
|
||||||
|
if conflict.Result().StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
ConflictType string `json:"conflictType"`
|
||||||
|
ExistingPath string `json:"existingPath"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(conflict.Result().Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode conflict: %v", err)
|
||||||
|
}
|
||||||
|
if payload.ConflictType != "document" || payload.ExistingPath != "inbox/report.md" {
|
||||||
|
t.Fatalf("conflict = %#v, want inbox document conflict", payload)
|
||||||
|
}
|
||||||
|
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read original document: %v", err)
|
||||||
|
}
|
||||||
|
if string(written) != "# First" {
|
||||||
|
t.Fatalf("original document = %q, want unchanged content", string(written))
|
||||||
|
}
|
||||||
|
|
||||||
|
renamed := httptest.NewRecorder()
|
||||||
|
server.handleUpload(renamed, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox", "rename"))
|
||||||
|
if renamed.Result().StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("renamed upload status = %d, want %d", renamed.Result().StatusCode, http.StatusCreated)
|
||||||
|
}
|
||||||
|
renamedContent, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report-2.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read renamed document: %v", err)
|
||||||
|
}
|
||||||
|
if string(renamedContent) != "# Second" {
|
||||||
|
t.Fatalf("renamed document = %q, want second content", string(renamedContent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUploadCanReuseExistingAttachment(t *testing.T) {
|
||||||
|
server, _ := setupUploadTestServer(t)
|
||||||
|
content := []byte("%PDF-1.4\n")
|
||||||
|
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
server.handleUpload(first, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
|
||||||
|
if first.Result().StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
conflict := httptest.NewRecorder()
|
||||||
|
server.handleUpload(conflict, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
|
||||||
|
if conflict.Result().StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
|
||||||
|
}
|
||||||
|
var conflictPayload struct {
|
||||||
|
ConflictType string `json:"conflictType"`
|
||||||
|
ExistingURL string `json:"existingUrl"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(conflict.Result().Body).Decode(&conflictPayload); err != nil {
|
||||||
|
t.Fatalf("decode conflict: %v", err)
|
||||||
|
}
|
||||||
|
if conflictPayload.ConflictType != "attachment" {
|
||||||
|
t.Fatalf("conflict type = %q, want attachment", conflictPayload.ConflictType)
|
||||||
|
}
|
||||||
|
|
||||||
|
reused := httptest.NewRecorder()
|
||||||
|
server.handleUpload(reused, multipartUploadRequest(t, "brief.pdf", content, "inbox", "reuse"))
|
||||||
|
if reused.Result().StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("reused upload status = %d, want %d", reused.Result().StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
var reusedPayload struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(reused.Result().Body).Decode(&reusedPayload); err != nil {
|
||||||
|
t.Fatalf("decode reused response: %v", err)
|
||||||
|
}
|
||||||
|
if reusedPayload.URL != conflictPayload.ExistingURL {
|
||||||
|
t.Fatalf("reused URL = %q, want %q", reusedPayload.URL, conflictPayload.ExistingURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupUploadTestServer(t *testing.T) (*Server, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("libsql", "file:"+filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStore, err := store.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create content store: %v", err)
|
||||||
|
}
|
||||||
|
sourceDir := t.TempDir()
|
||||||
|
repository := docs.NewRepository(db)
|
||||||
|
documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default())
|
||||||
|
authRepo := auth.NewRepository(db)
|
||||||
|
authService, err := auth.NewService(authRepo, "http://localhost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create auth service: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := authRepo.UpsertUser(context.Background(), auth.User{
|
||||||
|
ID: "user:test-admin",
|
||||||
|
Email: "test-admin@example.com",
|
||||||
|
DisplayName: "Test Admin",
|
||||||
|
Role: auth.RoleAdmin,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create test admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
documents: documents,
|
||||||
|
repository: repository,
|
||||||
|
contentStore: contentStore,
|
||||||
|
auth: authService,
|
||||||
|
}, sourceDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func multipartUploadRequest(t *testing.T, filename string, content []byte, folder string, duplicateAction ...string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
if folder != "" {
|
||||||
|
if err := writer.WriteField("folder", folder); err != nil {
|
||||||
|
t.Fatalf("write folder: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(duplicateAction) > 0 && duplicateAction[0] != "" {
|
||||||
|
if err := writer.WriteField("duplicateAction", duplicateAction[0]); err != nil {
|
||||||
|
t.Fatalf("write duplicate action: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part, err := writer.CreateFormFile("file", filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create file part: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(content); err != nil {
|
||||||
|
t.Fatalf("write file part: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
request = request.WithContext(withPrincipal(request.Context(), auth.Principal{
|
||||||
|
UserID: "user:test-admin",
|
||||||
|
Role: auth.RoleAdmin,
|
||||||
|
}))
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleHealthzReturnsOK(t *testing.T) {
|
||||||
|
server := setupHealthTestServer(t)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleHealthz(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload["status"] != "ok" {
|
||||||
|
t.Fatalf("status = %q, want ok", payload["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleReadyzReportsOKWhenDatabaseAndStoreAreReachable(t *testing.T) {
|
||||||
|
server := setupHealthTestServer(t)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload["status"] != "ok" {
|
||||||
|
t.Fatalf("status = %q, want ok", payload["status"])
|
||||||
|
}
|
||||||
|
if payload["database"] != "ok" {
|
||||||
|
t.Fatalf("database = %q, want ok", payload["database"])
|
||||||
|
}
|
||||||
|
if payload["contentStore"] != "ok" {
|
||||||
|
t.Fatalf("contentStore = %q, want ok", payload["contentStore"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleReadyzReportsUnavailableWhenContentStoreMissing(t *testing.T) {
|
||||||
|
server := setupHealthTestServer(t)
|
||||||
|
server.config.Content.StoreDir = filepath.Join(t.TempDir(), "does-not-exist")
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload["status"] != "unavailable" {
|
||||||
|
t.Fatalf("status = %q, want unavailable", payload["status"])
|
||||||
|
}
|
||||||
|
if payload["contentStore"] == "ok" {
|
||||||
|
t.Fatalf("contentStore = %q, want non-ok", payload["contentStore"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleHealthAliasForReadyz(t *testing.T) {
|
||||||
|
server := setupHealthTestServer(t)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleHealth(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload["status"] != "ok" {
|
||||||
|
t.Fatalf("status = %q, want ok", payload["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthzAndReadyzAreRegisteredOnRouter(t *testing.T) {
|
||||||
|
server := newAPITestServer(t)
|
||||||
|
|
||||||
|
for _, path := range []string{"/healthz", "/readyz", "/health"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupHealthTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
storeDir := filepath.Join(root, "files")
|
||||||
|
if err := os.MkdirAll(storeDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("create store dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("libsql", "file:"+filepath.Join(root, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStore, err := store.New(storeDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create content store: %v", err)
|
||||||
|
}
|
||||||
|
repository := docs.NewRepository(db)
|
||||||
|
documents := docs.NewService(t.TempDir(), contentStore, markdown.NewRenderer(), repository, slog.Default())
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
config: config.Config{
|
||||||
|
Content: config.ContentConfig{StoreDir: storeDir},
|
||||||
|
},
|
||||||
|
documents: documents,
|
||||||
|
repository: repository,
|
||||||
|
contentStore: contentStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const devLogoutCookieName = "cairnquire_dev_logout"
|
||||||
|
|
||||||
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
|
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
|
||||||
timeoutMiddleware := middleware.Timeout(timeout)
|
timeoutMiddleware := middleware.Timeout(timeout)
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
@@ -49,6 +54,173 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := s.authenticateRequest(r)
|
||||||
|
if ok {
|
||||||
|
r = r.WithContext(withPrincipal(r.Context(), principal))
|
||||||
|
}
|
||||||
|
|
||||||
|
required, protected := requiredScopeForPath(r)
|
||||||
|
if !protected {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
http.Redirect(w, r, "/login?next="+urlQueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowsProtectedRoute(principal, required) {
|
||||||
|
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowsProtectedRoute(principal auth.Principal, required auth.Scope) bool {
|
||||||
|
if required == auth.ScopeAdmin {
|
||||||
|
return auth.Allows(principal, required)
|
||||||
|
}
|
||||||
|
if len(principal.Scopes) > 0 {
|
||||||
|
return auth.HasScope(principal.Scopes, required)
|
||||||
|
}
|
||||||
|
return principal.UserID != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlQueryEscape(value string) string {
|
||||||
|
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setupMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := s.auth.GetInstanceSettings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "lookup initial setup state"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if settings.SetupComplete || isSetupAllowedPath(r.URL.Path) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusServiceUnavailable, map[string]string{"error": "initial setup is required"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSetupAllowedPath(path string) bool {
|
||||||
|
return path == "/setup" ||
|
||||||
|
path == "/api/setup" ||
|
||||||
|
path == "/health" ||
|
||||||
|
path == "/healthz" ||
|
||||||
|
path == "/readyz" ||
|
||||||
|
path == "/sw.js" ||
|
||||||
|
strings.HasPrefix(path, "/static/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||||
|
var token string
|
||||||
|
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
|
||||||
|
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||||
|
} else if r.URL.Path == "/ws" {
|
||||||
|
token = r.URL.Query().Get("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
if s.config.DevMode && token == "dev" {
|
||||||
|
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
|
||||||
|
return principal, err == nil
|
||||||
|
}
|
||||||
|
principal, err := s.auth.ValidateBearerToken(r.Context(), token)
|
||||||
|
if err == nil {
|
||||||
|
return principal, true
|
||||||
|
}
|
||||||
|
s.logger.Warn("invalid bearer token", "error", err)
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
cookie, err := r.Cookie(auth.SessionCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
if s.devUserID == "" || hasDevLogoutCookie(r) {
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
|
||||||
|
return principal, err == nil
|
||||||
|
}
|
||||||
|
principal, err := s.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
return principal, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDevLogoutCookie(r *http.Request) bool {
|
||||||
|
cookie, err := r.Cookie(devLogoutCookieName)
|
||||||
|
return err == nil && cookie.Value == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||||
|
path := r.URL.Path
|
||||||
|
method := r.Method
|
||||||
|
switch {
|
||||||
|
case path == "/" || (path == "/permissions" && method == http.MethodGet):
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case path == "/permissions" && method == http.MethodPost:
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
|
case strings.HasPrefix(path, "/api/auth/"):
|
||||||
|
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" || strings.HasPrefix(path, "/api/auth/passkeys/me/") {
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
case strings.HasPrefix(path, "/api/device/"):
|
||||||
|
if path == "/api/device/approve" || path == "/api/device/verify" {
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
case strings.HasPrefix(path, "/api/tokens"):
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case path == "/ws":
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case strings.HasPrefix(path, "/api/admin/"):
|
||||||
|
return auth.ScopeAdmin, true
|
||||||
|
case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet:
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
|
case path == "/api/documents" || path == "/api/search" || (path == "/api/permissions" && method == http.MethodGet):
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case path == "/api/permissions" && method == http.MethodPatch:
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
|
case strings.HasPrefix(path, "/api/uploads"):
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
|
case path == "/api/attachments":
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case strings.HasPrefix(path, "/api/sync/"):
|
||||||
|
if method == http.MethodPost {
|
||||||
|
return auth.ScopeSyncWrite, true
|
||||||
|
}
|
||||||
|
return auth.ScopeSyncRead, true
|
||||||
|
case strings.HasPrefix(path, "/api/content/"):
|
||||||
|
return auth.ScopeSyncRead, true
|
||||||
|
case strings.HasPrefix(path, "/api/comments"):
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case strings.HasPrefix(path, "/api/notifications"):
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case strings.HasPrefix(path, "/api/watch"):
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case path == "/docs" || strings.HasPrefix(path, "/docs/"):
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case strings.HasSuffix(path, "/edit"):
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type statusWriter struct {
|
type statusWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
status int
|
status int
|
||||||
|
|||||||
431
apps/server/internal/httpserver/permissions_handlers.go
Normal file
431
apps/server/internal/httpserver/permissions_handlers.go
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type permissionPageData struct {
|
||||||
|
ResourceType string
|
||||||
|
ResourceID string
|
||||||
|
Title string
|
||||||
|
Users []permissionUserData
|
||||||
|
}
|
||||||
|
|
||||||
|
type permissionUserData struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
DisplayName string
|
||||||
|
Role string
|
||||||
|
CreatedAt string
|
||||||
|
LastSeenAt string
|
||||||
|
Permission string
|
||||||
|
PermissionText string
|
||||||
|
GrantCreatedAt string
|
||||||
|
GrantedBy string
|
||||||
|
GrantSummary string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePermissionsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
resourceType, resourceID := permissionResourceFromRequest(r)
|
||||||
|
if resourceType == "" {
|
||||||
|
resourceType = auth.ResourceDocument
|
||||||
|
}
|
||||||
|
if resourceType == auth.ResourceDocument {
|
||||||
|
resourceID = strings.TrimSpace(resourceID)
|
||||||
|
if resourceID == "" {
|
||||||
|
resourceID = strings.TrimSpace(r.URL.Query().Get("path"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resourceID == "" && resourceType != auth.ResourceGlobal && resourceType != auth.ResourceFolder {
|
||||||
|
s.renderError(w, r, http.StatusBadRequest, "resource is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.canAdminResource(r, resourceType, resourceID, nil) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := s.buildPermissionPageData(r, principal, resourceType, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderTemplate(w, r, http.StatusOK, "permissions.gohtml", layoutData{
|
||||||
|
Title: "Permissions: " + data.Title,
|
||||||
|
BodyClass: "page-permissions",
|
||||||
|
BodyTemplate: "permissions_content",
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePermissionsForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderError(w, r, http.StatusBadRequest, "invalid permissions form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceType := auth.ResourceType(r.FormValue("resourceType"))
|
||||||
|
resourceID := r.FormValue("resourceId")
|
||||||
|
updates := permissionUpdatesFromForm(r)
|
||||||
|
if _, err := s.auth.UpdateResourcePermissions(r.Context(), principal, resourceType, resourceID, updates); err != nil {
|
||||||
|
s.renderError(w, r, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/permissions?resourceType="+queryEscape(string(resourceType))+"&resourceId="+queryEscape(resourceID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePermissionsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
s.handlePermissionsGetAPI(w, r)
|
||||||
|
case http.MethodPatch:
|
||||||
|
s.handlePermissionsPatchAPI(w, r)
|
||||||
|
default:
|
||||||
|
writeJSONWithStatus(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePermissionsGetAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
resourceType, resourceID := permissionResourceFromRequest(r)
|
||||||
|
if !s.canAdminResource(r, resourceType, resourceID, nil) {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permissions, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := s.auth.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"resourceType": resourceType,
|
||||||
|
"resourceId": resourceID,
|
||||||
|
"permissions": permissions,
|
||||||
|
"users": users,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePermissionsPatchAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
var req struct {
|
||||||
|
ResourceType auth.ResourceType `json:"resourceType"`
|
||||||
|
ResourceID string `json:"resourceId"`
|
||||||
|
Users []auth.ResourcePermissionUpdate `json:"users"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.canAdminResource(r, req.ResourceType, req.ResourceID, nil) {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permissions, err := s.auth.UpdateResourcePermissions(r.Context(), principal, req.ResourceType, req.ResourceID, req.Users)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"permissions": permissions})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildPermissionPageData(r *http.Request, principal auth.Principal, resourceType auth.ResourceType, resourceID string) (permissionPageData, error) {
|
||||||
|
users, err := s.auth.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return permissionPageData{}, err
|
||||||
|
}
|
||||||
|
grants, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
return permissionPageData{}, err
|
||||||
|
}
|
||||||
|
grantsByUser := make(map[string]auth.Permission, len(grants))
|
||||||
|
grantMetaByUser := make(map[string]auth.ResourcePermission, len(grants))
|
||||||
|
for _, grant := range grants {
|
||||||
|
grantsByUser[grant.UserID] = grant.Permission
|
||||||
|
grantMetaByUser[grant.UserID] = grant
|
||||||
|
}
|
||||||
|
usersByID := make(map[string]auth.User, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
usersByID[user.ID] = user
|
||||||
|
}
|
||||||
|
result := permissionPageData{
|
||||||
|
ResourceType: string(resourceType),
|
||||||
|
ResourceID: resourceID,
|
||||||
|
Title: permissionResourceTitle(resourceType, resourceID),
|
||||||
|
Users: make([]permissionUserData, 0, len(users)),
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if user.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
permission := grantsByUser[user.ID]
|
||||||
|
grant := grantMetaByUser[user.ID]
|
||||||
|
result.Users = append(result.Users, permissionUserData{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Role: string(user.Role),
|
||||||
|
CreatedAt: formatPermissionTime(user.CreatedAt),
|
||||||
|
LastSeenAt: formatPermissionTime(user.LastSeenAt),
|
||||||
|
Permission: string(permission),
|
||||||
|
PermissionText: permissionLabel(permission),
|
||||||
|
GrantCreatedAt: formatPermissionTime(grant.CreatedAt),
|
||||||
|
GrantedBy: grantActorLabel(grant.GrantedBy, usersByID),
|
||||||
|
GrantSummary: grantSummary(grant, usersByID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionResourceTitle(resourceType auth.ResourceType, resourceID string) string {
|
||||||
|
switch resourceType {
|
||||||
|
case auth.ResourceDocument:
|
||||||
|
return "Page " + resourceID
|
||||||
|
case auth.ResourceFolder:
|
||||||
|
if resourceID == "" {
|
||||||
|
return "Root folder"
|
||||||
|
}
|
||||||
|
return "Folder " + resourceID
|
||||||
|
case auth.ResourceCollection:
|
||||||
|
return "Collection " + resourceID
|
||||||
|
default:
|
||||||
|
return string(resourceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionResourceFromRequest(r *http.Request) (auth.ResourceType, string) {
|
||||||
|
return auth.ResourceType(r.URL.Query().Get("resourceType")), r.URL.Query().Get("resourceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionUpdatesFromForm(r *http.Request) []auth.ResourcePermissionUpdate {
|
||||||
|
var updates []auth.ResourcePermissionUpdate
|
||||||
|
for key, values := range r.PostForm {
|
||||||
|
if !strings.HasPrefix(key, "permission:") || len(values) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userID := strings.TrimPrefix(key, "permission:")
|
||||||
|
permission := auth.Permission(values[0])
|
||||||
|
updates = append(updates, auth.ResourcePermissionUpdate{UserID: userID, Permission: permission})
|
||||||
|
}
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
func permissionLabel(permission auth.Permission) string {
|
||||||
|
switch permission {
|
||||||
|
case auth.PermissionRead:
|
||||||
|
return "View"
|
||||||
|
case auth.PermissionWrite:
|
||||||
|
return "Edit"
|
||||||
|
case auth.PermissionAdmin:
|
||||||
|
return "Admin"
|
||||||
|
default:
|
||||||
|
return "No access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantSummary(grant auth.ResourcePermission, usersByID map[string]auth.User) string {
|
||||||
|
if grant.ID == "" {
|
||||||
|
return "No explicit grant"
|
||||||
|
}
|
||||||
|
parts := []string{"Explicit " + permissionLabel(grant.Permission)}
|
||||||
|
if grant.CreatedAt.IsZero() {
|
||||||
|
return strings.Join(parts, " · ")
|
||||||
|
}
|
||||||
|
parts = append(parts, "granted "+formatPermissionTime(grant.CreatedAt))
|
||||||
|
if actor := grantActorLabel(grant.GrantedBy, usersByID); actor != "" {
|
||||||
|
parts = append(parts, "by "+actor)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantActorLabel(userID string, usersByID map[string]auth.User) string {
|
||||||
|
if userID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if user, ok := usersByID[userID]; ok {
|
||||||
|
if user.DisplayName != "" {
|
||||||
|
return user.DisplayName
|
||||||
|
}
|
||||||
|
return user.Email
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPermissionTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return "Never"
|
||||||
|
}
|
||||||
|
return value.Local().Format("Jan 2, 2006 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) filterReadableDocuments(r *http.Request, records []docs.DocumentRecord) ([]docs.DocumentRecord, error) {
|
||||||
|
filtered := make([]docs.DocumentRecord, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
if ok, err := s.canReadDocumentRecord(r, record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
filtered = append(filtered, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) filterReadableSearchResults(r *http.Request, results []docs.SearchResult) ([]docs.SearchResult, error) {
|
||||||
|
filtered := make([]docs.SearchResult, 0, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
record, err := s.repository.GetDocumentByPath(r.Context(), result.Path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok, err := s.canReadDocumentRecord(r, *record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
filtered = append(filtered, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) documentRecordForRequestPath(r *http.Request, requestPath string) (*docs.DocumentRecord, error) {
|
||||||
|
clean := strings.Trim(strings.TrimSpace(requestPath), "/")
|
||||||
|
if clean == "" {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
|
||||||
|
clean += ".md"
|
||||||
|
}
|
||||||
|
return s.repository.GetDocumentByPath(r.Context(), clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canReadDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
|
||||||
|
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canWriteDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
|
||||||
|
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canAdminDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
|
||||||
|
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) documentPermissionAllows(r *http.Request, path string, tags []string, required auth.Permission) (bool, error) {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if principal.Role == auth.RoleAdmin {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceDocument, path, tags)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return auth.PermissionAllows(permission, required), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canAdminResource(r *http.Request, resourceType auth.ResourceType, resourceID string, tags []string) bool {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.Role == auth.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, resourceType, resourceID, tags)
|
||||||
|
return err == nil && auth.PermissionAllows(permission, auth.PermissionAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canWriteNewDocument(r *http.Request, pagePath string) bool {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.Role == auth.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.canWriteFolder(r, documentFolder(pagePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canReadNewDocument(r *http.Request, pagePath string) bool {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.Role == auth.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.canReadFolder(r, documentFolder(pagePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canReadFolder(r *http.Request, folder string) bool {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.Role == auth.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil)
|
||||||
|
return err == nil && auth.PermissionAllows(permission, auth.PermissionRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canWriteFolder(r *http.Request, folder string) bool {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.Role == auth.RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil)
|
||||||
|
return err == nil && auth.PermissionAllows(permission, auth.PermissionWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) grantDocumentCreatorAdmin(r *http.Request, path string) error {
|
||||||
|
principal, ok := principalFromContext(r.Context())
|
||||||
|
if !ok || principal.UserID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.auth.EnsureResourcePermission(r.Context(), principal, auth.ResourceDocument, path, auth.ResourcePermissionUpdate{
|
||||||
|
UserID: principal.UserID,
|
||||||
|
Permission: auth.PermissionAdmin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastPathSegment(path string) string {
|
||||||
|
trimmed := strings.Trim(path, "/")
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(trimmed, "/")
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentFolder(path string) string {
|
||||||
|
trimmed := strings.Trim(path, "/")
|
||||||
|
segment := lastPathSegment(trimmed)
|
||||||
|
if segment == "" || segment == trimmed {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(trimmed, "/"+segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryEscape(value string) string {
|
||||||
|
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
||||||
|
}
|
||||||
49
apps/server/internal/httpserver/rate_limit.go
Normal file
49
apps/server/internal/httpserver/rate_limit.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
window time.Duration
|
||||||
|
limit int
|
||||||
|
attempts map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
||||||
|
return &rateLimiter{
|
||||||
|
window: window,
|
||||||
|
limit: limit,
|
||||||
|
attempts: make(map[string][]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *rateLimiter) Allow(key string) bool {
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-l.window)
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
attempts := l.attempts[key]
|
||||||
|
kept := attempts[:0]
|
||||||
|
for _, attempt := range attempts {
|
||||||
|
if attempt.After(cutoff) {
|
||||||
|
kept = append(kept, attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) >= l.limit {
|
||||||
|
l.attempts[key] = kept
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
kept = append(kept, now)
|
||||||
|
l.attempts[key] = kept
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func authRateLimitKey(r *http.Request) string {
|
||||||
|
return requestIP(r) + ":" + r.URL.Path
|
||||||
|
}
|
||||||
19
apps/server/internal/httpserver/rate_limit_test.go
Normal file
19
apps/server/internal/httpserver/rate_limit_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRateLimiterRejectsAfterLimit(t *testing.T) {
|
||||||
|
limiter := newRateLimiter(2, time.Minute)
|
||||||
|
if !limiter.Allow("ip:path") {
|
||||||
|
t.Fatal("first attempt should be allowed")
|
||||||
|
}
|
||||||
|
if !limiter.Allow("ip:path") {
|
||||||
|
t.Fatal("second attempt should be allowed")
|
||||||
|
}
|
||||||
|
if limiter.Allow("ip:path") {
|
||||||
|
t.Fatal("third attempt should be rate limited")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"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/config"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||||
@@ -23,27 +26,32 @@ import (
|
|||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
Documents *docs.Service
|
Documents *docs.Service
|
||||||
Repository *docs.Repository
|
Repository *docs.Repository
|
||||||
ContentStore *store.ContentStore
|
ContentStore *store.ContentStore
|
||||||
Hub *realtime.Hub
|
Hub *realtime.Hub
|
||||||
SyncService *sync.Service
|
SyncService *sync.Service
|
||||||
SyncRepo *sync.Repository
|
SyncRepo *sync.Repository
|
||||||
|
Auth *auth.Service
|
||||||
|
Collaboration *collaboration.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config config.Config
|
config config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
documents *docs.Service
|
documents *docs.Service
|
||||||
repository *docs.Repository
|
repository *docs.Repository
|
||||||
contentStore *store.ContentStore
|
contentStore *store.ContentStore
|
||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
syncService *sync.Service
|
syncService *sync.Service
|
||||||
syncRepo *sync.Repository
|
syncRepo *sync.Repository
|
||||||
templates *template.Template
|
auth *auth.Service
|
||||||
webEnabled bool
|
collaboration *collaboration.Service
|
||||||
|
authLimiter *rateLimiter
|
||||||
|
templates *template.Template
|
||||||
|
devUserID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(deps Dependencies) (http.Handler, error) {
|
func New(deps Dependencies) (http.Handler, error) {
|
||||||
@@ -60,19 +68,27 @@ func New(deps Dependencies) (http.Handler, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server := &Server{
|
server := &Server{
|
||||||
config: deps.Config,
|
config: deps.Config,
|
||||||
logger: deps.Logger,
|
logger: deps.Logger,
|
||||||
documents: deps.Documents,
|
documents: deps.Documents,
|
||||||
repository: deps.Repository,
|
repository: deps.Repository,
|
||||||
contentStore: deps.ContentStore,
|
contentStore: deps.ContentStore,
|
||||||
hub: deps.Hub,
|
hub: deps.Hub,
|
||||||
syncService: deps.SyncService,
|
syncService: deps.SyncService,
|
||||||
syncRepo: deps.SyncRepo,
|
syncRepo: deps.SyncRepo,
|
||||||
templates: templates,
|
auth: deps.Auth,
|
||||||
|
collaboration: deps.Collaboration,
|
||||||
|
authLimiter: newRateLimiter(5, time.Minute),
|
||||||
|
templates: templates,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(deps.Config.Web.DistDir); err == nil {
|
if deps.Config.DevMode {
|
||||||
server.webEnabled = true
|
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()
|
router := chi.NewRouter()
|
||||||
@@ -82,25 +98,81 @@ func New(deps Dependencies) (http.Handler, error) {
|
|||||||
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
|
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
|
||||||
router.Use(server.requestLogger)
|
router.Use(server.requestLogger)
|
||||||
router.Use(server.securityHeaders)
|
router.Use(server.securityHeaders)
|
||||||
router.Use(server.apiKeyMiddleware)
|
router.Use(server.setupMiddleware)
|
||||||
|
router.Use(server.authMiddleware)
|
||||||
|
|
||||||
router.Get("/", server.handleIndex)
|
router.Get("/", server.handleIndex)
|
||||||
|
router.Get("/setup", server.handleSetupPage)
|
||||||
|
router.Get("/login", server.handleLoginPage)
|
||||||
|
router.Get("/password-reset", server.handlePasswordResetPage)
|
||||||
|
router.Get("/account", server.handleAccountPage)
|
||||||
|
router.Get("/account/tokens/new", server.handleTokenCreatePage)
|
||||||
|
router.Get("/permissions", server.handlePermissionsPage)
|
||||||
|
router.Post("/permissions", server.handlePermissionsForm)
|
||||||
|
router.Get("/device/verify", server.handleDeviceVerifyPage)
|
||||||
router.Get("/health", server.handleHealth)
|
router.Get("/health", server.handleHealth)
|
||||||
|
router.Get("/healthz", server.handleHealthz)
|
||||||
|
router.Get("/readyz", server.handleReadyz)
|
||||||
router.Get("/sw.js", server.handleServiceWorker)
|
router.Get("/sw.js", server.handleServiceWorker)
|
||||||
router.Get("/ws", server.handleWebSocket)
|
router.Get("/ws", server.handleWebSocket)
|
||||||
|
router.Get("/api/auth/me", server.handleAuthMe)
|
||||||
|
router.Post("/api/setup", server.handleSetup)
|
||||||
|
router.Post("/api/auth/register/password", server.handlePasswordRegister)
|
||||||
|
router.Post("/api/auth/login/password", server.handlePasswordLogin)
|
||||||
|
router.Post("/api/auth/login/dev", server.handleDevLogin)
|
||||||
|
router.Post("/api/auth/password/reset", server.handlePasswordReset)
|
||||||
|
router.Post("/api/auth/logout", server.handleLogout)
|
||||||
|
router.Post("/api/auth/password/change", server.handleChangePassword)
|
||||||
|
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
||||||
|
router.Post("/api/auth/passkeys/me/register/begin", server.handleCurrentUserPasskeyRegisterBegin)
|
||||||
|
router.Post("/api/auth/passkeys/me/register/finish", server.handleCurrentUserPasskeyRegisterFinish)
|
||||||
|
router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin)
|
||||||
|
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
||||||
|
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
|
||||||
|
router.Post("/api/auth/passkeys/login/finish", server.handlePasskeyLoginFinish)
|
||||||
|
router.Get("/api/tokens", server.handleListAPITokens)
|
||||||
|
router.Post("/api/tokens", server.handleCreateAPIToken)
|
||||||
|
router.Delete("/api/tokens/{id}", server.handleRevokeAPIToken)
|
||||||
|
router.Post("/api/device/start", server.handleDeviceStart)
|
||||||
|
router.Post("/api/device/approve", server.handleDeviceApprove)
|
||||||
|
router.Post("/api/device/token", server.handleDeviceToken)
|
||||||
|
router.Get("/api/device/verify", server.handleDeviceVerify)
|
||||||
router.Post("/api/admin/login", server.handleAdminLogin)
|
router.Post("/api/admin/login", server.handleAdminLogin)
|
||||||
router.Get("/api/admin/users", server.handleAdminUsers)
|
router.Get("/api/admin/users", server.handleAdminUsers)
|
||||||
router.Post("/api/admin/users", server.handleAdminCreateUser)
|
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.Get("/api/admin/workspace", server.handleAdminWorkspace)
|
||||||
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
|
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
|
||||||
router.Get("/docs", server.handleDocsIndex)
|
router.Get("/docs", server.handleDocsIndex)
|
||||||
router.Get("/docs/*", server.handleDocument)
|
router.Get("/docs/*", server.handleDocument)
|
||||||
router.Get("/api/documents", server.handleDocuments)
|
router.Get("/api/documents", server.handleDocuments)
|
||||||
router.Post("/api/documents/*", server.handleDocumentSave)
|
router.Post("/api/documents/*", server.handleDocumentMutation)
|
||||||
router.Get("/api/search", server.handleSearch)
|
router.Get("/api/search", server.handleSearch)
|
||||||
|
router.Get("/api/permissions", server.handlePermissionsAPI)
|
||||||
|
router.Patch("/api/permissions", server.handlePermissionsAPI)
|
||||||
router.Post("/api/uploads", server.handleUpload)
|
router.Post("/api/uploads", server.handleUpload)
|
||||||
|
router.Get("/api/attachments", server.handleAttachmentsList)
|
||||||
router.Get("/attachments/{hash}", server.handleAttachment)
|
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
|
// Sync protocol endpoints
|
||||||
router.Post("/api/sync/init", server.handleSyncInit)
|
router.Post("/api/sync/init", server.handleSyncInit)
|
||||||
router.Post("/api/sync/delta", server.handleSyncDelta)
|
router.Post("/api/sync/delta", server.handleSyncDelta)
|
||||||
@@ -109,18 +181,9 @@ func New(deps Dependencies) (http.Handler, error) {
|
|||||||
|
|
||||||
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
|
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
|
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 {
|
func mustSub(path string) http.FileSystem {
|
||||||
sub, err := fsSub(path)
|
sub, err := fsSub(path)
|
||||||
if err != nil {
|
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';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
487
apps/server/internal/httpserver/static/auth.js
Normal file
487
apps/server/internal/httpserver/static/auth.js
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
(() => {
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.auth-tab-list').forEach(tabList => {
|
||||||
|
const tabs = tabList.querySelectorAll('[data-tab]');
|
||||||
|
const panels = tabList.closest('.auth-tabs').querySelectorAll('[data-tab-panel]');
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const target = tab.dataset.tab;
|
||||||
|
|
||||||
|
tabs.forEach(t => {
|
||||||
|
t.classList.remove('is-active');
|
||||||
|
t.setAttribute('aria-selected', 'false');
|
||||||
|
});
|
||||||
|
tab.classList.add('is-active');
|
||||||
|
tab.setAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
panels.forEach(p => {
|
||||||
|
if (p.dataset.tabPanel === target) {
|
||||||
|
p.classList.add('is-active');
|
||||||
|
p.hidden = false;
|
||||||
|
} else {
|
||||||
|
p.classList.remove('is-active');
|
||||||
|
p.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register toggle
|
||||||
|
const registerToggle = document.querySelector('[data-register-toggle]');
|
||||||
|
const registerPanel = document.querySelector('[data-register-panel]');
|
||||||
|
const loginSection = document.querySelector('[data-login-section]');
|
||||||
|
const registerCancel = document.querySelector('[data-register-cancel]');
|
||||||
|
|
||||||
|
const authHeading = document.querySelector('[data-auth-heading]');
|
||||||
|
|
||||||
|
if (registerToggle && registerPanel && loginSection) {
|
||||||
|
registerToggle.addEventListener('click', () => {
|
||||||
|
loginSection.hidden = true;
|
||||||
|
registerPanel.hidden = false;
|
||||||
|
if (authHeading) authHeading.textContent = 'Create an Account';
|
||||||
|
document.title = 'Create an Account';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerCancel && registerPanel && loginSection) {
|
||||||
|
registerCancel.addEventListener('click', () => {
|
||||||
|
registerPanel.hidden = true;
|
||||||
|
loginSection.hidden = false;
|
||||||
|
if (authHeading) authHeading.textContent = 'Log In';
|
||||||
|
document.title = 'Sign in';
|
||||||
|
// Clear any registration form status messages
|
||||||
|
document.querySelector('[data-passkey-register-status]').textContent = '';
|
||||||
|
document.querySelector('[data-register-status]').textContent = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonHeaders = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
const postJSON = async (url, body, options = {}) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: options.method || "POST",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (selector, message, isError = false) => {
|
||||||
|
const target = document.querySelector(selector);
|
||||||
|
if (!target) return;
|
||||||
|
target.textContent = message;
|
||||||
|
target.dataset.state = isError ? "error" : "ok";
|
||||||
|
};
|
||||||
|
|
||||||
|
const authNext = () => document.querySelector("[data-auth-next]")?.value || "/account";
|
||||||
|
|
||||||
|
const formJSON = (form) => Object.fromEntries(new FormData(form).entries());
|
||||||
|
|
||||||
|
document.querySelector("[data-initial-setup]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
try {
|
||||||
|
await postJSON("/api/setup", {
|
||||||
|
email: form.elements.email.value,
|
||||||
|
displayName: form.elements.displayName.value,
|
||||||
|
password: form.elements.password.value,
|
||||||
|
signupsEnabled: form.elements.signupsEnabled.checked,
|
||||||
|
});
|
||||||
|
window.location.href = "/account";
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-setup-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const base64URLToBuffer = (value) => {
|
||||||
|
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes.buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bufferToBase64URL = (value) => {
|
||||||
|
const bytes = new Uint8Array(value);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentialToJSON = (credential) => {
|
||||||
|
if (credential instanceof ArrayBuffer) return bufferToBase64URL(credential);
|
||||||
|
if (ArrayBuffer.isView(credential)) return bufferToBase64URL(credential.buffer);
|
||||||
|
if (Array.isArray(credential)) return credential.map(credentialToJSON);
|
||||||
|
if (credential && typeof credential === "object") {
|
||||||
|
const next = {};
|
||||||
|
for (const [key, value] of Object.entries(credential)) next[key] = credentialToJSON(value);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return credential;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCreateOptions = (options) => {
|
||||||
|
const publicKey = { ...options.publicKey };
|
||||||
|
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
|
||||||
|
publicKey.user = { ...publicKey.user, id: base64URLToBuffer(publicKey.user.id) };
|
||||||
|
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map((credential) => ({
|
||||||
|
...credential,
|
||||||
|
id: base64URLToBuffer(credential.id),
|
||||||
|
}));
|
||||||
|
return { publicKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRequestOptions = (options) => {
|
||||||
|
const publicKey = { ...options.publicKey };
|
||||||
|
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
|
||||||
|
publicKey.allowCredentials = (publicKey.allowCredentials || []).map((credential) => ({
|
||||||
|
...credential,
|
||||||
|
id: base64URLToBuffer(credential.id),
|
||||||
|
}));
|
||||||
|
return { publicKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector("[data-auth-login]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/login/password", formJSON(event.currentTarget));
|
||||||
|
window.location.href = authNext();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-login-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-dev-login]")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/login/dev", {});
|
||||||
|
window.location.href = authNext();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-dev-login-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/register/password", formJSON(event.currentTarget));
|
||||||
|
setStatus("[data-register-status]", "Account created. Sign in with your password.");
|
||||||
|
event.currentTarget.reset();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-register-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const webAuthnErrorMessage = (error) => {
|
||||||
|
console.error("WebAuthn error:", error.name, error.message, error);
|
||||||
|
switch (error.name) {
|
||||||
|
case "NotAllowedError":
|
||||||
|
return "Passkey creation was cancelled or no authenticator is available. If you dismissed a prompt, try again. If no prompt appeared, your device may not support passkeys.";
|
||||||
|
case "NotSupportedError":
|
||||||
|
return "This browser or device does not support passkeys.";
|
||||||
|
case "SecurityError":
|
||||||
|
return "Passkeys require a secure context. Use https:// or localhost.";
|
||||||
|
case "InvalidStateError":
|
||||||
|
return "A passkey may already exist for this account.";
|
||||||
|
default:
|
||||||
|
return error.message || "Passkey failed. Check the console for details.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkPasskeySupport = async () => {
|
||||||
|
if (!window.PublicKeyCredential) {
|
||||||
|
return { supported: false, reason: "Passkeys are not available in this browser." };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
|
||||||
|
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||||
|
if (!available) {
|
||||||
|
return { supported: true, reason: "No platform authenticator found (e.g., Touch ID, Windows Hello). You can still use a security key if you have one." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { supported: true };
|
||||||
|
} catch {
|
||||||
|
return { supported: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const support = await checkPasskeySupport();
|
||||||
|
if (!support.supported) {
|
||||||
|
setStatus("[data-passkey-register-status]", support.reason, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (support.reason) {
|
||||||
|
console.warn("Passkey support:", support.reason);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const begin = await postJSON("/api/auth/passkeys/register/begin", formJSON(event.currentTarget));
|
||||||
|
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
|
||||||
|
await postJSON(`/api/auth/passkeys/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||||
|
setStatus("[data-passkey-register-status]", "Passkey created. Sign in with your passkey.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-passkey-register-status]", webAuthnErrorMessage(error), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const support = await checkPasskeySupport();
|
||||||
|
if (!support.supported) {
|
||||||
|
setStatus("[data-passkey-login-status]", support.reason, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (support.reason) {
|
||||||
|
console.warn("Passkey support:", support.reason);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const begin = await postJSON("/api/auth/passkeys/login/begin", formJSON(event.currentTarget));
|
||||||
|
const credential = await navigator.credentials.get(normalizeRequestOptions(begin.options));
|
||||||
|
await postJSON(`/api/auth/passkeys/login/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||||
|
window.location.href = authNext();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-passkey-login-status]", webAuthnErrorMessage(error), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-passkey-add]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const support = await checkPasskeySupport();
|
||||||
|
if (!support.supported) {
|
||||||
|
setStatus("[data-passkey-add-status]", support.reason, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (support.reason) {
|
||||||
|
console.warn("Passkey support:", support.reason);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const begin = await postJSON("/api/auth/passkeys/me/register/begin", {});
|
||||||
|
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
|
||||||
|
await postJSON(`/api/auth/passkeys/me/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||||
|
setStatus("[data-passkey-add-status]", "Passkey added. You can use it the next time you sign in.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-passkey-add-status]", webAuthnErrorMessage(error), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/logout", {});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Logout request failed:", error);
|
||||||
|
} finally {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadTokens = async () => {
|
||||||
|
const list = document.querySelector("[data-token-list]");
|
||||||
|
if (!list) return;
|
||||||
|
const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json());
|
||||||
|
if (!data.tokens?.length) {
|
||||||
|
const empty = document.createElement("li");
|
||||||
|
empty.className = "token-empty";
|
||||||
|
empty.textContent = "No access tokens yet. Create one when you connect the macOS app, CLI, or another trusted client.";
|
||||||
|
list.replaceChildren(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.replaceChildren(
|
||||||
|
...data.tokens.map((token) => {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.className = `token-row${token.revokedAt ? " is-revoked" : ""}`;
|
||||||
|
const meta = document.createElement("span");
|
||||||
|
meta.className = "token-meta";
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
name.textContent = token.name;
|
||||||
|
const details = document.createElement("small");
|
||||||
|
details.textContent = `${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
|
||||||
|
meta.append(name, details);
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "Revoke";
|
||||||
|
button.disabled = Boolean(token.revokedAt);
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await fetch(`/api/tokens/${encodeURIComponent(token.id.replace(/^api:/, ""))}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
await loadTokens();
|
||||||
|
});
|
||||||
|
item.append(meta, button);
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector("[data-token-create]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const scopes = [...form.querySelectorAll('input[name="scope"]:checked')].map((input) => input.value);
|
||||||
|
try {
|
||||||
|
const created = await postJSON("/api/tokens", { name: form.elements.name.value, scopes });
|
||||||
|
const output = document.querySelector("[data-token-output]");
|
||||||
|
if (output) {
|
||||||
|
output.hidden = false;
|
||||||
|
output.textContent = created.token;
|
||||||
|
}
|
||||||
|
setStatus("[data-token-status]", "Token created. Copy it now; it will not be shown again.");
|
||||||
|
form.reset();
|
||||||
|
await loadTokens();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-token-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-password-change]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/password/change", formJSON(event.currentTarget));
|
||||||
|
setStatus("[data-password-status]", "Password changed.");
|
||||||
|
event.currentTarget.reset();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-password-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-password-reset]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/password/reset", formJSON(event.currentTarget));
|
||||||
|
setStatus("[data-password-reset-status]", "Password reset. You can sign in with your new password.");
|
||||||
|
event.currentTarget.querySelector('input[name="newPassword"]').value = "";
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-password-reset-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!confirm("Delete this account permanently?")) return;
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/account", formJSON(event.currentTarget), { method: "DELETE" });
|
||||||
|
window.location.href = "/login";
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-delete-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-device-approve]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/device/approve", formJSON(event.currentTarget));
|
||||||
|
setStatus("[data-device-status]", "Device approved.");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === "authentication required") {
|
||||||
|
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("[data-device-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadAdminUsers = async () => {
|
||||||
|
const section = document.querySelector("[data-admin-users-section]");
|
||||||
|
const list = document.querySelector("[data-admin-user-list]");
|
||||||
|
if (!section || !list) return;
|
||||||
|
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
|
||||||
|
if (me?.principal?.role !== "admin") return;
|
||||||
|
section.hidden = false;
|
||||||
|
const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json());
|
||||||
|
list.replaceChildren(
|
||||||
|
...(data.users || []).map((user) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "user-row";
|
||||||
|
row.dataset.userId = user.id;
|
||||||
|
const identity = document.createElement("span");
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
name.textContent = user.displayName;
|
||||||
|
const email = document.createElement("small");
|
||||||
|
email.textContent = user.email;
|
||||||
|
identity.append(name, email);
|
||||||
|
const select = document.createElement("select");
|
||||||
|
for (const role of ["viewer", "editor", "admin"]) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = role;
|
||||||
|
option.textContent = role;
|
||||||
|
option.selected = user.role === role;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
select.name = "role";
|
||||||
|
select.setAttribute("aria-label", `Role for ${user.displayName}`);
|
||||||
|
const disabledLabel = document.createElement("label");
|
||||||
|
disabledLabel.className = "user-disable-toggle";
|
||||||
|
const disabled = document.createElement("input");
|
||||||
|
disabled.type = "checkbox";
|
||||||
|
disabled.name = "disabled";
|
||||||
|
disabled.checked = Boolean(user.disabled);
|
||||||
|
const disabledText = document.createElement("span");
|
||||||
|
disabledText.textContent = "Disabled";
|
||||||
|
disabledLabel.append(disabled, disabledText);
|
||||||
|
const reset = document.createElement("button");
|
||||||
|
reset.type = "button";
|
||||||
|
reset.textContent = "Send password reset";
|
||||||
|
reset.addEventListener("click", async () => {
|
||||||
|
if (!confirm(`Send a new password reset email to ${user.email}? Any earlier reset link will stop working.`)) return;
|
||||||
|
try {
|
||||||
|
await postJSON(`/api/admin/auth-users/${encodeURIComponent(user.id)}/password-reset`, {});
|
||||||
|
setStatus("[data-admin-users-status]", `Password reset email sent to ${user.email}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-admin-users-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.append(identity, select, disabledLabel, reset);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector("[data-admin-users]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const users = [...form.querySelectorAll("[data-user-id]")].map((row) => ({
|
||||||
|
id: row.dataset.userId,
|
||||||
|
role: row.querySelector('select[name="role"]').value,
|
||||||
|
disabled: row.querySelector('input[name="disabled"]').checked,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await postJSON("/api/admin/auth-users", { users }, { method: "PATCH" });
|
||||||
|
setStatus("[data-admin-users-status]", "User access settings saved.");
|
||||||
|
await loadAdminUsers();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-admin-users-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadAdminSettings = async () => {
|
||||||
|
const section = document.querySelector("[data-admin-settings-section]");
|
||||||
|
const form = document.querySelector("[data-admin-settings]");
|
||||||
|
if (!section || !form) return;
|
||||||
|
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
|
||||||
|
if (me?.principal?.role !== "admin") return;
|
||||||
|
section.hidden = false;
|
||||||
|
const data = await fetch("/api/admin/settings", { credentials: "same-origin" }).then((response) => response.json());
|
||||||
|
form.elements.signupsEnabled.checked = Boolean(data.settings?.signupsEnabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector("[data-admin-settings]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const form = event.currentTarget;
|
||||||
|
await postJSON("/api/admin/settings", { signupsEnabled: form.elements.signupsEnabled.checked }, { method: "PATCH" });
|
||||||
|
setStatus("[data-admin-settings-status]", "Registration setting saved.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-admin-settings-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadTokens().catch(() => {});
|
||||||
|
loadAdminUsers().catch(() => {});
|
||||||
|
loadAdminSettings().catch(() => {});
|
||||||
|
})();
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
const STORE_CONTENT = "content";
|
const STORE_CONTENT = "content";
|
||||||
const STORE_PAGES = "pages";
|
const STORE_PAGES = "pages";
|
||||||
const STORE_PENDING_EDITS = "pending_edits";
|
const STORE_PENDING_EDITS = "pending_edits";
|
||||||
|
const CACHE_ENTRY_LIMIT = 100;
|
||||||
|
|
||||||
function openDB() {
|
function openDB() {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
@@ -39,6 +40,27 @@
|
|||||||
return path.replace(/\/$/, "") || "/";
|
return path.replace(/\/$/, "") || "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneStore(store, limit) {
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = function () {
|
||||||
|
const records = request.result || [];
|
||||||
|
if (records.length <= limit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
records
|
||||||
|
.sort(function (a, b) {
|
||||||
|
return (a.cachedAt || 0) - (b.cachedAt || 0);
|
||||||
|
})
|
||||||
|
.slice(0, records.length - limit)
|
||||||
|
.forEach(function (record) {
|
||||||
|
if (record && record.path) {
|
||||||
|
store.delete(record.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cacheDocuments(documents) {
|
function cacheDocuments(documents) {
|
||||||
return openDB().then(function (db) {
|
return openDB().then(function (db) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
@@ -63,6 +85,7 @@
|
|||||||
const tx = db.transaction(STORE_CONTENT, "readwrite");
|
const tx = db.transaction(STORE_CONTENT, "readwrite");
|
||||||
const store = tx.objectStore(STORE_CONTENT);
|
const store = tx.objectStore(STORE_CONTENT);
|
||||||
store.put({ path: path, html: html, title: title, cachedAt: Date.now() });
|
store.put({ path: path, html: html, title: title, cachedAt: Date.now() });
|
||||||
|
pruneStore(store, CACHE_ENTRY_LIMIT);
|
||||||
tx.oncomplete = function () {
|
tx.oncomplete = function () {
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
@@ -117,6 +140,7 @@
|
|||||||
title: title,
|
title: title,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
pruneStore(store, CACHE_ENTRY_LIMIT);
|
||||||
tx.oncomplete = function () {
|
tx.oncomplete = function () {
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user