Compare commits

...

3 Commits

Author SHA1 Message Date
04a1f2bb6d ops design and app
add more comments feature, more tags feature, and other frontend updates
2026-06-09 18:27:59 -04:00
1e939f307d Refine macOS menu bar drop target icon 2026-06-09 16:41:10 -04:00
632621b226 Add live search to macOS popover 2026-06-09 16:40:02 -04:00
38 changed files with 3568 additions and 138 deletions

View File

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

View File

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

View File

@@ -210,6 +210,24 @@ public actor APIClient {
return try await perform(request)
}
public func searchDocuments(query: String) async throws -> [SearchResult] {
var components = URLComponents(url: baseURL.appendingPathComponent("api/search"), resolvingAgainstBaseURL: false)
components?.queryItems = [URLQueryItem(name: "q", value: query)]
guard let url = components?.url else {
throw APIError(error: "invalid search URL")
}
var request = URLRequest(url: url)
if let token = apiToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.setValue("application/json", forHTTPHeaderField: "Accept")
let response: SearchResponse = try await perform(request)
return response.results
}
// MARK: - Sync
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {

View File

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

View File

@@ -18,6 +18,11 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
@Published public private(set) var isLoadingRecentUploads = false
@Published public private(set) var recentUploadsError: String?
@Published public private(set) var clipboardNotice: ClipboardNotice?
@Published public var searchQuery = ""
@Published public private(set) var searchResults: [SearchResult] = []
@Published public private(set) var isSearching = false
@Published public private(set) var searchError: String?
@Published public var selectedSearchIndex = 0
public var cancellables = Set<AnyCancellable>()
@@ -27,6 +32,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
private var uploadManager: UploadManager
private var folderWatcher: FolderWatcher?
private var syncTimer: Timer?
private var searchTask: Task<Void, Never>?
private var settingsWindow: NSWindow?
private var uploadsWindow: NSWindow?
@@ -59,6 +65,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
uploadManager = UploadManager(client: apiClient, config: newConfig)
syncEngine = SyncEngine(client: apiClient, config: newConfig)
resetSearch()
await refreshRecentUploads()
await setupSync()
}
@@ -309,6 +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() {
switch config.effectiveNewNoteAction {
case .clipboard:

View File

@@ -2,6 +2,7 @@ import SwiftUI
public struct StatusPopoverView: View {
@ObservedObject public var controller: MenuBarController
@FocusState private var searchFocused: Bool
public init(controller: MenuBarController) {
self.controller = controller
@@ -14,6 +15,10 @@ public struct StatusPopoverView: View {
Divider()
searchSection
Divider()
// Quick actions
actionsSection
@@ -27,8 +32,23 @@ public struct StatusPopoverView: View {
// Footer
footerSection
}
.frame(width: 320)
.frame(width: 360)
.padding(.vertical, 12)
.onAppear {
Task { @MainActor in
searchFocused = true
}
}
.onMoveCommand { direction in
switch direction {
case .up:
controller.moveSearchSelection(by: -1)
case .down:
controller.moveSearchSelection(by: 1)
default:
break
}
}
}
// MARK: - Sections
@@ -131,6 +151,75 @@ public struct StatusPopoverView: View {
.padding(.vertical, 12)
}
@ViewBuilder
private var searchSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
TextField(
"Search documents",
text: Binding(
get: { controller.searchQuery },
set: { controller.updateSearchQuery($0) }
)
)
.textFieldStyle(.plain)
.focused($searchFocused)
.onSubmit {
controller.openSelectedSearchResult()
}
if controller.isSearching {
ProgressView()
.controlSize(.small)
.scaleEffect(0.65)
.frame(width: 14, height: 14)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color(NSColor.controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
if !controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).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
private var recentUploadsSection: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -272,6 +361,47 @@ private struct RecentUploadRow: View {
}
}
private struct SearchResultRow: View {
let result: SearchResult
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.font(.system(size: 14, weight: .medium))
.foregroundColor(isSelected ? .white : .accentColor)
.frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
Text(result.title.isEmpty ? result.path : result.title)
.font(.caption)
.foregroundColor(isSelected ? .white : .primary)
.lineLimit(1)
Text(result.path)
.font(.caption2)
.foregroundColor(isSelected ? .white.opacity(0.82) : .secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "return")
.font(.caption2)
.foregroundColor(isSelected ? .white.opacity(0.75) : .secondary)
.opacity(isSelected ? 1 : 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(height: 44)
.background(isSelected ? Color.accentColor : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
// MARK: - Action Button
struct ActionButton: View {

View File

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

BIN
apps/server/cairnquire Executable file

Binary file not shown.

View File

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

View File

@@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/go-webauthn/webauthn/webauthn"
@@ -284,6 +286,349 @@ func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessU
return nil
}
type resourceMatch struct {
resourceType ResourceType
resourceID string
}
func (r *Repository) ListResourcePermissions(ctx context.Context, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) {
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT p.id, p.user_id, COALESCE(u.email, ''), COALESCE(u.display_name, ''), p.resource_type, COALESCE(p.resource_id, ''), p.permission, COALESCE(p.granted_by, ''), p.created_at
FROM permissions p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.resource_type = ? AND COALESCE(p.resource_id, '') = ?
ORDER BY u.email ASC, p.created_at ASC
`, string(resourceType), resourceID)
if err != nil {
return nil, fmt.Errorf("list resource permissions: %w", err)
}
defer rows.Close()
var permissions []ResourcePermission
for rows.Next() {
permission, err := scanResourcePermission(rows)
if err != nil {
return nil, err
}
permissions = append(permissions, permission)
}
return permissions, rows.Err()
}
func (r *Repository) ReplaceResourcePermissions(ctx context.Context, resourceType ResourceType, resourceID string, actorID string, updates []ResourcePermissionUpdate) error {
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
if err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin resource permissions update: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
DELETE FROM permissions
WHERE resource_type = ? AND COALESCE(resource_id, '') = ?
`, string(resourceType), resourceID); err != nil {
return fmt.Errorf("clear resource permissions: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
for _, update := range updates {
update.UserID = strings.TrimSpace(update.UserID)
if update.UserID == "" || update.Permission == PermissionNone {
continue
}
if !validPermission(update.Permission) {
return fmt.Errorf("invalid permission")
}
var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ? AND disabled = 0`, update.UserID).Scan(&exists); err != nil {
return fmt.Errorf("check permission user: %w", err)
}
if exists == 0 {
return fmt.Errorf("user %s does not exist", update.UserID)
}
idPart, err := randomToken(18)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, "perm:"+idPart, update.UserID, string(resourceType), nullString(resourceID), string(update.Permission), nullString(actorID), now); err != nil {
return fmt.Errorf("insert resource permission: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit resource permissions update: %w", err)
}
return nil
}
func (r *Repository) EnsureResourcePermission(ctx context.Context, resourceType ResourceType, resourceID string, actorID string, update ResourcePermissionUpdate) error {
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
if err != nil {
return err
}
update.UserID = strings.TrimSpace(update.UserID)
if update.UserID == "" {
return fmt.Errorf("user id is required")
}
if !validPermission(update.Permission) || update.Permission == PermissionNone {
return fmt.Errorf("invalid permission")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin resource permission ensure: %w", err)
}
defer func() { _ = tx.Rollback() }()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ? AND disabled = 0`, update.UserID).Scan(&exists); err != nil {
return fmt.Errorf("check permission user: %w", err)
}
if exists == 0 {
return fmt.Errorf("user %s does not exist", update.UserID)
}
best := PermissionNone
rows, err := tx.QueryContext(ctx, `
SELECT permission
FROM permissions
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
`, update.UserID, string(resourceType), resourceID)
if err != nil {
return fmt.Errorf("lookup existing resource permission: %w", err)
}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
rows.Close()
return fmt.Errorf("scan existing resource permission: %w", err)
}
best = maxPermission(best, Permission(raw))
}
if err := rows.Close(); err != nil {
return err
}
if PermissionAllows(best, update.Permission) {
return tx.Commit()
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM permissions
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
`, update.UserID, string(resourceType), resourceID); err != nil {
return fmt.Errorf("clear user resource permission: %w", err)
}
idPart, err := randomToken(18)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, granted_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, "perm:"+idPart, update.UserID, string(resourceType), nullString(resourceID), string(update.Permission), nullString(actorID), time.Now().UTC().Format(time.RFC3339)); err != nil {
return fmt.Errorf("insert resource permission: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit resource permission ensure: %w", err)
}
return nil
}
func (r *Repository) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) {
if principal.UserID == "" {
return PermissionNone, nil
}
if principal.Role == RoleAdmin {
return PermissionAdmin, nil
}
resourceType, resourceID, err := normalizeResource(resourceType, resourceID)
if err != nil {
return PermissionNone, err
}
matches := []resourceMatch{{resourceType: ResourceGlobal, resourceID: ""}}
switch resourceType {
case ResourceDocument:
for _, folder := range folderMatchesForDocument(resourceID) {
matches = append(matches, resourceMatch{resourceType: ResourceFolder, resourceID: folder})
}
for _, collectionID := range collectionIDs {
normalized := normalizeCollectionID(collectionID)
if normalized != "" {
matches = append(matches, resourceMatch{resourceType: ResourceCollection, resourceID: normalized})
}
}
matches = append(matches, resourceMatch{resourceType: ResourceDocument, resourceID: resourceID})
case ResourceFolder:
for _, folder := range folderMatches(resourceID) {
matches = append(matches, resourceMatch{resourceType: ResourceFolder, resourceID: folder})
}
case ResourceCollection:
matches = append(matches, resourceMatch{resourceType: ResourceCollection, resourceID: resourceID})
default:
matches = append(matches, resourceMatch{resourceType: resourceType, resourceID: resourceID})
}
best := PermissionNone
for _, match := range matches {
rows, err := r.db.QueryContext(ctx, `
SELECT permission
FROM permissions
WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ?
`, principal.UserID, string(match.resourceType), match.resourceID)
if err != nil {
return PermissionNone, fmt.Errorf("lookup resource permission: %w", err)
}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
rows.Close()
return PermissionNone, fmt.Errorf("scan resource permission: %w", err)
}
best = maxPermission(best, Permission(raw))
}
if err := rows.Close(); err != nil {
return PermissionNone, err
}
}
return best, nil
}
func scanResourcePermission(rows *sql.Rows) (ResourcePermission, error) {
var (
permission ResourcePermission
resourceType string
resourceID string
rawPermission string
created string
)
if err := rows.Scan(&permission.ID, &permission.UserID, &permission.UserEmail, &permission.UserName, &resourceType, &resourceID, &rawPermission, &permission.GrantedBy, &created); err != nil {
return ResourcePermission{}, fmt.Errorf("scan resource permission: %w", err)
}
permission.ResourceType = ResourceType(resourceType)
permission.ResourceID = resourceID
permission.Permission = Permission(rawPermission)
createdAt, err := time.Parse(time.RFC3339, created)
if err != nil {
return ResourcePermission{}, fmt.Errorf("parse resource permission created_at: %w", err)
}
permission.CreatedAt = createdAt
return permission, nil
}
func normalizeResource(resourceType ResourceType, resourceID string) (ResourceType, string, error) {
resourceType = ResourceType(strings.TrimSpace(strings.ToLower(string(resourceType))))
switch resourceType {
case ResourceGlobal:
return resourceType, "", nil
case ResourceDocument:
resourceID = normalizeDocumentPath(resourceID)
if resourceID == "" {
return "", "", fmt.Errorf("document resource id is required")
}
return resourceType, resourceID, nil
case ResourceFolder:
return resourceType, normalizeFolderPath(resourceID), nil
case ResourceCollection:
resourceID = normalizeCollectionID(resourceID)
if resourceID == "" {
return "", "", fmt.Errorf("collection resource id is required")
}
return resourceType, resourceID, nil
default:
return "", "", fmt.Errorf("invalid resource type")
}
}
func normalizeDocumentPath(raw string) string {
clean := normalizeFolderPath(raw)
if clean == "" {
return ""
}
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
clean += ".md"
}
return clean
}
func normalizeFolderPath(raw string) string {
raw = strings.TrimSpace(strings.Trim(raw, "/"))
if raw == "" {
return ""
}
clean := path.Clean(strings.ReplaceAll(raw, "\\", "/"))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return ""
}
return strings.Trim(clean, "/")
}
func normalizeCollectionID(raw string) string {
return strings.Trim(strings.TrimSpace(raw), "#/")
}
func folderMatchesForDocument(documentPath string) []string {
folder := path.Dir(documentPath)
if folder == "." {
return []string{""}
}
return folderMatches(folder)
}
func folderMatches(folder string) []string {
folder = normalizeFolderPath(folder)
matches := []string{""}
if folder == "" {
return matches
}
parts := strings.Split(folder, "/")
for index := range parts {
matches = append(matches, strings.Join(parts[:index+1], "/"))
}
return matches
}
func validPermission(permission Permission) bool {
switch permission {
case PermissionNone, PermissionRead, PermissionWrite, PermissionAdmin:
return true
default:
return false
}
}
func maxPermission(left Permission, right Permission) Permission {
if permissionRank(right) > permissionRank(left) {
return right
}
return left
}
func permissionRank(permission Permission) int {
switch permission {
case PermissionRead:
return 1
case PermissionWrite:
return 2
case PermissionAdmin:
return 3
default:
return 0
}
}
func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
if err != nil {

View File

@@ -409,6 +409,96 @@ func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates
return users, nil
}
func (s *Service) ListResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) {
if actor.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
if actor.Role != RoleAdmin {
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
if err != nil {
return nil, err
}
if !PermissionAllows(permission, PermissionAdmin) {
return nil, fmt.Errorf("admin permission required")
}
}
return s.repo.ListResourcePermissions(ctx, resourceType, resourceID)
}
func (s *Service) UpdateResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, updates []ResourcePermissionUpdate) ([]ResourcePermission, error) {
if actor.UserID == "" {
return nil, fmt.Errorf("authentication required")
}
if actor.Role != RoleAdmin {
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
if err != nil {
return nil, err
}
if !PermissionAllows(permission, PermissionAdmin) {
return nil, fmt.Errorf("admin permission required")
}
}
normalized := make([]ResourcePermissionUpdate, 0, len(updates))
seen := make(map[string]struct{}, len(updates))
for _, update := range updates {
update.UserID = strings.TrimSpace(update.UserID)
if update.UserID == "" {
continue
}
if _, ok := seen[update.UserID]; ok {
return nil, fmt.Errorf("duplicate user permission")
}
seen[update.UserID] = struct{}{}
update.Permission = normalizePermission(update.Permission)
if !validPermission(update.Permission) {
return nil, fmt.Errorf("invalid permission")
}
normalized = append(normalized, update)
}
if err := s.repo.ReplaceResourcePermissions(ctx, resourceType, resourceID, actor.UserID, normalized); err != nil {
return nil, err
}
s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.update", string(resourceType), resourceID, "", "", map[string]any{
"updates": len(normalized),
})
return s.repo.ListResourcePermissions(ctx, resourceType, resourceID)
}
func (s *Service) EnsureResourcePermission(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, update ResourcePermissionUpdate) error {
if actor.UserID == "" {
return fmt.Errorf("authentication required")
}
update.UserID = strings.TrimSpace(update.UserID)
if update.UserID == "" {
return fmt.Errorf("user id is required")
}
update.Permission = normalizePermission(update.Permission)
if !validPermission(update.Permission) || update.Permission == PermissionNone {
return fmt.Errorf("invalid permission")
}
if update.UserID != actor.UserID && actor.Role != RoleAdmin {
permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil)
if err != nil {
return err
}
if !PermissionAllows(permission, PermissionAdmin) {
return fmt.Errorf("admin permission required")
}
}
if err := s.repo.EnsureResourcePermission(ctx, resourceType, resourceID, actor.UserID, update); err != nil {
return err
}
s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.ensure", string(resourceType), resourceID, "", "", map[string]any{
"userID": update.UserID,
"permission": update.Permission,
})
return nil
}
func (s *Service) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) {
return s.repo.EffectiveResourcePermission(ctx, principal, resourceType, resourceID, collectionIDs)
}
func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
if !Allows(actor, ScopeAdmin) {
return fmt.Errorf("admin role required")
@@ -715,6 +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 {
if principal.UserID == "" {
return false

View File

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

View File

@@ -24,6 +24,24 @@ const (
ScopeAdmin Scope = "admin"
)
type Permission string
const (
PermissionNone Permission = ""
PermissionRead Permission = "read"
PermissionWrite Permission = "write"
PermissionAdmin Permission = "admin"
)
type ResourceType string
const (
ResourceGlobal ResourceType = "global"
ResourceCollection ResourceType = "collection"
ResourceDocument ResourceType = "document"
ResourceFolder ResourceType = "folder"
)
type Principal struct {
UserID string `json:"userId"`
Email string `json:"email"`
@@ -54,6 +72,23 @@ type UserAccessUpdate struct {
Disabled bool `json:"disabled"`
}
type ResourcePermission struct {
ID string `json:"id"`
UserID string `json:"userId"`
UserEmail string `json:"userEmail,omitempty"`
UserName string `json:"userName,omitempty"`
ResourceType ResourceType `json:"resourceType"`
ResourceID string `json:"resourceId"`
Permission Permission `json:"permission"`
GrantedBy string `json:"grantedBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type ResourcePermissionUpdate struct {
UserID string `json:"userId"`
Permission Permission `json:"permission"`
}
type PasswordResetToken struct {
ID string
UserID string

View File

@@ -94,6 +94,10 @@ func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, a
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash)
}
func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) {
return s.repo.GetComment(ctx, commentID)
}
func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error {
if principal.UserID == "" {
return fmt.Errorf("authentication required")

View File

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

View File

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

View File

@@ -109,6 +109,38 @@ func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, pat
return r.getDocumentByPath(ctx, path, true)
}
func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) {
var (
record DocumentRecord
tagsJSON string
created string
updated string
archived sql.NullString
)
err := r.db.QueryRowContext(ctx, `
SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at
FROM documents
WHERE id = ? AND archived_at IS NULL
`, id).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived)
if err != nil {
return nil, err
}
if err := parseDocumentTimes(&record, created, updated); err != nil {
return nil, err
}
if archived.Valid && archived.String != "" {
t, err := time.Parse(time.RFC3339, archived.String)
if err != nil {
return nil, fmt.Errorf("parse document archived_at: %w", err)
}
record.ArchivedAt = &t
}
if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
return nil, fmt.Errorf("unmarshal document tags: %w", err)
}
return &record, nil
}
func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) {
var (
record DocumentRecord
@@ -416,6 +448,32 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc
return results, rows.Err()
}
func (r *Repository) SearchDocumentsByTag(ctx context.Context, tag string) ([]SearchResult, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT d.path, d.title
FROM documents d
WHERE EXISTS (
SELECT 1 FROM json_each(d.tags) WHERE json_each.value = ?
) AND d.archived_at IS NULL
ORDER BY d.path
`, tag)
if err != nil {
return nil, fmt.Errorf("search documents by tag: %w", err)
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var result SearchResult
if err := rows.Scan(&result.Path, &result.Title); err != nil {
return nil, fmt.Errorf("scan tag search result: %w", err)
}
results = append(results, result)
}
return results, rows.Err()
}
func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error {
if _, err := r.db.ExecContext(ctx, `
UPDATE document_search SET content = ?

View File

@@ -384,17 +384,36 @@ func passwordResetTokenFromEmail(t *testing.T, body string) string {
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper()
return s.tokenForUser(t, s.userID, scopes...)
}
created, err := s.auth.CreateAPIKey(context.Background(), s.userID, "test token", scopes, nil)
func (s *apiTestServer) tokenForUser(t *testing.T, userID string, scopes ...auth.Scope) string {
t.Helper()
created, err := s.auth.CreateAPIKey(context.Background(), userID, "test token", scopes, nil)
if err != nil {
t.Fatalf("create api token: %v", err)
}
return created.Token
}
func (s *apiTestServer) viewerUser(t *testing.T) auth.User {
t.Helper()
ctx := context.Background()
admin := auth.Principal{UserID: s.userID, Role: auth.RoleAdmin}
if _, err := s.auth.UpdateSignupsEnabled(ctx, admin, true); err != nil {
t.Fatalf("enable signups: %v", err)
}
user, err := s.auth.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("create viewer: %v", err)
}
return user
}
func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
@@ -446,6 +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) {
server := newAPITestServer(t)
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) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
@@ -575,10 +664,12 @@ func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
@@ -637,9 +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) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background()
nestedDir := filepath.Join(server.root, "content", "guide")
@@ -663,8 +789,10 @@ func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
@@ -675,7 +803,7 @@ func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background()
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
@@ -695,8 +823,10 @@ func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}

