update sync
This commit is contained in:
@@ -8,6 +8,8 @@ public struct ClipboardNotice: Identifiable, Equatable {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||||
|
public static let minimumSearchCharacters = 3
|
||||||
|
|
||||||
@Published public var config: SyncConfig
|
@Published public var config: SyncConfig
|
||||||
@Published public var isSyncing = false
|
@Published public var isSyncing = false
|
||||||
@Published public var lastSyncTime: Date?
|
@Published public var lastSyncTime: Date?
|
||||||
@@ -322,7 +324,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
|
|
||||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else {
|
guard trimmed.count >= Self.minimumSearchCharacters else {
|
||||||
searchResults = []
|
searchResults = []
|
||||||
isSearching = false
|
isSearching = false
|
||||||
searchError = nil
|
searchError = nil
|
||||||
@@ -333,11 +335,11 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
searchError = nil
|
searchError = nil
|
||||||
searchTask = Task { [weak self] in
|
searchTask = Task { [weak self] in
|
||||||
do {
|
do {
|
||||||
try await Task.sleep(for: .milliseconds(180))
|
try await Task.sleep(for: .milliseconds(300))
|
||||||
await self?.performSearch(for: trimmed)
|
await self?.performSearch(for: trimmed)
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if self?.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
|
if self?.isCurrentSearchQuery(trimmed) == true {
|
||||||
self?.isSearching = false
|
self?.isSearching = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,18 +370,24 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
do {
|
do {
|
||||||
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
|
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
guard searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == query else { return }
|
guard isCurrentSearchQuery(query) else { return }
|
||||||
searchResults = Array(results.prefix(8))
|
searchResults = Array(results.prefix(8))
|
||||||
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
|
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
|
||||||
searchError = nil
|
searchError = nil
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
guard isCurrentSearchQuery(query) else { return }
|
||||||
searchResults = []
|
searchResults = []
|
||||||
searchError = error.localizedDescription
|
searchError = error.localizedDescription
|
||||||
}
|
}
|
||||||
isSearching = false
|
isSearching = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isCurrentSearchQuery(_ query: String) -> Bool {
|
||||||
|
let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed == query && trimmed.count >= Self.minimumSearchCharacters
|
||||||
|
}
|
||||||
|
|
||||||
private func resetSearch() {
|
private func resetSearch() {
|
||||||
searchTask?.cancel()
|
searchTask?.cancel()
|
||||||
searchQuery = ""
|
searchQuery = ""
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public struct StatusPopoverView: View {
|
|||||||
.background(Color(NSColor.controlBackgroundColor))
|
.background(Color(NSColor.controlBackgroundColor))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
if !controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).count >= MenuBarController.minimumSearchCharacters {
|
||||||
searchResultsList
|
searchResultsList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
|
||||||
server := newAPITestServer(t)
|
server := newAPITestServer(t)
|
||||||
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||||
|
|||||||
@@ -359,8 +359,30 @@ func (s *Server) canWriteNewDocument(r *http.Request, pagePath string) bool {
|
|||||||
if principal.Role == auth.RoleAdmin {
|
if principal.Role == auth.RoleAdmin {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
folder := strings.TrimSuffix(pagePath, "/"+lastPathSegment(pagePath))
|
return s.canWriteFolder(r, documentFolder(pagePath))
|
||||||
return s.canWriteFolder(r, folder)
|
}
|
||||||
|
|
||||||
|
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 {
|
func (s *Server) canWriteFolder(r *http.Request, folder string) bool {
|
||||||
@@ -395,6 +417,15 @@ func lastPathSegment(path string) string {
|
|||||||
return parts[len(parts)-1]
|
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 {
|
func queryEscape(value string) string {
|
||||||
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"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)
|
record, err := s.repository.GetDocumentByPath(r.Context(), file.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return s.canReadNewDocument(r, file.Path)
|
||||||
|
}
|
||||||
accessErr = err
|
accessErr = err
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user