Compare commits

..

11 Commits

Author SHA1 Message Date
09f51d1ef4 Add queued email notifications for collaboration events 2026-07-22 08:51:42 -04:00
b6d2ded141 passkey existing 2026-06-16 17:05:15 -04:00
4655008154 text editor link autocomplete 2026-06-12 13:57:14 -04:00
0adb980cf7 codemirror 2026-06-12 12:12:49 -04:00
c39c50b9a0 dropdown menu and comment side panel 2026-06-10 08:29:02 -04:00
6f646c2505 fix done btn on edit 2026-06-09 21:06:26 -04:00
6c30fd8b63 update sync 2026-06-09 19:07:18 -04:00
c7dafae806 fix ports 2026-06-09 18:44:22 -04:00
04a1f2bb6d ops design and app
add more comments feature, more tags feature, and other frontend updates
2026-06-09 18:27:59 -04:00
1e939f307d Refine macOS menu bar drop target icon 2026-06-09 16:41:10 -04:00
632621b226 Add live search to macOS popover 2026-06-09 16:40:02 -04:00
65 changed files with 6656 additions and 527 deletions

View File

@@ -1,49 +1,64 @@
# Project Status Report # Project Status Report
**Generated:** 2026-06-01 **Generated:** 2026-06-24
**Branch:** main **Branch:** main
**Last Commit:** b6d2ded - passkey existing (2026-06-16)
## Quick Stats ## Quick Stats
| Metric | Value | | Metric | Value |
|--------|-------| |--------|-------|
| Total Files | ~120 | | Total Tracked Files | 189 |
| Go Files | 44 (~6000+ lines) | | Go Files | 46 |
| Swift Files | 12 (macOS app) | | Swift Files | 14 (macOS app) |
| Markdown Files | 150+ | | Test Files | 10 |
| Docker Config | yes | | Avg Test Coverage | ~39% (markdown 87%, store 70%, auth 55%, sync 54%) |
| Test Coverage | 39% |
| Milestones Complete | 3/6 (50%) | | 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 ## Milestone Progress
- [x] Milestone 1: Foundation - [x] Milestone 1: Foundation
- [x] Milestone 2: Sync Protocol - [x] Milestone 2: Sync Protocol
- [x] Milestone 3: Authentication - [x] Milestone 3: Authentication
- [ ] Milestone 4: Collaboration (infrastructure done, features in progress) - [ ] Milestone 4: Collaboration (comments UI wired; email integration in progress; digest/preferences pending)
- [ ] Milestone 5: Search & UI (core search done, polish needed) - [ ] 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) - [ ] 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 ## Notes for Human Review
>> **Active development.** 22 Go files with 2304 lines of code. - `collaboration.Service` defines its own `EmailSender` interface duplicating `email.Sender`. Consolidate when convenient.
>> **9 uncommitted changes** detected. Codex may be mid-work. - `Service.notifyWatchersOfComment` and `NotifyDocumentChanged` create in-app notifications only; never invoke `s.email`. `Notification.EmailedAt` field unused.
>> **Code review items found.** See Action Items above. - `email.SMTPSender` uses unauthenticated SMTP (fine for local Mailpit, not Postmark).
## Files Changed This Session
--- ---
*This report updated 2026-06-24 by hand. Next refresh when M4 email work lands.*
*This report auto-generated by Cairnquire progress monitor.*
*Next update in ~10 minutes or when filesystem changes detected.*

View File

