Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ func (d *Database) setSyncState(ctx context.Context, userID, key, value string)
return err
}

func (d *Database) hasAnyOtherUserWithLibraryAccess(ctx context.Context, excludeUserID string) (bool, error) {
var exists bool
err := d.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM syncState WHERE key = 'hasLibraryAccess' AND value = 'true' AND userID != ?)`,
excludeUserID,
).Scan(&exists)
return exists, err
}

func (d *Database) deleteSyncState(ctx context.Context, userID, key string) {
if _, err := d.db.ExecContext(ctx, "DELETE FROM syncState WHERE userID = ? AND key = ?", userID, key); err != nil {
log.Printf("[DB] Failed to delete sync state %s for user %s: %v", key, userID, err)
Expand Down
50 changes: 50 additions & 0 deletions backend/database_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1067,3 +1067,53 @@ func TestDeleteUserSyncData(t *testing.T) {
t.Errorf("expected other user syncState preserved, got %v", otherSync)
}
}

func TestHasAnyOtherUserWithLibraryAccess(t *testing.T) {
ctx := context.Background()
db := newTestDB(t)
otherUserID := "other-user-id"
if err := db.createUser(ctx, otherUserID, "other@example.com", "hashed"); err != nil {
t.Fatalf("create other user: %v", err)
}

has, err := db.hasAnyOtherUserWithLibraryAccess(ctx, testUserID)
if err != nil {
t.Fatalf("empty state: %v", err)
}
if has {
t.Errorf("empty state: expected false, got true")
}

if err := db.setSyncState(ctx, testUserID, "hasLibraryAccess", "true"); err != nil {
t.Fatalf("set excluded user: %v", err)
}
has, err = db.hasAnyOtherUserWithLibraryAccess(ctx, testUserID)
if err != nil {
t.Fatalf("only excluded set: %v", err)
}
if has {
t.Errorf("only excluded user has access: expected false, got true")
}

if err := db.setSyncState(ctx, otherUserID, "hasLibraryAccess", "false"); err != nil {
t.Fatalf("set other false: %v", err)
}
has, err = db.hasAnyOtherUserWithLibraryAccess(ctx, testUserID)
if err != nil {
t.Fatalf("other false: %v", err)
}
if has {
t.Errorf("other user has value=false: expected false, got true")
}

if err := db.setSyncState(ctx, otherUserID, "hasLibraryAccess", "true"); err != nil {
t.Fatalf("set other true: %v", err)
}
has, err = db.hasAnyOtherUserWithLibraryAccess(ctx, testUserID)
if err != nil {
t.Fatalf("other true: %v", err)
}
if !has {
t.Errorf("other user has value=true: expected true, got false")
}
}
1 change: 1 addition & 0 deletions backend/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type SyncStore interface {
getSyncState(ctx context.Context, userID, key string) (*string, error)
setSyncState(ctx context.Context, userID, key, value string) error
deleteSyncState(ctx context.Context, userID, key string)
hasAnyOtherUserWithLibraryAccess(ctx context.Context, excludeUserID string) (bool, error)
upsertAssets(ctx context.Context, userID string, assets []AssetRow) error
batchUpdateStackInfo(ctx context.Context, userID string, updates []stackUpdateRow) (int, error)
computeFrequentLocationClusters(ctx context.Context, userID string) ([]FrequentLocationRow, error)
Expand Down
15 changes: 15 additions & 0 deletions backend/syncService.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,21 @@ func (s *SyncService) fetchAndReplaceAlbumAssets(ctx context.Context, userID str
}

func (s *SyncService) syncLibraries(ctx context.Context, userID string, immich SyncImmichAPI) error {
hasAccess, err := s.db.getSyncState(ctx, userID, "hasLibraryAccess")
if err != nil {
return fmt.Errorf("failed to check library access: %w", err)
}
if hasAccess != nil && *hasAccess == "false" {
otherHasAccess, err := s.db.hasAnyOtherUserWithLibraryAccess(ctx, userID)
if err != nil {
return fmt.Errorf("failed to check other users library access: %w", err)
}
if otherHasAccess {
return nil
}
// no early return: retry the API in case access was granted since the last 403.
}

log.Printf("[Sync] Syncing libraries for user %s...", userID)

apiCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
Expand Down
64 changes: 64 additions & 0 deletions backend/syncService_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,70 @@ func TestSyncLibrariesDeletesStale(t *testing.T) {
}
}

func TestSyncLibrariesSkipsWhenOtherUserHasAccess(t *testing.T) {
ctx := context.Background()

factory, immich := newMockImmichFactoryNoRetry(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/libraries" {
t.Errorf("unexpected call to /api/libraries: should have been skipped")
}
http.NotFound(w, r)
})

db := newTestDB(t)
if err := db.setSyncState(ctx, testUserID, "hasLibraryAccess", "false"); err != nil {
t.Fatalf("set hasLibraryAccess for testUserID: %v", err)
}
if err := db.setSyncState(ctx, "otherUser", "hasLibraryAccess", "true"); err != nil {
t.Fatalf("set hasLibraryAccess for otherUser: %v", err)
}

svc := newSyncService(db, factory, newNominatimClient(10*time.Second))
if err := svc.syncLibraries(ctx, testUserID, immich); err != nil {
t.Fatalf("syncLibraries: %v", err)
}
}

func TestSyncLibrariesRetriesWhenNoOneHasAccess(t *testing.T) {
ctx := context.Background()

factory, immich := newMockImmichFactory(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/libraries" {
json.NewEncoder(w).Encode([]ImmichLibraryResponse{
{ID: "lib1", Name: "Photos", AssetCount: 100},
})
return
}
http.NotFound(w, r)
})

db := newTestDB(t)
if err := db.setSyncState(ctx, testUserID, "hasLibraryAccess", "false"); err != nil {
t.Fatalf("set hasLibraryAccess: %v", err)
}

svc := newSyncService(db, factory, newNominatimClient(10*time.Second))
if err := svc.syncLibraries(ctx, testUserID, immich); err != nil {
t.Fatalf("syncLibraries: %v", err)
}

libs, err := db.getLibraries(ctx)
if err != nil {
t.Fatalf("getLibraries: %v", err)
}
if len(libs) != 1 {
t.Fatalf("expected 1 library, got %d", len(libs))
}

hasAccess, err := db.getSyncState(ctx, testUserID, "hasLibraryAccess")
if err != nil {
t.Fatalf("get hasLibraryAccess: %v", err)
}
if hasAccess == nil || *hasAccess != "true" {
t.Errorf("expected hasLibraryAccess=true after successful retry, got %v", hasAccess)
}
}

func TestSyncAlbumsSuccess(t *testing.T) {
ctx := context.Background()

Expand Down
Loading