View File

@@ -16,10 +16,23 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document_id is required"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
anchorHash := r.URL.Query().Get("anchor_hash")
var comments []collaboration.Comment
var err error
if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
} else {
@@ -45,6 +58,20 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), input.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
comment, err := s.collaboration.CreateComment(r.Context(), principal, input)
if err != nil {
@@ -63,6 +90,25 @@ func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) {
}
commentID := chi.URLParam(r, "id")
comment, err := s.collaboration.GetComment(r.Context(), commentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), comment.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
return
}
if err := s.collaboration.ResolveComment(r.Context(), principal, commentID); err != nil {
s.logger.Warn("resolve comment", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})

View File

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

View File

@@ -14,6 +14,7 @@ import (
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
@@ -211,8 +212,13 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
}
listRequest := httptest.NewRequest(http.MethodGet, "/api/attachments", nil)
listRequest = listRequest.WithContext(withPrincipal(listRequest.Context(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}))
listRecorder := httptest.NewRecorder()
server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil))
server.handleAttachmentsList(listRecorder, listRequest)
var listPayload struct {
Attachments []struct {
Kind string `json:"kind"`
@@ -233,6 +239,17 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML)
}
grants, err := server.auth.ListResourcePermissions(context.Background(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}, auth.ResourceDocument, tt.wantPath)
if err != nil {
t.Fatalf("list promoted document permissions: %v", err)
}
if len(grants) != 1 || grants[0].UserID != "user:test-admin" || grants[0].Permission != auth.PermissionAdmin {
t.Fatalf("promoted document grants = %#v, want creator admin grant", grants)
}
})
}
}
@@ -376,11 +393,25 @@ func setupUploadTestServer(t *testing.T) (*Server, string) {
sourceDir := t.TempDir()
repository := docs.NewRepository(db)
documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default())
authRepo := auth.NewRepository(db)
authService, err := auth.NewService(authRepo, "http://localhost")
if err != nil {
t.Fatalf("create auth service: %v", err)
}
if _, err := authRepo.UpsertUser(context.Background(), auth.User{
ID: "user:test-admin",
Email: "test-admin@example.com",
DisplayName: "Test Admin",
Role: auth.RoleAdmin,
}); err != nil {
t.Fatalf("create test admin user: %v", err)
}
return &Server{
documents: documents,
repository: repository,
contentStore: contentStore,
auth: authService,
}, sourceDir
}
@@ -412,6 +443,10 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde
request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request = request.WithContext(withPrincipal(request.Context(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}))
return request
}