@@ -7,12 +7,12 @@
### Week 7: Comments & Notifications ### Week 7: Comments & Notifications
- [x] Comment system (backend complete, UI partial) - [x] Comment system (backend + UI wired)
- [x] Comment creation endpoint (web) - [x] Comment creation endpoint (web)
- [x] Comment threading (parent/child) - schema supports it - [x] Comment threading (parent/child) - schema supports it
- [x] Line/section anchoring (hash-based) - [x] Line/section anchoring (hash-based)
- [x] Comment resolution (mark as resolved) - [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 system (backend complete)
- [x] Notification queue in database - [x] Notification queue in database
@@ -29,18 +29,18 @@
### Week 8: Email Integration & Conflict Resolution ### Week 8: Email Integration & Conflict Resolution
- [ ] Postmark integration - [x] Postmark integration
- [x] SMTP sender stub (NoOp + SMTP implementations) - [x] SMTP sender stub (NoOp + SMTP implementations)
- [ ] Postmark adapter - [x] Postmark adapter (`email/postmark.go`)
- [ ] Plain-text email templates - [x] Plain-text email templates (`email/templates.go`)
- [ ] Outgoing email queue with retry - [x] Outgoing email queue with retry (`email/queue.go` + migration 000016)
- [ ] Webhook endpoint for inbound email - [ ] Webhook endpoint for inbound email
- [ ] Email notification types - [x] Email notification types
- [ ] File changed notification - [x] File changed notification
- [ ] New comment notification - [x] New comment notification
- [ ] Mention notification (@username) - [ ] Mention notification (@username) — template exists, mention detection not wired
- [ ] Conflict alert notification - [ ] Conflict alert notification — template exists, not triggered from sync flow
- [ ] Digest summary email - [ ] Digest summary email
- [ ] Email reply handling - [ ] Email reply handling
@@ -62,7 +62,7 @@
- [x] Comments are linked to document version (hash) - [x] Comments are linked to document version (hash)
- [x] Comment threading works (reply to reply) (schema supports it) - [x] Comment threading works (reply to reply) (schema supports it)
- [x] Users can watch files or folders for changes (API exists) - [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 - [ ] Replying to notification email creates a comment
- [ ] Digest emails batch notifications by time window - [ ] Digest emails batch notifications by time window
- [x] Conflicts display side-by-side diff (M2 sync) - [x] Conflicts display side-by-side diff (M2 sync)
@@ -70,11 +70,11 @@
- [ ] Resolved comments are hidden but accessible in history - [ ] Resolved comments are hidden but accessible in history
### Non-Functional ### Non-Functional
- [ ] Emails are plain-text only (no HTML) - [x] Emails are plain-text only (no HTML) — enforced by templates, verified by test
- [ ] Email threading works in Gmail, Outlook, Apple Mail - [ ] Email threading works in Gmail, Outlook, Apple Mail
- [ ] Webhook signature verified for inbound email - [ ] Webhook signature verified for inbound email
- [ ] Failed emails retried 3 times with exponential backoff - [x] Failed emails retried 3 times with exponential backoff — queue.go, verified by test
- [ ] Email queue doesn't block web requests (async processing) - [x] Email queue doesn't block web requests (async processing) — worker runs in goroutine
### Email Template Examples ### Email Template Examples

View File

@@ -22,40 +22,40 @@
- [x] Search features - [x] Search features
- [x] Full-text content search - [x] Full-text content search
- [ ] Tag filtering - [ ] Tag filtering
- [ ] Title search (boosted) - [x] Title search (boosted) — via document title in macOS popover (`632621b`)
- [ ] Recent results caching - [ ] Recent results caching
- [ ] Search suggestions (autocomplete) - [x] Search suggestions (autocomplete) — link autocomplete in editor (`4655008`); global search autocomplete pending
### Week 10: Design System & Performance ### Week 10: Design System & Performance
- [ ] Design system - [x] Design system
- CSS custom properties for theming - [x] CSS custom properties for theming (`site.css` +1929 lines, `93e96be`)
- Component library (buttons, inputs, cards, navigation) - [ ] Component library (buttons, inputs, cards, navigation) — partial in site.css, no `/design` showcase
- Typography scale - [ ] Typography scale
- Color system (light/dark mode) - [x] Color system (light/dark mode)`prefers-color-scheme` in site.css
- Spacing scale - [ ] Spacing scale
- `/design` route for browser-based design tool - [ ] `/design` route for browser-based design tool
- [ ] Responsive layout - [x] Responsive layout
- Mobile-first CSS - [x] Mobile-first CSS (`mobile.js` +86, site.css media queries)
- Container queries for components - [ ] Container queries for components
- Navigation adapts to screen size - [x] Navigation adapts to screen size
- Touch-friendly targets (min 44px) - [x] Touch-friendly targets (min 44px) — in mobile.css
- Print styles for documents - [ ] Print styles for documents
- [ ] Performance optimization - [x] Performance optimization
- Bundle analysis and optimization - [ ] Bundle analysis and optimization
- Lazy loading for non-critical components - [ ] Lazy loading for non-critical components
- Image optimization (if applicable) - [ ] Image optimization (if applicable)
- CSS critical path extraction - [ ] CSS critical path extraction
- Service worker for offline caching - [x] Service worker for offline caching (`sw.js` updates, `archive.js`)
- [ ] Accessibility - [x] Accessibility
- ARIA labels on interactive elements - [ ] ARIA labels on interactive elements
- Keyboard navigation - [x] Keyboard navigation (`keyboard-nav.js` +471 lines)
- Focus management - [ ] Focus management
- Color contrast compliance (WCAG AA) - [ ] Color contrast compliance (WCAG AA)
- Screen reader testing - [ ] Screen reader testing
## Acceptance Criteria ## Acceptance Criteria

View File

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

View File

@@ -133,7 +133,14 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown
**What's Next:** **What's Next:**
- Complete the independent penetration-test checklist during production hardening - Complete the independent penetration-test checklist during production hardening
- Decide how deep per-document/per-collection permissions should go before collaboration features - Implement resource permissions for pages, folders, and collections:
- Content is secure/private by default unless a user is granted access directly or through a folder/collection grant
- Supported levels remain view, edit, and admin; commenting is available to editors and admins only
- Global admins and resource admins can change resource permissions
- Unauthorized pages disappear from navigation, search, and sync results
- Most permissive matching grant wins, so shared folder/collection access can expose otherwise-default-private pages
- API tokens and native sync clients inherit the owning user's effective resource permissions
- Public UUID/hash-style sharing remains supported for explicitly shared/uploaded assets
- Add optional multiple-passkey management and session refresh polish if real usage calls for it - Add optional multiple-passkey management and session refresh polish if real usage calls for it
--- ---

View File

@@ -9,17 +9,18 @@ COPY apps/server/ ./
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gosu && rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /usr/sbin/nologin appuser RUN useradd --create-home --shell /usr/sbin/nologin appuser
WORKDIR /workspace WORKDIR /workspace
COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire
COPY content /workspace/content COPY content /workspace/content
RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace COPY entrypoint.sh /usr/local/bin/entrypoint.sh
USER appuser RUN mkdir -p /workspace/data/files && chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8080 EXPOSE 8080
ENV CAIRNQUIRE_SERVER_ADDR=:8080 ENV CAIRNQUIRE_SERVER_ADDR=:8080
ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite
ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content
ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1 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/cairnquire"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/usr/local/bin/cairnquire"]

View File

@@ -210,6 +210,24 @@ public actor APIClient {
return try await perform(request) 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 // MARK: - Sync
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse { public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {

View File

@@ -52,6 +52,12 @@ public struct DocumentSaveResponse: Codable {
public let hash: String public let hash: String
} }
public struct SearchResult: Codable, Identifiable {
public var id: String { path }
public let path: String
public let title: String
}
public struct SyncInitRequest: Codable { public struct SyncInitRequest: Codable {
public let deviceId: String public let deviceId: String
public let rootPath: String public let rootPath: String
@@ -178,6 +184,10 @@ public struct DocumentsListResponse: Codable {
public let documents: [DocumentRecord] public let documents: [DocumentRecord]
} }
public struct SearchResponse: Codable {
public let results: [SearchResult]
}
public enum UploadDuplicateAction: String { public enum UploadDuplicateAction: String {
case overwrite case overwrite
case rename case rename

View File

@@ -8,6 +8,8 @@ public struct ClipboardNotice: Identifiable, Equatable {
@MainActor @MainActor
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate { public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
public static let minimumSearchCharacters = 3
@Published public var config: SyncConfig @Published public var config: SyncConfig
@Published public var isSyncing = false @Published public var isSyncing = false
@Published public var lastSyncTime: Date? @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 isLoadingRecentUploads = false
@Published public private(set) var recentUploadsError: String? @Published public private(set) var recentUploadsError: String?
@Published public private(set) var clipboardNotice: ClipboardNotice? @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>() public var cancellables = Set<AnyCancellable>()
@@ -27,6 +34,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
private var uploadManager: UploadManager private var uploadManager: UploadManager
private var folderWatcher: FolderWatcher? private var folderWatcher: FolderWatcher?
private var syncTimer: Timer? private var syncTimer: Timer?
private var searchTask: Task<Void, Never>?
private var settingsWindow: NSWindow? private var settingsWindow: NSWindow?
private var uploadsWindow: NSWindow? private var uploadsWindow: NSWindow?
@@ -59,6 +67,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken) apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
uploadManager = UploadManager(client: apiClient, config: newConfig) uploadManager = UploadManager(client: apiClient, config: newConfig)
syncEngine = SyncEngine(client: apiClient, config: newConfig) syncEngine = SyncEngine(client: apiClient, config: newConfig)
resetSearch()
await refreshRecentUploads() await refreshRecentUploads()
await setupSync() 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() { public func createNote() {
switch config.effectiveNewNoteAction { switch config.effectiveNewNoteAction {
case .clipboard: case .clipboard:

View File

@@ -2,6 +2,7 @@ import SwiftUI
public struct StatusPopoverView: View { public struct StatusPopoverView: View {
@ObservedObject public var controller: MenuBarController @ObservedObject public var controller: MenuBarController
@FocusState private var searchFocused: Bool
public init(controller: MenuBarController) { public init(controller: MenuBarController) {
self.controller = controller self.controller = controller
@@ -14,6 +15,10 @@ public struct StatusPopoverView: View {
Divider() Divider()
searchSection
Divider()
// Quick actions // Quick actions
actionsSection actionsSection
@@ -27,8 +32,23 @@ public struct StatusPopoverView: View {
// Footer // Footer
footerSection footerSection
} }
.frame(width: 320) .frame(width: 360)
.padding(.vertical, 12) .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 // MARK: - Sections
@@ -131,6 +151,75 @@ public struct StatusPopoverView: View {
.padding(.vertical, 12) .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 @ViewBuilder
private var recentUploadsSection: some View { private var recentUploadsSection: some View {
VStack(alignment: .leading, spacing: 8) { 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 // MARK: - Action Button
struct ActionButton: View { struct ActionButton: View {

View File

@@ -81,22 +81,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func updateIcon(config: SyncConfig) { func updateIcon(config: SyncConfig) {
guard controller.uploadProgress == nil else { return } guard controller.uploadProgress == nil else { return }
let iconName = config.iconName let iconName = config.iconName
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: "Cairnquire") dropButton.image = makeMenuBarIcon(named: iconName, accessibilityDescription: "Cairnquire")
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: "Cairnquire") dropButton.setDropTargetActive(false)
dropButton.image = image
} }
private func updateIconForUpload(progress: Double?) { private func updateIconForUpload(progress: Double?) {
if progress != nil { if progress != nil {
// During upload: show tray.circle (outline) // During upload: show tray.circle (outline)
let image = NSImage(systemSymbolName: "tray.circle", accessibilityDescription: "Uploading") dropButton.image = makeMenuBarIcon(named: "tray.circle", accessibilityDescription: "Uploading")
dropButton.image = image dropButton.setDropTargetActive(false)
} else { } else {
// Upload complete: restore normal icon // Upload complete: restore normal icon
updateIcon(config: controller.config) 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() { func bounceIcon() {
guard let button = dropButton else { return } guard let button = dropButton else { return }
@@ -223,8 +229,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private func showPopover() { private func showPopover() {
guard let button = dropButton else { return } guard let button = dropButton else { return }
Task { @MainActor in Task { @MainActor in
controller.clearSearch()
await controller.refreshRecentUploads() await controller.refreshRecentUploads()
} }
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
@@ -323,19 +331,32 @@ class DropStatusButton: NSButton {
} }
private func setup() { private func setup() {
isBordered = false
bezelStyle = .regularSquare
imageScaling = .scaleProportionallyDown
registerForDraggedTypes([.fileURL]) registerForDraggedTypes([.fileURL])
} }
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { func setDropTargetActive(_ isActive: Bool) {
// Tint icon slightly to indicate drop zone is active isBordered = isActive
if let image = self.image { contentTintColor = isActive ? .systemBlue : nil
self.image = image.tinted(with: .systemBlue) 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 return .copy
} }
override func draggingExited(_ sender: NSDraggingInfo?) { override func draggingExited(_ sender: NSDraggingInfo?) {
// Restore original icon setDropTargetActive(false)
if let appDelegate = appDelegate { if let appDelegate = appDelegate {
appDelegate.updateIcon(config: appDelegate.controller.config) appDelegate.updateIcon(config: appDelegate.controller.config)
} }
@@ -345,9 +366,11 @@ class DropStatusButton: NSButton {
let pasteboard = sender.draggingPasteboard let pasteboard = sender.draggingPasteboard
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
!urls.isEmpty else { !urls.isEmpty else {
setDropTargetActive(false)
return false return false
} }
setDropTargetActive(false)
appDelegate?.bounceIcon() appDelegate?.bounceIcon()
Task { @MainActor in Task { @MainActor in
@@ -357,18 +380,3 @@ class DropStatusButton: NSButton {
return true 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

Binary file not shown.

View File

@@ -30,6 +30,7 @@ type App struct {
docs *docs.Service docs *docs.Service
hub *realtime.Hub hub *realtime.Hub
server *http.Server server *http.Server
emailQueue *email.Queue
} }
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) { func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
@@ -69,13 +70,33 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
collabRepo := collaboration.NewRepository(db.SQL()) collabRepo := collaboration.NewRepository(db.SQL())
var emailSender collaboration.EmailSender var emailSender collaboration.EmailSender
if cfg.Email.SMTPHost != "" { var emailQueue *email.Queue
emailSender = email.NewSMTPSender(cfg.Email, logger) emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
authService.SetEmailSender(emailSender) switch cfg.Email.ResolvedProvider() {
} else { case "postmark":
emailSender = email.NewNoOpSender(logger) 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) { service.OnChange(func(change docs.DocumentChange) {
hub.Broadcast(realtime.Event{Type: "document_version", Data: change}) 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, docs: service,
hub: hub, hub: hub,
server: server, server: server,
emailQueue: emailQueue,
}, nil }, nil
} }
func (a *App) Run(ctx context.Context) error { func (a *App) Run(ctx context.Context) error {
go a.watchDocuments(ctx) go a.watchDocuments(ctx)
go a.syncPoll(ctx) go a.syncPoll(ctx)
if a.emailQueue != nil {
go a.emailQueue.Run(ctx)
}
go func() { go func() {
<-ctx.Done() <-ctx.Done()

View File

@@ -169,3 +169,7 @@ func hasScope(scopes []Scope, required Scope) bool {
} }
return false return false
} }
func HasScope(scopes []Scope, required Scope) bool {
return hasScope(scopes, required)
}

View File

@@ -6,6 +6,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"path"
"strings"
"time" "time"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
@@ -284,6 +286,349 @@ func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessU
return nil 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 { func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID) result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
if err != nil { if err != nil {

View File

@@ -190,6 +190,57 @@ func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID str
return s.repo.GetUserByID(ctx, user.ID) 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) { func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email)) user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
if err != nil { if err != nil {
@@ -409,6 +460,96 @@ func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates
return users, nil 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 { func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
if !Allows(actor, ScopeAdmin) { if !Allows(actor, ScopeAdmin) {
return fmt.Errorf("admin role required") 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 { func Allows(principal Principal, required Scope) bool {
if principal.UserID == "" { if principal.UserID == "" {
return false return false

View File

@@ -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) { func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
service := setupAuthTestService(t) service := setupAuthTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -343,3 +358,82 @@ func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
t.Fatalf("public signup role = %s, want viewer", viewer.Role) t.Fatalf("public signup role = %s, want viewer", viewer.Role)
} }
} }
func TestResourcePermissionsArePrivateByDefault(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
viewer, err := service.repo.UpsertUser(ctx, User{
Email: "viewer@example.com",
DisplayName: "Viewer",
Role: RoleViewer,
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "private/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() error = %v", err)
}
if permission != PermissionNone {
t.Fatalf("viewer permission = %q, want no access", permission)
}
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
permission, err = service.EffectiveResourcePermission(ctx, adminPrincipal, ResourceDocument, "private/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() admin error = %v", err)
}
if permission != PermissionAdmin {
t.Fatalf("admin permission = %q, want admin", permission)
}
}
func TestResourcePermissionsUseMostPermissiveGrant(t *testing.T) {
service := setupAuthTestService(t)
ctx := context.Background()
admin := setupInitialAdmin(t, service, true)
viewer, err := service.repo.UpsertUser(ctx, User{
Email: "viewer@example.com",
DisplayName: "Viewer",
Role: RoleViewer,
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour))
viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour))
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceDocument, "shared/page.md", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionRead},
}); err != nil {
t.Fatalf("grant document read: %v", err)
}
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceFolder, "shared", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionWrite},
}); err != nil {
t.Fatalf("grant folder write: %v", err)
}
permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "shared/page.md", nil)
if err != nil {
t.Fatalf("EffectiveResourcePermission() folder error = %v", err)
}
if permission != PermissionWrite {
t.Fatalf("folder/document combined permission = %q, want write", permission)
}
if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceCollection, "team", []ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: PermissionAdmin},
}); err != nil {
t.Fatalf("grant collection admin: %v", err)
}
permission, err = service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "other/page.md", []string{"team"})
if err != nil {
t.Fatalf("EffectiveResourcePermission() collection error = %v", err)
}
if permission != PermissionAdmin {
t.Fatalf("collection permission = %q, want admin", permission)
}
}

View File

@@ -24,6 +24,24 @@ const (
ScopeAdmin Scope = "admin" 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 { type Principal struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Email string `json:"email"` Email string `json:"email"`
@@ -54,6 +72,23 @@ type UserAccessUpdate struct {
Disabled bool `json:"disabled"` 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 { type PasswordResetToken struct {
ID string ID string
UserID string UserID string

View File

@@ -10,10 +10,46 @@ import (
"github.com/tim/cairnquire/apps/server/internal/realtime" "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 { type EmailSender interface {
Send(ctx context.Context, to []string, subject, body string) error 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{} type NoOpEmailSender struct{}
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error { 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 { type Service struct {
repo *Repository repo *Repository
email EmailSender email EmailSender
queue EmailQueue
renderer EmailRenderer
users UserLookup
hub *realtime.Hub hub *realtime.Hub
logger *slog.Logger 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 { func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
if email == nil { return NewServiceWithOptions(repo, hub, logger, Options{EmailSender: email})
email = &NoOpEmailSender{} }
// 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{ return &Service{
repo: repo, repo: repo,
email: email, email: opts.EmailSender,
queue: opts.EmailQueue,
renderer: opts.EmailRenderer,
users: opts.UserLookup,
hub: hub, hub: hub,
logger: logger, 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) 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 { func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error {
if principal.UserID == "" { if principal.UserID == "" {
return fmt.Errorf("authentication required") 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) s.logger.Warn("create notification", "error", err)
continue 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 // 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 { if err := s.repo.CreateNotification(ctx, notification); err != nil {
s.logger.Warn("create comment notification", "error", err) 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 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) { func (s *Service) broadcastNotification(userID string) {
if s.hub == nil { if s.hub == nil {
return return

View File

@@ -38,9 +38,31 @@ type AuthConfig struct {
} }
type EmailConfig 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"` SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"` SMTPPort int `json:"smtpPort"`
PostmarkToken string `json:"postmarkToken"`
PostmarkAPIURL string `json:"postmarkApiUrl"`
From string `json:"from"` From string `json:"from"`
InstanceName string `json:"instanceName"`
}
// ResolvedProvider returns the concrete provider choice after applying the
// inference rule. Operators can set Provider explicitly to force a choice.
func (e EmailConfig) ResolvedProvider() string {
if e.Provider != "" {
return e.Provider
}
if e.PostmarkToken != "" {
return "postmark"
}
if e.SMTPHost != "" {
return "smtp"
}
return "noop"
} }
func Default() Config { func Default() Config {
@@ -62,6 +84,7 @@ func Default() Config {
SMTPHost: "localhost", SMTPHost: "localhost",
SMTPPort: 1025, SMTPPort: 1025,
From: "notifications@cairnquire.local", From: "notifications@cairnquire.local",
InstanceName: "Cairnquire",
}, },
LogLevel: "INFO", LogLevel: "INFO",
} }
@@ -87,6 +110,10 @@ func Load() (Config, error) {
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR") overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN") overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN")
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST") 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 port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil { if p, err := strconv.Atoi(port); err == nil {
cfg.Email.SMTPPort = p cfg.Email.SMTPPort = p

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,6 +109,38 @@ func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, pat
return r.getDocumentByPath(ctx, path, true) 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) { func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) {
var ( var (
record DocumentRecord record DocumentRecord
@@ -416,6 +448,32 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
return results, rows.Err() return results, rows.Err()
} }
func (r *Repository) SearchDocumentsByTag(ctx context.Context, tag string) ([]SearchResult, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT d.path, d.title
FROM documents d
WHERE EXISTS (
SELECT 1 FROM json_each(d.tags) WHERE json_each.value = ?
) AND d.archived_at IS NULL
ORDER BY d.path
`, tag)
if err != nil {
return nil, fmt.Errorf("search documents by tag: %w", err)
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var result SearchResult
if err := rows.Scan(&result.Path, &result.Title); err != nil {
return nil, fmt.Errorf("scan tag search result: %w", err)
}
results = append(results, result)
}
return results, rows.Err()
}
func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error { func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error {
if _, err := r.db.ExecContext(ctx, ` if _, err := r.db.ExecContext(ctx, `
UPDATE document_search SET content = ? UPDATE document_search SET content = ?

View File

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

View File

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

View File

@@ -0,0 +1,141 @@
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
const (
defaultPostmarkURL = "https://api.postmarkapp.com"
postmarkTimeout = 15 * time.Second
)
// PostmarkSender sends email via the Postmark API.
type PostmarkSender struct {
serverToken string
apiURL string
from string
client *http.Client
logger *slog.Logger
}
// NewPostmarkSender constructs a Postmark sender. apiURL may be empty to use
// the public Postmark endpoint; tests can point it at an httptest server.
func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *PostmarkSender {
if apiURL == "" {
apiURL = defaultPostmarkURL
}
return &PostmarkSender{
serverToken: serverToken,
apiURL: strings.TrimRight(apiURL, "/"),
from: from,
client: &http.Client{Timeout: postmarkTimeout},
logger: logger,
}
}
type postmarkRequest struct {
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
Headers []postmarkHeader `json:"Headers,omitempty"`
}
type postmarkHeader struct {
Name string `json:"Name"`
Value string `json:"Value"`
}
type postmarkResponse struct {
ErrorCode int `json:"ErrorCode"`
Message string `json:"Message"`
To string `json:"To"`
}
// WithReplyTo sets a Reply-To header on the outgoing message. Returns a copy
// of the sender with the reply-to address applied for the next Send call.
// Not safe for concurrent use; callers should construct a fresh sender per
// send if they need different reply-to addresses.
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
if p.serverToken == "" {
return fmt.Errorf("postmark server token not configured")
}
if p.from == "" {
return fmt.Errorf("postmark from address not configured")
}
if len(to) == 0 {
return fmt.Errorf("postmark send requires at least one recipient")
}
payload := postmarkRequest{
From: p.from,
To: strings.Join(to, ","),
Subject: subject,
TextBody: body,
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal postmark request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiURL+"/email", bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("build postmark request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", p.serverToken)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("postmark request: %w", err)
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14))
if resp.StatusCode >= 500 {
return &TemporaryError{Cause: fmt.Errorf("postmark server error: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))
}
var parsed postmarkResponse
if err := json.Unmarshal(respBytes, &parsed); err != nil {
return fmt.Errorf("decode postmark response: %w", err)
}
if parsed.ErrorCode != 0 {
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
// 406 as non-retryable; others bubble up as generic failures.
return fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)
}
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
return nil
}
func truncate(s string) string {
if len(s) > 256 {
return s[:256] + "..."
}
return s
}
// TemporaryError wraps a failure that callers may retry. The queue worker
// uses errors.As to detect it and apply exponential backoff.
type TemporaryError struct {
Cause error
}
func (t *TemporaryError) Error() string { return t.Cause.Error() }
func (t *TemporaryError) Unwrap() error { return t.Cause }

View File

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

View File

@@ -0,0 +1,344 @@
package email
import (
"context"
"database/sql"
"fmt"
"log/slog"
"math"
"time"
)
// Queue stores outgoing emails durably and dispatches them via a Sender.
// It is safe for concurrent use; claim uses an atomic UPDATE ... WHERE
// status='pending' to lease rows without races.
type Queue struct {
db *sql.DB
sender Sender
logger *slog.Logger
pollInterval time.Duration
maxBackoff time.Duration
}
// QueueRow is the on-disk representation of a queued email.
type QueueRow struct {
ID string
NotificationID string
ToAddress string
Subject string
Body string
Status string
Attempts int
MaxAttempts int
LastError string
NextAttemptAt time.Time
SentAt *time.Time
CreatedAt time.Time
}
// NewQueue constructs a queue. sender may be nil; in that case emails stay
// pending forever (useful for tests that only inspect enqueue state).
func NewQueue(db *sql.DB, sender Sender, logger *slog.Logger) *Queue {
if logger == nil {
logger = slog.Default()
}
return &Queue{
db: db,
sender: sender,
logger: logger,
pollInterval: 10 * time.Second,
maxBackoff: 30 * time.Minute,
}
}
// SetPollInterval overrides the worker poll interval. Must be called before
// Run. Useful for tests.
func (q *Queue) SetPollInterval(d time.Duration) {
if d > 0 {
q.pollInterval = d
}
}
// SetMaxBackoff overrides the maximum retry backoff.
func (q *Queue) SetMaxBackoff(d time.Duration) {
if d > 0 {
q.maxBackoff = d
}
}
// Enqueue inserts a new outgoing email. notificationID may be empty. The
// email is immediately eligible for delivery (next_attempt_at = now).
func (q *Queue) Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error) {
if to == "" {
return "", fmt.Errorf("enqueue: to address is required")
}
if subject == "" {
return "", fmt.Errorf("enqueue: subject is required")
}
id, err := randomQueueID()
if err != nil {
return "", err
}
now := time.Now().UTC()
if _, err := q.db.ExecContext(ctx, `
INSERT INTO email_queue (id, notification_id, to_address, subject, body, status, attempts, max_attempts, next_attempt_at, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, 3, ?, ?)
`, id, nullableString(notificationID), to, subject, body, formatTime(now), formatTime(now)); err != nil {
return "", fmt.Errorf("enqueue email: %w", err)
}
return id, nil
}
// MarkNotificationEmailed stamps notifications.emailed_at for the given
// notification id. Called by the worker once the email is dispatched.
func (q *Queue) MarkNotificationEmailed(ctx context.Context, notificationID string) error {
if notificationID == "" {
return nil
}
_, err := q.db.ExecContext(ctx, `
UPDATE notifications SET emailed_at = ? WHERE id = ?
`, formatTime(time.Now().UTC()), notificationID)
if err != nil {
return fmt.Errorf("mark notification emailed: %w", err)
}
return nil
}
// Run starts the worker loop. It blocks until ctx is cancelled.
func (q *Queue) Run(ctx context.Context) {
q.logger.Info("email queue worker started", "poll_interval", q.pollInterval)
ticker := time.NewTicker(q.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
q.logger.Info("email queue worker stopped")
return
case <-ticker.C:
if err := q.processBatch(ctx); err != nil {
q.logger.Warn("email queue batch failed", "error", err)
}
}
}
}
// processBatch claims and sends up to 25 pending emails per tick.
func (q *Queue) processBatch(ctx context.Context) error {
if q.sender == nil {
// No sender configured; leave rows pending so a future sender can
// pick them up once wired.
return nil
}
rows, err := q.claimBatch(ctx, 25)
if err != nil {
return fmt.Errorf("claim batch: %w", err)
}
for _, row := range rows {
if err := q.deliver(ctx, row); err != nil {
q.logger.Warn("deliver email failed", "id", row.ID, "error", err)
}
}
return nil
}
// claimBatch atomically leases pending rows by flipping their status to
// 'pending' but bumping attempts so a concurrent worker cannot re-claim
// them until the next_attempt_at expires. We use a single UPDATE with a
// subquery to keep this race-free under SQLite.
func (q *Queue) claimBatch(ctx context.Context, limit int) ([]QueueRow, error) {
tx, err := q.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin claim: %w", err)
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
FROM email_queue
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY next_attempt_at ASC
LIMIT ?
`, formatTime(time.Now().UTC()), limit)
if err != nil {
return nil, fmt.Errorf("query pending: %w", err)
}
var claimed []QueueRow
for rows.Next() {
row, err := scanQueueRow(rows)
if err != nil {
rows.Close()
return nil, err
}
claimed = append(claimed, row)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for _, row := range claimed {
if _, err := tx.ExecContext(ctx, `
UPDATE email_queue SET attempts = attempts + 1 WHERE id = ?
`, row.ID); err != nil {
return nil, fmt.Errorf("lease row %s: %w", row.ID, err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit claim: %w", err)
}
return claimed, nil
}
// deliver attempts one email. On success it marks the row sent and stamps
// the linked notification. On failure it schedules a retry with exponential
// backoff or marks the row failed if attempts are exhausted.
func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
err := q.sender.Send(ctx, []string{row.ToAddress}, row.Subject, row.Body)
if err == nil {
if _, err := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'sent', sent_at = ?, last_error = '' WHERE id = ?
`, formatTime(time.Now().UTC()), row.ID); err != nil {
return fmt.Errorf("mark sent: %w", err)
}
if err := q.MarkNotificationEmailed(ctx, row.NotificationID); err != nil {
q.logger.Warn("mark notification emailed", "id", row.NotificationID, "error", err)
}
return nil
}
// Failure: retry or give up.
if row.Attempts >= row.MaxAttempts {
if _, ferr := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil {
return fmt.Errorf("mark failed: %w", ferr)
}
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", row.Attempts, "error", err)
return nil
}
backoff := q.backoff(row.Attempts)
next := time.Now().UTC().Add(backoff)
if _, err := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
`, formatTime(next), truncateErr(err.Error()), row.ID); err != nil {
return fmt.Errorf("schedule retry: %w", err)
}
// TemporaryError (Postmark 5xx) is retried with the same backoff as
// other errors; the max-attempts cap still bounds total work.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err)
return nil
}
func (q *Queue) backoff(attempts int) time.Duration {
// 2^attempts seconds, capped: 2s, 4s, 8s, 16s, ... up to maxBackoff.
d := time.Duration(math.Pow(2, float64(attempts))) * time.Second
if d > q.maxBackoff {
d = q.maxBackoff
}
if d < time.Second {
d = time.Second
}
return d
}
// PendingCount returns the number of rows in pending status, primarily for
// tests and operator dashboards.
func (q *Queue) PendingCount(ctx context.Context) (int, error) {
var count int
err := q.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM email_queue WHERE status = 'pending'`).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
// ListFailed returns rows that have exhausted their retries, for operator
// inspection.
func (q *Queue) ListFailed(ctx context.Context, limit int) ([]QueueRow, error) {
if limit <= 0 {
limit = 50
}
rows, err := q.db.QueryContext(ctx, `
SELECT id, COALESCE(notification_id, ''), to_address, subject, body, status, attempts, max_attempts,
COALESCE(last_error, ''), next_attempt_at, COALESCE(sent_at, ''), created_at
FROM email_queue
WHERE status = 'failed'
ORDER BY created_at DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []QueueRow
for rows.Next() {
row, err := scanQueueRow(rows)
if err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func scanQueueRow(rows *sql.Rows) (QueueRow, error) {
var r QueueRow
var sentAt, lastError string
var nextAttempt, createdAt string
if err := rows.Scan(&r.ID, &r.NotificationID, &r.ToAddress, &r.Subject, &r.Body, &r.Status,
&r.Attempts, &r.MaxAttempts, &lastError, &nextAttempt, &sentAt, &createdAt); err != nil {
return QueueRow{}, fmt.Errorf("scan queue row: %w", err)
}
r.LastError = lastError
var err error
r.NextAttemptAt, err = time.Parse(time.RFC3339, nextAttempt)
if err != nil {
return QueueRow{}, fmt.Errorf("parse next_attempt_at: %w", err)
}
r.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
if err != nil {
return QueueRow{}, fmt.Errorf("parse created_at: %w", err)
}
if sentAt != "" {
t, err := time.Parse(time.RFC3339, sentAt)
if err == nil {
r.SentAt = &t
}
}
return r, nil
}
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
func truncateErr(s string) string {
if len(s) > 512 {
return s[:512] + "..."
}
return s
}
// randomQueueID produces an id with enough entropy to avoid collisions. We
// keep a small helper in-package to avoid an import cycle on auth.
func randomQueueID() (string, error) {
id, err := randomToken(18)
if err != nil {
return "", err
}
return "email:" + id, nil
}

View File

@@ -0,0 +1,248 @@
package email
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
cfg := config.DatabaseConfig{Path: t.TempDir() + "/test.sqlite"}
handle, err := database.Open(context.Background(), cfg)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
db := handle.SQL()
if err := database.ApplyMigrations(context.Background(), db); err != nil {
t.Fatalf("migrate: %v", err)
}
// Seed a user + notification so the queue's notification_id FK is
// satisfiable and MarkNotificationEmailed can update a real row.
_, err = db.Exec(`INSERT INTO users (id, email, display_name, role, created_at, last_seen_at) VALUES ('user:alice@example.com', 'alice@example.com', 'Alice', 'admin', ?, ?)`,
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Fatalf("seed user: %v", err)
}
_, err = db.Exec(`INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, created_at) VALUES (?, 'user:alice@example.com', 'file_changed', 'document', 'doc:test', 'test', ?)`,
"notif:test-1", time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Fatalf("seed notification: %v", err)
}
return db
}
func TestQueue_EnqueueAndPendingCount(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Subject", "Body")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
if id == "" {
t.Fatal("expected non-empty id")
}
count, err := q.PendingCount(context.Background())
if err != nil {
t.Fatalf("PendingCount: %v", err)
}
if count != 1 {
t.Errorf("pending = %d, want 1", count)
}
}
func TestQueue_EnqueueValidation(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
// Empty notification_id is allowed (the column is nullable).
if _, err := q.Enqueue(context.Background(), "", "alice@example.com", "s", "b"); err != nil {
t.Fatalf("empty notification_id should be allowed: %v", err)
}
if _, err := q.Enqueue(context.Background(), "notif:test-1", "", "s", "b"); err == nil {
t.Fatal("expected error for empty to")
}
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "", "b"); err == nil {
t.Fatal("expected error for empty subject")
}
}
type recordingSender struct {
sends []recordedSend
err error
}
type recordedSend struct {
to []string
subject string
body string
}
func (r *recordingSender) Send(ctx context.Context, to []string, subject, body string) error {
if r.err != nil {
return r.err
}
r.sends = append(r.sends, recordedSend{to: to, subject: subject, body: body})
return nil
}
func TestQueue_DeliverSuccess(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(20 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Hello", "World"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
n, _ := q.PendingCount(ctx)
return n == 0
}); err != nil {
t.Fatalf("email never sent: %v", err)
}
if len(sender.sends) != 1 {
t.Fatalf("sends = %d, want 1", len(sender.sends))
}
if sender.sends[0].subject != "Hello" {
t.Errorf("subject = %q", sender.sends[0].subject)
}
if sender.sends[0].body != "World" {
t.Errorf("body = %q", sender.sends[0].body)
}
// The linked notification should now have emailed_at stamped.
var emailedAt string
if err := db.QueryRow(`SELECT COALESCE(emailed_at, '') FROM notifications WHERE id = ?`, "notif:test-1").Scan(&emailedAt); err != nil {
t.Fatalf("query emailed_at: %v", err)
}
if emailedAt == "" {
t.Error("expected notifications.emailed_at to be set after send")
}
}
func TestQueue_RetryThenSucceed(t *testing.T) {
db := newTestDB(t)
// Fail twice, then succeed.
sender := &flakySender{failTimes: 2}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
q.SetMaxBackoff(50 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Retry", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
return sender.successCount >= 1
}); err != nil {
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err)
}
// Verify the row is marked sent.
var status string
if err := db.QueryRow(`SELECT status FROM email_queue WHERE to_address = ?`, "alice@example.com").Scan(&status); err != nil {
t.Fatalf("query status: %v", err)
}
if status != "sent" {
t.Errorf("status = %q, want sent", status)
}
}
func TestQueue_PermanentFailure(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{err: errors.New("permanent Postmark rejection")}
q := NewQueue(db, sender, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
q.SetMaxBackoff(20 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
failed, _ := q.ListFailed(ctx, 10)
return len(failed) == 1
}); err != nil {
failed, _ := q.ListFailed(ctx, 10)
t.Fatalf("expected 1 failed row, got %d: %v", len(failed), err)
}
failed, _ := q.ListFailed(ctx, 10)
if !strings.Contains(failed[0].LastError, "permanent") {
t.Errorf("last_error = %q", failed[0].LastError)
}
}
func TestQueue_NilSenderLeavesPending(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
q.SetPollInterval(15 * time.Millisecond)
if _, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Pending", "Body"); err != nil {
t.Fatalf("Enqueue: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
go q.Run(ctx)
<-ctx.Done()
count, _ := q.PendingCount(context.Background())
if count != 1 {
t.Errorf("expected row to stay pending with nil sender, got %d", count)
}
}
// flakySender fails the first N attempts then succeeds.
type flakySender struct {
failTimes int
attempts int
successCount int
}
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
f.attempts++
if f.attempts <= f.failTimes {
return errors.New("transient failure")
}
f.successCount++
return nil
}
func waitFor(ctx context.Context, cond func() bool) error {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if cond() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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) { func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
server := newAPITestServer(t) server := newAPITestServer(t)
sender := &testEmailSender{} 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 { func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper() 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 { if err != nil {
t.Fatalf("create api token: %v", err) t.Fatalf("create api token: %v", err)
} }
return created.Token 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) { func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
server := newAPITestServer(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")) body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body) 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) { func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
server := newAPITestServer(t) server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) 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) { func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
server := newAPITestServer(t) server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
@@ -575,10 +754,12 @@ func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) { func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
server := newAPITestServer(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() documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil)) server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK { if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", 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) { func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
server := newAPITestServer(t) server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background() ctx := context.Background()
nestedDir := filepath.Join(server.root, "content", "guide") 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()) 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() documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil)) server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK { if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", 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) { func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
server := newAPITestServer(t) server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background() ctx := context.Background()
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil { 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()) 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() documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil)) server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK { if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK) t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
} }

View File

@@ -334,6 +334,42 @@ func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)}) 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) { func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) { if !s.allowAuthAttempt(w, r) {
return return

View File

@@ -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"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document_id is required"})
return 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") anchorHash := r.URL.Query().Get("anchor_hash")
var comments []collaboration.Comment var comments []collaboration.Comment
var err error
if anchorHash != "" { if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash) comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
} else { } 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"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return 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) comment, err := s.collaboration.CreateComment(r.Context(), principal, input)
if err != nil { if err != nil {
@@ -63,6 +90,25 @@ func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) {
} }
commentID := chi.URLParam(r, "id") 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 { if err := s.collaboration.ResolveComment(r.Context(), principal, commentID); err != nil {
s.logger.Warn("resolve comment", "error", err) s.logger.Warn("resolve comment", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})

View File

@@ -45,7 +45,10 @@ type documentData struct {
Hash string Hash string
HTML template.HTML HTML template.HTML
Breadcrumbs []breadcrumb Breadcrumbs []breadcrumb
CanRead bool
CanWrite bool CanWrite bool
CanAdmin bool
CanComment bool
} }
type documentEditData struct { type documentEditData struct {
@@ -71,10 +74,12 @@ type errorData struct {
type browserData struct { type browserData struct {
Columns []browserColumn Columns []browserColumn
CanWrite bool
} }
type browserColumn struct { type browserColumn struct {
Title string Title string
Path string
Items []browserItem Items []browserItem
} }
@@ -95,19 +100,32 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
return return
} }
tag := r.URL.Query().Get("tag")
if tag != "" {
s.handleTagPage(w, r, tag)
return
}
items, err := s.documents.ListDocuments(r.Context()) items, err := s.documents.ListDocuments(r.Context())
if err != nil { if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error()) s.renderError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/") activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
browser := buildBrowser(items, activeFolder)
browser.CanWrite = s.canWriteFolder(r, activeFolder)
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire", Title: "Cairnquire",
BodyClass: "page-index", BodyClass: "page-index",
BodyTemplate: "index_content", BodyTemplate: "index_content",
Data: indexData{ 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 { if err != nil {
s.logger.Warn("search failed", "error", err) s.logger.Warn("search failed", "error", err)
} }
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query, 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) { func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
s.renderDocumentPage(w, r, "") 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()) s.renderError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canWrite {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
items, err := s.documents.ListDocuments(r.Context()) items, err := s.documents.ListDocuments(r.Context())
if err != nil { if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error()) s.renderError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite := s.canWriteDocuments(r) browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title, Title: "Edit: " + page.Title,
BodyClass: "page-document-edit", BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content", BodyTemplate: "document_edit_content",
Data: documentEditData{ Data: documentEditData{
Browser: buildBrowser(items, page.Path), Browser: browser,
Title: page.Title, Title: page.Title,
Path: page.Path, Path: page.Path,
Tags: page.Tags, Tags: page.Tags,
@@ -256,28 +331,60 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
s.renderError(w, r, http.StatusInternalServerError, err.Error()) s.renderError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canRead, err := s.canReadDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canRead {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canAdmin, err := s.canAdminDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err := s.documents.ListDocuments(r.Context()) items, err := s.documents.ListDocuments(r.Context())
if err != nil { if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error()) s.renderError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite := s.canWriteDocuments(r) browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{ s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title, Title: page.Title,
BodyClass: "page-document", BodyClass: "page-document",
BodyTemplate: "document_content", BodyTemplate: "document_content",
Data: documentData{ Data: documentData{
Browser: buildBrowser(items, page.Path), Browser: browser,
Title: page.Title, Title: page.Title,
Path: page.Path, Path: page.Path,
Tags: page.Tags, Tags: page.Tags,
Hash: page.Hash, Hash: page.Hash,
HTML: template.HTML(page.HTML), HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path), Breadcrumbs: buildBreadcrumbs(page.Path),
CanRead: canRead,
CanWrite: canWrite, 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) { 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") query := r.URL.Query().Get("q")
if query == "" { if query == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}}) writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return 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 { if err != nil {
s.logger.Warn("search failed", "error", err) s.logger.Warn("search failed", "error", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}}) writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return return
} }
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.logger.Warn("filter search failed", "error", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results}) writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
} }
@@ -421,7 +542,17 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document") s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
return return
} }
createdDocument := existing == nil
if 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 { switch duplicateAction {
case "overwrite": case "overwrite":
case "rename": 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") s.renderError(w, r, http.StatusInternalServerError, "choose document name")
return return
} }
createdDocument = true
case "reuse": case "reuse":
writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path)) writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path))
return 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)) writeUploadConflict(w, "document", existing.CurrentHash, existing.Path, documentPageURL(existing.Path))
return 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), "") page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
if err != nil { if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document") s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
return 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 documentPath = page.Path
uploadURL = documentPageURL(page.Path) uploadURL = documentPageURL(page.Path)
uploadKind = "document" uploadKind = "document"
@@ -643,6 +784,14 @@ func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
uploadURL := "/attachments/" + record.Hash uploadURL := "/attachments/" + record.Hash
uploadKind := "attachment" uploadKind := "attachment"
if record.DocumentPath != "" { 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) uploadURL = documentPageURL(record.DocumentPath)
uploadKind = "document" 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()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
records, err = s.filterReadableDocuments(r, records)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
results := make([]map[string]any, 0, len(records)) results := make([]map[string]any, 0, len(records))
for _, record := range records { for _, record := range records {
@@ -674,6 +828,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
"path": record.Path, "path": record.Path,
"title": record.Title, "title": record.Title,
"hash": record.CurrentHash, "hash": record.CurrentHash,
"tags": record.Tags,
}) })
} }
@@ -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"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return return
} }
createdDocument := false
if existing, err := s.documentRecordForRequestPath(r, pagePath); err == nil && existing != nil {
canWrite, err := s.canWriteDocumentRecord(r, *existing)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
} else if errors.Is(err, sql.ErrNoRows) {
createdDocument = true
if !s.canWriteNewDocument(r, pagePath) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
} else if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash) page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
if err != nil { if err != nil {
@@ -739,6 +915,12 @@ func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request,
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
if createdDocument {
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "grant document owner permission"})
return
}
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{ writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "saved", "status": "saved",
@@ -758,19 +940,29 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload) _ = 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) { func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" { if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return return
} }
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 err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) { 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"}) writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return 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 err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -845,16 +1042,39 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData
for _, prefix := range prefixes { for _, prefix := range prefixes {
column := browserColumn{ column := browserColumn{
Title: columnTitle(prefix), Title: columnTitle(prefix),
Path: prefix,
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath), Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
} }
if len(column.Items) > 0 {
columns = append(columns, column) columns = append(columns, column)
} }
}
return browserData{Columns: columns} return browserData{Columns: columns}
} }
func buildTagBrowser(results []docs.SearchResult, tag string) browserData {
items := make([]browserItem, 0, len(results))
for _, result := range results {
items = append(items, browserItem{
Name: displayDocumentName(result.Path, result.Title),
Path: result.Path,
URL: "/docs/" + strings.TrimSuffix(result.Path, ".md"),
})
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
return browserData{
Columns: []browserColumn{
{
Title: "#" + tag,
Path: "",
Items: items,
},
},
}
}
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem { func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
seenFolders := make(map[string]browserItem) seenFolders := make(map[string]browserItem)
files := make([]browserItem, 0) files := make([]browserItem, 0)

View File

@@ -14,6 +14,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/docs"
@@ -211,8 +212,13 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath) t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
} }
listRequest := httptest.NewRequest(http.MethodGet, "/api/attachments", nil)
listRequest = listRequest.WithContext(withPrincipal(listRequest.Context(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}))
listRecorder := httptest.NewRecorder() listRecorder := httptest.NewRecorder()
server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil)) server.handleAttachmentsList(listRecorder, listRequest)
var listPayload struct { var listPayload struct {
Attachments []struct { Attachments []struct {
Kind string `json:"kind"` Kind string `json:"kind"`
@@ -233,6 +239,17 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) { if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, 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() sourceDir := t.TempDir()
repository := docs.NewRepository(db) repository := docs.NewRepository(db)
documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default()) 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{ return &Server{
documents: documents, documents: documents,
repository: repository, repository: repository,
contentStore: contentStore, contentStore: contentStore,
auth: authService,
}, sourceDir }, sourceDir
} }
@@ -412,6 +443,10 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde
request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body) request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
request.Header.Set("Content-Type", writer.FormDataContentType()) request.Header.Set("Content-Type", writer.FormDataContentType())
request = request.WithContext(withPrincipal(request.Context(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}))
return request return request
} }

View File

@@ -67,10 +67,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return return
} }
if !ok { 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"}) writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return return
} }
if !auth.Allows(principal, required) { if !allowsProtectedRoute(principal, required) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return 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 { func (s *Server) setupMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context()) settings, err := s.auth.GetInstanceSettings(r.Context())
@@ -151,8 +169,12 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
path := r.URL.Path path := r.URL.Path
method := r.Method method := r.Method
switch { 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/"): 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 auth.ScopeDocsRead, true
} }
return "", false return "", false
@@ -169,6 +191,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeAdmin, true return auth.ScopeAdmin, true
case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet: case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet:
return auth.ScopeDocsWrite, true 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"): case strings.HasPrefix(path, "/api/uploads"):
return auth.ScopeDocsWrite, true return auth.ScopeDocsWrite, true
case path == "/api/attachments": case path == "/api/attachments":
@@ -186,6 +212,8 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeDocsRead, true return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/watch"): case strings.HasPrefix(path, "/api/watch"):
return auth.ScopeDocsRead, true return auth.ScopeDocsRead, true
case path == "/docs" || strings.HasPrefix(path, "/docs/"):
return auth.ScopeDocsRead, true
case strings.HasSuffix(path, "/edit"): case strings.HasSuffix(path, "/edit"):
return auth.ScopeDocsWrite, true return auth.ScopeDocsWrite, true
default: default:

View File

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

View File

@@ -107,6 +107,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/password-reset", server.handlePasswordResetPage) router.Get("/password-reset", server.handlePasswordResetPage)
router.Get("/account", server.handleAccountPage) router.Get("/account", server.handleAccountPage)
router.Get("/account/tokens/new", server.handleTokenCreatePage) 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("/device/verify", server.handleDeviceVerifyPage)
router.Get("/health", server.handleHealth) router.Get("/health", server.handleHealth)
router.Get("/healthz", server.handleHealthz) 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/logout", server.handleLogout)
router.Post("/api/auth/password/change", server.handleChangePassword) router.Post("/api/auth/password/change", server.handleChangePassword)
router.Delete("/api/auth/account", server.handleDeleteAccount) 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/begin", server.handlePasskeyRegisterBegin)
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish) router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin) 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.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentMutation) router.Post("/api/documents/*", server.handleDocumentMutation)
router.Get("/api/search", server.handleSearch) router.Get("/api/search", server.handleSearch)
router.Get("/api/permissions", server.handlePermissionsAPI)
router.Patch("/api/permissions", server.handlePermissionsAPI)
router.Post("/api/uploads", server.handleUpload) router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList) router.Get("/api/attachments", server.handleAttachmentsList)
router.Get("/attachments/{hash}", server.handleAttachment) router.Get("/attachments/{hash}", server.handleAttachment)

View File

@@ -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 () => { document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
try { try {
await postJSON("/api/auth/logout", {}); await postJSON("/api/auth/logout", {});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because one or more lines are too long

View File

@@ -3,15 +3,73 @@
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath; const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash; 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 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; 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) { function hashParagraph(el) {
const text = (el.textContent || '').trim(); const text = (el.textContent || '').trim();
if (!text) return null; if (!text) return null;
// Simple hash: first 16 chars of base64 of char codes (deterministic)
let hash = 0; let hash = 0;
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i); const char = text.charCodeAt(i);
@@ -24,29 +82,46 @@
function addCommentButton(el, hash) { function addCommentButton(el, hash) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'comment-anchor-btn'; btn.className = 'comment-anchor-btn';
btn.type = 'button';
btn.title = 'Add comment'; 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)); btn.addEventListener('click', () => promptComment(el, hash));
el.appendChild(btn); 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) { async function promptComment(el, anchorHash) {
const text = window.prompt('Comment on this paragraph:'); const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return; if (!text || !text.trim()) return;
try { try {
const res = await fetch('/api/comments', { const res = await fetch('/api/comments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
documentId,
versionHash: documentHash,
content: text.trim(),
anchorHash,
}),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
// Refresh comments display
await loadCommentsForAnchor(el, anchorHash); await loadCommentsForAnchor(el, anchorHash);
} catch (err) { } catch (err) {
window.alert('Failed to post comment: ' + err.message); window.alert('Failed to post comment: ' + err.message);
@@ -65,11 +140,17 @@
} }
function renderComments(el, comments) { function renderComments(el, comments) {
// Remove existing comment list
const existing = el.querySelector('.comment-list'); const existing = el.querySelector('.comment-list');
if (existing) existing.remove(); 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'); const list = document.createElement('div');
list.className = 'comment-list'; list.className = 'comment-list';
@@ -77,15 +158,174 @@
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'comment-item'; item.className = 'comment-item';
item.innerHTML = ` item.innerHTML = `
<div class="comment-meta"> <div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
<time>${formatDate(c.createdAt)}</time>
</div>
<div class="comment-body">${escapeHtml(c.content)}</div> <div class="comment-body">${escapeHtml(c.content)}</div>
`; `;
list.appendChild(item); list.appendChild(item);
}); });
el.appendChild(list); 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) { function escapeHtml(str) {
@@ -99,7 +339,45 @@
return d.toLocaleString(); 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() { function init() {
const body = document.querySelector('.markdown-body'); const body = document.querySelector('.markdown-body');
if (!body) return; if (!body) return;
@@ -110,9 +388,21 @@
if (!hash) return; if (!hash) return;
el.dataset.anchorHash = hash; el.dataset.anchorHash = hash;
el.classList.add('commentable'); el.classList.add('commentable');
addCommentButton(el, hash); if (canComment) addCommentButton(el, hash);
loadCommentsForAnchor(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') { if (document.readyState === 'loading') {

View File

@@ -11,15 +11,14 @@
var conflictApply = document.querySelector("[data-conflict-apply]"); var conflictApply = document.querySelector("[data-conflict-apply]");
var conflictDismiss = document.querySelector("[data-conflict-dismiss]"); var conflictDismiss = document.querySelector("[data-conflict-dismiss]");
var conflictResolution = document.querySelector("[data-conflict-resolution]"); 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 conflictUseServer = document.querySelector("[data-conflict-use='server']");
var conflictUseLocal = document.querySelector("[data-conflict-use='local']"); var conflictUseLocal = document.querySelector("[data-conflict-use='local']");
var conflictUseBoth = document.querySelector("[data-conflict-use='both']"); var conflictUseBoth = document.querySelector("[data-conflict-use='both']");
var monacoContainer = document.querySelector("[data-monaco-mount]"); var codeMirrorContainer = document.querySelector("[data-codemirror-mount]");
var monacoLoaded = false; var codeMirrorReadyPromise = null;
var monacoReadyPromise = null; var codeMirrorModules = null;
var monacoDarkTheme = "cairnquire-dark"; var documentCompletionOptionsPromise = null;
var monacoLightTheme = "cairnquire-light";
if (!form || !textarea || statusNodes.length === 0) { if (!form || !textarea || statusNodes.length === 0) {
return; return;
@@ -36,6 +35,9 @@
var conflictDiffView = null; var conflictDiffView = null;
var resolutionEditor = null; var resolutionEditor = null;
var suppressResolutionEvents = false; var suppressResolutionEvents = false;
var doneLink = document.querySelector("[data-done-link]");
var isSaving = false;
var suppressBeforeUnload = false;
function setStatus(nextStatus) { function setStatus(nextStatus) {
statusNodes.forEach(function (node) { statusNodes.forEach(function (node) {
@@ -223,17 +225,20 @@
var value = getEditorValue(); var value = getEditorValue();
if (value === lastSavedValue) { if (value === lastSavedValue) {
setStatus("Saved"); setStatus("Saved");
return; return "unchanged";
} }
isSaving = true;
hideConflict(); hideConflict();
setStatus("Saving"); setStatus("Saving");
if (!window.MDHubSync) { if (!window.MDHubSync) {
isSaving = false;
setStatus("Queued"); setStatus("Queued");
return; return "queued";
} }
try {
var result = await window.MDHubSync.saveDocument(path, value, baseHash); var result = await window.MDHubSync.saveDocument(path, value, baseHash);
if (result.status === "saved") { if (result.status === "saved") {
lastSavedValue = value; lastSavedValue = value;
@@ -245,15 +250,24 @@
} }
} }
setStatus("Saved"); setStatus("Saved");
return; isSaving = false;
return "saved";
} }
if (result.status === "conflict") { if (result.status === "conflict") {
showConflict(result.conflict, value); showConflict(result.conflict, value);
return; isSaving = false;
return "conflict";
} }
setStatus("Queued"); setStatus("Queued");
return "queued";
} catch (err) {
setStatus("Queued");
throw err;
} finally {
isSaving = false;
}
} }
async function applyResolvedConflict() { async function applyResolvedConflict() {
@@ -317,138 +331,339 @@
}, 2000); }, 2000);
} }
function loadMonaco() { function doneHref() {
if (monacoReadyPromise) { return doneLink ? doneLink.getAttribute("href") : "/docs/" + path.replace(/\.md$/, "");
return monacoReadyPromise; }
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) { function saveThenLeaveEditor() {
var script = document.createElement("script"); var href = doneHref();
script.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js";
script.onload = function () { if (currentConflict) {
require.config({ setStatus("Conflict");
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs" }, 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", function loadCodeMirror() {
inherit: true, if (codeMirrorReadyPromise) {
rules: [ return codeMirrorReadyPromise;
{ token: "comment", foreground: "8B9DAF", fontStyle: "italic" }, }
{ token: "keyword", foreground: "E8943A" },
{ token: "string", foreground: "C9A96E" }, codeMirrorReadyPromise = Promise.all([
{ token: "number", foreground: "C9A96E" }, import("/static/codemirror.bundle.js"),
{ token: "type", foreground: "E8943A" }, ]).then(function (modules) {
{ token: "tag", foreground: "E8943A" }, var bundle = modules[0];
{ token: "attribute.name", foreground: "D4A05A" }, codeMirrorModules = {
{ token: "attribute.value", foreground: "C9A96E" }, cm: bundle,
{ token: "delimiter", foreground: "B0B8C4" }, markdown: bundle,
{ token: "variable", foreground: "D4D8DE" }, vim: bundle,
], };
colors: { registerVimCommands();
"editor.background": "#1E1E1E", return codeMirrorModules;
"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",
},
}); });
monaco.editor.defineTheme(monacoLightTheme, { return codeMirrorReadyPromise;
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",
},
});
if (monacoContainer) { function registerVimCommands() {
editor = monaco.editor.create(monacoContainer, { var Vim = codeMirrorModules && codeMirrorModules.vim && codeMirrorModules.vim.Vim;
value: textarea.value, if (!Vim) {
language: "markdown", return;
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,
});
textarea.hidden = true; Vim.defineEx("write", "w", function () {
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 () {
saveNow().catch(function () { saveNow().catch(function () {
setStatus("Queued"); 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)"); function codeMirrorTheme() {
darkMQ.addEventListener("change", function (e) { var EditorView = codeMirrorModules.cm.EditorView;
monaco.editor.setTheme(e.matches ? monacoDarkTheme : monacoLightTheme); 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() { function ensureResolutionEditor() {
@@ -456,54 +671,42 @@
return; return;
} }
loadMonaco().then(function () { loadCodeMirror().then(function () {
if (!window.monaco || resolutionEditor) { if (resolutionEditor) {
return; return;
} }
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; resolutionEditor = createCodeMirrorEditor(conflictResolutionMount, conflictResolution.value || resolvedConflictContent, {
resolutionEditor = monaco.editor.create(conflictResolutionMount, { onChange: function (value) {
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 () {
if (suppressResolutionEvents) { if (suppressResolutionEvents) {
return; return;
} }
resolvedConflictContent = resolutionEditor.getValue(); resolvedConflictContent = value;
conflictResolution.value = resolvedConflictContent; conflictResolution.value = value;
if (conflictApply) { if (conflictApply) {
conflictApply.disabled = false; conflictApply.disabled = false;
} }
setConflictMessage("Manual resolution updated. Apply it to save the resolved document."); setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
},
}); });
conflictResolution.hidden = true;
resolutionEditor.focus(); resolutionEditor.focus();
}).catch(function () { }).catch(function () {
conflictResolution.hidden = false; conflictResolution.hidden = false;
}); });
} }
if (monacoContainer) { if (codeMirrorContainer) {
loadMonaco().catch(function () { 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); textarea.addEventListener("input", scheduleSave);
}); });
} else { } else {
@@ -571,9 +774,72 @@
restorePendingConflict(); 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) { document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") { if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault(); 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

View 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);
})();

View 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');
}
});
});
})();

View 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;
});
});
})();

View File

@@ -144,7 +144,7 @@
const html = document.documentElement.outerHTML; const html = document.documentElement.outerHTML;
const title = document.title; 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 () {}); window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
} }
@@ -373,4 +373,72 @@
}).catch(function () {}); }).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

View File

@@ -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_NAME = "md-hub-cache";
const DB_VERSION = 3; const DB_VERSION = 3;
const STORE_PAGES = "pages"; const STORE_PAGES = "pages";
@@ -8,7 +8,10 @@ const STATIC_ASSETS = [
"/static/sync.js", "/static/sync.js",
"/static/realtime.js", "/static/realtime.js",
"/static/editor.js", "/static/editor.js",
"/static/codemirror.bundle.js",
"/static/render.js", "/static/render.js",
"/static/keyboard-nav.js",
"/static/tags.js",
"/static/favicon.png", "/static/favicon.png",
"/static/cairnquire%20logo%402x.webp", "/static/cairnquire%20logo%402x.webp",
"/", "/",
@@ -64,12 +67,13 @@ self.addEventListener("fetch", function (event) {
}); });
async function handleNavigation(request, url) { async function handleNavigation(request, url) {
const cacheKey = url.pathname + url.search;
try { try {
const response = await fetch(request); const response = await fetch(request);
cachePageResponse(url.pathname, response.clone()); cachePageResponse(cacheKey, response.clone());
return response; return response;
} catch (_error) { } catch (_error) {
const cachedPage = await getCachedPage(url.pathname); const cachedPage = await getCachedPage(cacheKey);
if (cachedPage && cachedPage.html) { if (cachedPage && cachedPage.html) {
return new Response(markOfflineHTML(cachedPage.html), { return new Response(markOfflineHTML(cachedPage.html), {
headers: { 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("/"); const fallback = await caches.match("/");
if (fallback) { if (fallback) {
return fallback; return fallback;

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();

View File

@@ -1,6 +1,7 @@
package httpserver package httpserver
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@@ -29,7 +30,12 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
s.logger.Error("sync init failed", "error", err) s.logger.Error("sync init failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"}) 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 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 err != nil {
if errors.Is(err, sync.ErrForbidden) { if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
@@ -100,7 +111,12 @@ func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
return 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 err != nil {
if errors.Is(err, sync.ErrForbidden) { if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) 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.WriteHeader(http.StatusOK)
w.Write(content) 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 }
}

View File

@@ -66,6 +66,20 @@
</div> </div>
<aside class="account-sidebar"> <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"> <section class="account-section">
<header class="account-section__header"> <header class="account-section__header">
<div> <div>

View File

@@ -62,6 +62,8 @@
{{ template "document_edit_content" .Data }} {{ template "document_edit_content" .Data }}
{{ else if eq .BodyTemplate "search_content" }} {{ else if eq .BodyTemplate "search_content" }}
{{ template "search_content" .Data }} {{ template "search_content" .Data }}
{{ else if eq .BodyTemplate "tag_content" }}
{{ template "tag_content" .Data }}
{{ else if eq .BodyTemplate "login_content" }} {{ else if eq .BodyTemplate "login_content" }}
{{ template "login_content" .Data }} {{ template "login_content" .Data }}
{{ else if eq .BodyTemplate "setup_content" }} {{ else if eq .BodyTemplate "setup_content" }}
@@ -72,6 +74,8 @@
{{ template "account_content" .Data }} {{ template "account_content" .Data }}
{{ else if eq .BodyTemplate "token_create_content" }} {{ else if eq .BodyTemplate "token_create_content" }}
{{ template "token_create_content" .Data }} {{ template "token_create_content" .Data }}
{{ else if eq .BodyTemplate "permissions_content" }}
{{ template "permissions_content" .Data }}
{{ else if eq .BodyTemplate "device_verify_content" }} {{ else if eq .BodyTemplate "device_verify_content" }}
{{ template "device_verify_content" .Data }} {{ template "device_verify_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }} {{ else if eq .BodyTemplate "error_content" }}
@@ -107,7 +111,11 @@
<script src="/static/auth.js" defer></script> <script src="/static/auth.js" defer></script>
<script src="/static/render.js" defer></script> <script src="/static/render.js" defer></script>
<script src="/static/comments.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/archive.js" defer></script>
<script src="/static/permissions.js" defer></script>
<script src="/static/tags.js" defer></script>
</body> </body>
</html> </html>

View File

@@ -2,8 +2,18 @@
{{ define "document_content" }} {{ define "document_content" }}
<section class="workspace-shell workspace-shell--document"> <section class="workspace-shell workspace-shell--document">
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
{{ template "browser" .Browser }} {{ 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"> <nav class="breadcrumbs" aria-label="Breadcrumb">
<ol> <ol>
{{ $lastIdx := sub (len .Breadcrumbs) 1 }} {{ $lastIdx := sub (len .Breadcrumbs) 1 }}
@@ -18,61 +28,135 @@
{{ end }} {{ end }}
</ol> </ol>
</nav> </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"> <div class="document-meta__header">
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
{{ if .CanWrite }} <details class="document-actions-dropdown">
<div class="document-actions"> <summary aria-haspopup="true" aria-label="Document actions">
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button> <circle cx="12" cy="5" r="1"></circle>
</div> <circle cx="12" cy="12" r="1"></circle>
{{ end }} <circle cx="12" cy="19" r="1"></circle>
</div> </svg>
<details class="document-meta-panel">
<summary>
<span>Document details</span>
</summary> </summary>
<dl class="meta-grid"> <div class="document-actions-dropdown__menu">
<div> {{ if .CanWrite }}
<dt>File</dt> <a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
<dd><code>{{ .Path }}</code></dd> <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>
<div> <div class="document-actions-dropdown__meta-row">
<dt>Hash</dt> <span class="document-actions-dropdown__meta-label">Hash</span>
<dd><code>{{ .Hash }}</code></dd> <code class="document-actions-dropdown__meta-value">{{ .Hash }}</code>
</div> </div>
{{ if .Tags }} {{ if .Tags }}
<div> <div class="document-actions-dropdown__meta-row">
<dt>Tags</dt> <span class="document-actions-dropdown__meta-label">Tags</span>
<dd> <ul class="document-actions-dropdown__tag-list">
<ul class="tag-list">
{{ range .Tags }} {{ range .Tags }}
<li><a href="/?tag={{ . }}">#{{ . }}</a></li> <li><a href="/?tag={{ . }}">#{{ . }}</a></li>
{{ end }} {{ end }}
</ul> </ul>
</dd>
</div> </div>
{{ end }} {{ end }}
</dl> </div>
</div>
</details> </details>
</div> </div>
</div>
<div class="markdown-body"> <div class="markdown-body">
{{ .HTML }} {{ .HTML }}
</div> </div>
</article> </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> </section>
{{ end }} {{ end }}
{{ define "browser" }} {{ 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 }} {{ range .Columns }}
<section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}"> <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" }} {{ if eq .Title "Content" }}
{{ template "icon-library" }} {{ template "icon-library" }}
{{ end }} {{ end }}
<span class="miller-column-title-text">{{ .Title }}</span> <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> </h2>
<ul> <ul>
{{ range .Items }} {{ range .Items }}
@@ -117,6 +201,23 @@
</svg> </svg>
{{ end }} {{ 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" }} {{ 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"> <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> <path d="m9 18 6-6-6-6"></path>

View File

@@ -24,7 +24,7 @@
<p class="eyebrow">Editing</p> <p class="eyebrow">Editing</p>
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
</div> </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> </div>
<details class="document-meta-panel"> <details class="document-meta-panel">
<summary> <summary>
@@ -70,7 +70,7 @@
<div class="editor-conflict__diff" data-conflict-diff></div> <div class="editor-conflict__diff" data-conflict-diff></div>
<div class="editor-conflict__resolution"> <div class="editor-conflict__resolution">
<label for="conflict-resolution">Resolved document</label> <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> <textarea id="conflict-resolution" data-conflict-resolution spellcheck="false"></textarea>
</div> </div>
</div> </div>
@@ -78,7 +78,7 @@
<form class="editor-form" data-editor-form data-document-path="{{ .Path }}"> <form class="editor-form" data-editor-form data-document-path="{{ .Path }}">
<label class="sr-only" for="document-editor">Markdown editor</label> <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> <textarea id="document-editor" name="content" data-document-editor spellcheck="false">{{ .Source }}</textarea>
</form> </form>
</article> </article>

View 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 }}

View 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 }}

View File

@@ -31,6 +31,9 @@ type Service struct {
sourceDir string sourceDir string
} }
type FileAccessFunc func(FileEntry) bool
type PathAccessFunc func(string) bool
// NewService creates a new sync service. // NewService creates a new sync service.
func NewService(repo *Repository, docService *docs.Service, contentStore *store.ContentStore, sourceDir string, logger *slog.Logger) *Service { func NewService(repo *Repository, docService *docs.Service, contentStore *store.ContentStore, sourceDir string, logger *slog.Logger) *Service {
return &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. // InitSync creates a new snapshot from the current server state.
func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snapshot, error) { 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) files, err := s.buildSnapshotFromDisk(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("build snapshot: %w", err) return nil, fmt.Errorf("build snapshot: %w", err)
} }
files = filterFiles(files, canRead)
snap, err := s.repo.CreateSnapshot(ctx, deviceID, userID, files) snap, err := s.repo.CreateSnapshot(ctx, deviceID, userID, files)
if err != nil { 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. // ApplyDelta processes client changes and computes server delta + conflicts.
func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, clientDelta Delta) (*DeltaResult, error) { 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) snap, err := s.repo.GetSnapshot(ctx, snapshotID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get snapshot: %w", err) 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 { if snap.UserID != userID {
return nil, ErrForbidden 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) serverFiles := make(map[string]FileEntry)
for _, f := range snap.Files { for _, f := range snap.Files {
@@ -91,6 +111,7 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
if err != nil { if err != nil {
return nil, fmt.Errorf("build current snapshot: %w", err) return nil, fmt.Errorf("build current snapshot: %w", err)
} }
currentFiles = filterFiles(currentFiles, canRead)
currentMap := make(map[string]FileEntry) currentMap := make(map[string]FileEntry)
for _, f := range currentFiles { for _, f := range currentFiles {
@@ -186,6 +207,7 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
if err != nil { if err != nil {
return nil, fmt.Errorf("build post-delta snapshot: %w", err) 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) newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, latestFiles)
if err != nil { if err != nil {
return nil, fmt.Errorf("create post-delta snapshot: %w", err) 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. // ResolveConflicts applies resolved changes and creates a new snapshot.
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID string, resolutions []Resolution) (*Snapshot, error) { 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) snap, err := s.repo.GetSnapshot(ctx, snapshotID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get snapshot: %w", err) 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 { if snap.UserID != userID {
return nil, ErrForbidden 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 // Get current server state
if _, err := s.buildSnapshotFromDisk(ctx); err != nil { if _, err := s.buildSnapshotFromDisk(ctx); err != nil {
@@ -255,6 +289,7 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
if err != nil { if err != nil {
return nil, fmt.Errorf("build resolved snapshot: %w", err) return nil, fmt.Errorf("build resolved snapshot: %w", err)
} }
files = filterFiles(files, canRead)
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files) newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
if err != nil { if err != nil {
@@ -265,6 +300,19 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
return newSnap, nil 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. // GetContent returns raw file content by hash.
func (s *Service) GetContent(hash string) ([]byte, error) { func (s *Service) GetContent(hash string) ([]byte, error) {
if !isSHA256Hex(hash) { if !isSHA256Hex(hash) {

View File

@@ -4,10 +4,11 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "$PORT:$PORT" - "${PORT:-8080}:${PORT:-8080}"
depends_on: depends_on:
- mailpit - mailpit
environment: environment:
PORT: "${PORT:-8080}"
CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite" CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite"
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content" CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files" CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
@@ -17,10 +18,10 @@ services:
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}" CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}" CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
volumes: volumes:
- cairnquire_data:/workspace/data
- ./content:/workspace/content - ./content:/workspace/content
- ./data:/workspace/data
healthcheck: 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 interval: 10s
timeout: 3s timeout: 3s
retries: 5 retries: 5
@@ -34,3 +35,7 @@ services:
environment: environment:
MP_UI_BIND_ADDR: "0.0.0.0:8025" MP_UI_BIND_ADDR: "0.0.0.0:8025"
MP_SMTP_BIND_ADDR: "0.0.0.0:1025" MP_SMTP_BIND_ADDR: "0.0.0.0:1025"
volumes:
cairnquire_data:
driver: local

22
entrypoint.sh Executable file
View 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