diff --git a/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift b/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift index a57e0ee..17e9143 100644 --- a/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift +++ b/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift @@ -8,6 +8,8 @@ public struct ClipboardNotice: Identifiable, Equatable { @MainActor public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate { + public static let minimumSearchCharacters = 3 + @Published public var config: SyncConfig @Published public var isSyncing = false @Published public var lastSyncTime: Date? @@ -322,7 +324,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate { searchTask?.cancel() let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { + guard trimmed.count >= Self.minimumSearchCharacters else { searchResults = [] isSearching = false searchError = nil @@ -333,11 +335,11 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate { searchError = nil searchTask = Task { [weak self] in do { - try await Task.sleep(for: .milliseconds(180)) + try await Task.sleep(for: .milliseconds(300)) await self?.performSearch(for: trimmed) } catch { await MainActor.run { - if self?.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed { + if self?.isCurrentSearchQuery(trimmed) == true { self?.isSearching = false } } @@ -368,18 +370,24 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate { do { let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query)) guard !Task.isCancelled else { return } - guard searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == query else { return } + guard isCurrentSearchQuery(query) else { return } searchResults = Array(results.prefix(8)) selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0)) searchError = nil } catch { guard !Task.isCancelled else { return } + guard isCurrentSearchQuery(query) else { return } searchResults = [] searchError = error.localizedDescription } isSearching = false } + private func isCurrentSearchQuery(_ query: String) -> Bool { + let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed == query && trimmed.count >= Self.minimumSearchCharacters + } + private func resetSearch() { searchTask?.cancel() searchQuery = "" diff --git a/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift b/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift index 9830415..ce15b18 100644 --- a/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift +++ b/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift @@ -184,7 +184,7 @@ public struct StatusPopoverView: View { .background(Color(NSColor.controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 8)) - if !controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).count >= MenuBarController.minimumSearchCharacters { searchResultsList } } diff --git a/apps/server/internal/httpserver/api_handlers_test.go b/apps/server/internal/httpserver/api_handlers_test.go index f927e43..bcd9ff8 100644 --- a/apps/server/internal/httpserver/api_handlers_test.go +++ b/apps/server/internal/httpserver/api_handlers_test.go @@ -552,6 +552,48 @@ func TestAPISyncSnapshotInheritsResourcePermissions(t *testing.T) { } } +func TestAPISyncSnapshotHandlesUnindexedDiskFilesByFolderPermission(t *testing.T) { + server := newAPITestServer(t) + viewer := server.viewerUser(t) + token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite) + + if err := os.WriteFile(filepath.Join(server.root, "content", "unindexed.md"), []byte("# Unindexed\n"), 0o644); err != nil { + t.Fatalf("write unindexed document: %v", err) + } + + initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-1"}) + initRequest.Header.Set("Authorization", "Bearer "+token) + initRecorder := httptest.NewRecorder() + server.handler.ServeHTTP(initRecorder, initRequest) + if initRecorder.Code != http.StatusOK { + t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String()) + } + if strings.Contains(initRecorder.Body.String(), "sql: no rows") { + t.Fatalf("sync init leaked missing document row: %s", initRecorder.Body.String()) + } + if strings.Contains(initRecorder.Body.String(), "unindexed.md") { + t.Fatalf("private unindexed document leaked before grant: %s", initRecorder.Body.String()) + } + + admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin} + if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{ + {UserID: viewer.ID, Permission: auth.PermissionRead}, + }); err != nil { + t.Fatalf("grant root folder read: %v", err) + } + + initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-unindexed-2"}) + initRequest.Header.Set("Authorization", "Bearer "+token) + initRecorder = httptest.NewRecorder() + server.handler.ServeHTTP(initRecorder, initRequest) + if initRecorder.Code != http.StatusOK { + t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String()) + } + if !strings.Contains(initRecorder.Body.String(), "unindexed.md") { + t.Fatalf("sync snapshot missing unindexed document after folder grant: %s", initRecorder.Body.String()) + } +} + func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) diff --git a/apps/server/internal/httpserver/permissions_handlers.go b/apps/server/internal/httpserver/permissions_handlers.go index acbc645..f92c3a5 100644 --- a/apps/server/internal/httpserver/permissions_handlers.go +++ b/apps/server/internal/httpserver/permissions_handlers.go @@ -359,8 +359,30 @@ func (s *Server) canWriteNewDocument(r *http.Request, pagePath string) bool { if principal.Role == auth.RoleAdmin { return true } - folder := strings.TrimSuffix(pagePath, "/"+lastPathSegment(pagePath)) - return s.canWriteFolder(r, folder) + return s.canWriteFolder(r, documentFolder(pagePath)) +} + +func (s *Server) canReadNewDocument(r *http.Request, pagePath string) bool { + principal, ok := principalFromContext(r.Context()) + if !ok { + return false + } + if principal.Role == auth.RoleAdmin { + return true + } + return s.canReadFolder(r, documentFolder(pagePath)) +} + +func (s *Server) canReadFolder(r *http.Request, folder string) bool { + principal, ok := principalFromContext(r.Context()) + if !ok { + return false + } + if principal.Role == auth.RoleAdmin { + return true + } + permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil) + return err == nil && auth.PermissionAllows(permission, auth.PermissionRead) } func (s *Server) canWriteFolder(r *http.Request, folder string) bool { @@ -395,6 +417,15 @@ func lastPathSegment(path string) string { return parts[len(parts)-1] } +func documentFolder(path string) string { + trimmed := strings.Trim(path, "/") + segment := lastPathSegment(trimmed) + if segment == "" || segment == trimmed { + return "" + } + return strings.TrimSuffix(trimmed, "/"+segment) +} + func queryEscape(value string) string { return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value) } diff --git a/apps/server/internal/httpserver/sync_handlers.go b/apps/server/internal/httpserver/sync_handlers.go index 124ce8b..e32e145 100644 --- a/apps/server/internal/httpserver/sync_handlers.go +++ b/apps/server/internal/httpserver/sync_handlers.go @@ -1,6 +1,7 @@ package httpserver import ( + "database/sql" "encoding/json" "errors" "net/http" @@ -172,6 +173,9 @@ func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.P } record, err := s.repository.GetDocumentByPath(r.Context(), file.Path) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return s.canReadNewDocument(r, file.Path) + } accessErr = err return false }