View File

@@ -67,10 +67,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
if !ok {
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
http.Redirect(w, r, "/login?next="+urlQueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if !auth.Allows(principal, required) {
if !allowsProtectedRoute(principal, required) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
@@ -78,6 +82,20 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
})
}
func allowsProtectedRoute(principal auth.Principal, required auth.Scope) bool {
if required == auth.ScopeAdmin {
return auth.Allows(principal, required)
}
if len(principal.Scopes) > 0 {
return auth.HasScope(principal.Scopes, required)
}
return principal.UserID != ""
}
func urlQueryEscape(value string) string {
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
}
func (s *Server) setupMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context())
@@ -151,6 +169,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
path := r.URL.Path
method := r.Method
switch {
case path == "/" || (path == "/permissions" && method == http.MethodGet):
return auth.ScopeDocsRead, true
case path == "/permissions" && method == http.MethodPost:
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/auth/"):
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
return auth.ScopeDocsRead, true
@@ -169,6 +191,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeAdmin, true
case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet:
return auth.ScopeDocsWrite, true
case path == "/api/documents" || path == "/api/search" || (path == "/api/permissions" && method == http.MethodGet):
return auth.ScopeDocsRead, true
case path == "/api/permissions" && method == http.MethodPatch:
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/uploads"):
return auth.ScopeDocsWrite, true
case path == "/api/attachments":
@@ -186,6 +212,8 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/watch"):
return auth.ScopeDocsRead, true
case path == "/docs" || strings.HasPrefix(path, "/docs/"):
return auth.ScopeDocsRead, true
case strings.HasSuffix(path, "/edit"):
return auth.ScopeDocsWrite, true
default:

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

View File

@@ -107,6 +107,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/password-reset", server.handlePasswordResetPage)
router.Get("/account", server.handleAccountPage)
router.Get("/account/tokens/new", server.handleTokenCreatePage)
router.Get("/permissions", server.handlePermissionsPage)
router.Post("/permissions", server.handlePermissionsForm)
router.Get("/device/verify", server.handleDeviceVerifyPage)
router.Get("/health", server.handleHealth)
router.Get("/healthz", server.handleHealthz)
@@ -149,6 +151,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentMutation)
router.Get("/api/search", server.handleSearch)
router.Get("/api/permissions", server.handlePermissionsAPI)
router.Patch("/api/permissions", server.handlePermissionsAPI)
router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList)
router.Get("/attachments/{hash}", server.handleAttachment)

