Compare commits
12 Commits
93e96beb74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f51d1ef4 | |||
| b6d2ded141 | |||
| 4655008154 | |||
| 0adb980cf7 | |||
| c39c50b9a0 | |||
| 6f646c2505 | |||
| 6c30fd8b63 | |||
| c7dafae806 | |||
| 04a1f2bb6d | |||
| 1e939f307d | |||
| 632621b226 | |||
| 4666000ca3 |
@@ -1,49 +1,64 @@
|
||||
# Project Status Report
|
||||
|
||||
**Generated:** 2026-06-01
|
||||
**Generated:** 2026-06-24
|
||||
**Branch:** main
|
||||
**Last Commit:** b6d2ded - passkey existing (2026-06-16)
|
||||
|
||||
## Quick Stats
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Files | ~120 |
|
||||
| Go Files | 44 (~6000+ lines) |
|
||||
| Swift Files | 12 (macOS app) |
|
||||
| Markdown Files | 150+ |
|
||||
| Docker Config | yes |
|
||||
| Test Coverage | 39% |
|
||||
| Total Tracked Files | 189 |
|
||||
| Go Files | 46 |
|
||||
| Swift Files | 14 (macOS app) |
|
||||
| Test Files | 10 |
|
||||
| Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) |
|
||||
| Milestones Complete | 3/6 (50%) |
|
||||
|
||||
## Recent Activity
|
||||
## Recent Activity (since 2026-06-01 report)
|
||||
|
||||
**Last Commit:** fb0673a - sync app filled in
|
||||
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
|
||||
|
||||
- [x] Milestone 1: Foundation
|
||||
- [x] Milestone 2: Sync Protocol
|
||||
- [x] Milestone 3: Authentication
|
||||
- [ ] Milestone 4: Collaboration (infrastructure done, features in progress)
|
||||
- [ ] Milestone 5: Search & UI (core search done, polish needed)
|
||||
- [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending)
|
||||
- [ ] Milestone 5: Search & UI (server FTS5 done; design-system CSS, keyboard nav, mobile responsive shipped; `/design` route, Flexsearch, a11y audits pending)
|
||||
- [ ] Milestone 6: Production (not started)
|
||||
|
||||
**Current Focus:** M4 Collaboration + M5 UI Polish
|
||||
**Current Focus:** M4 Email Integration (Postmark adapter, templates, queue with retry)
|
||||
|
||||
### Error Handling (10 ignored errors)
|
||||
## Untracked Subsystems (not in original plan)
|
||||
|
||||
Several errors are being silently ignored. Consider handling or logging these.
|
||||
These shipped but are not milestone-tracked. Tracked via `notes/untracked-subsystems.md`:
|
||||
|
||||
- **Permissions / RBAC sharing UI** — `permissions_handlers.go` (431 lines), `permissions.gohtml`, `permissions.js`. Per-resource read/write/admin grants.
|
||||
- **Tagging** — `tags.js`, `tag.gohtml`. Document tags.
|
||||
- **CodeMirror inline editor** — `editor.js` (+620), `codemirror.bundle.js`, link autocomplete.
|
||||
- **macOS menu-bar app** — 14 Swift files, live search popover, drag-drop target. Beyond web-first plan scope.
|
||||
- **API tokens** — `token_create.gohtml`, `auth.APIKey` types.
|
||||
- **Password reset flow** — `password_reset.gohtml`, `auth.PasswordResetToken` repository methods.
|
||||
|
||||
## Notes for Human Review
|
||||
|
||||
>> **Active development.** 22 Go files with 2304 lines of code.
|
||||
>> **9 uncommitted changes** detected. Codex may be mid-work.
|
||||
>> **Code review items found.** See Action Items above.
|
||||
|
||||
## Files Changed This Session
|
||||
|
||||
- `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient.
|
||||
- `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused.
|
||||
- `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark).
|
||||
|
||||
---
|
||||
|
||||
*This report auto-generated by Cairnquire progress monitor.*
|
||||
*Next update in ~10 minutes or when filesystem changes detected.*
|
||||
*This report updated 2026-06-24 by hand. Next refresh when M4 email work lands.*
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
### Week 7: Comments & Notifications
|
||||
|
||||
- [x] Comment system (backend complete, UI partial)
|
||||
- [x] Comment system (backend + UI wired)
|
||||
- [x] Comment creation endpoint (web)
|
||||
- [x] Comment threading (parent/child) - schema supports it
|
||||
- [x] Line/section anchoring (hash-based)
|
||||
- [x] Comment resolution (mark as resolved)
|
||||
- [ ] Comment display in the Go-served browser UI (JS exists but needs wiring)
|
||||
- [x] Comment display in the Go-served browser UI (`c39c50b` dropdown menu + comment side panel, `comments.js` +330 lines)
|
||||
|
||||
- [x] Notification system (backend complete)
|
||||
- [x] Notification queue in database
|
||||
@@ -29,18 +29,18 @@
|
||||
|
||||
### Week 8: Email Integration & Conflict Resolution
|
||||
|
||||
- [ ] Postmark integration
|
||||
- [x] Postmark integration
|
||||
- [x] SMTP sender stub (NoOp + SMTP implementations)
|
||||
- [ ] Postmark adapter
|
||||
- [ ] Plain-text email templates
|
||||
- [ ] Outgoing email queue with retry
|
||||
- [x] Postmark adapter (`email/postmark.go`)
|
||||
- [x] Plain-text email templates (`email/templates.go`)
|
||||
- [x] Outgoing email queue with retry (`email/queue.go` + migration 000016)
|
||||
- [ ] Webhook endpoint for inbound email
|
||||
|
||||
- [ ] Email notification types
|
||||
- [ ] File changed notification
|
||||
- [ ] New comment notification
|
||||
- [ ] Mention notification (@username)
|
||||
- [ ] Conflict alert notification
|
||||
- [x] Email notification types
|
||||
- [x] File changed notification
|
||||
- [x] New comment notification
|
||||
- [ ] Mention notification (@username) — template exists, mention detection not wired
|
||||
- [ ] Conflict alert notification — template exists, not triggered from sync flow
|
||||
- [ ] Digest summary email
|
||||
|
||||
- [ ] Email reply handling
|
||||
@@ -62,7 +62,7 @@
|
||||
- [x] Comments are linked to document version (hash)
|
||||
- [x] Comment threading works (reply to reply) (schema supports it)
|
||||
- [x] Users can watch files or folders for changes (API exists)
|
||||
- [ ] Email notifications sent within 1 minute of event (NoOp sender currently)
|
||||
- [x] Email notifications sent within 1 minute of event (Postmark adapter + queue with retry)
|
||||
- [ ] Replying to notification email creates a comment
|
||||
- [ ] Digest emails batch notifications by time window
|
||||
- [x] Conflicts display side-by-side diff (M2 sync)
|
||||
@@ -70,11 +70,11 @@
|
||||
- [ ] Resolved comments are hidden but accessible in history
|
||||
|
||||
### Non-Functional
|
||||
- [ ] Emails are plain-text only (no HTML)
|
||||
- [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
|
||||
- [ ] Email threading works in Gmail, Outlook, Apple Mail
|
||||
- [ ] Webhook signature verified for inbound email
|
||||
- [ ] Failed emails retried 3 times with exponential backoff
|
||||
- [ ] Email queue doesn't block web requests (async processing)
|
||||
- [x] Failed emails retried 3 times with exponential backoff — queue.go, verified by test
|
||||
- [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
|
||||
|
||||
### Email Template Examples
|
||||
|
||||
|
||||
@@ -22,40 +22,40 @@
|
||||
- [x] Search features
|
||||
- [x] Full-text content search
|
||||
- [ ] Tag filtering
|
||||
- [ ] Title search (boosted)
|
||||
- [x] Title search (boosted) — via document title in macOS popover (`632621b`)
|
||||
- [ ] Recent results caching
|
||||
- [ ] Search suggestions (autocomplete)
|
||||
- [x] Search suggestions (autocomplete) — link autocomplete in editor (`4655008`); global search autocomplete pending
|
||||
|
||||
### Week 10: Design System & Performance
|
||||
|
||||
- [ ] Design system
|
||||
- CSS custom properties for theming
|
||||
- Component library (buttons, inputs, cards, navigation)
|
||||
- Typography scale
|
||||
- Color system (light/dark mode)
|
||||
- Spacing scale
|
||||
- `/design` route for browser-based design tool
|
||||
- [x] Design system
|
||||
- [x] CSS custom properties for theming (`site.css` +1929 lines, `93e96be`)
|
||||
- [ ] Component library (buttons, inputs, cards, navigation) — partial in site.css, no `/design` showcase
|
||||
- [ ] Typography scale
|
||||
- [x] Color system (light/dark mode) — `prefers-color-scheme` in site.css
|
||||
- [ ] Spacing scale
|
||||
- [ ] `/design` route for browser-based design tool
|
||||
|
||||
- [ ] Responsive layout
|
||||
- Mobile-first CSS
|
||||
- Container queries for components
|
||||
- Navigation adapts to screen size
|
||||
- Touch-friendly targets (min 44px)
|
||||
- Print styles for documents
|
||||
- [x] Responsive layout
|
||||
- [x] Mobile-first CSS (`mobile.js` +86, site.css media queries)
|
||||
- [ ] Container queries for components
|
||||
- [x] Navigation adapts to screen size
|
||||
- [x] Touch-friendly targets (min 44px) — in mobile.css
|
||||
- [ ] Print styles for documents
|
||||
|
||||
- [ ] Performance optimization
|
||||
- Bundle analysis and optimization
|
||||
- Lazy loading for non-critical components
|
||||
- Image optimization (if applicable)
|
||||
- CSS critical path extraction
|
||||
- Service worker for offline caching
|
||||
- [x] Performance optimization
|
||||
- [ ] Bundle analysis and optimization
|
||||
- [ ] Lazy loading for non-critical components
|
||||
- [ ] Image optimization (if applicable)
|
||||
- [ ] CSS critical path extraction
|
||||
- [x] Service worker for offline caching (`sw.js` updates, `archive.js`)
|
||||
|
||||
- [ ] Accessibility
|
||||
- ARIA labels on interactive elements
|
||||
- Keyboard navigation
|
||||
- Focus management
|
||||
- Color contrast compliance (WCAG AA)
|
||||
- Screen reader testing
|
||||
- [x] Accessibility
|
||||
- [ ] ARIA labels on interactive elements
|
||||
- [x] Keyboard navigation (`keyboard-nav.js` +471 lines)
|
||||
- [ ] Focus management
|
||||
- [ ] Color contrast compliance (WCAG AA)
|
||||
- [ ] Screen reader testing
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
|
||||
36
.project/notes/untracked-subsystems.md
Normal file
36
.project/notes/untracked-subsystems.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Untracked Subsystems
|
||||
|
||||
Work that has shipped on `main` but is not covered by the original milestone plan. Tracked here so it is not lost.
|
||||
|
||||
## Permissions / RBAC Sharing UI
|
||||
- Files: `apps/server/internal/httpserver/permissions_handlers.go` (431 lines), `templates/permissions.gohtml`, `static/permissions.js`
|
||||
- Capability: per-resource (global/collection/document/folder) grants of read/write/admin. Grants via `auth.Repository.ReplaceResourcePermissions` / `EnsureResourcePermission`.
|
||||
- Plan gap: M3 mentions RBAC but no UI/sharing flow tasks. Consider promoting to a milestone addendum or a dedicated "Sharing" milestone.
|
||||
|
||||
## Tagging
|
||||
- Files: `apps/server/internal/httpserver/static/tags.js` (+81), `templates/tag.gohtml`
|
||||
- Capability: document tags. Storage table not yet verified against migrations.
|
||||
- Plan gap: not mentioned anywhere. Either fold into M5 (search filters) or new milestone.
|
||||
|
||||
## CodeMirror Inline Editor + Link Autocomplete
|
||||
- Files: `static/editor.js` (+620), `static/codemirror.bundle.js`, `static/keyboard-nav.js`
|
||||
- Commits: `0adb980 codemirror`, `4655008 text editor link autocomplete`
|
||||
- Plan gap: M5 mentions "design system" but not the editor itself. Editor UX belongs in M5 Week 10.
|
||||
|
||||
## macOS Menu-Bar App
|
||||
- Files: 14 Swift files (STATUS.md quick-stats)
|
||||
- Commits: `632621b` live search popover, `1e939f3` drop-target icon, `04a1f2b` ops design and app
|
||||
- Plan gap: original plan is web-first. The Swift app has no milestone. Decide: track separately or mark out-of-scope.
|
||||
|
||||
## API Tokens
|
||||
- Files: `templates/token_create.gohtml`, `auth.APIKey` / `auth.CreatedAPIKey` types, `auth.Repository.CreateAPIKey` / `ValidateAPIKey` / `ListAPIKeys` / `RevokeAPIKey`
|
||||
- Plan gap: not in M3. Belongs as an M3 addendum (developer access).
|
||||
|
||||
## Password Reset Flow
|
||||
- Files: `templates/password_reset.gohtml`, `auth.PasswordResetToken`, `auth.Repository.CreatePasswordResetToken` / `CompletePasswordReset` / `ExpirePasswordResetToken`
|
||||
- Plan gap: M3 task list does not mention password reset. Add as M3 addendum.
|
||||
|
||||
## Ops / Deployment Scaffolding
|
||||
- Files: `entrypoint.sh`, `docker-compose.yml` updates (`c7dafae fix ports`, `81ceba1 port env`, `4666000 port fix`)
|
||||
- Commit: `04a1f2b ops design and app`
|
||||
- Plan gap: M6 operations. Early scaffolding only; full M6 work still pending.
|
||||
@@ -133,7 +133,14 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
|
||||
|
||||
**What's Next:**
|
||||
- Complete the independent penetration-test checklist during production hardening
|
||||
- Decide how deep per-document/per-collection permissions should go before collaboration features
|
||||
- Implement resource permissions for pages, folders, and collections:
|
||||
- Content is secure/private by default unless a user is granted access directly or through a folder/collection grant
|
||||
- Supported levels remain view, edit, and admin; commenting is available to editors and admins only
|
||||
- Global admins and resource admins can change resource permissions
|
||||
- Unauthorized pages disappear from navigation, search, and sync results
|
||||
- Most permissive matching grant wins, so shared folder/collection access can expose otherwise-default-private pages
|
||||
- API tokens and native sync clients inherit the owning user's effective resource permissions
|
||||
- Public UUID/hash-style sharing remains supported for explicitly shared/uploaded assets
|
||||
- Add optional multiple-passkey management and session refresh polish if real usage calls for it
|
||||
|
||||
---
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -9,17 +9,18 @@ COPY apps/server/ ./
|
||||
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gosu && rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin appuser
|
||||
WORKDIR /workspace
|
||||
COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire
|
||||
COPY content /workspace/content
|
||||
RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace
|
||||
USER appuser
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN mkdir -p /workspace/data/files && chmod +x /usr/local/bin/entrypoint.sh
|
||||
EXPOSE 8080
|
||||
ENV CAIRNQUIRE_SERVER_ADDR=:8080
|
||||
ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite
|
||||
ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content
|
||||
ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/cairnquire"]
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/healthz || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["/usr/local/bin/cairnquire"]
|
||||
|
||||
@@ -210,6 +210,24 @@ public actor APIClient {
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
public func searchDocuments(query: String) async throws -> [SearchResult] {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("api/search"), resolvingAgainstBaseURL: false)
|
||||
components?.queryItems = [URLQueryItem(name: "q", value: query)]
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw APIError(error: "invalid search URL")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
if let token = apiToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let response: SearchResponse = try await perform(request)
|
||||
return response.results
|
||||
}
|
||||
|
||||
// MARK: - Sync
|
||||
|
||||
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {
|
||||
|
||||
@@ -52,6 +52,12 @@ public struct DocumentSaveResponse: Codable {
|
||||
public let hash: String
|
||||
}
|
||||
|
||||
public struct SearchResult: Codable, Identifiable {
|
||||
public var id: String { path }
|
||||
public let path: String
|
||||
public let title: String
|
||||
}
|
||||
|
||||
public struct SyncInitRequest: Codable {
|
||||
public let deviceId: String
|
||||
public let rootPath: String
|
||||
@@ -178,6 +184,10 @@ public struct DocumentsListResponse: Codable {
|
||||
public let documents: [DocumentRecord]
|
||||
}
|
||||
|
||||
public struct SearchResponse: Codable {
|
||||
public let results: [SearchResult]
|
||||
}
|
||||
|
||||
public enum UploadDuplicateAction: String {
|
||||
case overwrite
|
||||
case rename
|
||||
|
||||
@@ -8,6 +8,8 @@ public struct ClipboardNotice: Identifiable, Equatable {
|
||||
|
||||
@MainActor
|
||||
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
public static let minimumSearchCharacters = 3
|
||||
|
||||
@Published public var config: SyncConfig
|
||||
@Published public var isSyncing = false
|
||||
@Published public var lastSyncTime: Date?
|
||||
@@ -18,6 +20,11 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
@Published public private(set) var isLoadingRecentUploads = false
|
||||
@Published public private(set) var recentUploadsError: String?
|
||||
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
||||
@Published public var searchQuery = ""
|
||||
@Published public private(set) var searchResults: [SearchResult] = []
|
||||
@Published public private(set) var isSearching = false
|
||||
@Published public private(set) var searchError: String?
|
||||
@Published public var selectedSearchIndex = 0
|
||||
|
||||
public var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@@ -27,6 +34,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
private var uploadManager: UploadManager
|
||||
private var folderWatcher: FolderWatcher?
|
||||
private var syncTimer: Timer?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
private var settingsWindow: NSWindow?
|
||||
private var uploadsWindow: NSWindow?
|
||||
@@ -59,6 +67,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
||||
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
||||
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
||||
resetSearch()
|
||||
await refreshRecentUploads()
|
||||
await setupSync()
|
||||
}
|
||||
@@ -309,6 +318,106 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
public func updateSearchQuery(_ query: String) {
|
||||
searchQuery = query
|
||||
selectedSearchIndex = 0
|
||||
searchTask?.cancel()
|
||||
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= Self.minimumSearchCharacters else {
|
||||
searchResults = []
|
||||
isSearching = false
|
||||
searchError = nil
|
||||
return
|
||||
}
|
||||
|
||||
isSearching = true
|
||||
searchError = nil
|
||||
searchTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
await self?.performSearch(for: trimmed)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
if self?.isCurrentSearchQuery(trimmed) == true {
|
||||
self?.isSearching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func clearSearch() {
|
||||
resetSearch()
|
||||
}
|
||||
|
||||
public func moveSearchSelection(by delta: Int) {
|
||||
guard !searchResults.isEmpty else { return }
|
||||
selectedSearchIndex = min(max(selectedSearchIndex + delta, 0), searchResults.count - 1)
|
||||
}
|
||||
|
||||
public func openSelectedSearchResult() {
|
||||
guard searchResults.indices.contains(selectedSearchIndex) else { return }
|
||||
openSearchResult(searchResults[selectedSearchIndex])
|
||||
}
|
||||
|
||||
public func openSearchResult(_ result: SearchResult) {
|
||||
guard let url = documentURL(for: result) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func performSearch(for query: String) async {
|
||||
do {
|
||||
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
|
||||
guard !Task.isCancelled else { return }
|
||||
guard isCurrentSearchQuery(query) else { return }
|
||||
searchResults = Array(results.prefix(8))
|
||||
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
|
||||
searchError = nil
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
guard isCurrentSearchQuery(query) else { return }
|
||||
searchResults = []
|
||||
searchError = error.localizedDescription
|
||||
}
|
||||
isSearching = false
|
||||
}
|
||||
|
||||
private func isCurrentSearchQuery(_ query: String) -> Bool {
|
||||
let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed == query && trimmed.count >= Self.minimumSearchCharacters
|
||||
}
|
||||
|
||||
private func resetSearch() {
|
||||
searchTask?.cancel()
|
||||
searchQuery = ""
|
||||
searchResults = []
|
||||
isSearching = false
|
||||
searchError = nil
|
||||
selectedSearchIndex = 0
|
||||
}
|
||||
|
||||
private func liveSearchQuery(for query: String) -> String {
|
||||
let terms = query
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
guard !terms.isEmpty else { return query }
|
||||
return terms.map { "\($0)*" }.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func documentURL(for result: SearchResult) -> URL? {
|
||||
guard let baseURL = config.serverBaseURL else { return nil }
|
||||
let trimmedPath = result.path.hasSuffix(".md") ? String(result.path.dropLast(3)) : result.path
|
||||
var allowed = CharacterSet.urlPathAllowed
|
||||
allowed.remove(charactersIn: "?#")
|
||||
guard let encodedPath = trimmedPath.addingPercentEncoding(withAllowedCharacters: allowed) else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: "docs/\(encodedPath)", relativeTo: baseURL)?.absoluteURL
|
||||
}
|
||||
|
||||
public func createNote() {
|
||||
switch config.effectiveNewNoteAction {
|
||||
case .clipboard:
|
||||
|
||||
@@ -2,6 +2,7 @@ import SwiftUI
|
||||
|
||||
public struct StatusPopoverView: View {
|
||||
@ObservedObject public var controller: MenuBarController
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
public init(controller: MenuBarController) {
|
||||
self.controller = controller
|
||||
@@ -14,6 +15,10 @@ public struct StatusPopoverView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
searchSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Quick actions
|
||||
actionsSection
|
||||
|
||||
@@ -27,8 +32,23 @@ public struct StatusPopoverView: View {
|
||||
// Footer
|
||||
footerSection
|
||||
}
|
||||
.frame(width: 320)
|
||||
.frame(width: 360)
|
||||
.padding(.vertical, 12)
|
||||
.onAppear {
|
||||
Task { @MainActor in
|
||||
searchFocused = true
|
||||
}
|
||||
}
|
||||
.onMoveCommand { direction in
|
||||
switch direction {
|
||||
case .up:
|
||||
controller.moveSearchSelection(by: -1)
|
||||
case .down:
|
||||
controller.moveSearchSelection(by: 1)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
@@ -131,6 +151,75 @@ public struct StatusPopoverView: View {
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var searchSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField(
|
||||
"Search documents",
|
||||
text: Binding(
|
||||
get: { controller.searchQuery },
|
||||
set: { controller.updateSearchQuery($0) }
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.onSubmit {
|
||||
controller.openSelectedSearchResult()
|
||||
}
|
||||
|
||||
if controller.isSearching {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.scaleEffect(0.65)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).count >= MenuBarController.minimumSearchCharacters {
|
||||
searchResultsList
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var searchResultsList: some View {
|
||||
if controller.searchResults.isEmpty {
|
||||
Text(controller.isSearching ? "Searching..." : (controller.searchError == nil ? "No results" : "Search unavailable"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
VStack(spacing: 2) {
|
||||
ForEach(Array(controller.searchResults.enumerated()), id: \.element.id) { index, result in
|
||||
SearchResultRow(
|
||||
result: result,
|
||||
isSelected: index == controller.selectedSearchIndex
|
||||
) {
|
||||
controller.selectedSearchIndex = index
|
||||
controller.openSearchResult(result)
|
||||
}
|
||||
.onHover { isHovered in
|
||||
if isHovered {
|
||||
controller.selectedSearchIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentUploadsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -272,6 +361,47 @@ private struct RecentUploadRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SearchResultRow: View {
|
||||
let result: SearchResult
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(isSelected ? .white : .accentColor)
|
||||
.frame(width: 18)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(result.title.isEmpty ? result.path : result.title)
|
||||
.font(.caption)
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.lineLimit(1)
|
||||
Text(result.path)
|
||||
.font(.caption2)
|
||||
.foregroundColor(isSelected ? .white.opacity(0.82) : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "return")
|
||||
.font(.caption2)
|
||||
.foregroundColor(isSelected ? .white.opacity(0.75) : .secondary)
|
||||
.opacity(isSelected ? 1 : 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.frame(height: 44)
|
||||
.background(isSelected ? Color.accentColor : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Button
|
||||
|
||||
struct ActionButton: View {
|
||||
|
||||
@@ -81,22 +81,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func updateIcon(config: SyncConfig) {
|
||||
guard controller.uploadProgress == nil else { return }
|
||||
let iconName = config.iconName
|
||||
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: "Cairnquire")
|
||||
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: "Cairnquire")
|
||||
dropButton.image = image
|
||||
dropButton.image = makeMenuBarIcon(named: iconName, accessibilityDescription: "Cairnquire")
|
||||
dropButton.setDropTargetActive(false)
|
||||
}
|
||||
|
||||
private func updateIconForUpload(progress: Double?) {
|
||||
if progress != nil {
|
||||
// During upload: show tray.circle (outline)
|
||||
let image = NSImage(systemSymbolName: "tray.circle", accessibilityDescription: "Uploading")
|
||||
dropButton.image = image
|
||||
dropButton.image = makeMenuBarIcon(named: "tray.circle", accessibilityDescription: "Uploading")
|
||||
dropButton.setDropTargetActive(false)
|
||||
} else {
|
||||
// Upload complete: restore normal icon
|
||||
updateIcon(config: controller.config)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMenuBarIcon(named iconName: String, accessibilityDescription: String) -> NSImage? {
|
||||
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: accessibilityDescription)
|
||||
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: accessibilityDescription)
|
||||
image?.isTemplate = true
|
||||
return image
|
||||
}
|
||||
|
||||
func bounceIcon() {
|
||||
guard let button = dropButton else { return }
|
||||
|
||||
@@ -223,8 +229,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private func showPopover() {
|
||||
guard let button = dropButton else { return }
|
||||
Task { @MainActor in
|
||||
controller.clearSearch()
|
||||
await controller.refreshRecentUploads()
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
|
||||
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
||||
@@ -323,19 +331,32 @@ class DropStatusButton: NSButton {
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
isBordered = false
|
||||
bezelStyle = .regularSquare
|
||||
imageScaling = .scaleProportionallyDown
|
||||
registerForDraggedTypes([.fileURL])
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
// Tint icon slightly to indicate drop zone is active
|
||||
if let image = self.image {
|
||||
self.image = image.tinted(with: .systemBlue)
|
||||
func setDropTargetActive(_ isActive: Bool) {
|
||||
isBordered = isActive
|
||||
contentTintColor = isActive ? .systemBlue : nil
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
let pasteboard = sender.draggingPasteboard
|
||||
guard pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) else {
|
||||
setDropTargetActive(false)
|
||||
return []
|
||||
}
|
||||
|
||||
// Show the button frame only while the menu bar icon is a valid drop target.
|
||||
setDropTargetActive(true)
|
||||
return .copy
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||
// Restore original icon
|
||||
setDropTargetActive(false)
|
||||
if let appDelegate = appDelegate {
|
||||
appDelegate.updateIcon(config: appDelegate.controller.config)
|
||||
}
|
||||
@@ -345,9 +366,11 @@ class DropStatusButton: NSButton {
|
||||
let pasteboard = sender.draggingPasteboard
|
||||
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
|
||||
!urls.isEmpty else {
|
||||
setDropTargetActive(false)
|
||||
return false
|
||||
}
|
||||
|
||||
setDropTargetActive(false)
|
||||
appDelegate?.bounceIcon()
|
||||
|
||||
Task { @MainActor in
|
||||
@@ -357,18 +380,3 @@ class DropStatusButton: NSButton {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension NSImage {
|
||||
func tinted(with color: NSColor) -> NSImage {
|
||||
let image = self.copy() as! NSImage
|
||||
image.isTemplate = false
|
||||
|
||||
let rect = NSRect(origin: .zero, size: image.size)
|
||||
image.lockFocus()
|
||||
color.set()
|
||||
rect.fill(using: .sourceAtop)
|
||||
image.unlockFocus()
|
||||
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/server/cairnquire
Executable file
BIN
apps/server/cairnquire
Executable file
Binary file not shown.
@@ -30,6 +30,7 @@ type App struct {
|
||||
docs *docs.Service
|
||||
hub *realtime.Hub
|
||||
server *http.Server
|
||||
emailQueue *email.Queue
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
|
||||
@@ -69,13 +70,33 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
||||
|
||||
collabRepo := collaboration.NewRepository(db.SQL())
|
||||
var emailSender collaboration.EmailSender
|
||||
if cfg.Email.SMTPHost != "" {
|
||||
emailSender = email.NewSMTPSender(cfg.Email, logger)
|
||||
authService.SetEmailSender(emailSender)
|
||||
} else {
|
||||
emailSender = email.NewNoOpSender(logger)
|
||||
var emailQueue *email.Queue
|
||||
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
|
||||
switch cfg.Email.ResolvedProvider() {
|
||||
case "postmark":
|
||||
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
|
||||
emailSender = pm
|
||||
authService.SetEmailSender(pm)
|
||||
case "smtp":
|
||||
sm := email.NewSMTPSender(cfg.Email, logger)
|
||||
emailSender = sm
|
||||
authService.SetEmailSender(sm)
|
||||
default:
|
||||
noOp := email.NewNoOpSender(logger)
|
||||
emailSender = noOp
|
||||
authService.SetEmailSender(noOp)
|
||||
}
|
||||
collabService := collaboration.NewService(collabRepo, emailSender, hub, logger)
|
||||
// The durable queue wraps whichever sender is configured so retries
|
||||
// survive process restarts. It is nil-safe in the service: if absent,
|
||||
// notifications stay in-app only.
|
||||
emailQueue = email.NewQueue(db.SQL(), emailSender, logger)
|
||||
collabService := collaboration.NewServiceWithOptions(collabRepo, hub, logger, collaboration.Options{
|
||||
EmailSender: emailSender,
|
||||
EmailQueue: emailQueue,
|
||||
EmailRenderer: emailRenderer,
|
||||
UserLookup: authRepo,
|
||||
PublicOrigin: cfg.Auth.PublicOrigin,
|
||||
})
|
||||
|
||||
service.OnChange(func(change docs.DocumentChange) {
|
||||
hub.Broadcast(realtime.Event{Type: "document_version", Data: change})
|
||||
@@ -116,12 +137,16 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
||||
docs: service,
|
||||
hub: hub,
|
||||
server: server,
|
||||
emailQueue: emailQueue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
go a.watchDocuments(ctx)
|
||||
go a.syncPoll(ctx)
|
||||
if a.emailQueue != nil {
|
||||
go a.emailQueue.Run(ctx)
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -169,3 +169,7 @@ func hasScope(scopes []Scope, required Scope) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func HasScope(scopes []Scope, required Scope) bool {
|
||||
return hasScope(scopes, required)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
@@ -284,6 +286,349 @@ func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessU
|
||||
return nil
|
||||
}
|
||||
|
||||
type resourceMatch struct {
|
||||
resourceType ResourceType
|
||||
resourceID string
|
||||
}
|
||||
|
||||
func (r *Repository) ListResourcePermissions(ctx context.Context, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) {
|
||||
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT p.id, p.user_id, COALESCE(u.email, ''), COALESCE(u.display_name, ''), p.resource_type, COALESCE(p.resource_id, ''), p.permission, COALESCE(p.granted_by, ''), p.created_at
|
||||
FROM permissions p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.resource_type = ? AND COALESCE(p.resource_id, '') = ?
|
||||
ORDER BY u.email ASC, p.created_at ASC
|
||||
`, string(resourceType), resourceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list resource permissions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var permissions []ResourcePermission
|
||||
for rows.Next() {
|
||||
permission, err := scanResourcePermission(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
permissions = append(permissions, permission)
|
||||
}
|
||||
return permissions, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) ReplaceResourcePermissions(ctx context.Context, resourceType ResourceType, resourceID string, actorID string, updates []ResourcePermissionUpdate) error {
|
||||
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin resource permissions update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM permissions
|
||||
WHERE resource_type = ? AND COALESCE(resource_id, '') = ?
|
||||
`, string(resourceType), resourceID); err != nil {
|
||||
return fmt.Errorf("clear resource permissions: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
for _, update := range updates {
|
||||
update.UserID = strings.TrimSpace(update.UserID)
|
||||
if update.UserID == "" || update.Permission == PermissionNone {
|
||||
continue
|
||||
}
|
||||
if !validPermission(update.Permission) {
|
||||
return fmt.Errorf("invalid permission")
|
||||
}
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ? AND disabled = 0`, update.UserID).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check permission user: %w", err)
|
||||
}
|
||||
if exists == 0 {
|
||||
return fmt.Errorf("user %s does not exist", update.UserID)
|
||||
}
|
||||
idPart, err := randomToken(18)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, "perm:"+idPart, update.UserID, string(resourceType), nullString(resourceID), string(update.Permission), nullString(actorID), now); err != nil {
|
||||
return fmt.Errorf("insert resource permission: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit resource permissions update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) EnsureResourcePermission(ctx context.Context, resourceType ResourceType, resourceID string, actorID string, update ResourcePermissionUpdate) error {
|
||||
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
update.UserID = strings.TrimSpace(update.UserID)
|
||||
if update.UserID == "" {
|
||||
return fmt.Errorf("user id is required")
|
||||
}
|
||||
if !validPermission(update.Permission) || update.Permission == PermissionNone {
|
||||
return fmt.Errorf("invalid permission")
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin resource permission ensure: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ? AND disabled = 0`, update.UserID).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check permission user: %w", err)
|
||||
}
|
||||
if exists == 0 {
|
||||
return fmt.Errorf("user %s does not exist", update.UserID)
|
||||
}
|
||||
|
||||
best := PermissionNone
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT permission
|
||||
FROM permissions
|
||||
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
|
||||
`, update.UserID, string(resourceType), resourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup existing resource permission: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan existing resource permission: %w", err)
|
||||
}
|
||||
best = maxPermission(best, Permission(raw))
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if PermissionAllows(best, update.Permission) {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM permissions
|
||||
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
|
||||
`, update.UserID, string(resourceType), resourceID); err != nil {
|
||||
return fmt.Errorf("clear user resource permission: %w", err)
|
||||
}
|
||||
|
||||
idPart, err := randomToken(18)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, "perm:"+idPart, update.UserID, string(resourceType), nullString(resourceID), string(update.Permission), nullString(actorID), time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("insert resource permission: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit resource permission ensure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) {
|
||||
if principal.UserID == "" {
|
||||
return PermissionNone, nil
|
||||
}
|
||||
if principal.Role == RoleAdmin {
|
||||
return PermissionAdmin, nil
|
||||
}
|
||||
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
|
||||
if err != nil {
|
||||
return PermissionNone, err
|
||||
}
|
||||
|
||||
matches := []resourceMatch{{resourceType: ResourceGlobal, resourceID: ""}}
|
||||
switch resourceType {
|
||||
case ResourceDocument:
|
||||
for _, folder := range folderMatchesForDocument(resourceID) {
|
||||
matches = append(matches, resourceMatch{resourceType: ResourceFolder, resourceID: folder})
|
||||
}
|
||||
for _, collectionID := range collectionIDs {
|
||||
normalized := normalizeCollectionID(collectionID)
|
||||
if normalized != "" {
|
||||
matches = append(matches, resourceMatch{resourceType: ResourceCollection, resourceID: normalized})
|
||||
}
|
||||
}
|
||||
matches = append(matches, resourceMatch{resourceType: ResourceDocument, resourceID: resourceID})
|
||||
case ResourceFolder:
|
||||
for _, folder := range folderMatches(resourceID) {
|
||||
matches = append(matches, resourceMatch{resourceType: ResourceFolder, resourceID: folder})
|
||||
}
|
||||
case ResourceCollection:
|
||||
matches = append(matches, resourceMatch{resourceType: ResourceCollection, resourceID: resourceID})
|
||||
default:
|
||||
matches = append(matches, resourceMatch{resourceType: resourceType, resourceID: resourceID})
|
||||
}
|
||||
|
||||
best := PermissionNone
|
||||
for _, match := range matches {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT permission
|
||||
FROM permissions
|
||||
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
|
||||
`, principal.UserID, string(match.resourceType), match.resourceID)
|
||||
if err != nil {
|
||||
return PermissionNone, fmt.Errorf("lookup resource permission: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
rows.Close()
|
||||
return PermissionNone, fmt.Errorf("scan resource permission: %w", err)
|
||||
}
|
||||
best = maxPermission(best, Permission(raw))
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return PermissionNone, err
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func scanResourcePermission(rows *sql.Rows) (ResourcePermission, error) {
|
||||
var (
|
||||
permission ResourcePermission
|
||||
resourceType string
|
||||
resourceID string
|
||||
rawPermission string
|
||||
created string
|
||||
)
|
||||
if err := rows.Scan(&permission.ID, &permission.UserID, &permission.UserEmail, &permission.UserName, &resourceType, &resourceID, &rawPermission, &permission.GrantedBy, &created); err != nil {
|
||||
return ResourcePermission{}, fmt.Errorf("scan resource permission: %w", err)
|
||||
}
|
||||
permission.ResourceType = ResourceType(resourceType)
|
||||
permission.ResourceID = resourceID
|
||||
permission.Permission = Permission(rawPermission)
|
||||
createdAt, err := time.Parse(time.RFC3339, created)
|
||||
if err != nil {
|
||||
return ResourcePermission{}, fmt.Errorf("parse resource permission created_at: %w", err)
|
||||
}
|
||||
permission.CreatedAt = createdAt
|
||||
return permission, nil
|
||||
}
|
||||
|
||||
func normalizeResource(resourceType ResourceType, resourceID string) (ResourceType, string, error) {
|
||||
resourceType = ResourceType(strings.TrimSpace(strings.ToLower(string(resourceType))))
|
||||
switch resourceType {
|
||||
case ResourceGlobal:
|
||||
return resourceType, "", nil
|
||||
case ResourceDocument:
|
||||
resourceID = normalizeDocumentPath(resourceID)
|
||||
if resourceID == "" {
|
||||
return "", "", fmt.Errorf("document resource id is required")
|
||||
}
|
||||
return resourceType, resourceID, nil
|
||||
case ResourceFolder:
|
||||
return resourceType, normalizeFolderPath(resourceID), nil
|
||||
case ResourceCollection:
|
||||
resourceID = normalizeCollectionID(resourceID)
|
||||
if resourceID == "" {
|
||||
return "", "", fmt.Errorf("collection resource id is required")
|
||||
}
|
||||
return resourceType, resourceID, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("invalid resource type")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDocumentPath(raw string) string {
|
||||
clean := normalizeFolderPath(raw)
|
||||
if clean == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
|
||||
clean += ".md"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func normalizeFolderPath(raw string) string {
|
||||
raw = strings.TrimSpace(strings.Trim(raw, "/"))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
clean := path.Clean(strings.ReplaceAll(raw, "\\", "/"))
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(clean, "/")
|
||||
}
|
||||
|
||||
func normalizeCollectionID(raw string) string {
|
||||
return strings.Trim(strings.TrimSpace(raw), "#/")
|
||||
}
|
||||
|
||||
func folderMatchesForDocument(documentPath string) []string {
|
||||
folder := path.Dir(documentPath)
|
||||
if folder == "." {
|
||||
return []string{""}
|
||||
}
|
||||
return folderMatches(folder)
|
||||
}
|
||||
|
||||
func folderMatches(folder string) []string {
|
||||
folder = normalizeFolderPath(folder)
|
||||
matches := []string{""}
|
||||
if folder == "" {
|
||||
return matches
|
||||
}
|
||||
parts := strings.Split(folder, "/")
|
||||
for index := range parts {
|
||||
matches = append(matches, strings.Join(parts[:index+1], "/"))
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func validPermission(permission Permission) bool {
|
||||
switch permission {
|
||||
case PermissionNone, PermissionRead, PermissionWrite, PermissionAdmin:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func maxPermission(left Permission, right Permission) Permission {
|
||||
if permissionRank(right) > permissionRank(left) {
|
||||
return right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func permissionRank(permission Permission) int {
|
||||
switch permission {
|
||||
case PermissionRead:
|
||||
return 1
|
||||
case PermissionWrite:
|
||||
return 2
|
||||
case PermissionAdmin:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -190,6 +190,57 @@ func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID str
|
||||
return s.repo.GetUserByID(ctx, user.ID)
|
||||
}
|
||||
|
||||
func (s *Service) BeginCurrentUserPasskeyRegistration(ctx context.Context, principal Principal) (any, string, error) {
|
||||
if principal.UserID == "" {
|
||||
return nil, "", fmt.Errorf("authentication required")
|
||||
}
|
||||
user, err := s.repo.GetUserByID(ctx, principal.UserID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if user.Disabled {
|
||||
return nil, "", fmt.Errorf("account disabled")
|
||||
}
|
||||
creation, session, err := s.webauthn.BeginRegistration(user)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return creation, challengeID, nil
|
||||
}
|
||||
|
||||
func (s *Service) FinishCurrentUserPasskeyRegistration(ctx context.Context, principal Principal, challengeID string, r *http.Request) (User, error) {
|
||||
if principal.UserID == "" {
|
||||
return User{}, fmt.Errorf("authentication required")
|
||||
}
|
||||
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if userID != principal.UserID {
|
||||
return User{}, fmt.Errorf("challenge does not belong to the current user")
|
||||
}
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return User{}, fmt.Errorf("account disabled")
|
||||
}
|
||||
credential, err := s.webauthn.FinishRegistration(user, session, r)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.repo.Audit(ctx, user.ID, "auth.passkey.add", "user", user.ID, clientIP(r), r.UserAgent(), nil)
|
||||
return s.repo.GetUserByID(ctx, user.ID)
|
||||
}
|
||||
|
||||
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
|
||||
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||
if err != nil {
|
||||
@@ -409,6 +460,96 @@ func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) {
|
||||
if actor.UserID == "" {
|
||||
return nil, fmt.Errorf("authentication required")
|
||||
}
|
||||
if actor.Role != RoleAdmin {
|
||||
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !PermissionAllows(permission, PermissionAdmin) {
|
||||
return nil, fmt.Errorf("admin permission required")
|
||||
}
|
||||
}
|
||||
return s.repo.ListResourcePermissions(ctx, resourceType, resourceID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, updates []ResourcePermissionUpdate) ([]ResourcePermission, error) {
|
||||
if actor.UserID == "" {
|
||||
return nil, fmt.Errorf("authentication required")
|
||||
}
|
||||
if actor.Role != RoleAdmin {
|
||||
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !PermissionAllows(permission, PermissionAdmin) {
|
||||
return nil, fmt.Errorf("admin permission required")
|
||||
}
|
||||
}
|
||||
normalized := make([]ResourcePermissionUpdate, 0, len(updates))
|
||||
seen := make(map[string]struct{}, len(updates))
|
||||
for _, update := range updates {
|
||||
update.UserID = strings.TrimSpace(update.UserID)
|
||||
if update.UserID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[update.UserID]; ok {
|
||||
return nil, fmt.Errorf("duplicate user permission")
|
||||
}
|
||||
seen[update.UserID] = struct{}{}
|
||||
update.Permission = normalizePermission(update.Permission)
|
||||
if !validPermission(update.Permission) {
|
||||
return nil, fmt.Errorf("invalid permission")
|
||||
}
|
||||
normalized = append(normalized, update)
|
||||
}
|
||||
if err := s.repo.ReplaceResourcePermissions(ctx, resourceType, resourceID, actor.UserID, normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.update", string(resourceType), resourceID, "", "", map[string]any{
|
||||
"updates": len(normalized),
|
||||
})
|
||||
return s.repo.ListResourcePermissions(ctx, resourceType, resourceID)
|
||||
}
|
||||
|
||||
func (s *Service) EnsureResourcePermission(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, update ResourcePermissionUpdate) error {
|
||||
if actor.UserID == "" {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
update.UserID = strings.TrimSpace(update.UserID)
|
||||
if update.UserID == "" {
|
||||
return fmt.Errorf("user id is required")
|
||||
}
|
||||
update.Permission = normalizePermission(update.Permission)
|
||||
if !validPermission(update.Permission) || update.Permission == PermissionNone {
|
||||
return fmt.Errorf("invalid permission")
|
||||
}
|
||||
if update.UserID != actor.UserID && actor.Role != RoleAdmin {
|
||||
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !PermissionAllows(permission, PermissionAdmin) {
|
||||
return fmt.Errorf("admin permission required")
|
||||
}
|
||||
}
|
||||
if err := s.repo.EnsureResourcePermission(ctx, resourceType, resourceID, actor.UserID, update); err != nil {
|
||||
return err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.ensure", string(resourceType), resourceID, "", "", map[string]any{
|
||||
"userID": update.UserID,
|
||||
"permission": update.Permission,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) {
|
||||
return s.repo.EffectiveResourcePermission(ctx, principal, resourceType, resourceID, collectionIDs)
|
||||
}
|
||||
|
||||
func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return fmt.Errorf("admin role required")
|
||||
@@ -715,6 +856,23 @@ func RoleAllows(role Role, required Scope) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func PermissionAllows(actual Permission, required Permission) bool {
|
||||
return permissionRank(actual) >= permissionRank(required)
|
||||
}
|
||||
|
||||
func normalizePermission(permission Permission) Permission {
|
||||
switch Permission(strings.ToLower(strings.TrimSpace(string(permission)))) {
|
||||
case "view":
|
||||
return PermissionRead
|
||||
case "edit":
|
||||
return PermissionWrite
|
||||
case PermissionRead, PermissionWrite, PermissionAdmin, PermissionNone:
|
||||
return Permission(strings.ToLower(strings.TrimSpace(string(permission))))
|
||||
default:
|
||||
return Permission(strings.ToLower(strings.TrimSpace(string(permission))))
|
||||
}
|
||||
}
|
||||
|
||||
func Allows(principal Principal, required Scope) bool {
|
||||
if principal.UserID == "" {
|
||||
return false
|
||||
|
||||
@@ -315,6 +315,21 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentUserCanBeginPasskeyRegistrationForExistingAccount(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := setupInitialAdmin(t, service, false)
|
||||
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||
options, challengeID, err := service.BeginCurrentUserPasskeyRegistration(ctx, principal)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginCurrentUserPasskeyRegistration() error = %v", err)
|
||||
}
|
||||
if options == nil || challengeID == "" {
|
||||
t.Fatalf("options = %#v, challengeID = %q; want registration options and challenge", options, challengeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -343,3 +358,82 @@ func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
||||
t.Fatalf("public signup role = %s, want viewer", viewer.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePermissionsArePrivateByDefault(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, true)
|
||||
viewer, err := service.repo.UpsertUser(ctx, User{
|
||||
Email: "viewer@example.com",
|
||||
DisplayName: "Viewer",
|
||||
Role: RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
|
||||
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
|
||||
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "private/page.md", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveResourcePermission() error = %v", err)
|
||||
}
|
||||
if permission != PermissionNone {
|
||||
t.Fatalf("viewer permission = %q, want no access", permission)
|
||||
}
|
||||
|
||||
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||
permission, err = service.EffectiveResourcePermission(ctx, adminPrincipal, ResourceDocument, "private/page.md", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveResourcePermission() admin error = %v", err)
|
||||
}
|
||||
if permission != PermissionAdmin {
|
||||
t.Fatalf("admin permission = %q, want admin", permission)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePermissionsUseMostPermissiveGrant(t *testing.T) {
|
||||
service := setupAuthTestService(t)
|
||||
ctx := context.Background()
|
||||
admin := setupInitialAdmin(t, service, true)
|
||||
viewer, err := service.repo.UpsertUser(ctx, User{
|
||||
Email: "viewer@example.com",
|
||||
DisplayName: "Viewer",
|
||||
Role: RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
|
||||
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
|
||||
|
||||
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceDocument, "shared/page.md", []ResourcePermissionUpdate{
|
||||
{UserID: viewer.ID, Permission: PermissionRead},
|
||||
}); err != nil {
|
||||
t.Fatalf("grant document read: %v", err)
|
||||
}
|
||||
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceFolder, "shared", []ResourcePermissionUpdate{
|
||||
{UserID: viewer.ID, Permission: PermissionWrite},
|
||||
}); err != nil {
|
||||
t.Fatalf("grant folder write: %v", err)
|
||||
}
|
||||
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "shared/page.md", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveResourcePermission() folder error = %v", err)
|
||||
}
|
||||
if permission != PermissionWrite {
|
||||
t.Fatalf("folder/document combined permission = %q, want write", permission)
|
||||
}
|
||||
|
||||
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceCollection, "team", []ResourcePermissionUpdate{
|
||||
{UserID: viewer.ID, Permission: PermissionAdmin},
|
||||
}); err != nil {
|
||||
t.Fatalf("grant collection admin: %v", err)
|
||||
}
|
||||
permission, err = service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "other/page.md", []string{"team"})
|
||||
if err != nil {
|
||||
t.Fatalf("EffectiveResourcePermission() collection error = %v", err)
|
||||
}
|
||||
if permission != PermissionAdmin {
|
||||
t.Fatalf("collection permission = %q, want admin", permission)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,24 @@ const (
|
||||
ScopeAdmin Scope = "admin"
|
||||
)
|
||||
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
PermissionNone Permission = ""
|
||||
PermissionRead Permission = "read"
|
||||
PermissionWrite Permission = "write"
|
||||
PermissionAdmin Permission = "admin"
|
||||
)
|
||||
|
||||
type ResourceType string
|
||||
|
||||
const (
|
||||
ResourceGlobal ResourceType = "global"
|
||||
ResourceCollection ResourceType = "collection"
|
||||
ResourceDocument ResourceType = "document"
|
||||
ResourceFolder ResourceType = "folder"
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
@@ -54,6 +72,23 @@ type UserAccessUpdate struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type ResourcePermission struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ResourceType ResourceType `json:"resourceType"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
Permission Permission `json:"permission"`
|
||||
GrantedBy string `json:"grantedBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ResourcePermissionUpdate struct {
|
||||
UserID string `json:"userId"`
|
||||
Permission Permission `json:"permission"`
|
||||
}
|
||||
|
||||
type PasswordResetToken struct {
|
||||
ID string
|
||||
UserID string
|
||||
|
||||
@@ -10,10 +10,46 @@ import (
|
||||
"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 {
|
||||
@@ -24,19 +60,43 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
email EmailSender
|
||||
queue EmailQueue
|
||||
renderer EmailRenderer
|
||||
users UserLookup
|
||||
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 {
|
||||
if email == nil {
|
||||
email = &NoOpEmailSender{}
|
||||
return NewServiceWithOptions(repo, hub, logger, Options{EmailSender: email})
|
||||
}
|
||||
|
||||
// NewServiceWithOptions constructs the service with email queue + renderer +
|
||||
// user lookup wiring. Callers that want email delivery should pass all of
|
||||
// EmailQueue, EmailRenderer, UserLookup; otherwise notifications stay
|
||||
// in-app only.
|
||||
func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Logger, opts Options) *Service {
|
||||
if opts.EmailSender == nil {
|
||||
opts.EmailSender = &NoOpEmailSender{}
|
||||
}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
email: email,
|
||||
email: opts.EmailSender,
|
||||
queue: opts.EmailQueue,
|
||||
renderer: opts.EmailRenderer,
|
||||
users: opts.UserLookup,
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
publicOrigin: opts.PublicOrigin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +154,10 @@ func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, a
|
||||
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")
|
||||
@@ -220,6 +284,11 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
|
||||
s.logger.Warn("create notification", "error", err)
|
||||
continue
|
||||
}
|
||||
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{
|
||||
ActorName: actorName,
|
||||
DocumentPath: documentPath,
|
||||
ViewURL: s.documentURL(documentPath),
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast bell update to all connected clients
|
||||
@@ -253,11 +322,52 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
|
||||
}
|
||||
if err := s.repo.CreateNotification(ctx, notification); err != nil {
|
||||
s.logger.Warn("create comment notification", "error", err)
|
||||
continue
|
||||
}
|
||||
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
|
||||
ActorName: comment.AuthorName,
|
||||
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
|
||||
|
||||
@@ -38,9 +38,31 @@ type AuthConfig struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -62,6 +84,7 @@ func Default() Config {
|
||||
SMTPHost: "localhost",
|
||||
SMTPPort: 1025,
|
||||
From: "notifications@cairnquire.local",
|
||||
InstanceName: "Cairnquire",
|
||||
},
|
||||
LogLevel: "INFO",
|
||||
}
|
||||
@@ -87,6 +110,10 @@ func Load() (Config, error) {
|
||||
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
|
||||
overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN")
|
||||
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
|
||||
overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN")
|
||||
overrideString(&cfg.Email.PostmarkAPIURL, "CAIRNQUIRE_EMAIL_POSTMARK_API_URL")
|
||||
overrideString(&cfg.Email.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
|
||||
|
||||
@@ -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);
|
||||
@@ -109,6 +109,38 @@ func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, pat
|
||||
return r.getDocumentByPath(ctx, path, true)
|
||||
}
|
||||
|
||||
func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) {
|
||||
var (
|
||||
record DocumentRecord
|
||||
tagsJSON string
|
||||
created string
|
||||
updated string
|
||||
archived sql.NullString
|
||||
)
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
|
||||
FROM documents
|
||||
WHERE id = ? AND archived_at IS NULL
|
||||
`, id).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := parseDocumentTimes(&record, created, updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if archived.Valid && archived.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, archived.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse document archived_at: %w", err)
|
||||
}
|
||||
record.ArchivedAt = &t
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal document tags: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) {
|
||||
var (
|
||||
record DocumentRecord
|
||||
@@ -416,6 +448,32 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) SearchDocumentsByTag(ctx context.Context, tag string) ([]SearchResult, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT d.path, d.title
|
||||
FROM documents d
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM json_each(d.tags) WHERE json_each.value = ?
|
||||
) AND d.archived_at IS NULL
|
||||
ORDER BY d.path
|
||||
`, tag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search documents by tag: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []SearchResult
|
||||
for rows.Next() {
|
||||
var result SearchResult
|
||||
if err := rows.Scan(&result.Path, &result.Title); err != nil {
|
||||
return nil, fmt.Errorf("scan tag search result: %w", err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
UPDATE document_search SET content = ?
|
||||
|
||||
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:
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -301,6 +301,54 @@ func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordUserCanBeginPasskeyRegistrationFromAccount(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "very secure password",
|
||||
}))
|
||||
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||
}
|
||||
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
|
||||
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
accountRequest.AddCookie(session)
|
||||
account := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(account, accountRequest)
|
||||
if account.Code != http.StatusOK || !bytes.Contains(account.Body.Bytes(), []byte("data-passkey-add")) {
|
||||
t.Fatalf("account page response = %d %q, want add-passkey form", account.Code, account.Body.String())
|
||||
}
|
||||
|
||||
beginRequest := jsonRequest(http.MethodPost, "/api/auth/passkeys/me/register/begin", map[string]any{})
|
||||
beginRequest.AddCookie(session)
|
||||
begin := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(begin, beginRequest)
|
||||
if begin.Code != http.StatusOK {
|
||||
t.Fatalf("passkey begin response = %d %q", begin.Code, begin.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
Options any `json:"options"`
|
||||
}
|
||||
if err := json.NewDecoder(begin.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode passkey begin response: %v", err)
|
||||
}
|
||||
if body.ChallengeID == "" || body.Options == nil {
|
||||
t.Fatalf("passkey begin body = %#v, want challenge and options", body)
|
||||
}
|
||||
|
||||
publicBegin := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(publicBegin, jsonRequest(http.MethodPost, "/api/auth/passkeys/register/begin", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
}))
|
||||
if publicBegin.Code == http.StatusOK {
|
||||
t.Fatalf("public passkey begin response = %d %q, want failure for existing account", publicBegin.Code, publicBegin.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
sender := &testEmailSender{}
|
||||
@@ -384,17 +432,36 @@ func passwordResetTokenFromEmail(t *testing.T, body string) string {
|
||||
|
||||
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
|
||||
t.Helper()
|
||||
return s.tokenForUser(t, s.userID, scopes...)
|
||||
}
|
||||
|
||||
created, err := s.auth.CreateAPIKey(context.Background(), s.userID, "test token", scopes, nil)
|
||||
func (s *apiTestServer) tokenForUser(t *testing.T, userID string, scopes ...auth.Scope) string {
|
||||
t.Helper()
|
||||
|
||||
created, err := s.auth.CreateAPIKey(context.Background(), userID, "test token", scopes, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create api token: %v", err)
|
||||
}
|
||||
return created.Token
|
||||
}
|
||||
|
||||
func (s *apiTestServer) viewerUser(t *testing.T) auth.User {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
admin := auth.Principal{UserID: s.userID, Role: auth.RoleAdmin}
|
||||
if _, err := s.auth.UpdateSignupsEnabled(ctx, admin, true); err != nil {
|
||||
t.Fatalf("enable signups: %v", err)
|
||||
}
|
||||
user, err := s.auth.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
|
||||
body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
|
||||
@@ -446,6 +513,41 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentsArePrivateUntilResourceGrant(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
viewer := server.viewerUser(t)
|
||||
token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead)
|
||||
|
||||
listRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||
listRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
listRecorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(listRecorder, listRequest)
|
||||
if listRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
|
||||
}
|
||||
if strings.Contains(listRecorder.Body.String(), "hello.md") {
|
||||
t.Fatalf("private document leaked before grant: %s", listRecorder.Body.String())
|
||||
}
|
||||
|
||||
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
|
||||
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
|
||||
{UserID: viewer.ID, Permission: auth.PermissionRead},
|
||||
}); err != nil {
|
||||
t.Fatalf("grant root folder read: %v", err)
|
||||
}
|
||||
|
||||
listRequest = httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||
listRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
listRecorder = httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(listRecorder, listRequest)
|
||||
if listRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("documents after grant status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(listRecorder.Body.String(), "hello.md") {
|
||||
t.Fatalf("document missing after folder grant: %s", listRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||
@@ -463,6 +565,83 @@ func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISyncSnapshotInheritsResourcePermissions(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
viewer := server.viewerUser(t)
|
||||
token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||
|
||||
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
|
||||
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
initRecorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||
if initRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||
}
|
||||
if strings.Contains(initRecorder.Body.String(), "hello.md") {
|
||||
t.Fatalf("private sync snapshot leaked before grant: %s", initRecorder.Body.String())
|
||||
}
|
||||
|
||||
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
|
||||
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
|
||||
{UserID: viewer.ID, Permission: auth.PermissionRead},
|
||||
}); err != nil {
|
||||
t.Fatalf("grant root folder read: %v", err)
|
||||
}
|
||||
|
||||
initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-2"})
|
||||
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
initRecorder = httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||
if initRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(initRecorder.Body.String(), "hello.md") {
|
||||
t.Fatalf("sync snapshot missing document after folder grant: %s", initRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISyncSnapshotHandlesUnindexedDiskFilesByFolderPermission(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
viewer := server.viewerUser(t)
|
||||
token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(server.root, "content", "unindexed.md"), []byte("# Unindexed\n"), 0o644); err != nil {
|
||||
t.Fatalf("write unindexed document: %v", err)
|
||||
}
|
||||
|
||||
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-1"})
|
||||
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
initRecorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||
if initRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||
}
|
||||
if strings.Contains(initRecorder.Body.String(), "sql: no rows") {
|
||||
t.Fatalf("sync init leaked missing document row: %s", initRecorder.Body.String())
|
||||
}
|
||||
if strings.Contains(initRecorder.Body.String(), "unindexed.md") {
|
||||
t.Fatalf("private unindexed document leaked before grant: %s", initRecorder.Body.String())
|
||||
}
|
||||
|
||||
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
|
||||
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
|
||||
{UserID: viewer.ID, Permission: auth.PermissionRead},
|
||||
}); err != nil {
|
||||
t.Fatalf("grant root folder read: %v", err)
|
||||
}
|
||||
|
||||
initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-2"})
|
||||
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
initRecorder = httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||
if initRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(initRecorder.Body.String(), "unindexed.md") {
|
||||
t.Fatalf("sync snapshot missing unindexed document after folder grant: %s", initRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||
@@ -575,10 +754,12 @@ func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
|
||||
|
||||
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
|
||||
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||
documentsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
|
||||
server.handler.ServeHTTP(documents, documentsRequest)
|
||||
if documents.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||
}
|
||||
@@ -637,9 +818,44 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
|
||||
save := jsonRequest(http.MethodPost, "/api/documents/inbox/new-note", map[string]string{
|
||||
"content": "# New Note\n\nCreated in browser",
|
||||
})
|
||||
save.Header.Set("Authorization", "Bearer "+token)
|
||||
saveRecorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(saveRecorder, save)
|
||||
|
||||
if saveRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusOK, saveRecorder.Body.String())
|
||||
}
|
||||
|
||||
permissions := httptest.NewRequest(http.MethodGet, "/api/permissions?resourceType=document&resourceId=inbox/new-note.md", nil)
|
||||
permissions.Header.Set("Authorization", "Bearer "+token)
|
||||
permissionsRecorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(permissionsRecorder, permissions)
|
||||
|
||||
if permissionsRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("permissions status = %d, want %d; body=%s", permissionsRecorder.Code, http.StatusOK, permissionsRecorder.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Permissions []auth.ResourcePermission `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(permissionsRecorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode permissions: %v", err)
|
||||
}
|
||||
if len(payload.Permissions) != 1 || payload.Permissions[0].UserID != server.userID || payload.Permissions[0].Permission != auth.PermissionAdmin {
|
||||
t.Fatalf("permissions = %#v, want creator admin grant", payload.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
ctx := context.Background()
|
||||
|
||||
nestedDir := filepath.Join(server.root, "content", "guide")
|
||||
@@ -663,8 +879,10 @@ func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
|
||||
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
|
||||
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||
documentsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
|
||||
server.handler.ServeHTTP(documents, documentsRequest)
|
||||
if documents.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||
}
|
||||
@@ -675,7 +893,7 @@ func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
|
||||
|
||||
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
|
||||
@@ -695,8 +913,10 @@ func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
|
||||
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
|
||||
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||
documentsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
|
||||
server.handler.ServeHTTP(documents, documentsRequest)
|
||||
if documents.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -334,6 +334,42 @@ func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||
}
|
||||
|
||||
func (s *Server) handleCurrentUserPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
options, challengeID, err := s.auth.BeginCurrentUserPasskeyRegistration(r.Context(), principal)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||
}
|
||||
|
||||
func (s *Server) handleCurrentUserPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
challengeID := r.URL.Query().Get("challengeId")
|
||||
if challengeID == "" {
|
||||
var req passkeyFinishRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
challengeID = req.ChallengeID
|
||||
}
|
||||
if challengeID == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||
return
|
||||
}
|
||||
user, err := s.auth.FinishCurrentUserPasskeyRegistration(r.Context(), principal, challengeID, r)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
|
||||
@@ -16,10 +16,23 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document_id is required"})
|
||||
return
|
||||
}
|
||||
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
canRead, err := s.canReadDocumentRecord(r, *document)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !canRead {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
anchorHash := r.URL.Query().Get("anchor_hash")
|
||||
var comments []collaboration.Comment
|
||||
var err error
|
||||
if anchorHash != "" {
|
||||
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
|
||||
} else {
|
||||
@@ -45,6 +58,20 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
document, err := s.repository.GetDocumentByID(r.Context(), input.DocumentID)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
canWrite, err := s.canWriteDocumentRecord(r, *document)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !canWrite {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
comment, err := s.collaboration.CreateComment(r.Context(), principal, input)
|
||||
if err != nil {
|
||||
@@ -63,6 +90,25 @@ func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
commentID := chi.URLParam(r, "id")
|
||||
comment, err := s.collaboration.GetComment(r.Context(), commentID)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
|
||||
return
|
||||
}
|
||||
document, err := s.repository.GetDocumentByID(r.Context(), comment.DocumentID)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
canWrite, err := s.canWriteDocumentRecord(r, *document)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !canWrite {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
|
||||
return
|
||||
}
|
||||
if err := s.collaboration.ResolveComment(r.Context(), principal, commentID); err != nil {
|
||||
s.logger.Warn("resolve comment", "error", err)
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
|
||||
@@ -45,7 +45,10 @@ type documentData struct {
|
||||
Hash string
|
||||
HTML template.HTML
|
||||
Breadcrumbs []breadcrumb
|
||||
CanRead bool
|
||||
CanWrite bool
|
||||
CanAdmin bool
|
||||
CanComment bool
|
||||
}
|
||||
|
||||
type documentEditData struct {
|
||||
@@ -71,10 +74,12 @@ type errorData struct {
|
||||
|
||||
type browserData struct {
|
||||
Columns []browserColumn
|
||||
CanWrite bool
|
||||
}
|
||||
|
||||
type browserColumn struct {
|
||||
Title string
|
||||
Path string
|
||||
Items []browserItem
|
||||
}
|
||||
|
||||
@@ -95,19 +100,32 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tag := r.URL.Query().Get("tag")
|
||||
if tag != "" {
|
||||
s.handleTagPage(w, r, tag)
|
||||
return
|
||||
}
|
||||
|
||||
items, err := s.documents.ListDocuments(r.Context())
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items, err = s.filterReadableDocuments(r, items)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
|
||||
browser := buildBrowser(items, activeFolder)
|
||||
browser.CanWrite = s.canWriteFolder(r, activeFolder)
|
||||
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
|
||||
Title: "Cairnquire",
|
||||
BodyClass: "page-index",
|
||||
BodyTemplate: "index_content",
|
||||
Data: indexData{
|
||||
Browser: buildBrowser(items, activeFolder),
|
||||
Browser: browser,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -120,6 +138,11 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
|
||||
if err != nil {
|
||||
s.logger.Warn("search failed", "error", err)
|
||||
}
|
||||
results, err = s.filterReadableSearchResults(r, results)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
|
||||
Title: "Search: " + query,
|
||||
@@ -135,6 +158,39 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTagPage(w http.ResponseWriter, r *http.Request, tag string) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := s.repository.SearchDocumentsByTag(ctx, tag)
|
||||
if err != nil {
|
||||
s.logger.Warn("tag search failed", "error", err)
|
||||
}
|
||||
results, err = s.filterReadableSearchResults(r, results)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
browser := buildTagBrowser(results, tag)
|
||||
browser.CanWrite = s.canWriteFolder(r, "")
|
||||
|
||||
s.renderTemplate(w, r, http.StatusOK, "tag.gohtml", layoutData{
|
||||
Title: "Tag: " + tag,
|
||||
BodyClass: "page-tag",
|
||||
BodyTemplate: "tag_content",
|
||||
Data: struct {
|
||||
Tag string
|
||||
Results []docs.SearchResult
|
||||
Browser browserData
|
||||
}{
|
||||
Tag: tag,
|
||||
Results: results,
|
||||
Browser: browser,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDocumentPage(w, r, "")
|
||||
}
|
||||
@@ -220,21 +276,40 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
canWrite, err := s.canWriteDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !canWrite {
|
||||
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := s.documents.ListDocuments(r.Context())
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items, err = s.filterReadableDocuments(r, items)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
canWrite := s.canWriteDocuments(r)
|
||||
|
||||
browser := buildBrowser(items, page.Path)
|
||||
browser.CanWrite = canWrite
|
||||
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
|
||||
Title: "Edit: " + page.Title,
|
||||
BodyClass: "page-document-edit",
|
||||
BodyTemplate: "document_edit_content",
|
||||
Data: documentEditData{
|
||||
Browser: buildBrowser(items, page.Path),
|
||||
Browser: browser,
|
||||
Title: page.Title,
|
||||
Path: page.Path,
|
||||
Tags: page.Tags,
|
||||
@@ -256,28 +331,60 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
canRead, err := s.canReadDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !canRead {
|
||||
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
canWrite, err := s.canWriteDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
canAdmin, err := s.canAdminDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
items, err := s.documents.ListDocuments(r.Context())
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items, err = s.filterReadableDocuments(r, items)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
canWrite := s.canWriteDocuments(r)
|
||||
|
||||
browser := buildBrowser(items, page.Path)
|
||||
browser.CanWrite = canWrite
|
||||
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
|
||||
Title: page.Title,
|
||||
BodyClass: "page-document",
|
||||
BodyTemplate: "document_content",
|
||||
Data: documentData{
|
||||
Browser: buildBrowser(items, page.Path),
|
||||
Browser: browser,
|
||||
Title: page.Title,
|
||||
Path: page.Path,
|
||||
Tags: page.Tags,
|
||||
Hash: page.Hash,
|
||||
HTML: template.HTML(page.HTML),
|
||||
Breadcrumbs: buildBreadcrumbs(page.Path),
|
||||
CanRead: canRead,
|
||||
CanWrite: canWrite,
|
||||
CanAdmin: canAdmin,
|
||||
CanComment: canWrite,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -333,21 +440,35 @@ func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var results []docs.SearchResult
|
||||
var err error
|
||||
|
||||
tag := r.URL.Query().Get("tag")
|
||||
if tag != "" {
|
||||
results, err = s.repository.SearchDocumentsByTag(ctx, tag)
|
||||
} else {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||
return
|
||||
}
|
||||
results, err = s.repository.SearchDocuments(ctx, query)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := s.repository.SearchDocuments(ctx, query)
|
||||
if err != nil {
|
||||
s.logger.Warn("search failed", "error", err)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||
return
|
||||
}
|
||||
results, err = s.filterReadableSearchResults(r, results)
|
||||
if err != nil {
|
||||
s.logger.Warn("filter search failed", "error", err)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
||||
}
|
||||
@@ -421,7 +542,17 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
|
||||
return
|
||||
}
|
||||
createdDocument := existing == nil
|
||||
if existing != nil {
|
||||
canWriteExisting, err := s.canWriteDocumentRecord(r, *existing)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "check document permissions")
|
||||
return
|
||||
}
|
||||
if !canWriteExisting {
|
||||
s.renderError(w, r, http.StatusForbidden, "permission denied")
|
||||
return
|
||||
}
|
||||
switch duplicateAction {
|
||||
case "overwrite":
|
||||
case "rename":
|
||||
@@ -430,6 +561,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "choose document name")
|
||||
return
|
||||
}
|
||||
createdDocument = true
|
||||
case "reuse":
|
||||
writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path))
|
||||
return
|
||||
@@ -437,12 +569,21 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
writeUploadConflict(w, "document", existing.CurrentHash, existing.Path, documentPageURL(existing.Path))
|
||||
return
|
||||
}
|
||||
} else if !s.canWriteNewDocument(r, path) {
|
||||
s.renderError(w, r, http.StatusForbidden, "permission denied")
|
||||
return
|
||||
}
|
||||
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
|
||||
return
|
||||
}
|
||||
if createdDocument {
|
||||
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "grant document owner permission")
|
||||
return
|
||||
}
|
||||
}
|
||||
documentPath = page.Path
|
||||
uploadURL = documentPageURL(page.Path)
|
||||
uploadKind = "document"
|
||||
@@ -643,6 +784,14 @@ func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
|
||||
uploadURL := "/attachments/" + record.Hash
|
||||
uploadKind := "attachment"
|
||||
if record.DocumentPath != "" {
|
||||
document, err := s.repository.GetDocumentByPath(r.Context(), record.DocumentPath)
|
||||
if err != nil || document == nil {
|
||||
continue
|
||||
}
|
||||
canRead, err := s.canReadDocumentRecord(r, *document)
|
||||
if err != nil || !canRead {
|
||||
continue
|
||||
}
|
||||
uploadURL = documentPageURL(record.DocumentPath)
|
||||
uploadKind = "document"
|
||||
}
|
||||
@@ -667,6 +816,11 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
records, err = s.filterReadableDocuments(r, records)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]map[string]any, 0, len(records))
|
||||
for _, record := range records {
|
||||
@@ -674,6 +828,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
"path": record.Path,
|
||||
"title": record.Title,
|
||||
"hash": record.CurrentHash,
|
||||
"tags": record.Tags,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -717,6 +872,27 @@ func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request,
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
createdDocument := false
|
||||
if existing, err := s.documentRecordForRequestPath(r, pagePath); err == nil && existing != nil {
|
||||
canWrite, err := s.canWriteDocumentRecord(r, *existing)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !canWrite {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
} else if errors.Is(err, sql.ErrNoRows) {
|
||||
createdDocument = true
|
||||
if !s.canWriteNewDocument(r, pagePath) {
|
||||
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
|
||||
if err != nil {
|
||||
@@ -739,6 +915,12 @@ func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request,
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if createdDocument {
|
||||
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "grant document owner permission"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSONWithStatus(w, http.StatusOK, map[string]any{
|
||||
"status": "saved",
|
||||
@@ -758,19 +940,29 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func (s *Server) canWriteDocuments(r *http.Request) bool {
|
||||
principal, ok := principalFromContext(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return auth.Allows(principal, auth.ScopeDocsWrite)
|
||||
}
|
||||
|
||||
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||
if pagePath == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
||||
return
|
||||
}
|
||||
record, err := s.documentRecordForRequestPath(r, pagePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
canWrite, err := s.canWriteDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !canWrite {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -789,6 +981,11 @@ func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Reques
|
||||
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) {
|
||||
@@ -845,16 +1042,39 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData
|
||||
for _, prefix := range prefixes {
|
||||
column := browserColumn{
|
||||
Title: columnTitle(prefix),
|
||||
Path: prefix,
|
||||
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
|
||||
}
|
||||
if len(column.Items) > 0 {
|
||||
columns = append(columns, column)
|
||||
}
|
||||
}
|
||||
|
||||
return browserData{Columns: columns}
|
||||
}
|
||||
|
||||
func buildTagBrowser(results []docs.SearchResult, tag string) browserData {
|
||||
items := make([]browserItem, 0, len(results))
|
||||
for _, result := range results {
|
||||
items = append(items, browserItem{
|
||||
Name: displayDocumentName(result.Path, result.Title),
|
||||
Path: result.Path,
|
||||
URL: "/docs/" + strings.TrimSuffix(result.Path, ".md"),
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||
})
|
||||
|
||||
return browserData{
|
||||
Columns: []browserColumn{
|
||||
{
|
||||
Title: "#" + tag,
|
||||
Path: "",
|
||||
Items: items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
|
||||
seenFolders := make(map[string]browserItem)
|
||||
files := make([]browserItem, 0)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
@@ -211,8 +212,13 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
|
||||
t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
|
||||
}
|
||||
|
||||
listRequest := httptest.NewRequest(http.MethodGet, "/api/attachments", nil)
|
||||
listRequest = listRequest.WithContext(withPrincipal(listRequest.Context(), auth.Principal{
|
||||
UserID: "user:test-admin",
|
||||
Role: auth.RoleAdmin,
|
||||
}))
|
||||
listRecorder := httptest.NewRecorder()
|
||||
server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil))
|
||||
server.handleAttachmentsList(listRecorder, listRequest)
|
||||
var listPayload struct {
|
||||
Attachments []struct {
|
||||
Kind string `json:"kind"`
|
||||
@@ -233,6 +239,17 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
|
||||
if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
|
||||
t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML)
|
||||
}
|
||||
|
||||
grants, err := server.auth.ListResourcePermissions(context.Background(), auth.Principal{
|
||||
UserID: "user:test-admin",
|
||||
Role: auth.RoleAdmin,
|
||||
}, auth.ResourceDocument, tt.wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("list promoted document permissions: %v", err)
|
||||
}
|
||||
if len(grants) != 1 || grants[0].UserID != "user:test-admin" || grants[0].Permission != auth.PermissionAdmin {
|
||||
t.Fatalf("promoted document grants = %#v, want creator admin grant", grants)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -376,11 +393,25 @@ func setupUploadTestServer(t *testing.T) (*Server, string) {
|
||||
sourceDir := t.TempDir()
|
||||
repository := docs.NewRepository(db)
|
||||
documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default())
|
||||
authRepo := auth.NewRepository(db)
|
||||
authService, err := auth.NewService(authRepo, "http://localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("create auth service: %v", err)
|
||||
}
|
||||
if _, err := authRepo.UpsertUser(context.Background(), auth.User{
|
||||
ID: "user:test-admin",
|
||||
Email: "test-admin@example.com",
|
||||
DisplayName: "Test Admin",
|
||||
Role: auth.RoleAdmin,
|
||||
}); err != nil {
|
||||
t.Fatalf("create test admin user: %v", err)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
documents: documents,
|
||||
repository: repository,
|
||||
contentStore: contentStore,
|
||||
auth: authService,
|
||||
}, sourceDir
|
||||
}
|
||||
|
||||
@@ -412,6 +443,10 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request = request.WithContext(withPrincipal(request.Context(), auth.Principal{
|
||||
UserID: "user:test-admin",
|
||||
Role: auth.RoleAdmin,
|
||||
}))
|
||||
return request
|
||||
}
|
||||
|
||||
|
||||
@@ -67,10 +67,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.Redirect(w, r, "/login?next="+urlQueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if !auth.Allows(principal, required) {
|
||||
if !allowsProtectedRoute(principal, required) {
|
||||
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
@@ -78,6 +82,20 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func allowsProtectedRoute(principal auth.Principal, required auth.Scope) bool {
|
||||
if required == auth.ScopeAdmin {
|
||||
return auth.Allows(principal, required)
|
||||
}
|
||||
if len(principal.Scopes) > 0 {
|
||||
return auth.HasScope(principal.Scopes, required)
|
||||
}
|
||||
return principal.UserID != ""
|
||||
}
|
||||
|
||||
func urlQueryEscape(value string) string {
|
||||
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
||||
}
|
||||
|
||||
func (s *Server) setupMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.auth.GetInstanceSettings(r.Context())
|
||||
@@ -151,8 +169,12 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
path := r.URL.Path
|
||||
method := r.Method
|
||||
switch {
|
||||
case path == "/" || (path == "/permissions" && method == http.MethodGet):
|
||||
return auth.ScopeDocsRead, true
|
||||
case path == "/permissions" && method == http.MethodPost:
|
||||
return auth.ScopeDocsWrite, true
|
||||
case strings.HasPrefix(path, "/api/auth/"):
|
||||
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
|
||||
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" || strings.HasPrefix(path, "/api/auth/passkeys/me/") {
|
||||
return auth.ScopeDocsRead, true
|
||||
}
|
||||
return "", false
|
||||
@@ -169,6 +191,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
return auth.ScopeAdmin, true
|
||||
case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet:
|
||||
return auth.ScopeDocsWrite, true
|
||||
case path == "/api/documents" || path == "/api/search" || (path == "/api/permissions" && method == http.MethodGet):
|
||||
return auth.ScopeDocsRead, true
|
||||
case path == "/api/permissions" && method == http.MethodPatch:
|
||||
return auth.ScopeDocsWrite, true
|
||||
case strings.HasPrefix(path, "/api/uploads"):
|
||||
return auth.ScopeDocsWrite, true
|
||||
case path == "/api/attachments":
|
||||
@@ -186,6 +212,8 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
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:
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -107,6 +107,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Get("/password-reset", server.handlePasswordResetPage)
|
||||
router.Get("/account", server.handleAccountPage)
|
||||
router.Get("/account/tokens/new", server.handleTokenCreatePage)
|
||||
router.Get("/permissions", server.handlePermissionsPage)
|
||||
router.Post("/permissions", server.handlePermissionsForm)
|
||||
router.Get("/device/verify", server.handleDeviceVerifyPage)
|
||||
router.Get("/health", server.handleHealth)
|
||||
router.Get("/healthz", server.handleHealthz)
|
||||
@@ -122,6 +124,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Post("/api/auth/logout", server.handleLogout)
|
||||
router.Post("/api/auth/password/change", server.handleChangePassword)
|
||||
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
||||
router.Post("/api/auth/passkeys/me/register/begin", server.handleCurrentUserPasskeyRegisterBegin)
|
||||
router.Post("/api/auth/passkeys/me/register/finish", server.handleCurrentUserPasskeyRegisterFinish)
|
||||
router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin)
|
||||
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
||||
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
|
||||
@@ -149,6 +153,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Get("/api/documents", server.handleDocuments)
|
||||
router.Post("/api/documents/*", server.handleDocumentMutation)
|
||||
router.Get("/api/search", server.handleSearch)
|
||||
router.Get("/api/permissions", server.handlePermissionsAPI)
|
||||
router.Patch("/api/permissions", server.handlePermissionsAPI)
|
||||
router.Post("/api/uploads", server.handleUpload)
|
||||
router.Get("/api/attachments", server.handleAttachmentsList)
|
||||
router.Get("/attachments/{hash}", server.handleAttachment)
|
||||
|
||||
@@ -251,6 +251,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-passkey-add]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const support = await checkPasskeySupport();
|
||||
if (!support.supported) {
|
||||
setStatus("[data-passkey-add-status]", support.reason, true);
|
||||
return;
|
||||
}
|
||||
if (support.reason) {
|
||||
console.warn("Passkey support:", support.reason);
|
||||
}
|
||||
try {
|
||||
const begin = await postJSON("/api/auth/passkeys/me/register/begin", {});
|
||||
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
|
||||
await postJSON(`/api/auth/passkeys/me/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||
setStatus("[data-passkey-add-status]", "Passkey added. You can use it the next time you sign in.");
|
||||
} catch (error) {
|
||||
setStatus("[data-passkey-add-status]", webAuthnErrorMessage(error), true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 1.3 MiB |
67
apps/server/internal/httpserver/static/codemirror.bundle.js
Normal file
67
apps/server/internal/httpserver/static/codemirror.bundle.js
Normal file
File diff suppressed because one or more lines are too long
@@ -3,15 +3,73 @@
|
||||
|
||||
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
|
||||
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
|
||||
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
|
||||
const article = document.querySelector('[data-document-path]');
|
||||
const workspaceShell = article?.closest('.workspace-shell--document');
|
||||
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
|
||||
const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
|
||||
const stripTrack = document.querySelector('[data-comments-strip-track]');
|
||||
const commentsAside = document.querySelector('[data-comments-aside]');
|
||||
const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
|
||||
const commentsSheetClose = document.querySelector('[data-close-comments]');
|
||||
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
|
||||
const commentToggle = document.querySelector('[data-comment-toggle]');
|
||||
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
|
||||
|
||||
function readCommentVisibility() {
|
||||
if (!commentVisibilityKey) return false;
|
||||
try {
|
||||
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!documentId) return;
|
||||
|
||||
// Hash a paragraph's text content for anchoring
|
||||
function syncVisibility() {
|
||||
const hidden = readCommentVisibility();
|
||||
if (article) article.classList.toggle('comments-hidden', hidden);
|
||||
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
||||
}
|
||||
|
||||
syncVisibility();
|
||||
|
||||
function commentsHidden() {
|
||||
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
|
||||
}
|
||||
|
||||
function setCommentsHidden(hidden) {
|
||||
if (article) article.classList.toggle('comments-hidden', hidden);
|
||||
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
||||
if (commentVisibilityKey) {
|
||||
try {
|
||||
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
|
||||
} catch (err) {
|
||||
// Ignore persistence failures
|
||||
}
|
||||
}
|
||||
if (commentToggle) {
|
||||
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
|
||||
commentToggle.setAttribute('aria-label', hidden ? 'Show comments' : 'Hide comments');
|
||||
commentToggle.title = hidden ? 'Show comments' : 'Hide comments';
|
||||
}
|
||||
if (!hidden) {
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
}
|
||||
}
|
||||
|
||||
if (commentToggle) {
|
||||
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
|
||||
commentToggle.addEventListener('click', () => {
|
||||
setCommentsHidden(!commentsHidden());
|
||||
});
|
||||
}
|
||||
|
||||
function hashParagraph(el) {
|
||||
const text = (el.textContent || '').trim();
|
||||
if (!text) return null;
|
||||
// Simple hash: first 16 chars of base64 of char codes (deterministic)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charCodeAt(i);
|
||||
@@ -24,29 +82,46 @@
|
||||
function addCommentButton(el, hash) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'comment-anchor-btn';
|
||||
btn.type = 'button';
|
||||
btn.title = 'Add comment';
|
||||
btn.innerHTML = '+';
|
||||
btn.setAttribute('aria-label', 'Add comment');
|
||||
btn.innerHTML = `
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||
<path d="M12 8v4"></path>
|
||||
<path d="M10 10h4"></path>
|
||||
</svg>
|
||||
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
|
||||
<span class="sr-only">Add comment</span>
|
||||
`;
|
||||
btn.addEventListener('click', () => promptComment(el, hash));
|
||||
el.appendChild(btn);
|
||||
}
|
||||
|
||||
function updateCommentButtonState(el, count) {
|
||||
const btn = el.querySelector('.comment-anchor-btn');
|
||||
if (!btn) return;
|
||||
const hasComments = count > 0;
|
||||
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
|
||||
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
|
||||
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
|
||||
const countNode = btn.querySelector('.comment-anchor-count');
|
||||
if (countNode) {
|
||||
countNode.hidden = !hasComments;
|
||||
countNode.textContent = count > 99 ? '99+' : String(count);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptComment(el, anchorHash) {
|
||||
const text = window.prompt('Comment on this paragraph:');
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
versionHash: documentHash,
|
||||
content: text.trim(),
|
||||
anchorHash,
|
||||
}),
|
||||
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
// Refresh comments display
|
||||
await loadCommentsForAnchor(el, anchorHash);
|
||||
} catch (err) {
|
||||
window.alert('Failed to post comment: ' + err.message);
|
||||
@@ -65,11 +140,17 @@
|
||||
}
|
||||
|
||||
function renderComments(el, comments) {
|
||||
// Remove existing comment list
|
||||
const existing = el.querySelector('.comment-list');
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!comments.length) return;
|
||||
updateCommentButtonState(el, comments.length);
|
||||
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
|
||||
|
||||
if (!comments.length) {
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'comment-list';
|
||||
@@ -77,15 +158,174 @@
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comment-item';
|
||||
item.innerHTML = `
|
||||
<div class="comment-meta">
|
||||
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
|
||||
<time>${formatDate(c.createdAt)}</time>
|
||||
</div>
|
||||
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
|
||||
<div class="comment-body">${escapeHtml(c.content)}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
el.appendChild(list);
|
||||
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
}
|
||||
|
||||
function buildAsideItemFor(el) {
|
||||
const comments = [];
|
||||
el.querySelectorAll('.comment-item').forEach(item => {
|
||||
const meta = item.querySelector('.comment-meta');
|
||||
const bodyEl = item.querySelector('.comment-body');
|
||||
comments.push({
|
||||
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
|
||||
createdAt: meta?.querySelector('time')?.textContent || '',
|
||||
content: bodyEl?.textContent || '',
|
||||
});
|
||||
});
|
||||
if (!comments.length) return null;
|
||||
|
||||
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
|
||||
|
||||
const asideItem = document.createElement('div');
|
||||
asideItem.className = 'comments-aside-item';
|
||||
asideItem.dataset.anchorHash = el.dataset.anchorHash;
|
||||
|
||||
const targetEl = document.createElement('div');
|
||||
targetEl.className = 'comments-aside-item__target';
|
||||
targetEl.textContent = truncated || 'Commented section';
|
||||
targetEl.addEventListener('click', () => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
asideItem.appendChild(targetEl);
|
||||
|
||||
comments.forEach(c => {
|
||||
const commentEl = document.createElement('div');
|
||||
commentEl.className = 'comments-aside-item__comment';
|
||||
commentEl.innerHTML = `
|
||||
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
|
||||
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
|
||||
`;
|
||||
asideItem.appendChild(commentEl);
|
||||
});
|
||||
|
||||
return asideItem;
|
||||
}
|
||||
|
||||
function renderCommentsInto(container) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body) return;
|
||||
|
||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
||||
if (!commentables.length) {
|
||||
container.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
commentables.forEach(el => {
|
||||
const item = buildAsideItemFor(el);
|
||||
if (item) container.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCommentsAside() {
|
||||
renderCommentsInto(commentsAsideList);
|
||||
renderCommentsInto(commentsAsideListMobile);
|
||||
}
|
||||
|
||||
function updateStripMarkers() {
|
||||
if (!stripTrack) return;
|
||||
stripTrack.innerHTML = '';
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
const shell = document.querySelector('.document-shell');
|
||||
if (!body || !shell) return;
|
||||
|
||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
const trackHeight = stripTrack.clientHeight;
|
||||
|
||||
commentables.forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
|
||||
const percent = (relativeTop / shell.scrollHeight) * 100;
|
||||
const clamped = Math.max(0, Math.min(95, percent));
|
||||
|
||||
const marker = document.createElement('button');
|
||||
marker.className = 'document-comments-strip__marker';
|
||||
marker.type = 'button';
|
||||
marker.style.top = clamped + '%';
|
||||
marker.dataset.anchorHash = el.dataset.anchorHash;
|
||||
marker.title = 'Jump to comments';
|
||||
marker.addEventListener('click', () => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (commentsHidden()) setCommentsHidden(false);
|
||||
// Highlight the corresponding sidebar item
|
||||
const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`);
|
||||
if (asideItem) {
|
||||
asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
asideItem.classList.add('is-highlighted');
|
||||
setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500);
|
||||
}
|
||||
});
|
||||
stripTrack.appendChild(marker);
|
||||
});
|
||||
}
|
||||
|
||||
let bidirectionalHoverInitialized = false;
|
||||
function initBidirectionalHover() {
|
||||
if (bidirectionalHoverInitialized) return;
|
||||
bidirectionalHoverInitialized = true;
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body || !commentsAsideList) return;
|
||||
|
||||
// Body → sidebar + marker
|
||||
body.addEventListener('mouseenter', (e) => {
|
||||
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
||||
if (!el) return;
|
||||
const hash = el.dataset.anchorHash;
|
||||
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (asideItem) asideItem.classList.add('is-highlighted');
|
||||
if (marker) marker.classList.add('is-active');
|
||||
el.classList.add('is-highlighted');
|
||||
}, true);
|
||||
|
||||
body.addEventListener('mouseleave', (e) => {
|
||||
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
||||
if (!el) return;
|
||||
const hash = el.dataset.anchorHash;
|
||||
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (asideItem) asideItem.classList.remove('is-highlighted');
|
||||
if (marker) marker.classList.remove('is-active');
|
||||
el.classList.remove('is-highlighted');
|
||||
}, true);
|
||||
|
||||
// Sidebar → paragraph + marker (event delegation so it survives re-renders)
|
||||
commentsAsideList.addEventListener('mouseenter', (e) => {
|
||||
const item = e.target.closest('.comments-aside-item');
|
||||
if (!item) return;
|
||||
const hash = item.dataset.anchorHash;
|
||||
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (para) para.classList.add('is-highlighted');
|
||||
if (marker) marker.classList.add('is-active');
|
||||
item.classList.add('is-highlighted');
|
||||
}, true);
|
||||
|
||||
commentsAsideList.addEventListener('mouseleave', (e) => {
|
||||
const item = e.target.closest('.comments-aside-item');
|
||||
if (!item) return;
|
||||
const hash = item.dataset.anchorHash;
|
||||
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (para) para.classList.remove('is-highlighted');
|
||||
if (marker) marker.classList.remove('is-active');
|
||||
item.classList.remove('is-highlighted');
|
||||
}, true);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -99,7 +339,45 @@
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
// Initialize: hash all paragraphs in markdown body
|
||||
function commentsSheetOpen() {
|
||||
return commentsAside?.classList.contains('is-open');
|
||||
}
|
||||
|
||||
function setCommentsSheetOpen(open) {
|
||||
if (!commentsAside) return;
|
||||
commentsAside.classList.toggle('is-open', open);
|
||||
if (commentsSheetToggle) {
|
||||
commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments');
|
||||
}
|
||||
if (open) {
|
||||
document.body.classList.add('drawer-open');
|
||||
renderCommentsAside();
|
||||
} else {
|
||||
if (!pickerOpen() && !metaOpen()) {
|
||||
document.body.classList.remove('drawer-open');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pickerOpen() {
|
||||
return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open');
|
||||
}
|
||||
|
||||
function metaOpen() {
|
||||
return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open');
|
||||
}
|
||||
|
||||
if (commentsSheetToggle) {
|
||||
commentsSheetToggle.addEventListener('click', () => {
|
||||
setCommentsSheetOpen(!commentsSheetOpen());
|
||||
});
|
||||
}
|
||||
|
||||
if (commentsSheetClose) {
|
||||
commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false));
|
||||
}
|
||||
|
||||
function init() {
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body) return;
|
||||
@@ -110,9 +388,21 @@
|
||||
if (!hash) return;
|
||||
el.dataset.anchorHash = hash;
|
||||
el.classList.add('commentable');
|
||||
addCommentButton(el, hash);
|
||||
if (canComment) addCommentButton(el, hash);
|
||||
loadCommentsForAnchor(el, hash);
|
||||
});
|
||||
|
||||
renderCommentsAside();
|
||||
initBidirectionalHover();
|
||||
|
||||
// Update markers on scroll/resize
|
||||
const shell = document.querySelector('.document-shell');
|
||||
if (shell) {
|
||||
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
|
||||
}
|
||||
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
|
||||
// Initial marker update after layout settles
|
||||
setTimeout(updateStripMarkers, 100);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -11,15 +11,14 @@
|
||||
var conflictApply = document.querySelector("[data-conflict-apply]");
|
||||
var conflictDismiss = document.querySelector("[data-conflict-dismiss]");
|
||||
var conflictResolution = document.querySelector("[data-conflict-resolution]");
|
||||
var conflictResolutionMount = document.querySelector("[data-conflict-monaco-mount]");
|
||||
var conflictResolutionMount = document.querySelector("[data-conflict-codemirror-mount]");
|
||||
var conflictUseServer = document.querySelector("[data-conflict-use='server']");
|
||||
var conflictUseLocal = document.querySelector("[data-conflict-use='local']");
|
||||
var conflictUseBoth = document.querySelector("[data-conflict-use='both']");
|
||||
var monacoContainer = document.querySelector("[data-monaco-mount]");
|
||||
var monacoLoaded = false;
|
||||
var monacoReadyPromise = null;
|
||||
var monacoDarkTheme = "cairnquire-dark";
|
||||
var monacoLightTheme = "cairnquire-light";
|
||||
var codeMirrorContainer = document.querySelector("[data-codemirror-mount]");
|
||||
var codeMirrorReadyPromise = null;
|
||||
var codeMirrorModules = null;
|
||||
var documentCompletionOptionsPromise = null;
|
||||
|
||||
if (!form || !textarea || statusNodes.length === 0) {
|
||||
return;
|
||||
@@ -36,6 +35,9 @@
|
||||
var conflictDiffView = null;
|
||||
var resolutionEditor = null;
|
||||
var suppressResolutionEvents = false;
|
||||
var doneLink = document.querySelector("[data-done-link]");
|
||||
var isSaving = false;
|
||||
var suppressBeforeUnload = false;
|
||||
|
||||
function setStatus(nextStatus) {
|
||||
statusNodes.forEach(function (node) {
|
||||
@@ -223,17 +225,20 @@
|
||||
var value = getEditorValue();
|
||||
if (value === lastSavedValue) {
|
||||
setStatus("Saved");
|
||||
return;
|
||||
return "unchanged";
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
hideConflict();
|
||||
setStatus("Saving");
|
||||
|
||||
if (!window.MDHubSync) {
|
||||
isSaving = false;
|
||||
setStatus("Queued");
|
||||
return;
|
||||
return "queued";
|
||||
}
|
||||
|
||||
try {
|
||||
var result = await window.MDHubSync.saveDocument(path, value, baseHash);
|
||||
if (result.status === "saved") {
|
||||
lastSavedValue = value;
|
||||
@@ -245,15 +250,24 @@
|
||||
}
|
||||
}
|
||||
setStatus("Saved");
|
||||
return;
|
||||
isSaving = false;
|
||||
return "saved";
|
||||
}
|
||||
|
||||
if (result.status === "conflict") {
|
||||
showConflict(result.conflict, value);
|
||||
return;
|
||||
isSaving = false;
|
||||
return "conflict";
|
||||
}
|
||||
|
||||
setStatus("Queued");
|
||||
return "queued";
|
||||
} catch (err) {
|
||||
setStatus("Queued");
|
||||
throw err;
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyResolvedConflict() {
|
||||
@@ -317,138 +331,339 @@
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function loadMonaco() {
|
||||
if (monacoReadyPromise) {
|
||||
return monacoReadyPromise;
|
||||
function doneHref() {
|
||||
return doneLink ? doneLink.getAttribute("href") : "/docs/" + path.replace(/\.md$/, "");
|
||||
}
|
||||
|
||||
function clearScheduledSave() {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
if (monacoLoaded && window.monaco) {
|
||||
monacoReadyPromise = Promise.resolve();
|
||||
return monacoReadyPromise;
|
||||
}
|
||||
monacoLoaded = true;
|
||||
|
||||
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
function leaveEditorNow() {
|
||||
clearScheduledSave();
|
||||
suppressBeforeUnload = true;
|
||||
window.location.href = doneHref();
|
||||
}
|
||||
|
||||
monacoReadyPromise = new Promise(function (resolve) {
|
||||
var script = document.createElement("script");
|
||||
script.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js";
|
||||
script.onload = function () {
|
||||
require.config({
|
||||
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs" },
|
||||
function saveThenLeaveEditor() {
|
||||
var href = doneHref();
|
||||
|
||||
if (currentConflict) {
|
||||
setStatus("Conflict");
|
||||
if (conflictNotice) {
|
||||
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearScheduledSave();
|
||||
|
||||
if (isSaving) {
|
||||
waitForSaveThenNavigate(href);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Saving");
|
||||
saveNow()
|
||||
.then(function (result) {
|
||||
if (result === "saved" || result === "unchanged") {
|
||||
suppressBeforeUnload = true;
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === "conflict") {
|
||||
setStatus("Conflict");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Queued");
|
||||
if (window.confirm("Could not save. Leave without saving?")) {
|
||||
suppressBeforeUnload = true;
|
||||
window.location.href = href;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
setStatus("Queued");
|
||||
if (window.confirm("Could not save. Leave without saving?")) {
|
||||
suppressBeforeUnload = true;
|
||||
window.location.href = href;
|
||||
}
|
||||
});
|
||||
require(["vs/editor/editor.main"], function () {
|
||||
monaco.editor.defineTheme(monacoDarkTheme, {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "comment", foreground: "8B9DAF", fontStyle: "italic" },
|
||||
{ token: "keyword", foreground: "E8943A" },
|
||||
{ token: "string", foreground: "C9A96E" },
|
||||
{ token: "number", foreground: "C9A96E" },
|
||||
{ token: "type", foreground: "E8943A" },
|
||||
{ token: "tag", foreground: "E8943A" },
|
||||
{ token: "attribute.name", foreground: "D4A05A" },
|
||||
{ token: "attribute.value", foreground: "C9A96E" },
|
||||
{ token: "delimiter", foreground: "B0B8C4" },
|
||||
{ token: "variable", foreground: "D4D8DE" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#1E1E1E",
|
||||
"editor.foreground": "#E8ECF0",
|
||||
"editor.lineHighlightBackground": "#282828",
|
||||
"editor.selectionBackground": "#3A3A3A",
|
||||
"editorLineNumber.foreground": "#555555",
|
||||
"editorLineNumber.activeForeground": "#E8943A",
|
||||
"editor.inactiveSelectionBackground": "#333333",
|
||||
"editorCursor.foreground": "#E8943A",
|
||||
"editorIndentGuide.background": "#2A2A2A",
|
||||
"editorIndentGuide.activeBackground": "#3A3A3A",
|
||||
"editorWhitespace.foreground": "#2A2A2A",
|
||||
"editorGutter.background": "#1E1E1E",
|
||||
"editorOverviewRuler.border": "#1E1E1E",
|
||||
"scrollbarSlider.background": "#3A3A3A88",
|
||||
"scrollbarSlider.hoverBackground": "#4A4A4A88",
|
||||
"scrollbarSlider.activeBackground": "#5A5A5A88",
|
||||
"minimap.background": "#1E1E1E",
|
||||
},
|
||||
}
|
||||
|
||||
function loadCodeMirror() {
|
||||
if (codeMirrorReadyPromise) {
|
||||
return codeMirrorReadyPromise;
|
||||
}
|
||||
|
||||
codeMirrorReadyPromise = Promise.all([
|
||||
import("/static/codemirror.bundle.js"),
|
||||
]).then(function (modules) {
|
||||
var bundle = modules[0];
|
||||
codeMirrorModules = {
|
||||
cm: bundle,
|
||||
markdown: bundle,
|
||||
vim: bundle,
|
||||
};
|
||||
registerVimCommands();
|
||||
return codeMirrorModules;
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme(monacoLightTheme, {
|
||||
base: "vs",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "comment", foreground: "6A7B8C", fontStyle: "italic" },
|
||||
{ token: "keyword", foreground: "C67A2A" },
|
||||
{ token: "string", foreground: "8A6D3B" },
|
||||
{ token: "number", foreground: "8A6D3B" },
|
||||
{ token: "type", foreground: "C67A2A" },
|
||||
{ token: "tag", foreground: "C67A2A" },
|
||||
{ token: "attribute.name", foreground: "A87D3A" },
|
||||
{ token: "attribute.value", foreground: "8A6D3B" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#FCFAF5",
|
||||
"editor.foreground": "#1C2430",
|
||||
"editor.lineHighlightBackground": "#F0EDE5",
|
||||
"editor.selectionBackground": "#E8943A33",
|
||||
"editorLineNumber.foreground": "#AAAAAA",
|
||||
"editorLineNumber.activeForeground": "#C67A2A",
|
||||
"editorCursor.foreground": "#C67A2A",
|
||||
"editorIndentGuide.background": "#E8E5DD",
|
||||
"editorGutter.background": "#FCFAF5",
|
||||
},
|
||||
});
|
||||
return codeMirrorReadyPromise;
|
||||
}
|
||||
|
||||
if (monacoContainer) {
|
||||
editor = monaco.editor.create(monacoContainer, {
|
||||
value: textarea.value,
|
||||
language: "markdown",
|
||||
theme: isDark ? monacoDarkTheme : monacoLightTheme,
|
||||
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 12 },
|
||||
renderLineHighlight: "line",
|
||||
smoothScrolling: true,
|
||||
cursorBlinking: "smooth",
|
||||
cursorSmoothCaretAnimation: "on",
|
||||
bracketPairColorization: { enabled: true },
|
||||
automaticLayout: true,
|
||||
});
|
||||
function registerVimCommands() {
|
||||
var Vim = codeMirrorModules && codeMirrorModules.vim && codeMirrorModules.vim.Vim;
|
||||
if (!Vim) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.hidden = true;
|
||||
monacoContainer.classList.add("monaco-mounted");
|
||||
|
||||
editor.onDidChangeModelContent(function () {
|
||||
textarea.value = editor.getValue();
|
||||
scheduleSave();
|
||||
});
|
||||
|
||||
if (window.MDHubSync) {
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
|
||||
Vim.defineEx("write", "w", function () {
|
||||
saveNow().catch(function () {
|
||||
setStatus("Queued");
|
||||
});
|
||||
});
|
||||
Vim.defineEx("quit", "q", function () {
|
||||
leaveEditorNow();
|
||||
});
|
||||
Vim.defineEx("wq", "wq", function () {
|
||||
saveThenLeaveEditor();
|
||||
});
|
||||
Vim.defineEx("xit", "x", function () {
|
||||
saveThenLeaveEditor();
|
||||
});
|
||||
Vim.map("<Esc>", ":q<CR>");
|
||||
}
|
||||
|
||||
var darkMQ = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
darkMQ.addEventListener("change", function (e) {
|
||||
monaco.editor.setTheme(e.matches ? monacoDarkTheme : monacoLightTheme);
|
||||
});
|
||||
function codeMirrorTheme() {
|
||||
var EditorView = codeMirrorModules.cm.EditorView;
|
||||
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
editor.focus();
|
||||
return EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
minHeight: "60vh",
|
||||
backgroundColor: "var(--panel)",
|
||||
color: "var(--text)",
|
||||
fontSize: "14px",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-scroller": {
|
||||
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
|
||||
lineHeight: "22px",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "12px 0",
|
||||
caretColor: "var(--accent)",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 14px",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--panel-strong)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
color: "var(--muted)",
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: isDark ? "oklch(0.24 0.012 55)" : "#f0ede5",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "color-mix(in srgb, var(--accent) 22%, transparent)",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "var(--accent)",
|
||||
},
|
||||
".cm-panel": {
|
||||
backgroundColor: "var(--panel-strong)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
color: "var(--text)",
|
||||
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
|
||||
},
|
||||
".cm-vim-panel input": {
|
||||
color: "var(--text)",
|
||||
},
|
||||
}, { dark: isDark });
|
||||
}
|
||||
|
||||
resolve();
|
||||
function codeMirrorHighlighting() {
|
||||
var cm = codeMirrorModules.cm;
|
||||
var tags = codeMirrorModules.cm.tags;
|
||||
var urlColor = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "oklch(0.78 0.14 190)"
|
||||
: "#047c78";
|
||||
|
||||
return cm.syntaxHighlighting(cm.HighlightStyle.define([
|
||||
{
|
||||
tag: [tags.url, tags.link],
|
||||
color: urlColor,
|
||||
textDecoration: "underline",
|
||||
textUnderlineOffset: "2px",
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
function normalizeCompletionPath(doc) {
|
||||
var docPath = doc && doc.path ? String(doc.path) : "";
|
||||
return docPath.replace(/\.md$/, "");
|
||||
}
|
||||
|
||||
function buildDocumentCompletionOptions(documents) {
|
||||
var seen = new Set();
|
||||
|
||||
return (documents || [])
|
||||
.map(function (doc) {
|
||||
var label = normalizeCompletionPath(doc);
|
||||
if (!label || seen.has(label) || label === path.replace(/\.md$/, "")) {
|
||||
return null;
|
||||
}
|
||||
seen.add(label);
|
||||
|
||||
return {
|
||||
label: label,
|
||||
detail: doc.title && doc.title !== label ? doc.title : doc.path,
|
||||
type: "file",
|
||||
apply: function (view, completion, from, to) {
|
||||
var insert = completion.label;
|
||||
var after = view.state.doc.sliceString(to, Math.min(view.state.doc.length, to + 2));
|
||||
if (after !== "]]") {
|
||||
insert += "]]";
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: from, to: to, insert: insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
},
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort(function (a, b) {
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
return monacoReadyPromise;
|
||||
}
|
||||
|
||||
function fetchDocumentCompletionOptions() {
|
||||
return fetch("/api/documents", { credentials: "same-origin" })
|
||||
.then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error("Could not load documents");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var documents = data.documents || [];
|
||||
if (window.MDHubCache && window.MDHubCache.cacheDocuments) {
|
||||
window.MDHubCache.cacheDocuments(documents).catch(function () {});
|
||||
}
|
||||
return buildDocumentCompletionOptions(documents);
|
||||
});
|
||||
}
|
||||
|
||||
function loadDocumentCompletionOptions() {
|
||||
if (documentCompletionOptionsPromise) {
|
||||
return documentCompletionOptionsPromise;
|
||||
}
|
||||
|
||||
documentCompletionOptionsPromise = Promise.resolve()
|
||||
.then(function () {
|
||||
if (window.MDHubCache && window.MDHubCache.getCachedDocuments) {
|
||||
return window.MDHubCache.getCachedDocuments().then(function (documents) {
|
||||
if (documents && documents.length > 0) {
|
||||
return buildDocumentCompletionOptions(documents);
|
||||
}
|
||||
return fetchDocumentCompletionOptions();
|
||||
});
|
||||
}
|
||||
return fetchDocumentCompletionOptions();
|
||||
})
|
||||
.catch(function () {
|
||||
return [];
|
||||
});
|
||||
|
||||
return documentCompletionOptionsPromise;
|
||||
}
|
||||
|
||||
function wikiLinkCompletions(context) {
|
||||
var before = context.matchBefore(/\[\[([^\]\n]*)$/);
|
||||
if (!before) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return loadDocumentCompletionOptions().then(function (options) {
|
||||
if (!options.length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: before.from + 2,
|
||||
to: before.to,
|
||||
options: options,
|
||||
validFor: /^[^\]\n]*$/,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createCodeMirrorEditor(mount, initialValue, options) {
|
||||
var cm = codeMirrorModules.cm;
|
||||
var markdownModule = codeMirrorModules.markdown;
|
||||
var vimModule = codeMirrorModules.vim;
|
||||
var onChange = options && options.onChange ? options.onChange : function () {};
|
||||
var extensions = [
|
||||
vimModule.vim({ status: true }),
|
||||
cm.basicSetup,
|
||||
markdownModule.markdown({
|
||||
base: markdownModule.markdownLanguage,
|
||||
}),
|
||||
cm.EditorView.lineWrapping,
|
||||
codeMirrorTheme(),
|
||||
codeMirrorHighlighting(),
|
||||
cm.autocompletion({
|
||||
override: [wikiLinkCompletions],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
cm.EditorView.updateListener.of(function (update) {
|
||||
if (update.docChanged) {
|
||||
onChange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
mount.replaceChildren();
|
||||
mount.classList.add("codemirror-mounted");
|
||||
|
||||
var view = new cm.EditorView({
|
||||
doc: initialValue,
|
||||
extensions: extensions,
|
||||
parent: mount,
|
||||
});
|
||||
|
||||
return {
|
||||
view: view,
|
||||
getValue: function () {
|
||||
return view.state.doc.toString();
|
||||
},
|
||||
setValue: function (value) {
|
||||
if (view.state.doc.toString() === value) {
|
||||
return;
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
},
|
||||
focus: function () {
|
||||
view.focus();
|
||||
},
|
||||
hasTextFocus: function () {
|
||||
return view.hasFocus;
|
||||
},
|
||||
destroy: function () {
|
||||
view.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureResolutionEditor() {
|
||||
@@ -456,54 +671,42 @@
|
||||
return;
|
||||
}
|
||||
|
||||
loadMonaco().then(function () {
|
||||
if (!window.monaco || resolutionEditor) {
|
||||
loadCodeMirror().then(function () {
|
||||
if (resolutionEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
resolutionEditor = monaco.editor.create(conflictResolutionMount, {
|
||||
value: conflictResolution.value || resolvedConflictContent,
|
||||
language: "markdown",
|
||||
theme: isDark ? monacoDarkTheme : monacoLightTheme,
|
||||
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 12 },
|
||||
renderLineHighlight: "line",
|
||||
smoothScrolling: true,
|
||||
cursorBlinking: "smooth",
|
||||
cursorSmoothCaretAnimation: "on",
|
||||
bracketPairColorization: { enabled: true },
|
||||
automaticLayout: true,
|
||||
});
|
||||
|
||||
conflictResolution.hidden = true;
|
||||
conflictResolutionMount.classList.add("monaco-mounted");
|
||||
|
||||
resolutionEditor.onDidChangeModelContent(function () {
|
||||
resolutionEditor = createCodeMirrorEditor(conflictResolutionMount, conflictResolution.value || resolvedConflictContent, {
|
||||
onChange: function (value) {
|
||||
if (suppressResolutionEvents) {
|
||||
return;
|
||||
}
|
||||
resolvedConflictContent = resolutionEditor.getValue();
|
||||
conflictResolution.value = resolvedConflictContent;
|
||||
resolvedConflictContent = value;
|
||||
conflictResolution.value = value;
|
||||
if (conflictApply) {
|
||||
conflictApply.disabled = false;
|
||||
}
|
||||
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
|
||||
},
|
||||
});
|
||||
|
||||
conflictResolution.hidden = true;
|
||||
resolutionEditor.focus();
|
||||
}).catch(function () {
|
||||
conflictResolution.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (monacoContainer) {
|
||||
loadMonaco().catch(function () {
|
||||
if (codeMirrorContainer) {
|
||||
loadCodeMirror().then(function () {
|
||||
editor = createCodeMirrorEditor(codeMirrorContainer, textarea.value, {
|
||||
onChange: function (value) {
|
||||
textarea.value = value;
|
||||
scheduleSave();
|
||||
},
|
||||
});
|
||||
textarea.hidden = true;
|
||||
editor.focus();
|
||||
}).catch(function () {
|
||||
textarea.addEventListener("input", scheduleSave);
|
||||
});
|
||||
} else {
|
||||
@@ -571,9 +774,72 @@
|
||||
|
||||
restorePendingConflict();
|
||||
|
||||
if (doneLink) {
|
||||
doneLink.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
saveThenLeaveEditor();
|
||||
});
|
||||
}
|
||||
|
||||
function waitForSaveThenNavigate(href) {
|
||||
var checkInterval = setInterval(function () {
|
||||
if (!isSaving) {
|
||||
clearInterval(checkInterval);
|
||||
if (currentConflict) {
|
||||
setStatus("Conflict");
|
||||
if (conflictNotice) {
|
||||
conflictNotice.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
suppressBeforeUnload = true;
|
||||
window.location.href = href;
|
||||
}
|
||||
}, 100);
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(function () {
|
||||
clearInterval(checkInterval);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Warn on browser unload if there are unsaved changes
|
||||
window.addEventListener("beforeunload", function (e) {
|
||||
if (suppressBeforeUnload) {
|
||||
return;
|
||||
}
|
||||
if (getEditorValue() !== lastSavedValue || currentConflict) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
function editorSurfaceHasFocus(target) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
if (target === textarea || target.closest("[data-codemirror-mount], [data-conflict-codemirror-mount], [data-document-editor], .cm-editor")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(editor && editor.hasTextFocus && editor.hasTextFocus());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
}
|
||||
saveNow().catch(function () {
|
||||
setStatus("Queued");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.metaKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveThenLeaveEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editorSurfaceHasFocus(e.target)) {
|
||||
return;
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.6 KiB |
471
apps/server/internal/httpserver/static/keyboard-nav.js
Normal file
471
apps/server/internal/httpserver/static/keyboard-nav.js
Normal file
@@ -0,0 +1,471 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.querySelector("[data-editor-form]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
var browser = document.querySelector(".miller-browser");
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
var documentShell = document.querySelector(".document-shell[data-document-path]");
|
||||
var markdownBody = document.querySelector(".markdown-body");
|
||||
var mode = "browser";
|
||||
var selectedColumnIndex = 0;
|
||||
var selectedLink = null;
|
||||
var selectedParagraphIndex = -1;
|
||||
|
||||
function getColumns() {
|
||||
return Array.prototype.slice.call(browser.querySelectorAll(".miller-column"));
|
||||
}
|
||||
|
||||
function getColumnLinks(columnIndex) {
|
||||
var columns = getColumns();
|
||||
if (!columns[columnIndex]) {
|
||||
return [];
|
||||
}
|
||||
return Array.prototype.slice.call(columns[columnIndex].querySelectorAll("a[href]"));
|
||||
}
|
||||
|
||||
function getParagraphTargets() {
|
||||
if (!markdownBody) {
|
||||
return [];
|
||||
}
|
||||
return Array.prototype.slice.call(
|
||||
markdownBody.querySelectorAll(".commentable, p, h1, h2, h3, h4, h5, h6, li, blockquote, pre")
|
||||
).filter(function (el) {
|
||||
return (el.textContent || "").trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest(
|
||||
"input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox'], .monaco-editor"
|
||||
));
|
||||
}
|
||||
|
||||
function isKeyboardNavigationTarget(target) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest(".miller-browser a[href], .markdown-body .is-keyboard-selected"));
|
||||
}
|
||||
|
||||
function shouldIgnoreTarget(target) {
|
||||
if (isEditableTarget(target)) {
|
||||
return true;
|
||||
}
|
||||
if (isKeyboardNavigationTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target && target.closest("a[href], button, summary, details, [role='button'], [role='menuitem']"));
|
||||
}
|
||||
|
||||
function showKeyboardUI() {
|
||||
document.body.classList.add("keyboard-navigation-active");
|
||||
}
|
||||
|
||||
function clearBrowserSelection() {
|
||||
browser.querySelectorAll(".is-keyboard-selected").forEach(function (node) {
|
||||
node.classList.remove("is-keyboard-selected");
|
||||
node.removeAttribute("aria-selected");
|
||||
});
|
||||
}
|
||||
|
||||
function clearParagraphSelection() {
|
||||
getParagraphTargets().forEach(function (node) {
|
||||
node.classList.remove("is-keyboard-selected");
|
||||
node.removeAttribute("tabindex");
|
||||
node.removeAttribute("aria-selected");
|
||||
});
|
||||
}
|
||||
|
||||
function scrollBrowserColumnIntoView(column) {
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
var leftEdge = column.offsetLeft;
|
||||
var rightEdge = leftEdge + column.offsetWidth;
|
||||
var targetLeft = browser.scrollLeft;
|
||||
|
||||
if (leftEdge < browser.scrollLeft) {
|
||||
targetLeft = leftEdge - 8;
|
||||
} else if (rightEdge > browser.scrollLeft + browser.clientWidth) {
|
||||
targetLeft = rightEdge - browser.clientWidth + 8;
|
||||
}
|
||||
|
||||
if (targetLeft !== browser.scrollLeft) {
|
||||
browser.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
function expandColumnForKeyboard(column) {
|
||||
getColumns().forEach(function (candidate) {
|
||||
if (candidate !== column) {
|
||||
candidate.classList.remove("is-preview-expanded");
|
||||
candidate.style.removeProperty("--expanded-column-width");
|
||||
}
|
||||
});
|
||||
|
||||
if (!column || !column.matches(".miller-column:not(:first-child):not(:last-child)")) {
|
||||
return;
|
||||
}
|
||||
|
||||
var expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384);
|
||||
column.style.setProperty("--expanded-column-width", expandedWidth + "px");
|
||||
column.classList.add("is-preview-expanded");
|
||||
}
|
||||
|
||||
function updateBrowserSelection(focusSelected) {
|
||||
clearBrowserSelection();
|
||||
var columns = getColumns();
|
||||
var column = columns[selectedColumnIndex];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandColumnForKeyboard(column);
|
||||
scrollBrowserColumnIntoView(column);
|
||||
|
||||
if (!selectedLink || !column.contains(selectedLink)) {
|
||||
selectedLink = column.querySelector("a.is-active[href]") || column.querySelector("a[href]");
|
||||
}
|
||||
if (!selectedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLink.classList.add("is-keyboard-selected");
|
||||
selectedLink.setAttribute("aria-selected", "true");
|
||||
if (focusSelected) {
|
||||
selectedLink.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function selectBrowserColumn(columnIndex, focusSelected) {
|
||||
var columns = getColumns();
|
||||
if (!columns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
mode = "browser";
|
||||
selectedColumnIndex = clamp(columnIndex, 0, columns.length - 1);
|
||||
var links = getColumnLinks(selectedColumnIndex);
|
||||
selectedLink = links.find(function (link) {
|
||||
return link.classList.contains("is-active");
|
||||
}) || links[0] || null;
|
||||
clearParagraphSelection();
|
||||
updateBrowserSelection(focusSelected);
|
||||
}
|
||||
|
||||
function moveBrowserVertical(delta) {
|
||||
var links = getColumnLinks(selectedColumnIndex);
|
||||
if (!links.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentIndex = links.indexOf(selectedLink);
|
||||
if (currentIndex < 0) {
|
||||
currentIndex = links.findIndex(function (link) {
|
||||
return link.classList.contains("is-active");
|
||||
});
|
||||
}
|
||||
if (currentIndex < 0) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
selectedLink = links[clamp(currentIndex + delta, 0, links.length - 1)];
|
||||
updateBrowserSelection(true);
|
||||
}
|
||||
|
||||
function navigateTo(link) {
|
||||
if (!link || !link.href) {
|
||||
return;
|
||||
}
|
||||
window.location.href = link.href;
|
||||
}
|
||||
|
||||
function moveBrowserRight() {
|
||||
var columns = getColumns();
|
||||
if (!columns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedColumnIndex < columns.length - 1 && selectedLink && selectedLink.classList.contains("is-active")) {
|
||||
selectBrowserColumn(selectedColumnIndex + 1, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedColumnIndex < columns.length - 1 && !selectedLink) {
|
||||
selectBrowserColumn(selectedColumnIndex + 1, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedLink && !selectedLink.classList.contains("is-active")) {
|
||||
navigateTo(selectedLink);
|
||||
return;
|
||||
}
|
||||
|
||||
if (markdownBody && getParagraphTargets().length) {
|
||||
enterParagraphMode(0);
|
||||
} else if (selectedLink) {
|
||||
navigateTo(selectedLink);
|
||||
}
|
||||
}
|
||||
|
||||
function nearestParagraphIndex() {
|
||||
var targets = getParagraphTargets();
|
||||
if (!targets.length) {
|
||||
return -1;
|
||||
}
|
||||
if (selectedParagraphIndex >= 0 && selectedParagraphIndex < targets.length) {
|
||||
return selectedParagraphIndex;
|
||||
}
|
||||
|
||||
var shellRect = documentShell ? documentShell.getBoundingClientRect() : null;
|
||||
var targetY = shellRect ? shellRect.top + shellRect.height * 0.35 : window.innerHeight * 0.35;
|
||||
var bestIndex = 0;
|
||||
var bestDistance = Number.POSITIVE_INFINITY;
|
||||
targets.forEach(function (el, index) {
|
||||
var distance = Math.abs(el.getBoundingClientRect().top - targetY);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestIndex = index;
|
||||
}
|
||||
});
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
function selectParagraph(index) {
|
||||
var targets = getParagraphTargets();
|
||||
if (!targets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearBrowserSelection();
|
||||
clearParagraphSelection();
|
||||
mode = "paragraph";
|
||||
selectedParagraphIndex = clamp(index, 0, targets.length - 1);
|
||||
|
||||
var target = targets[selectedParagraphIndex];
|
||||
target.classList.add("is-keyboard-selected");
|
||||
target.setAttribute("aria-selected", "true");
|
||||
target.setAttribute("tabindex", "-1");
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function enterParagraphMode(offset) {
|
||||
var index = nearestParagraphIndex();
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
selectParagraph(index + offset);
|
||||
}
|
||||
|
||||
function moveParagraphVertical(delta) {
|
||||
var targets = getParagraphTargets();
|
||||
if (!targets.length) {
|
||||
return;
|
||||
}
|
||||
var currentIndex = selectedParagraphIndex;
|
||||
if (currentIndex < 0 || currentIndex >= targets.length) {
|
||||
currentIndex = nearestParagraphIndex();
|
||||
}
|
||||
selectParagraph(currentIndex + delta);
|
||||
}
|
||||
|
||||
function openSelectedParagraphInEditor() {
|
||||
var editLink = document.querySelector(".document-actions-dropdown__item[href$='/edit']");
|
||||
if (editLink) {
|
||||
navigateTo(editLink);
|
||||
return;
|
||||
}
|
||||
var path = documentShell ? documentShell.getAttribute("data-document-path") : "";
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.location.href = "/docs/" + path.replace(/\.md$/, "") + "/edit";
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
var search = document.querySelector(".site-search input[type='search'], .site-search input[name='q']");
|
||||
if (!search) {
|
||||
return;
|
||||
}
|
||||
clearBrowserSelection();
|
||||
clearParagraphSelection();
|
||||
search.focus();
|
||||
search.select();
|
||||
}
|
||||
|
||||
function focusCommentsSidebar() {
|
||||
var desktopToggle = document.querySelector("[data-comment-toggle]");
|
||||
if (desktopToggle && desktopToggle.getAttribute("aria-pressed") === "false") {
|
||||
desktopToggle.click();
|
||||
}
|
||||
|
||||
var mobileToggle = document.querySelector("[data-toggle-comments]");
|
||||
if (mobileToggle && window.matchMedia("(max-width: 760px)").matches && mobileToggle.getAttribute("aria-expanded") === "false") {
|
||||
mobileToggle.click();
|
||||
}
|
||||
|
||||
var focusTarget = document.querySelector("[data-comments-aside-list] .comments-aside-item") ||
|
||||
document.querySelector("[data-comments-aside-list]") ||
|
||||
document.querySelector("[data-comments-aside]");
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearBrowserSelection();
|
||||
clearParagraphSelection();
|
||||
focusTarget.setAttribute("tabindex", "-1");
|
||||
focusTarget.focus({ preventScroll: false });
|
||||
}
|
||||
|
||||
function focusDocumentActions() {
|
||||
var dropdown = document.querySelector(".document-actions-dropdown");
|
||||
if (!dropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.open = true;
|
||||
var focusTarget = dropdown.querySelector(".document-actions-dropdown__item[href], .document-actions-dropdown__item:not([disabled])") ||
|
||||
dropdown.querySelector("summary");
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearBrowserSelection();
|
||||
clearParagraphSelection();
|
||||
focusTarget.focus();
|
||||
}
|
||||
|
||||
function closeDocumentActions() {
|
||||
var dropdown = document.querySelector(".document-actions-dropdown[open]");
|
||||
if (dropdown) {
|
||||
dropdown.open = false;
|
||||
var summary = dropdown.querySelector("summary");
|
||||
if (summary) {
|
||||
summary.focus();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
if (mode === "paragraph") {
|
||||
openSelectedParagraphInEditor();
|
||||
return;
|
||||
}
|
||||
navigateTo(selectedLink);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || shouldIgnoreTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var key = event.key;
|
||||
var normalized = key.length === 1 ? key.toLowerCase() : key;
|
||||
|
||||
if (event.code === "Backslash" || key === "\\") {
|
||||
event.preventDefault();
|
||||
focusSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized === "c") {
|
||||
event.preventDefault();
|
||||
focusCommentsSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized === "e") {
|
||||
event.preventDefault();
|
||||
focusDocumentActions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Escape" && closeDocumentActions()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowUp" || normalized === "k") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
if (mode === "paragraph") {
|
||||
moveParagraphVertical(-1);
|
||||
} else {
|
||||
moveBrowserVertical(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || normalized === "j") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
if (mode === "paragraph") {
|
||||
moveParagraphVertical(1);
|
||||
} else {
|
||||
moveBrowserVertical(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowLeft" || normalized === "h") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
if (mode === "paragraph") {
|
||||
selectBrowserColumn(getColumns().length - 1, true);
|
||||
} else {
|
||||
selectBrowserColumn(selectedColumnIndex - 1, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowRight" || normalized === "l") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
if (mode === "paragraph") {
|
||||
return;
|
||||
}
|
||||
moveBrowserRight();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
showKeyboardUI();
|
||||
handleEnter();
|
||||
}
|
||||
}
|
||||
|
||||
function initSelection() {
|
||||
var columns = getColumns();
|
||||
if (!columns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var activeLinks = Array.prototype.slice.call(browser.querySelectorAll("a.is-active[href]"));
|
||||
var activeLink = activeLinks[activeLinks.length - 1] || browser.querySelector("a[href]");
|
||||
selectedLink = activeLink;
|
||||
if (activeLink) {
|
||||
var activeColumn = activeLink.closest(".miller-column");
|
||||
selectedColumnIndex = Math.max(0, columns.indexOf(activeColumn));
|
||||
}
|
||||
}
|
||||
|
||||
initSelection();
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
})();
|
||||
86
apps/server/internal/httpserver/static/mobile.js
Normal file
86
apps/server/internal/httpserver/static/mobile.js
Normal file
@@ -0,0 +1,86 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const backdrop = document.querySelector('[data-mobile-drawer-backdrop]');
|
||||
const pickerDrawer = document.querySelector('[data-picker-drawer]');
|
||||
const metaDrawer = document.querySelector('[data-meta-drawer]');
|
||||
const commentsAside = document.querySelector('[data-comments-aside]');
|
||||
const pickerToggle = document.querySelector('[data-toggle-picker]');
|
||||
const metaToggle = document.querySelector('[data-toggle-meta]');
|
||||
const pickerClose = document.querySelector('[data-close-picker]');
|
||||
const metaClose = document.querySelector('[data-close-meta]');
|
||||
|
||||
function anyDrawerOpen() {
|
||||
return Boolean(
|
||||
pickerDrawer?.classList.contains('is-open') ||
|
||||
metaDrawer?.classList.contains('is-open') ||
|
||||
commentsAside?.classList.contains('is-open')
|
||||
);
|
||||
}
|
||||
|
||||
function updateBackdrop() {
|
||||
document.body.classList.toggle('drawer-open', anyDrawerOpen());
|
||||
}
|
||||
|
||||
function setPickerOpen(open) {
|
||||
if (!pickerDrawer) return;
|
||||
pickerDrawer.classList.toggle('is-open', open);
|
||||
if (pickerToggle) pickerToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
updateBackdrop();
|
||||
}
|
||||
|
||||
function setMetaOpen(open) {
|
||||
if (!metaDrawer) return;
|
||||
metaDrawer.classList.toggle('is-open', open);
|
||||
if (metaToggle) metaToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
updateBackdrop();
|
||||
}
|
||||
|
||||
function closeAllDrawers() {
|
||||
setPickerOpen(false);
|
||||
setMetaOpen(false);
|
||||
if (commentsAside) {
|
||||
commentsAside.classList.remove('is-open');
|
||||
const toggle = document.querySelector('[data-toggle-comments]');
|
||||
if (toggle) toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
updateBackdrop();
|
||||
}
|
||||
|
||||
if (pickerToggle) {
|
||||
pickerToggle.addEventListener('click', () => {
|
||||
const willOpen = !pickerDrawer?.classList.contains('is-open');
|
||||
closeAllDrawers();
|
||||
setPickerOpen(willOpen);
|
||||
});
|
||||
}
|
||||
|
||||
if (metaToggle) {
|
||||
metaToggle.addEventListener('click', () => {
|
||||
const willOpen = !metaDrawer?.classList.contains('is-open');
|
||||
closeAllDrawers();
|
||||
setMetaOpen(willOpen);
|
||||
});
|
||||
}
|
||||
|
||||
if (pickerClose) pickerClose.addEventListener('click', () => setPickerOpen(false));
|
||||
if (metaClose) metaClose.addEventListener('click', () => setMetaOpen(false));
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', closeAllDrawers);
|
||||
}
|
||||
|
||||
// Close drawers on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAllDrawers();
|
||||
});
|
||||
|
||||
// Close action dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('.document-actions-dropdown[open]').forEach((details) => {
|
||||
if (!details.contains(e.target)) {
|
||||
details.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
36
apps/server/internal/httpserver/static/permissions.js
Normal file
36
apps/server/internal/httpserver/static/permissions.js
Normal file
@@ -0,0 +1,36 @@
|
||||
(() => {
|
||||
const root = document.querySelector("[data-permission-bulk]");
|
||||
if (!root) return;
|
||||
|
||||
const selectAll = root.querySelector("[data-permission-select-all]");
|
||||
const bulkLevel = root.querySelector("[data-permission-bulk-level]");
|
||||
const apply = root.querySelector("[data-permission-apply]");
|
||||
const rows = Array.from(document.querySelectorAll(".permission-user-row"));
|
||||
|
||||
const checkedRows = () => rows.filter((row) => row.querySelector("[data-permission-user]")?.checked);
|
||||
|
||||
const updateSelectAll = () => {
|
||||
const selected = checkedRows().length;
|
||||
selectAll.checked = selected > 0 && selected === rows.length;
|
||||
selectAll.indeterminate = selected > 0 && selected < rows.length;
|
||||
};
|
||||
|
||||
selectAll?.addEventListener("change", () => {
|
||||
rows.forEach((row) => {
|
||||
const checkbox = row.querySelector("[data-permission-user]");
|
||||
if (checkbox) checkbox.checked = selectAll.checked;
|
||||
});
|
||||
updateSelectAll();
|
||||
});
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.querySelector("[data-permission-user]")?.addEventListener("change", updateSelectAll);
|
||||
});
|
||||
|
||||
apply?.addEventListener("click", () => {
|
||||
checkedRows().forEach((row) => {
|
||||
const select = row.querySelector("[data-permission-select]");
|
||||
if (select) select.value = bulkLevel.value;
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -144,7 +144,7 @@
|
||||
|
||||
const html = document.documentElement.outerHTML;
|
||||
const title = document.title;
|
||||
const pagePath = window.MDHubCache.normalizePath(window.location.pathname);
|
||||
const pagePath = window.MDHubCache.normalizePath(window.location.pathname + window.location.search);
|
||||
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
|
||||
}
|
||||
|
||||
@@ -373,4 +373,72 @@
|
||||
}).catch(function () {});
|
||||
}
|
||||
});
|
||||
|
||||
// Add folder button handlers
|
||||
if (browser) {
|
||||
browser.querySelectorAll(".miller-column-add-folder").forEach(function (button) {
|
||||
button.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var parentPath = button.getAttribute("data-folder-path");
|
||||
var folderName = window.prompt("Folder name:");
|
||||
if (!folderName || !folderName.trim()) return;
|
||||
folderName = folderName.trim().replace(/[\\/]/g, "-");
|
||||
var docPath = parentPath ? parentPath + "/" + folderName + "/index.md" : folderName + "/index.md";
|
||||
var displayPath = docPath.replace(/\.md$/, "");
|
||||
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "# " + folderName + "\n\n", baseHash: "" }),
|
||||
}).then(function (res) {
|
||||
if (res.ok) {
|
||||
return res.json().then(function (data) {
|
||||
var path = data.path || docPath;
|
||||
var urlPath = path.replace(/\.md$/, "");
|
||||
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
|
||||
});
|
||||
} else {
|
||||
return res.json().then(function (data) {
|
||||
throw new Error(data.error || "Failed to create folder");
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
window.alert(err.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
browser.querySelectorAll(".miller-column-new-file").forEach(function (button) {
|
||||
button.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var parentPath = button.getAttribute("data-folder-path");
|
||||
var fileName = window.prompt("File name:");
|
||||
if (!fileName || !fileName.trim()) return;
|
||||
fileName = fileName.trim().replace(/[\\/]/g, "-");
|
||||
if (!fileName.endsWith(".md")) fileName += ".md";
|
||||
var docPath = parentPath ? parentPath + "/" + fileName : fileName;
|
||||
var displayPath = docPath.replace(/\.md$/, "");
|
||||
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "# " + fileName.replace(/\.md$/, "") + "\n\n", baseHash: "" }),
|
||||
}).then(function (res) {
|
||||
if (res.ok) {
|
||||
return res.json().then(function (data) {
|
||||
var path = data.path || docPath;
|
||||
var urlPath = path.replace(/\.md$/, "");
|
||||
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
|
||||
});
|
||||
} else {
|
||||
return res.json().then(function (data) {
|
||||
throw new Error(data.error || "Failed to create file");
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
window.alert(err.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
const STATIC_CACHE = "md-hub-static-v2";
|
||||
const STATIC_CACHE = "md-hub-static-v8";
|
||||
const DB_NAME = "md-hub-cache";
|
||||
const DB_VERSION = 3;
|
||||
const STORE_PAGES = "pages";
|
||||
@@ -8,7 +8,10 @@ const STATIC_ASSETS = [
|
||||
"/static/sync.js",
|
||||
"/static/realtime.js",
|
||||
"/static/editor.js",
|
||||
"/static/codemirror.bundle.js",
|
||||
"/static/render.js",
|
||||
"/static/keyboard-nav.js",
|
||||
"/static/tags.js",
|
||||
"/static/favicon.png",
|
||||
"/static/cairnquire%20logo%402x.webp",
|
||||
"/",
|
||||
@@ -64,12 +67,13 @@ self.addEventListener("fetch", function (event) {
|
||||
});
|
||||
|
||||
async function handleNavigation(request, url) {
|
||||
const cacheKey = url.pathname + url.search;
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
cachePageResponse(url.pathname, response.clone());
|
||||
cachePageResponse(cacheKey, response.clone());
|
||||
return response;
|
||||
} catch (_error) {
|
||||
const cachedPage = await getCachedPage(url.pathname);
|
||||
const cachedPage = await getCachedPage(cacheKey);
|
||||
if (cachedPage && cachedPage.html) {
|
||||
return new Response(markOfflineHTML(cachedPage.html), {
|
||||
headers: {
|
||||
@@ -80,6 +84,19 @@ async function handleNavigation(request, url) {
|
||||
});
|
||||
}
|
||||
|
||||
if (cacheKey !== "/") {
|
||||
const fallback = await getCachedPage("/");
|
||||
if (fallback && fallback.html) {
|
||||
return new Response(markOfflineHTML(fallback.html), {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-MDHub-Cache": "offline",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await caches.match("/");
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
|
||||
81
apps/server/internal/httpserver/static/tags.js
Normal file
81
apps/server/internal/httpserver/static/tags.js
Normal file
@@ -0,0 +1,81 @@
|
||||
(function () {
|
||||
const url = new URL(window.location.href);
|
||||
const tag = url.searchParams.get("tag");
|
||||
if (!tag) return;
|
||||
|
||||
// Check if we're on a proper tag page or a fallback
|
||||
const hasTagPreview = document.querySelector(".tag-preview");
|
||||
if (hasTagPreview) return; // Server rendered tag page correctly
|
||||
|
||||
// We're on a fallback page (likely offline) — build tag view from cache
|
||||
if (!window.MDHubCache) return;
|
||||
|
||||
window.MDHubCache.getCachedDocuments().then(function (docs) {
|
||||
if (!docs || !docs.length) return;
|
||||
|
||||
const tagged = docs.filter(function (doc) {
|
||||
return doc.tags && doc.tags.indexOf(tag) !== -1;
|
||||
});
|
||||
|
||||
// Build a minimal tag browser
|
||||
const shell = document.querySelector(".workspace-shell");
|
||||
if (!shell) return;
|
||||
|
||||
const browser = shell.querySelector(".miller-browser");
|
||||
const preview = shell.querySelector(".empty-preview") || shell.querySelector(".document-shell");
|
||||
|
||||
if (browser) {
|
||||
// Replace browser with a single column of tagged docs
|
||||
const itemsHtml = tagged
|
||||
.sort(function (a, b) {
|
||||
const aName = (a.title || a.path).toLowerCase();
|
||||
const bName = (b.title || b.path).toLowerCase();
|
||||
return aName < bName ? -1 : aName > bName ? 1 : 0;
|
||||
})
|
||||
.map(function (doc) {
|
||||
const name = doc.title || doc.path;
|
||||
const path = doc.path.replace(/\.md$/, "");
|
||||
return (
|
||||
'<li><a href="/docs/' +
|
||||
escapeHtml(path) +
|
||||
'" title="' +
|
||||
escapeHtml(name) +
|
||||
'"><span class="browser-item-label"><svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path></svg><span class="browser-item-name">' +
|
||||
escapeHtml(name) +
|
||||
"</span></span></a></li>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
browser.innerHTML =
|
||||
'<section class="miller-column miller-column--root" aria-label="#' +
|
||||
escapeHtml(tag) +
|
||||
'"><h2><span class="miller-column-title-wrap"><span class="miller-column-title-text">#' +
|
||||
escapeHtml(tag) +
|
||||
"</span></span></h2><ul>" +
|
||||
itemsHtml +
|
||||
"</ul></section>";
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
preview.outerHTML =
|
||||
'<section class="tag-preview"><p class="eyebrow">Tag</p><h1>#' +
|
||||
escapeHtml(tag) +
|
||||
'</h1><p class="lede">' +
|
||||
tagged.length +
|
||||
" document" +
|
||||
(tagged.length === 1 ? "" : "s") +
|
||||
" found</p></section>";
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -29,7 +30,12 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, principal.UserID)
|
||||
canRead, _, accessErr := s.syncAccessFilters(r)
|
||||
snapshot, err := s.syncService.InitSyncFiltered(r.Context(), req.DeviceID, principal.UserID, canRead)
|
||||
if accessErr() != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("sync init failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
|
||||
@@ -60,7 +66,12 @@ func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta})
|
||||
canRead, canWrite, accessErr := s.syncAccessFilters(r)
|
||||
result, err := s.syncService.ApplyDeltaFiltered(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta}, canRead, canWrite)
|
||||
if accessErr() != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
@@ -100,7 +111,12 @@ func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions)
|
||||
canRead, canWrite, accessErr := s.syncAccessFilters(r)
|
||||
snapshot, err := s.syncService.ResolveConflictsFiltered(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions, canRead, canWrite)
|
||||
if accessErr() != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
@@ -148,3 +164,48 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(content)
|
||||
}
|
||||
|
||||
func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.PathAccessFunc, func() error) {
|
||||
var accessErr error
|
||||
canRead := func(file sync.FileEntry) bool {
|
||||
if accessErr != nil {
|
||||
return false
|
||||
}
|
||||
record, err := s.repository.GetDocumentByPath(r.Context(), file.Path)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return s.canReadNewDocument(r, file.Path)
|
||||
}
|
||||
accessErr = err
|
||||
return false
|
||||
}
|
||||
ok, err := s.canReadDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
accessErr = err
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
canWrite := func(path string) bool {
|
||||
if accessErr != nil {
|
||||
return false
|
||||
}
|
||||
record, err := s.repository.GetDocumentByPath(r.Context(), path)
|
||||
if err == nil && record != nil {
|
||||
ok, err := s.canWriteDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
accessErr = err
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return s.canWriteNewDocument(r, path)
|
||||
}
|
||||
if err != nil {
|
||||
return s.canWriteNewDocument(r, path)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return canRead, canWrite, func() error { return accessErr }
|
||||
}
|
||||
|
||||
@@ -66,6 +66,20 @@
|
||||
</div>
|
||||
|
||||
<aside class="account-sidebar">
|
||||
<section class="account-section">
|
||||
<header class="account-section__header">
|
||||
<div>
|
||||
<p class="account-section__kicker">Security</p>
|
||||
<h2>Passkeys</h2>
|
||||
</div>
|
||||
<p>Add a passkey to sign in with this device, a password manager, or a hardware security key.</p>
|
||||
</header>
|
||||
<form class="auth-form" data-passkey-add>
|
||||
<button type="submit">Add passkey</button>
|
||||
<p class="form-status" data-passkey-add-status></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="account-section">
|
||||
<header class="account-section__header">
|
||||
<div>
|
||||
|
||||
@@ -62,6 +62,8 @@
|
||||
{{ template "document_edit_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "search_content" }}
|
||||
{{ template "search_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "tag_content" }}
|
||||
{{ template "tag_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "login_content" }}
|
||||
{{ template "login_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "setup_content" }}
|
||||
@@ -72,6 +74,8 @@
|
||||
{{ template "account_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "token_create_content" }}
|
||||
{{ template "token_create_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "permissions_content" }}
|
||||
{{ template "permissions_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "device_verify_content" }}
|
||||
{{ template "device_verify_content" .Data }}
|
||||
{{ else if eq .BodyTemplate "error_content" }}
|
||||
@@ -107,7 +111,11 @@
|
||||
<script src="/static/auth.js" defer></script>
|
||||
<script src="/static/render.js" defer></script>
|
||||
<script src="/static/comments.js" defer></script>
|
||||
<script src="/static/keyboard-nav.js" defer></script>
|
||||
<script src="/static/mobile.js" defer></script>
|
||||
<script src="/static/archive.js" defer></script>
|
||||
<script src="/static/permissions.js" defer></script>
|
||||
<script src="/static/tags.js" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
|
||||
{{ define "document_content" }}
|
||||
<section class="workspace-shell workspace-shell--document">
|
||||
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
|
||||
{{ template "browser" .Browser }}
|
||||
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
|
||||
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
|
||||
<div class="document-mobile-bar">
|
||||
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
|
||||
</button>
|
||||
<span class="document-mobile-bar__title" aria-hidden="true">{{ .Title }}</span>
|
||||
<button class="document-mobile-bar__btn document-mobile-bar__btn--meta" type="button" data-toggle-meta aria-label="Open document info" aria-expanded="false">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
|
||||
@@ -18,61 +28,135 @@
|
||||
{{ end }}
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="document-meta">
|
||||
<div class="document-meta" data-meta-drawer>
|
||||
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-meta aria-label="Close document info">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
<div class="document-meta__header">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ if .CanWrite }}
|
||||
<div class="document-actions">
|
||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
||||
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<details class="document-meta-panel">
|
||||
<summary>
|
||||
<span>Document details</span>
|
||||
<details class="document-actions-dropdown">
|
||||
<summary aria-haspopup="true" aria-label="Document actions">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</summary>
|
||||
<dl class="meta-grid">
|
||||
<div>
|
||||
<dt>File</dt>
|
||||
<dd><code>{{ .Path }}</code></dd>
|
||||
<div class="document-actions-dropdown__menu">
|
||||
{{ if .CanWrite }}
|
||||
<a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
<span>Edit</span>
|
||||
</a>
|
||||
{{ if .CanAdmin }}
|
||||
<a class="document-actions-dropdown__item" href="/permissions?resourceType=document&resourceId={{ .Path }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
||||
<span>Permissions</span>
|
||||
</a>
|
||||
{{ end }}
|
||||
<button class="document-actions-dropdown__item document-actions-dropdown__item--danger" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
|
||||
<span>Archive</span>
|
||||
</button>
|
||||
{{ end }}
|
||||
<hr class="document-actions-dropdown__divider">
|
||||
<div class="document-actions-dropdown__meta">
|
||||
<div class="document-actions-dropdown__meta-row">
|
||||
<span class="document-actions-dropdown__meta-label">File</span>
|
||||
<code class="document-actions-dropdown__meta-value">{{ .Path }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Hash</dt>
|
||||
<dd><code>{{ .Hash }}</code></dd>
|
||||
<div class="document-actions-dropdown__meta-row">
|
||||
<span class="document-actions-dropdown__meta-label">Hash</span>
|
||||
<code class="document-actions-dropdown__meta-value">{{ .Hash }}</code>
|
||||
</div>
|
||||
{{ if .Tags }}
|
||||
<div>
|
||||
<dt>Tags</dt>
|
||||
<dd>
|
||||
<ul class="tag-list">
|
||||
<div class="document-actions-dropdown__meta-row">
|
||||
<span class="document-actions-dropdown__meta-label">Tags</span>
|
||||
<ul class="document-actions-dropdown__tag-list">
|
||||
{{ range .Tags }}
|
||||
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
{{ end }}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="markdown-body">
|
||||
{{ .HTML }}
|
||||
</div>
|
||||
</article>
|
||||
<aside class="document-comments-aside" data-comments-aside aria-label="Comments">
|
||||
<div class="document-comments-strip" data-comments-strip>
|
||||
<button
|
||||
class="document-comments-strip__toggle"
|
||||
type="button"
|
||||
data-comment-toggle
|
||||
aria-pressed="false"
|
||||
aria-label="Toggle comments panel"
|
||||
title="Toggle comments"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="document-comments-strip__track" data-comments-strip-track></div>
|
||||
</div>
|
||||
<div class="document-comments-panel">
|
||||
<div class="document-comments-panel__list" data-comments-aside-list></div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile bottom sheet -->
|
||||
<div class="document-comments-sheet" data-comments-sheet>
|
||||
<div class="document-comments-sheet__handle">
|
||||
<button class="document-comments-sheet__toggle" type="button" data-toggle-comments aria-expanded="false" aria-label="Toggle comments">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||
</svg>
|
||||
<span>Comments</span>
|
||||
</button>
|
||||
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-comments aria-label="Close comments">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="document-comments-sheet__panel">
|
||||
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "browser" }}
|
||||
<nav class="miller-browser" aria-label="Documents">
|
||||
<nav class="miller-browser" aria-label="Documents" data-picker-drawer>
|
||||
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-picker aria-label="Close file picker">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
{{ range .Columns }}
|
||||
<section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}">
|
||||
<h2 title="{{ .Title }}">
|
||||
<h2 title="{{ .Title }}" {{ if $.CanWrite }}class="has-actions"{{ end }}>
|
||||
<span class="miller-column-title-wrap">
|
||||
{{ if eq .Title "Content" }}
|
||||
{{ template "icon-library" }}
|
||||
{{ end }}
|
||||
<span class="miller-column-title-text">{{ .Title }}</span>
|
||||
</span>
|
||||
{{ if $.CanWrite }}
|
||||
<span class="miller-column-actions">
|
||||
<button type="button" class="miller-column-new-file" data-folder-path="{{ .Path }}" aria-label="New file in {{ .Title }}">
|
||||
{{ template "icon-file-plus" }}
|
||||
</button>
|
||||
<button type="button" class="miller-column-add-folder" data-folder-path="{{ .Path }}" aria-label="Add folder to {{ .Title }}">
|
||||
{{ template "icon-folder-plus" }}
|
||||
</button>
|
||||
</span>
|
||||
{{ end }}
|
||||
</h2>
|
||||
<ul>
|
||||
{{ range .Items }}
|
||||
@@ -117,6 +201,23 @@
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-folder-plus" }}
|
||||
<svg class="browser-icon browser-icon--folder" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
|
||||
<line x1="12" y1="11" x2="12" y2="17"></line>
|
||||
<line x1="9" y1="14" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-file-plus" }}
|
||||
<svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
|
||||
<line x1="12" y1="11" x2="12" y2="17"></line>
|
||||
<line x1="9" y1="14" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-chevron-right" }}
|
||||
<svg class="browser-item-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6"></path>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<p class="eyebrow">Editing</p>
|
||||
<h1>{{ .Title }}</h1>
|
||||
</div>
|
||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}">Done</a>
|
||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}" data-done-link>Done</a>
|
||||
</div>
|
||||
<details class="document-meta-panel">
|
||||
<summary>
|
||||
@@ -70,7 +70,7 @@
|
||||
<div class="editor-conflict__diff" data-conflict-diff></div>
|
||||
<div class="editor-conflict__resolution">
|
||||
<label for="conflict-resolution">Resolved document</label>
|
||||
<div data-conflict-monaco-mount></div>
|
||||
<div data-conflict-codemirror-mount></div>
|
||||
<textarea id="conflict-resolution" data-conflict-resolution spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
<form class="editor-form" data-editor-form data-document-path="{{ .Path }}">
|
||||
<label class="sr-only" for="document-editor">Markdown editor</label>
|
||||
<div data-monaco-mount></div>
|
||||
<div data-codemirror-mount></div>
|
||||
<textarea id="document-editor" name="content" data-document-editor spellcheck="false">{{ .Source }}</textarea>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
71
apps/server/internal/httpserver/templates/permissions.gohtml
Normal file
71
apps/server/internal/httpserver/templates/permissions.gohtml
Normal file
@@ -0,0 +1,71 @@
|
||||
{{ define "permissions.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "permissions_content" }}
|
||||
<section class="permissions-shell">
|
||||
<header class="permissions-header">
|
||||
<div>
|
||||
<p class="eyebrow">Access control</p>
|
||||
<h1>{{ .Title }}</h1>
|
||||
</div>
|
||||
{{ if eq .ResourceType "document" }}
|
||||
<a class="account-action" href="/docs/{{ trimMd .ResourceID }}">Back to page</a>
|
||||
{{ end }}
|
||||
</header>
|
||||
|
||||
<form class="permissions-form" method="post" action="/permissions">
|
||||
<input type="hidden" name="resourceType" value="{{ .ResourceType }}" />
|
||||
<input type="hidden" name="resourceId" value="{{ .ResourceID }}" />
|
||||
<section class="permissions-panel">
|
||||
<header class="permissions-panel__header">
|
||||
<div>
|
||||
<p class="account-section__kicker">People</p>
|
||||
<h2>User permissions</h2>
|
||||
</div>
|
||||
<div class="permission-bulk-actions" data-permission-bulk>
|
||||
<label class="permission-select-all">
|
||||
<input type="checkbox" data-permission-select-all />
|
||||
<span>Select all</span>
|
||||
</label>
|
||||
<select data-permission-bulk-level aria-label="Access level for selected users">
|
||||
<option value="">No access</option>
|
||||
<option value="read">View</option>
|
||||
<option value="write">Edit</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-secondary permission-apply" type="button" data-permission-apply>Apply</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="permission-user-list">
|
||||
{{ range .Users }}
|
||||
<label class="permission-user-row">
|
||||
<input class="permission-user-check" type="checkbox" data-permission-user />
|
||||
<span class="permission-user-main">
|
||||
<span class="permission-user-name">
|
||||
<strong>{{ .DisplayName }}</strong>
|
||||
<em>{{ .Role }}</em>
|
||||
</span>
|
||||
<span class="permission-user-email">{{ .Email }}</span>
|
||||
<span class="permission-user-meta">Created {{ .CreatedAt }} · Last seen {{ .LastSeenAt }}</span>
|
||||
</span>
|
||||
<span class="permission-grant-meta">
|
||||
<strong>{{ .PermissionText }}</strong>
|
||||
<small>{{ .GrantSummary }}</small>
|
||||
</span>
|
||||
<span class="permission-select-wrap">
|
||||
<select name="permission:{{ .ID }}" aria-label="Permission for {{ .DisplayName }}" data-permission-select>
|
||||
<option value="" {{ if eq .Permission "" }}selected{{ end }}>No access</option>
|
||||
<option value="read" {{ if eq .Permission "read" }}selected{{ end }}>View</option>
|
||||
<option value="write" {{ if eq .Permission "write" }}selected{{ end }}>Edit</option>
|
||||
<option value="admin" {{ if eq .Permission "admin" }}selected{{ end }}>Admin</option>
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="permissions-footer">
|
||||
<button class="btn-primary" type="submit">Save permissions</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
||||
12
apps/server/internal/httpserver/templates/tag.gohtml
Normal file
12
apps/server/internal/httpserver/templates/tag.gohtml
Normal file
@@ -0,0 +1,12 @@
|
||||
{{ define "tag.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "tag_content" }}
|
||||
<section class="workspace-shell workspace-shell--empty">
|
||||
{{ template "browser" .Browser }}
|
||||
<section class="tag-preview">
|
||||
<p class="eyebrow">Tag</p>
|
||||
<h1>#{{ .Tag }}</h1>
|
||||
<p class="lede">{{ len .Results }} document{{ if ne (len .Results) 1 }}s{{ end }} found</p>
|
||||
</section>
|
||||
</section>
|
||||
{{ end }}
|
||||
@@ -31,6 +31,9 @@ type Service struct {
|
||||
sourceDir string
|
||||
}
|
||||
|
||||
type FileAccessFunc func(FileEntry) bool
|
||||
type PathAccessFunc func(string) bool
|
||||
|
||||
// NewService creates a new sync service.
|
||||
func NewService(repo *Repository, docService *docs.Service, contentStore *store.ContentStore, sourceDir string, logger *slog.Logger) *Service {
|
||||
return &Service{
|
||||
@@ -44,10 +47,15 @@ func NewService(repo *Repository, docService *docs.Service, contentStore *store.
|
||||
|
||||
// InitSync creates a new snapshot from the current server state.
|
||||
func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snapshot, error) {
|
||||
return s.InitSyncFiltered(ctx, deviceID, userID, nil)
|
||||
}
|
||||
|
||||
func (s *Service) InitSyncFiltered(ctx context.Context, deviceID, userID string, canRead FileAccessFunc) (*Snapshot, error) {
|
||||
files, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build snapshot: %w", err)
|
||||
}
|
||||
files = filterFiles(files, canRead)
|
||||
|
||||
snap, err := s.repo.CreateSnapshot(ctx, deviceID, userID, files)
|
||||
if err != nil {
|
||||
@@ -60,6 +68,10 @@ func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snaps
|
||||
|
||||
// ApplyDelta processes client changes and computes server delta + conflicts.
|
||||
func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, clientDelta Delta) (*DeltaResult, error) {
|
||||
return s.ApplyDeltaFiltered(ctx, snapshotID, userID, clientDelta, nil, nil)
|
||||
}
|
||||
|
||||
func (s *Service) ApplyDeltaFiltered(ctx context.Context, snapshotID, userID string, clientDelta Delta, canRead FileAccessFunc, canWrite PathAccessFunc) (*DeltaResult, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
@@ -67,6 +79,14 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
for _, clientChange := range clientDelta.Changes {
|
||||
if canWrite != nil && !canWrite(clientChange.Path) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if clientChange.OldPath != "" && canWrite != nil && !canWrite(clientChange.OldPath) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
serverFiles := make(map[string]FileEntry)
|
||||
for _, f := range snap.Files {
|
||||
@@ -91,6 +111,7 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build current snapshot: %w", err)
|
||||
}
|
||||
currentFiles = filterFiles(currentFiles, canRead)
|
||||
|
||||
currentMap := make(map[string]FileEntry)
|
||||
for _, f := range currentFiles {
|
||||
@@ -186,6 +207,7 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-delta snapshot: %w", err)
|
||||
}
|
||||
latestFiles = filterFiles(latestFiles, canRead)
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, latestFiles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create post-delta snapshot: %w", err)
|
||||
@@ -200,6 +222,10 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
|
||||
|
||||
// ResolveConflicts applies resolved changes and creates a new snapshot.
|
||||
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID string, resolutions []Resolution) (*Snapshot, error) {
|
||||
return s.ResolveConflictsFiltered(ctx, snapshotID, userID, resolutions, nil, nil)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveConflictsFiltered(ctx context.Context, snapshotID, userID string, resolutions []Resolution, canRead FileAccessFunc, canWrite PathAccessFunc) (*Snapshot, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
@@ -207,6 +233,14 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if canWrite != nil && !canWrite(resolution.Path) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if resolution.NewPath != "" && canWrite != nil && !canWrite(resolution.NewPath) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
// Get current server state
|
||||
if _, err := s.buildSnapshotFromDisk(ctx); err != nil {
|
||||
@@ -255,6 +289,7 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build resolved snapshot: %w", err)
|
||||
}
|
||||
files = filterFiles(files, canRead)
|
||||
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
|
||||
if err != nil {
|
||||
@@ -265,6 +300,19 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
|
||||
return newSnap, nil
|
||||
}
|
||||
|
||||
func filterFiles(files []FileEntry, canRead FileAccessFunc) []FileEntry {
|
||||
if canRead == nil {
|
||||
return files
|
||||
}
|
||||
filtered := make([]FileEntry, 0, len(files))
|
||||
for _, file := range files {
|
||||
if canRead(file) {
|
||||
filtered = append(filtered, file)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// GetContent returns raw file content by hash.
|
||||
func (s *Service) GetContent(hash string) ([]byte, error) {
|
||||
if !isSHA256Hex(hash) {
|
||||
|
||||
@@ -4,10 +4,11 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "{$PORT}:{$PORT}"
|
||||
- "${PORT:-8080}:${PORT:-8080}"
|
||||
depends_on:
|
||||
- mailpit
|
||||
environment:
|
||||
PORT: "${PORT:-8080}"
|
||||
CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite"
|
||||
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
|
||||
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
|
||||
@@ -17,10 +18,10 @@ services:
|
||||
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
|
||||
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
|
||||
volumes:
|
||||
- cairnquire_data:/workspace/data
|
||||
- ./content:/workspace/content
|
||||
- ./data:/workspace/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:{$PORT}/health"]
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:${PORT:-8080}/healthz"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
@@ -34,3 +35,7 @@ services:
|
||||
environment:
|
||||
MP_UI_BIND_ADDR: "0.0.0.0:8025"
|
||||
MP_SMTP_BIND_ADDR: "0.0.0.0:1025"
|
||||
|
||||
volumes:
|
||||
cairnquire_data:
|
||||
driver: local
|
||||
|
||||
22
entrypoint.sh
Executable file
22
entrypoint.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Ensure runtime directories exist before chowning them; otherwise chown -R on a
|
||||
# bind-mounted or freshly-created named volume can race with MkdirAll in the app.
|
||||
mkdir -p /workspace/data/files /workspace/content
|
||||
|
||||
# Make the runtime dirs owned by appuser so the Go process can write attachments
|
||||
# and read content even when /workspace/data is a host bind mount created as root
|
||||
# (or a named volume initialized with the container's root uid).
|
||||
chown -R appuser:appuser /workspace/data /workspace/content
|
||||
|
||||
# Drop to the unprivileged appuser and hand control to the container CMD.
|
||||
# gosu handles signal forwarding and TTY correctly; plain `su` and `su -c` do not.
|
||||
exec gosu appuser "$@"
|
||||
|
||||
# Smoke test (run on a host with Docker available):
|
||||
# docker compose build app
|
||||
# docker compose up -d app
|
||||
# docker compose exec app id # should print: uid=<appuser-uid>(appuser) gid=<appuser-gid>(appuser)
|
||||
# docker compose exec app ls -ld /workspace/data # should be owned by appuser
|
||||
# curl -fsS http://localhost:${PORT:-8080}/healthz
|
||||
Reference in New Issue
Block a user