Compare commits
3 Commits
4666000ca3
...
04a1f2bb6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 04a1f2bb6d | |||
| 1e939f307d | |||
| 632621b226 |
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -9,17 +9,18 @@ COPY apps/server/ ./
|
|||||||
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire
|
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"]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,6 +18,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 +32,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 +65,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 +316,100 @@ 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.isEmpty else {
|
||||||
|
searchResults = []
|
||||||
|
isSearching = false
|
||||||
|
searchError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching = true
|
||||||
|
searchError = nil
|
||||||
|
searchTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .milliseconds(180))
|
||||||
|
await self?.performSearch(for: trimmed)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
if self?.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
|
||||||
|
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 searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == query else { return }
|
||||||
|
searchResults = Array(results.prefix(8))
|
||||||
|
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
|
||||||
|
searchError = nil
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
searchResults = []
|
||||||
|
searchError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isSearching = false
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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).isEmpty {
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDropTargetActive(_ isActive: Bool) {
|
||||||
|
isBordered = isActive
|
||||||
|
contentTintColor = isActive ? .systemBlue : nil
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||||
// Tint icon slightly to indicate drop zone is active
|
let pasteboard = sender.draggingPasteboard
|
||||||
if let image = self.image {
|
guard pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) else {
|
||||||
self.image = image.tinted(with: .systemBlue)
|
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
BIN
apps/server/cairnquire
Executable file
Binary file not shown.
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -409,6 +409,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 +805,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
|
||||||
|
|||||||
@@ -343,3 +343,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ 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
|
||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -94,6 +94,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")
|
||||||
@@ -149,9 +153,9 @@ func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, d
|
|||||||
return fmt.Errorf("authentication required")
|
return fmt.Errorf("authentication required")
|
||||||
}
|
}
|
||||||
return s.repo.AddWatcher(ctx, Watcher{
|
return s.repo.AddWatcher(ctx, Watcher{
|
||||||
UserID: principal.UserID,
|
UserID: principal.UserID,
|
||||||
DocumentID: &documentID,
|
DocumentID: &documentID,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +171,9 @@ func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, fol
|
|||||||
return fmt.Errorf("authentication required")
|
return fmt.Errorf("authentication required")
|
||||||
}
|
}
|
||||||
return s.repo.AddWatcher(ctx, Watcher{
|
return s.repo.AddWatcher(ctx, Watcher{
|
||||||
UserID: principal.UserID,
|
UserID: principal.UserID,
|
||||||
FolderPath: &folderPath,
|
FolderPath: &folderPath,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS permissions_previous (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document')),
|
||||||
|
resource_id TEXT,
|
||||||
|
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')),
|
||||||
|
granted_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO permissions_previous (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
|
||||||
|
SELECT id, user_id, resource_type, resource_id, permission, granted_by, created_at
|
||||||
|
FROM permissions
|
||||||
|
WHERE resource_type IN ('global', 'collection', 'document');
|
||||||
|
|
||||||
|
DROP TABLE permissions;
|
||||||
|
ALTER TABLE permissions_previous RENAME TO permissions;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS permissions_next (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document', 'folder')),
|
||||||
|
resource_id TEXT,
|
||||||
|
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')),
|
||||||
|
granted_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO permissions_next (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
|
||||||
|
SELECT id, user_id, resource_type, resource_id, permission, granted_by, created_at
|
||||||
|
FROM permissions;
|
||||||
|
|
||||||
|
DROP TABLE permissions;
|
||||||
|
ALTER TABLE permissions_next RENAME TO permissions;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource_type, resource_id);
|
||||||
@@ -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 = ?
|
||||||
|
|||||||
@@ -384,17 +384,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 +465,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 +517,41 @@ 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 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 +664,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 +728,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 +789,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 +803,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 +823,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()})
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -70,11 +73,13 @@ type errorData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type browserData struct {
|
type browserData struct {
|
||||||
Columns []browserColumn
|
Columns []browserColumn
|
||||||
|
CanWrite bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type browserColumn struct {
|
type browserColumn struct {
|
||||||
Title string
|
Title string
|
||||||
|
Path string
|
||||||
Items []browserItem
|
Items []browserItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
query := r.URL.Query().Get("q")
|
|
||||||
if query == "" {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
results, err := s.repository.SearchDocuments(ctx, query)
|
var results []docs.SearchResult
|
||||||
|
var err error
|
||||||
|
|
||||||
|
tag := r.URL.Query().Get("tag")
|
||||||
|
if tag != "" {
|
||||||
|
results, err = s.repository.SearchDocumentsByTag(ctx, tag)
|
||||||
|
} else {
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
if query == "" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err = s.repository.SearchDocuments(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("search failed", "error", err)
|
s.logger.Warn("search failed", "error", err)
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
results, err = s.filterReadableSearchResults(r, results)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("filter search failed", "error", err)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +169,10 @@ 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" {
|
||||||
return auth.ScopeDocsRead, true
|
return auth.ScopeDocsRead, true
|
||||||
@@ -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:
|
||||||
|
|||||||
400
apps/server/internal/httpserver/permissions_handlers.go
Normal file
400
apps/server/internal/httpserver/permissions_handlers.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
folder := strings.TrimSuffix(pagePath, "/"+lastPathSegment(pagePath))
|
||||||
|
return s.canWriteFolder(r, folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 queryEscape(value string) string {
|
||||||
|
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
||||||
|
}
|
||||||
@@ -107,6 +107,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
|||||||
router.Get("/password-reset", server.handlePasswordResetPage)
|
router.Get("/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)
|
||||||
@@ -149,6 +151,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)
|
||||||
|
|||||||
@@ -3,15 +3,69 @@
|
|||||||
|
|
||||||
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 stripTrack = document.querySelector('[data-comments-strip-track]');
|
||||||
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 +78,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 +136,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 +154,165 @@
|
|||||||
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 renderCommentsAside() {
|
||||||
|
if (!commentsAsideList) return;
|
||||||
|
commentsAsideList.innerHTML = '';
|
||||||
|
|
||||||
|
const body = document.querySelector('.markdown-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
||||||
|
if (!commentables.length) {
|
||||||
|
commentsAsideList.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentables.forEach(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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
commentsAsideList.appendChild(asideItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +326,6 @@
|
|||||||
return d.toLocaleString();
|
return d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize: hash all paragraphs in markdown body
|
|
||||||
function init() {
|
function init() {
|
||||||
const body = document.querySelector('.markdown-body');
|
const body = document.querySelector('.markdown-body');
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
@@ -110,9 +336,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') {
|
||||||
|
|||||||
36
apps/server/internal/httpserver/static/permissions.js
Normal file
36
apps/server/internal/httpserver/static/permissions.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
(() => {
|
||||||
|
const root = document.querySelector("[data-permission-bulk]");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const selectAll = root.querySelector("[data-permission-select-all]");
|
||||||
|
const bulkLevel = root.querySelector("[data-permission-bulk-level]");
|
||||||
|
const apply = root.querySelector("[data-permission-apply]");
|
||||||
|
const rows = Array.from(document.querySelectorAll(".permission-user-row"));
|
||||||
|
|
||||||
|
const checkedRows = () => rows.filter((row) => row.querySelector("[data-permission-user]")?.checked);
|
||||||
|
|
||||||
|
const updateSelectAll = () => {
|
||||||
|
const selected = checkedRows().length;
|
||||||
|
selectAll.checked = selected > 0 && selected === rows.length;
|
||||||
|
selectAll.indeterminate = selected > 0 && selected < rows.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
selectAll?.addEventListener("change", () => {
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const checkbox = row.querySelector("[data-permission-user]");
|
||||||
|
if (checkbox) checkbox.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
updateSelectAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
row.querySelector("[data-permission-user]")?.addEventListener("change", updateSelectAll);
|
||||||
|
});
|
||||||
|
|
||||||
|
apply?.addEventListener("click", () => {
|
||||||
|
checkedRows().forEach((row) => {
|
||||||
|
const select = row.querySelector("[data-permission-select]");
|
||||||
|
if (select) select.value = bulkLevel.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
const html = document.documentElement.outerHTML;
|
const 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
@@ -9,6 +9,7 @@ const STATIC_ASSETS = [
|
|||||||
"/static/realtime.js",
|
"/static/realtime.js",
|
||||||
"/static/editor.js",
|
"/static/editor.js",
|
||||||
"/static/render.js",
|
"/static/render.js",
|
||||||
|
"/static/tags.js",
|
||||||
"/static/favicon.png",
|
"/static/favicon.png",
|
||||||
"/static/cairnquire%20logo%402x.webp",
|
"/static/cairnquire%20logo%402x.webp",
|
||||||
"/",
|
"/",
|
||||||
@@ -64,12 +65,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 +82,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;
|
||||||
|
|||||||
81
apps/server/internal/httpserver/static/tags.js
Normal file
81
apps/server/internal/httpserver/static/tags.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
(function () {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const tag = url.searchParams.get("tag");
|
||||||
|
if (!tag) return;
|
||||||
|
|
||||||
|
// Check if we're on a proper tag page or a fallback
|
||||||
|
const hasTagPreview = document.querySelector(".tag-preview");
|
||||||
|
if (hasTagPreview) return; // Server rendered tag page correctly
|
||||||
|
|
||||||
|
// We're on a fallback page (likely offline) — build tag view from cache
|
||||||
|
if (!window.MDHubCache) return;
|
||||||
|
|
||||||
|
window.MDHubCache.getCachedDocuments().then(function (docs) {
|
||||||
|
if (!docs || !docs.length) return;
|
||||||
|
|
||||||
|
const tagged = docs.filter(function (doc) {
|
||||||
|
return doc.tags && doc.tags.indexOf(tag) !== -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a minimal tag browser
|
||||||
|
const shell = document.querySelector(".workspace-shell");
|
||||||
|
if (!shell) return;
|
||||||
|
|
||||||
|
const browser = shell.querySelector(".miller-browser");
|
||||||
|
const preview = shell.querySelector(".empty-preview") || shell.querySelector(".document-shell");
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
// Replace browser with a single column of tagged docs
|
||||||
|
const itemsHtml = tagged
|
||||||
|
.sort(function (a, b) {
|
||||||
|
const aName = (a.title || a.path).toLowerCase();
|
||||||
|
const bName = (b.title || b.path).toLowerCase();
|
||||||
|
return aName < bName ? -1 : aName > bName ? 1 : 0;
|
||||||
|
})
|
||||||
|
.map(function (doc) {
|
||||||
|
const name = doc.title || doc.path;
|
||||||
|
const path = doc.path.replace(/\.md$/, "");
|
||||||
|
return (
|
||||||
|
'<li><a href="/docs/' +
|
||||||
|
escapeHtml(path) +
|
||||||
|
'" title="' +
|
||||||
|
escapeHtml(name) +
|
||||||
|
'"><span class="browser-item-label"><svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path></svg><span class="browser-item-name">' +
|
||||||
|
escapeHtml(name) +
|
||||||
|
"</span></span></a></li>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
browser.innerHTML =
|
||||||
|
'<section class="miller-column miller-column--root" aria-label="#' +
|
||||||
|
escapeHtml(tag) +
|
||||||
|
'"><h2><span class="miller-column-title-wrap"><span class="miller-column-title-text">#' +
|
||||||
|
escapeHtml(tag) +
|
||||||
|
"</span></span></h2><ul>" +
|
||||||
|
itemsHtml +
|
||||||
|
"</ul></section>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
preview.outerHTML =
|
||||||
|
'<section class="tag-preview"><p class="eyebrow">Tag</p><h1>#' +
|
||||||
|
escapeHtml(tag) +
|
||||||
|
'</h1><p class="lede">' +
|
||||||
|
tagged.length +
|
||||||
|
" document" +
|
||||||
|
(tagged.length === 1 ? "" : "s") +
|
||||||
|
" found</p></section>";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -29,7 +29,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 +65,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 +110,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 +163,45 @@ 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 {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" }}
|
||||||
@@ -108,6 +112,8 @@
|
|||||||
<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/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>
|
||||||
|
|||||||
@@ -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,15 +28,21 @@
|
|||||||
{{ 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 }}
|
|
||||||
<div class="document-actions">
|
<div class="document-actions">
|
||||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
{{ if .CanWrite }}
|
||||||
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
|
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
||||||
|
{{ if .CanAdmin }}
|
||||||
|
<a class="document-edit-link" href="/permissions?resourceType=document&resourceId={{ .Path }}">Permissions</a>
|
||||||
|
{{ end }}
|
||||||
|
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
|
||||||
</div>
|
</div>
|
||||||
<details class="document-meta-panel">
|
<details class="document-meta-panel">
|
||||||
<summary>
|
<summary>
|
||||||
@@ -61,18 +77,74 @@
|
|||||||
{{ .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 }}>
|
||||||
{{ if eq .Title "Content" }}
|
<span class="miller-column-title-wrap">
|
||||||
{{ template "icon-library" }}
|
{{ if eq .Title "Content" }}
|
||||||
|
{{ template "icon-library" }}
|
||||||
|
{{ end }}
|
||||||
|
<span class="miller-column-title-text">{{ .Title }}</span>
|
||||||
|
</span>
|
||||||
|
{{ if $.CanWrite }}
|
||||||
|
<span class="miller-column-actions">
|
||||||
|
<button type="button" class="miller-column-new-file" data-folder-path="{{ .Path }}" aria-label="New file in {{ .Title }}">
|
||||||
|
{{ template "icon-file-plus" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="miller-column-add-folder" data-folder-path="{{ .Path }}" aria-label="Add folder to {{ .Title }}">
|
||||||
|
{{ template "icon-folder-plus" }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<span class="miller-column-title-text">{{ .Title }}</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{{ range .Items }}
|
{{ range .Items }}
|
||||||
@@ -117,6 +189,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>
|
||||||
|
|||||||
71
apps/server/internal/httpserver/templates/permissions.gohtml
Normal file
71
apps/server/internal/httpserver/templates/permissions.gohtml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{{ define "permissions.gohtml" }}{{ template "base" . }}{{ end }}
|
||||||
|
|
||||||
|
{{ define "permissions_content" }}
|
||||||
|
<section class="permissions-shell">
|
||||||
|
<header class="permissions-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Access control</p>
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
</div>
|
||||||
|
{{ if eq .ResourceType "document" }}
|
||||||
|
<a class="account-action" href="/docs/{{ trimMd .ResourceID }}">Back to page</a>
|
||||||
|
{{ end }}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="permissions-form" method="post" action="/permissions">
|
||||||
|
<input type="hidden" name="resourceType" value="{{ .ResourceType }}" />
|
||||||
|
<input type="hidden" name="resourceId" value="{{ .ResourceID }}" />
|
||||||
|
<section class="permissions-panel">
|
||||||
|
<header class="permissions-panel__header">
|
||||||
|
<div>
|
||||||
|
<p class="account-section__kicker">People</p>
|
||||||
|
<h2>User permissions</h2>
|
||||||
|
</div>
|
||||||
|
<div class="permission-bulk-actions" data-permission-bulk>
|
||||||
|
<label class="permission-select-all">
|
||||||
|
<input type="checkbox" data-permission-select-all />
|
||||||
|
<span>Select all</span>
|
||||||
|
</label>
|
||||||
|
<select data-permission-bulk-level aria-label="Access level for selected users">
|
||||||
|
<option value="">No access</option>
|
||||||
|
<option value="read">View</option>
|
||||||
|
<option value="write">Edit</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-secondary permission-apply" type="button" data-permission-apply>Apply</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="permission-user-list">
|
||||||
|
{{ range .Users }}
|
||||||
|
<label class="permission-user-row">
|
||||||
|
<input class="permission-user-check" type="checkbox" data-permission-user />
|
||||||
|
<span class="permission-user-main">
|
||||||
|
<span class="permission-user-name">
|
||||||
|
<strong>{{ .DisplayName }}</strong>
|
||||||
|
<em>{{ .Role }}</em>
|
||||||
|
</span>
|
||||||
|
<span class="permission-user-email">{{ .Email }}</span>
|
||||||
|
<span class="permission-user-meta">Created {{ .CreatedAt }} · Last seen {{ .LastSeenAt }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="permission-grant-meta">
|
||||||
|
<strong>{{ .PermissionText }}</strong>
|
||||||
|
<small>{{ .GrantSummary }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="permission-select-wrap">
|
||||||
|
<select name="permission:{{ .ID }}" aria-label="Permission for {{ .DisplayName }}" data-permission-select>
|
||||||
|
<option value="" {{ if eq .Permission "" }}selected{{ end }}>No access</option>
|
||||||
|
<option value="read" {{ if eq .Permission "read" }}selected{{ end }}>View</option>
|
||||||
|
<option value="write" {{ if eq .Permission "write" }}selected{{ end }}>Edit</option>
|
||||||
|
<option value="admin" {{ if eq .Permission "admin" }}selected{{ end }}>Admin</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="permissions-footer">
|
||||||
|
<button class="btn-primary" type="submit">Save permissions</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
12
apps/server/internal/httpserver/templates/tag.gohtml
Normal file
12
apps/server/internal/httpserver/templates/tag.gohtml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{{ define "tag.gohtml" }}{{ template "base" . }}{{ end }}
|
||||||
|
|
||||||
|
{{ define "tag_content" }}
|
||||||
|
<section class="workspace-shell workspace-shell--empty">
|
||||||
|
{{ template "browser" .Browser }}
|
||||||
|
<section class="tag-preview">
|
||||||
|
<p class="eyebrow">Tag</p>
|
||||||
|
<h1>#{{ .Tag }}</h1>
|
||||||
|
<p class="lede">{{ len .Results }} document{{ if ne (len .Results) 1 }}s{{ end }} found</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
@@ -31,6 +31,9 @@ type Service struct {
|
|||||||
sourceDir string
|
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) {
|
||||||
|
|||||||
@@ -17,10 +17,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}/healthz"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -34,3 +34,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
22
entrypoint.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Ensure runtime directories exist before chowning them; otherwise chown -R on a
|
||||||
|
# bind-mounted or freshly-created named volume can race with MkdirAll in the app.
|
||||||
|
mkdir -p /workspace/data/files /workspace/content
|
||||||
|
|
||||||
|
# Make the runtime dirs owned by appuser so the Go process can write attachments
|
||||||
|
# and read content even when /workspace/data is a host bind mount created as root
|
||||||
|
# (or a named volume initialized with the container's root uid).
|
||||||
|
chown -R appuser:appuser /workspace/data /workspace/content
|
||||||
|
|
||||||
|
# Drop to the unprivileged appuser and hand control to the container CMD.
|
||||||
|
# gosu handles signal forwarding and TTY correctly; plain `su` and `su -c` do not.
|
||||||
|
exec gosu appuser "$@"
|
||||||
|
|
||||||
|
# Smoke test (run on a host with Docker available):
|
||||||
|
# docker compose build app
|
||||||
|
# docker compose up -d app
|
||||||
|
# docker compose exec app id # should print: uid=<appuser-uid>(appuser) gid=<appuser-gid>(appuser)
|
||||||
|
# docker compose exec app ls -ld /workspace/data # should be owned by appuser
|
||||||
|
# curl -fsS http://localhost:${PORT:-8080}/healthz
|
||||||
Reference in New Issue
Block a user