View File

@@ -3,15 +3,69 @@
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
const article = document.querySelector('[data-document-path]');
const workspaceShell = article?.closest('.workspace-shell--document');
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
const stripTrack = document.querySelector('[data-comments-strip-track]');
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
const commentToggle = document.querySelector('[data-comment-toggle]');
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
function readCommentVisibility() {
if (!commentVisibilityKey) return false;
try {
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
} catch (err) {
return false;
}
}
if (!documentId) return;
// Hash a paragraph's text content for anchoring
function syncVisibility() {
const hidden = readCommentVisibility();
if (article) article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
}
syncVisibility();
function commentsHidden() {
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
}
function setCommentsHidden(hidden) {
if (article) article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
if (commentVisibilityKey) {
try {
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
} catch (err) {
// Ignore persistence failures
}
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
commentToggle.setAttribute('aria-label', hidden ? 'Show comments' : 'Hide comments');
commentToggle.title = hidden ? 'Show comments' : 'Hide comments';
}
if (!hidden) {
renderCommentsAside();
updateStripMarkers();
}
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
commentToggle.addEventListener('click', () => {
setCommentsHidden(!commentsHidden());
});
}
function hashParagraph(el) {
const text = (el.textContent || '').trim();
if (!text) return null;
// Simple hash: first 16 chars of base64 of char codes (deterministic)
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
@@ -24,29 +78,46 @@
function addCommentButton(el, hash) {
const btn = document.createElement('button');
btn.className = 'comment-anchor-btn';
btn.type = 'button';
btn.title = 'Add comment';
btn.innerHTML = '+';
btn.setAttribute('aria-label', 'Add comment');
btn.innerHTML = `
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
<path d="M12 8v4"></path>
<path d="M10 10h4"></path>
</svg>
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
<span class="sr-only">Add comment</span>
`;
btn.addEventListener('click', () => promptComment(el, hash));
el.appendChild(btn);
}
function updateCommentButtonState(el, count) {
const btn = el.querySelector('.comment-anchor-btn');
if (!btn) return;
const hasComments = count > 0;
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
const countNode = btn.querySelector('.comment-anchor-count');
if (countNode) {
countNode.hidden = !hasComments;
countNode.textContent = count > 99 ? '99+' : String(count);
}
}
async function promptComment(el, anchorHash) {
const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return;
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId,
versionHash: documentHash,
content: text.trim(),
anchorHash,
}),
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
});
if (!res.ok) throw new Error(await res.text());
// Refresh comments display
await loadCommentsForAnchor(el, anchorHash);
} catch (err) {
window.alert('Failed to post comment: ' + err.message);
@@ -65,11 +136,17 @@
}
function renderComments(el, comments) {
// Remove existing comment list
const existing = el.querySelector('.comment-list');
if (existing) existing.remove();
if (!comments.length) return;
updateCommentButtonState(el, comments.length);
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
if (!comments.length) {
renderCommentsAside();
updateStripMarkers();
return;
}
const list = document.createElement('div');
list.className = 'comment-list';
@@ -77,15 +154,165 @@
const item = document.createElement('div');
item.className = 'comment-item';
item.innerHTML = `
<div class="comment-meta">
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
<time>${formatDate(c.createdAt)}</time>
</div>
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
<div class="comment-body">${escapeHtml(c.content)}</div>
`;
list.appendChild(item);
});
el.appendChild(list);
renderCommentsAside();
updateStripMarkers();
}
function 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) {
@@ -99,7 +326,6 @@
return d.toLocaleString();
}
// Initialize: hash all paragraphs in markdown body
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
@@ -110,9 +336,21 @@
if (!hash) return;
el.dataset.anchorHash = hash;
el.classList.add('commentable');
addCommentButton(el, hash);
if (canComment) addCommentButton(el, hash);
loadCommentsForAnchor(el, hash);
});
renderCommentsAside();
initBidirectionalHover();
// Update markers on scroll/resize
const shell = document.querySelector('.document-shell');
if (shell) {
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
}
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
// Initial marker update after layout settles
setTimeout(updateStripMarkers, 100);
}
if (document.readyState === 'loading') {

View File

@@ -0,0 +1,36 @@
(() => {
const root = document.querySelector("[data-permission-bulk]");
if (!root) return;
const selectAll = root.querySelector("[data-permission-select-all]");
const bulkLevel = root.querySelector("[data-permission-bulk-level]");
const apply = root.querySelector("[data-permission-apply]");
const rows = Array.from(document.querySelectorAll(".permission-user-row"));
const checkedRows = () => rows.filter((row) => row.querySelector("[data-permission-user]")?.checked);
const updateSelectAll = () => {
const selected = checkedRows().length;
selectAll.checked = selected > 0 && selected === rows.length;
selectAll.indeterminate = selected > 0 && selected < rows.length;
};
selectAll?.addEventListener("change", () => {
rows.forEach((row) => {
const checkbox = row.querySelector("[data-permission-user]");
if (checkbox) checkbox.checked = selectAll.checked;
});
updateSelectAll();
});
rows.forEach((row) => {
row.querySelector("[data-permission-user]")?.addEventListener("change", updateSelectAll);
});
apply?.addEventListener("click", () => {
checkedRows().forEach((row) => {
const select = row.querySelector("[data-permission-select]");
if (select) select.value = bulkLevel.value;
});
});
})();

View File

@@ -144,7 +144,7 @@
const html = document.documentElement.outerHTML;
const title = document.title;
const pagePath = window.MDHubCache.normalizePath(window.location.pathname);
const pagePath = window.MDHubCache.normalizePath(window.location.pathname + window.location.search);
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
}
@@ -373,4 +373,72 @@
}).catch(function () {});
}
});
// Add folder button handlers
if (browser) {
browser.querySelectorAll(".miller-column-add-folder").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var parentPath = button.getAttribute("data-folder-path");
var folderName = window.prompt("Folder name:");
if (!folderName || !folderName.trim()) return;
folderName = folderName.trim().replace(/[\\/]/g, "-");
var docPath = parentPath ? parentPath + "/" + folderName + "/index.md" : folderName + "/index.md";
var displayPath = docPath.replace(/\.md$/, "");
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# " + folderName + "\n\n", baseHash: "" }),
}).then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
var path = data.path || docPath;
var urlPath = path.replace(/\.md$/, "");
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
});
} else {
return res.json().then(function (data) {
throw new Error(data.error || "Failed to create folder");
});
}
}).catch(function (err) {
window.alert(err.message);
});
});
});
browser.querySelectorAll(".miller-column-new-file").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var parentPath = button.getAttribute("data-folder-path");
var fileName = window.prompt("File name:");
if (!fileName || !fileName.trim()) return;
fileName = fileName.trim().replace(/[\\/]/g, "-");
if (!fileName.endsWith(".md")) fileName += ".md";
var docPath = parentPath ? parentPath + "/" + fileName : fileName;
var displayPath = docPath.replace(/\.md$/, "");
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# " + fileName.replace(/\.md$/, "") + "\n\n", baseHash: "" }),
}).then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
var path = data.path || docPath;
var urlPath = path.replace(/\.md$/, "");
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
});
} else {
return res.json().then(function (data) {
throw new Error(data.error || "Failed to create file");
});
}
}).catch(function (err) {
window.alert(err.message);
});
});
});
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ const STATIC_ASSETS = [
"/static/realtime.js",
"/static/editor.js",
"/static/render.js",
"/static/tags.js",
"/static/favicon.png",
"/static/cairnquire%20logo%402x.webp",
"/",
@@ -64,12 +65,13 @@ self.addEventListener("fetch", function (event) {
});
async function handleNavigation(request, url) {
const cacheKey = url.pathname + url.search;
try {
const response = await fetch(request);
cachePageResponse(url.pathname, response.clone());
cachePageResponse(cacheKey, response.clone());
return response;
} catch (_error) {
const cachedPage = await getCachedPage(url.pathname);
const cachedPage = await getCachedPage(cacheKey);
if (cachedPage && cachedPage.html) {
return new Response(markOfflineHTML(cachedPage.html), {
headers: {
@@ -80,6 +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("/");
if (fallback) {
return fallback;

View File

@@ -0,0 +1,81 @@
(function () {
const url = new URL(window.location.href);
const tag = url.searchParams.get("tag");
if (!tag) return;
// Check if we're on a proper tag page or a fallback
const hasTagPreview = document.querySelector(".tag-preview");
if (hasTagPreview) return; // Server rendered tag page correctly
// We're on a fallback page (likely offline) — build tag view from cache
if (!window.MDHubCache) return;
window.MDHubCache.getCachedDocuments().then(function (docs) {
if (!docs || !docs.length) return;
const tagged = docs.filter(function (doc) {
return doc.tags && doc.tags.indexOf(tag) !== -1;
});
// Build a minimal tag browser
const shell = document.querySelector(".workspace-shell");
if (!shell) return;
const browser = shell.querySelector(".miller-browser");
const preview = shell.querySelector(".empty-preview") || shell.querySelector(".document-shell");
if (browser) {
// Replace browser with a single column of tagged docs
const itemsHtml = tagged
.sort(function (a, b) {
const aName = (a.title || a.path).toLowerCase();
const bName = (b.title || b.path).toLowerCase();
return aName < bName ? -1 : aName > bName ? 1 : 0;
})
.map(function (doc) {
const name = doc.title || doc.path;
const path = doc.path.replace(/\.md$/, "");
return (
'<li><a href="/docs/' +
escapeHtml(path) +
'" title="' +
escapeHtml(name) +
'"><span class="browser-item-label"><svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path></svg><span class="browser-item-name">' +
escapeHtml(name) +
"</span></span></a></li>"
);
})
.join("");
browser.innerHTML =
'<section class="miller-column miller-column--root" aria-label="#' +
escapeHtml(tag) +
'"><h2><span class="miller-column-title-wrap"><span class="miller-column-title-text">#' +
escapeHtml(tag) +
"</span></span></h2><ul>" +
itemsHtml +
"</ul></section>";
}
if (preview) {
preview.outerHTML =
'<section class="tag-preview"><p class="eyebrow">Tag</p><h1>#' +
escapeHtml(tag) +
'</h1><p class="lede">' +
tagged.length +
" document" +
(tagged.length === 1 ? "" : "s") +
" found</p></section>";
}
});
function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();

View File

@@ -29,7 +29,12 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
return
}
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, principal.UserID)
canRead, _, accessErr := s.syncAccessFilters(r)
snapshot, err := s.syncService.InitSyncFiltered(r.Context(), req.DeviceID, principal.UserID, canRead)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
s.logger.Error("sync init failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
@@ -60,7 +65,12 @@ func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
return
}
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta})
canRead, canWrite, accessErr := s.syncAccessFilters(r)
result, err := s.syncService.ApplyDeltaFiltered(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta}, canRead, canWrite)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
@@ -100,7 +110,12 @@ func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
return
}
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions)
canRead, canWrite, accessErr := s.syncAccessFilters(r)
snapshot, err := s.syncService.ResolveConflictsFiltered(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions, canRead, canWrite)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
@@ -148,3 +163,45 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(content)
}
func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.PathAccessFunc, func() error) {
var accessErr error
canRead := func(file sync.FileEntry) bool {
if accessErr != nil {
return false
}
record, err := s.repository.GetDocumentByPath(r.Context(), file.Path)
if err != nil {
accessErr = err
return false
}
ok, err := s.canReadDocumentRecord(r, *record)
if err != nil {
accessErr = err
return false
}
return ok
}
canWrite := func(path string) bool {
if accessErr != nil {
return false
}
record, err := s.repository.GetDocumentByPath(r.Context(), path)
if err == nil && record != nil {
ok, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
accessErr = err
return false
}
return ok
}
if os.IsNotExist(err) {
return s.canWriteNewDocument(r, path)
}
if err != nil {
return s.canWriteNewDocument(r, path)
}
return false
}
return canRead, canWrite, func() error { return accessErr }
}

View File

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

View File

@@ -2,8 +2,18 @@
{{ define "document_content" }}
<section class="workspace-shell workspace-shell--document">
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
{{ template "browser" .Browser }}
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
<div class="document-mobile-bar">
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
</button>
<span class="document-mobile-bar__title" aria-hidden="true">{{ .Title }}</span>
<button class="document-mobile-bar__btn document-mobile-bar__btn--meta" type="button" data-toggle-meta aria-label="Open document info" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
</button>
</div>
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
@@ -18,15 +28,21 @@
{{ end }}
</ol>
</nav>
<div class="document-meta">
<div class="document-meta" data-meta-drawer>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-meta aria-label="Close document info">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<div class="document-meta__header">
<h1>{{ .Title }}</h1>
{{ if .CanWrite }}
<div class="document-actions">
{{ if .CanWrite }}
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
</div>
{{ 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>
<details class="document-meta-panel">
<summary>
@@ -61,18 +77,74 @@
{{ .HTML }}
</div>
</article>
<aside class="document-comments-aside" data-comments-aside aria-label="Comments">
<div class="document-comments-strip" data-comments-strip>
<button
class="document-comments-strip__toggle"
type="button"
data-comment-toggle
aria-pressed="false"
aria-label="Toggle comments panel"
title="Toggle comments"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
</svg>
</button>
<div class="document-comments-strip__track" data-comments-strip-track></div>
</div>
<div class="document-comments-panel">
<div class="document-comments-panel__list" data-comments-aside-list></div>
</div>
<!-- Mobile bottom sheet -->
<div class="document-comments-sheet" data-comments-sheet>
<div class="document-comments-sheet__handle">
<button class="document-comments-sheet__toggle" type="button" data-toggle-comments aria-expanded="false" aria-label="Toggle comments">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
</svg>
<span>Comments</span>
</button>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-comments aria-label="Close comments">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="document-comments-sheet__panel">
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
</div>
</div>
</aside>
</section>
{{ end }}
{{ define "browser" }}
<nav class="miller-browser" aria-label="Documents">
<nav class="miller-browser" aria-label="Documents" data-picker-drawer>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-picker aria-label="Close file picker">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
{{ range .Columns }}
<section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}">
<h2 title="{{ .Title }}">
<h2 title="{{ .Title }}" {{ if $.CanWrite }}class="has-actions"{{ end }}>
<span class="miller-column-title-wrap">
{{ if eq .Title "Content" }}
{{ template "icon-library" }}
{{ end }}
<span class="miller-column-title-text">{{ .Title }}</span>
</span>
{{ if $.CanWrite }}
<span class="miller-column-actions">
<button type="button" class="miller-column-new-file" data-folder-path="{{ .Path }}" aria-label="New file in {{ .Title }}">
{{ template "icon-file-plus" }}
</button>
<button type="button" class="miller-column-add-folder" data-folder-path="{{ .Path }}" aria-label="Add folder to {{ .Title }}">
{{ template "icon-folder-plus" }}
</button>
</span>
{{ end }}
</h2>
<ul>
{{ range .Items }}
@@ -117,6 +189,23 @@
</svg>
{{ end }}
{{ define "icon-folder-plus" }}
<svg class="browser-icon browser-icon--folder" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
{{ end }}
{{ define "icon-file-plus" }}
<svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
{{ end }}
{{ define "icon-chevron-right" }}
<svg class="browser-item-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"></path>

View File

@@ -0,0 +1,71 @@
{{ define "permissions.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "permissions_content" }}
<section class="permissions-shell">
<header class="permissions-header">
<div>
<p class="eyebrow">Access control</p>
<h1>{{ .Title }}</h1>
</div>
{{ if eq .ResourceType "document" }}
<a class="account-action" href="/docs/{{ trimMd .ResourceID }}">Back to page</a>
{{ end }}
</header>
<form class="permissions-form" method="post" action="/permissions">
<input type="hidden" name="resourceType" value="{{ .ResourceType }}" />
<input type="hidden" name="resourceId" value="{{ .ResourceID }}" />
<section class="permissions-panel">
<header class="permissions-panel__header">
<div>
<p class="account-section__kicker">People</p>
<h2>User permissions</h2>
</div>
<div class="permission-bulk-actions" data-permission-bulk>
<label class="permission-select-all">
<input type="checkbox" data-permission-select-all />
<span>Select all</span>
</label>
<select data-permission-bulk-level aria-label="Access level for selected users">
<option value="">No access</option>
<option value="read">View</option>
<option value="write">Edit</option>
<option value="admin">Admin</option>
</select>
<button class="btn-secondary permission-apply" type="button" data-permission-apply>Apply</button>
</div>
</header>
<div class="permission-user-list">
{{ range .Users }}
<label class="permission-user-row">
<input class="permission-user-check" type="checkbox" data-permission-user />
<span class="permission-user-main">
<span class="permission-user-name">
<strong>{{ .DisplayName }}</strong>
<em>{{ .Role }}</em>
</span>
<span class="permission-user-email">{{ .Email }}</span>
<span class="permission-user-meta">Created {{ .CreatedAt }} · Last seen {{ .LastSeenAt }}</span>
</span>
<span class="permission-grant-meta">
<strong>{{ .PermissionText }}</strong>
<small>{{ .GrantSummary }}</small>
</span>
<span class="permission-select-wrap">
<select name="permission:{{ .ID }}" aria-label="Permission for {{ .DisplayName }}" data-permission-select>
<option value="" {{ if eq .Permission "" }}selected{{ end }}>No access</option>
<option value="read" {{ if eq .Permission "read" }}selected{{ end }}>View</option>
<option value="write" {{ if eq .Permission "write" }}selected{{ end }}>Edit</option>
<option value="admin" {{ if eq .Permission "admin" }}selected{{ end }}>Admin</option>
</select>
</span>
</label>
{{ end }}
</div>
<div class="permissions-footer">
<button class="btn-primary" type="submit">Save permissions</button>
</div>
</section>
</form>
</section>
{{ end }}

View File

@@ -0,0 +1,12 @@
{{ define "tag.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "tag_content" }}
<section class="workspace-shell workspace-shell--empty">
{{ template "browser" .Browser }}
<section class="tag-preview">
<p class="eyebrow">Tag</p>
<h1>#{{ .Tag }}</h1>
<p class="lede">{{ len .Results }} document{{ if ne (len .Results) 1 }}s{{ end }} found</p>
</section>
</section>
{{ end }}

View File

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

View File

@@ -17,10 +17,10 @@ services:
CAIRNQUIRE_EMAIL_FROM: "${CAIRNQUIRE_EMAIL_FROM:-notifications@cairnquire.local}"
CAIRNQUIRE_LOG_LEVEL: "${CAIRNQUIRE_LOG_LEVEL:-INFO}"
volumes:
- cairnquire_data:/workspace/data
- ./content:/workspace/content
- ./data:/workspace/data
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:{$PORT}/health"]
test: ["CMD", "curl", "--fail", "http://127.0.0.1:${PORT}/healthz"]
interval: 10s
timeout: 3s
retries: 5
@@ -34,3 +34,7 @@ services:
environment:
MP_UI_BIND_ADDR: "0.0.0.0:8025"
MP_SMTP_BIND_ADDR: "0.0.0.0:1025"
volumes:
cairnquire_data:
driver: local

22
entrypoint.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -eu
# Ensure runtime directories exist before chowning them; otherwise chown -R on a
# bind-mounted or freshly-created named volume can race with MkdirAll in the app.
mkdir -p /workspace/data/files /workspace/content
# Make the runtime dirs owned by appuser so the Go process can write attachments
# and read content even when /workspace/data is a host bind mount created as root
# (or a named volume initialized with the container's root uid).
chown -R appuser:appuser /workspace/data /workspace/content
# Drop to the unprivileged appuser and hand control to the container CMD.
# gosu handles signal forwarding and TTY correctly; plain `su` and `su -c` do not.
exec gosu appuser "$@"
# Smoke test (run on a host with Docker available):
# docker compose build app
# docker compose up -d app
# docker compose exec app id # should print: uid=<appuser-uid>(appuser) gid=<appuser-gid>(appuser)
# docker compose exec app ls -ld /workspace/data # should be owned by appuser
# curl -fsS http://localhost:${PORT:-8080}/healthz