diff --git a/backend/cmd/server/router_test.go b/backend/cmd/server/router_test.go index 91e0789..cf8d5fb 100644 --- a/backend/cmd/server/router_test.go +++ b/backend/cmd/server/router_test.go @@ -10,6 +10,7 @@ var expectedRoutes = []string{ "GET /api/v1/health", "POST /api/v1/devices", "POST /api/v1/auth/login", + "GET /api/v1/shares/links/:token", "GET /api/v1/files", "GET /api/v1/files/:id", diff --git a/backend/db/migrations/000009_create_shares.down.sql b/backend/db/migrations/000009_create_shares.down.sql new file mode 100644 index 0000000..10829b4 --- /dev/null +++ b/backend/db/migrations/000009_create_shares.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS share_links; +DROP TABLE IF EXISTS shares; diff --git a/backend/db/migrations/000009_create_shares.up.sql b/backend/db/migrations/000009_create_shares.up.sql new file mode 100644 index 0000000..63e1b88 --- /dev/null +++ b/backend/db/migrations/000009_create_shares.up.sql @@ -0,0 +1,34 @@ +-- Partages user→user (docs/api-v1.md §6.1) : une seule ligne active par +-- (resource, grantee) — soft-revoked_at ; le déclin de l'outbox propage +-- état, pas l'inverse. +CREATE TABLE shares ( + id TEXT PRIMARY KEY CHECK (id ~ '^[0-9a-f]{32}$'), + resource_id TEXT NOT NULL REFERENCES resources(resource_id) ON DELETE CASCADE, + grantee_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access TEXT NOT NULL CHECK (access IN ('viewer','commenter','editor')), + inherit BOOLEAN NOT NULL DEFAULT TRUE, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_shares_resource ON shares(resource_id); +CREATE INDEX idx_shares_grantee ON shares(grantee_user_id) WHERE revoked_at IS NULL; +-- Une seule ligne active par paire (resource, grantee) +CREATE UNIQUE INDEX idx_shares_active ON shares(resource_id, grantee_user_id) WHERE revoked_at IS NULL; + +-- Liens de partage publics : token = id (généré par le client, 32-hex). +CREATE TABLE share_links ( + id TEXT PRIMARY KEY CHECK (id ~ '^[0-9a-f]{32}$'), + resource_id TEXT NOT NULL REFERENCES resources(resource_id) ON DELETE CASCADE, + access TEXT NOT NULL CHECK (access IN ('viewer','commenter','editor')), + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_share_links_token ON share_links(id) WHERE revoked_at IS NULL; +CREATE INDEX idx_share_links_resource ON share_links(resource_id); diff --git a/backend/db/migrations_test.go b/backend/db/migrations_test.go index 85ab2aa..4d0742f 100644 --- a/backend/db/migrations_test.go +++ b/backend/db/migrations_test.go @@ -221,6 +221,55 @@ func TestMigrationsUpDown(t *testing.T) { t.Error("contrainte UNIQUE(device_id, operation_id) manquante sur operations") } + // --- 000009 : shares + share_links ------------------------------------ + assertTables(t, conn, "users", "devices", "resources", "operations", "ocr_jobs", "shares", "share_links", "schema_migrations") + + assertHexCheck(t, conn, "shares", "id") + assertHexCheck(t, conn, "share_links", "id") + + // shares access CHECK + var sharesAccessCheck int + err = conn.QueryRow(` + SELECT COUNT(*) FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'shares' + AND pg_get_constraintdef(c.oid) LIKE '%viewer%' + AND pg_get_constraintdef(c.oid) LIKE '%editor%'`).Scan(&sharesAccessCheck) + if err != nil { + t.Fatalf("check shares access: %v", err) + } + if sharesAccessCheck == 0 { + t.Error("CHECK (access IN ('viewer','commenter','editor')) manquant sur shares") + } + + // shares unique partiel (resource_id, grantee_user_id) WHERE revoked_at IS NULL + var sharesActiveIdx int + err = conn.QueryRow(` + SELECT COUNT(*) FROM pg_index i + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'shares' AND i.indisunique + AND i.indpred IS NOT NULL`).Scan(&sharesActiveIdx) + if err != nil { + t.Fatalf("shares active index: %v", err) + } + if sharesActiveIdx == 0 { + t.Error("index unique partiel (resource_id, grantee_user_id) WHERE revoked_at IS NULL manquant sur shares") + } + + // share_links token unique partiel + var linksTokenIdx int + err = conn.QueryRow(` + SELECT COUNT(*) FROM pg_index i + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'share_links' AND i.indisunique + AND i.indpred IS NOT NULL`).Scan(&linksTokenIdx) + if err != nil { + t.Fatalf("share_links token index: %v", err) + } + if linksTokenIdx == 0 { + t.Error("index unique partiel (id) WHERE revoked_at IS NULL manquant sur share_links") + } + if err := MigrateDownDatabase(url); err != nil { t.Fatalf("migrate down: %v", err) } diff --git a/backend/handlers/router.go b/backend/handlers/router.go index 98713c5..2b75b6a 100644 --- a/backend/handlers/router.go +++ b/backend/handlers/router.go @@ -10,14 +10,16 @@ import ( ) // RegisterRoutes wires the full /api/v1 surface (public + protected). -// Public: /health, /devices, /auth/login. Everything else requires a user -// bearer token (subject = user_id, claim = device_id, cf. docs/api-v1.md §2). +// Public: /health, /devices, /auth/login, /shares/links/:token (résolution de +// lien anonyme). Everything else requires a user bearer token (subject = +// user_id, claim = device_id, cf. docs/api-v1.md §2). func RegisterRoutes(r *gin.Engine) { public := r.Group("/api/v1") { public.GET("/health", Health) public.POST("/devices", DevicesRegister) public.POST("/auth/login", AuthLogin) + public.GET("/shares/links/:token", ShareLinkGet) } protected := r.Group("/api/v1") diff --git a/backend/handlers/share_links.go b/backend/handlers/share_links.go new file mode 100644 index 0000000..865e653 --- /dev/null +++ b/backend/handlers/share_links.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/vaultdrop/backend/pkg/api" + "github.com/vaultdrop/backend/repository" +) + +// ShareLinkGet resolves a public share link without authentication +// (GET /shares/links/:token). Returns resource metadata only — never the +// owner's identity beyond what the contract exposes. +func ShareLinkGet(c *gin.Context) { + if Store == nil || Store.Repository == nil { + api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") + return + } + token := c.Param("token") + link, err := Store.Repository.Shares.GetPublicLink(token) + if err != nil { + if err == repository.ErrNotFound { + api.Error(c, http.StatusNotFound, "NOT_FOUND", "link not found or revoked") + return + } + api.Error(c, http.StatusInternalServerError, "INTERNAL", "could not resolve link") + return + } + api.OK(c, link) +} diff --git a/backend/handlers/share_links_test.go b/backend/handlers/share_links_test.go new file mode 100644 index 0000000..32cfb1b --- /dev/null +++ b/backend/handlers/share_links_test.go @@ -0,0 +1,87 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/vaultdrop/backend/repository" +) + +func TestShareLinkPublicResolution(t *testing.T) { + r, _, repo := setup(t) + device := repository.NewID() + token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sln"), "share-link-pw", device) + + // Owner creates a file + a share link via the outbox. + fileID := repository.NewID() + linkToken := repository.NewID() + ops := []map[string]any{ + op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "public.pdf"}), + op(repository.NewID(), fileID, "create_link", "file", map[string]any{"token": linkToken, "access": "viewer"}), + } + rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json") + env := expectOK(t, rec, "sync-ops-link") + var result struct { + Applied int `json:"applied"` + Failed any `json:"failed"` + } + if err := json.Unmarshal(env.Data, &result); err != nil { + t.Fatalf("sync-ops-link: unmarshal: %v", err) + } + if result.Applied != 2 || result.Failed != nil { + t.Fatalf("attendu applied=2 failed=null, got %+v", result) + } + + // Résolution publique, SANS token. + rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/shares/links/"+linkToken, "", nil, "") + env = expectOK(t, rec, "get-public-link") + var link struct { + Token string `json:"token"` + ResourceID string `json:"resource_id"` + ResourceType string `json:"resourceType"` + Name string `json:"name"` + Access string `json:"access"` + } + if err := json.Unmarshal(env.Data, &link); err != nil { + t.Fatalf("get-public-link: unmarshal: %v", err) + } + if link.Token != linkToken || link.ResourceID != fileID || link.Name != "public.pdf" || link.Access != "viewer" || link.ResourceType != "file" { + t.Errorf("lien résolu inattendu: %+v", link) + } +} + +func TestShareLinkRevokedNotFound(t *testing.T) { + r, _, repo := setup(t) + device := repository.NewID() + token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "slr"), "share-link-pw", device) + + fileID := repository.NewID() + linkToken := repository.NewID() + ops := []map[string]any{ + op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "x.pdf"}), + op(repository.NewID(), fileID, "create_link", "file", map[string]any{"token": linkToken, "access": "viewer"}), + op(repository.NewID(), fileID, "revoke_link", "file", map[string]any{"token": linkToken}), + } + rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json") + env := expectOK(t, rec, "sync-ops-revoke-link") + var result struct { + Applied int `json:"applied"` + } + if err := json.Unmarshal(env.Data, &result); err != nil { + t.Fatalf("sync-ops-revoke-link: unmarshal: %v", err) + } + if result.Applied != 3 { + t.Fatalf("attendu applied=3, got %d", result.Applied) + } + + // Lien révoqué → 404. + rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/shares/links/"+linkToken, "", nil, "") + expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-revoked-link") +} + +func TestShareLinkUnknownTokenNotFound(t *testing.T) { + r, _, _ := setup(t) + rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/shares/links/"+repository.NewID(), "", nil, "") + expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-unknown-link") +} diff --git a/backend/handlers/store.go b/backend/handlers/store.go index 8c4c757..86f459a 100644 --- a/backend/handlers/store.go +++ b/backend/handlers/store.go @@ -21,6 +21,8 @@ func writeError(c *gin.Context, err error) { switch { case errors.Is(err, repository.ErrNotFound): api.Error(c, 404, "NOT_FOUND", "resource not found") + case errors.Is(err, repository.ErrGranteeNotFound): + api.Error(c, 404, "GRANTEE_NOT_FOUND", "the specified user does not exist") case errors.Is(err, repository.ErrNameConflict): api.Error(c, 409, "NAME_CONFLICT", "a resource with this name already exists here") case errors.Is(err, service.FileTooLargeError): diff --git a/backend/repository/repository.go b/backend/repository/repository.go index e40c177..6630abe 100644 --- a/backend/repository/repository.go +++ b/backend/repository/repository.go @@ -11,6 +11,7 @@ type Repository struct { Operations *Operations OcrJobs *OcrJobs Users *Users + Shares *Shares } func NewRepository(conn *sql.DB) *Repository { @@ -20,5 +21,6 @@ func NewRepository(conn *sql.DB) *Repository { Operations: &Operations{DB: conn}, OcrJobs: &OcrJobs{DB: conn}, Users: &Users{DB: conn}, + Shares: &Shares{DB: conn}, } } diff --git a/backend/repository/resources.go b/backend/repository/resources.go index ed282fd..dbd1ed2 100644 --- a/backend/repository/resources.go +++ b/backend/repository/resources.go @@ -74,6 +74,39 @@ func sortClause(sort, order string) (string, string) { return column, direction } +// grantedCTE expands, for the user bound to $1, the set of accessible +// resource ids — owned rows (each its own seed) + active direct grants + +// every node reached by an inherit=true grant up its chain (passing through +// intermediate nodes, per docs/api-v1.md §6.2 rule 5). Only the visibility +// SET matters here (which resources the user can read, viewer+); the effective +// rank is validated by repository.Shares.EffectiveAccess. Walk bounded at +// depth < 100. Append a query that references +// `resource_id IN (SELECT id FROM granted)`. +const grantedCTE = ` + seeded(id, type, rank, inherit, depth) AS ( + SELECT r.resource_id, r.type, 4::int, FALSE, 0 + FROM resources r + WHERE r.user_id = $1 AND r.deleted_at IS NULL + UNION ALL + SELECT r.resource_id, r.type, + CASE sh.access WHEN 'viewer' THEN 1 WHEN 'commenter' THEN 2 WHEN 'editor' THEN 3 ELSE 0 END, + sh.inherit, 0 + FROM shares sh + JOIN resources r ON r.resource_id = sh.resource_id + WHERE sh.grantee_user_id = $1 AND sh.revoked_at IS NULL + AND (sh.expires_at IS NULL OR sh.expires_at > NOW()) + AND r.deleted_at IS NULL + ), + granted(id, rank, inherit, depth) AS ( + SELECT id, rank, inherit, 0 FROM seeded + UNION ALL + SELECT r.resource_id, g.rank, TRUE, g.depth + 1 + FROM resources r + JOIN granted g ON r.parent_id = g.id + WHERE g.inherit AND g.rank BETWEEN 1 AND 3 + AND r.deleted_at IS NULL AND g.depth < 100 + )` + func (r *Resources) folderExists(ownerID, folderID string) (bool, error) { var exists int err := r.DB.QueryRow( @@ -161,6 +194,38 @@ func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset in return files, total, rows.Err() } +// ListFilesVisible returns the user's accessible files (owned or shared, +// viewer+) within a folder (or at the root when folderResourceID is empty). +func (r *Resources) ListFilesVisible(userID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) { + column, direction := sortClause(sort, order) + where := `type = 'file' AND deleted_at IS NULL AND resource_id IN (SELECT id FROM granted) + AND ($2::text = '' AND parent_id IS NULL OR parent_id = $2)` + + var total int + if err := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+` SELECT COUNT(*) FROM resources WHERE `+where, userID, folderResourceID).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := r.DB.Query( + fmt.Sprintf(`WITH RECURSIVE %s SELECT %s FROM resources WHERE %s ORDER BY %s %s LIMIT $3 OFFSET $4`, + grantedCTE, fileColumns, where, column, direction), + userID, folderResourceID, limit, offset, + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + files := make([]FileRow, 0) + for rows.Next() { + row, err := r.scanFile(rows.Scan) + if err != nil { + return nil, 0, err + } + files = append(files, row) + } + return files, total, rows.Err() +} + +// GetFile returns a file owned by the device (no-rows → ErrNotFound). func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) { row := r.DB.QueryRow( `SELECT `+fileColumns+` FROM resources @@ -174,6 +239,21 @@ func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) { return file, err } +// GetFileVisible returns a file the user can access (owned or shared, viewer+). +func (r *Resources) GetFileVisible(userID, resourceID string) (FileRow, error) { + row := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+` + SELECT `+fileColumns+` FROM resources + WHERE type = 'file' AND deleted_at IS NULL AND resource_id = $2 + AND resource_id IN (SELECT id FROM granted)`, + userID, resourceID, + ) + file, err := r.scanFile(row.Scan) + if errors.Is(err, sql.ErrNoRows) { + return FileRow{}, ErrNotFound + } + return file, err +} + // DeleteFile soft-deletes the file (deleted_at), returning its id. func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) { result, err := r.DB.Exec( @@ -224,6 +304,36 @@ func (r *Resources) SearchFiles(ownerID, q string, limit, offset int) ([]FileRow return files, total, rows.Err() } +// SearchFilesVisible returns the user's accessible files (owned or shared, +// viewer+) whose name matches q (case-insensitive substring). +func (r *Resources) SearchFilesVisible(userID, q string, limit, offset int) ([]FileRow, int, error) { + pattern := `%` + escapeLike(q) + `%` + where := `type = 'file' AND deleted_at IS NULL AND resource_id IN (SELECT id FROM granted) AND name ILIKE $2 ESCAPE '\'` + + var total int + if err := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+` SELECT COUNT(*) FROM resources WHERE `+where, userID, pattern).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := r.DB.Query( + fmt.Sprintf(`WITH RECURSIVE %s SELECT %s FROM resources WHERE %s ORDER BY name ASC LIMIT $3 OFFSET $4`, + grantedCTE, fileColumns, where), + userID, pattern, limit, offset, + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + files := make([]FileRow, 0) + for rows.Next() { + row, err := r.scanFile(rows.Scan) + if err != nil { + return nil, 0, err + } + files = append(files, row) + } + return files, total, rows.Err() +} + func escapeLike(q string) string { replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) return replacer.Replace(q) @@ -299,6 +409,30 @@ func (r *Resources) UpdateName(ownerID, resourceID, name string) error { return nil } +// UpdateNameByID renames a resource without owner scoping — the caller +// (service) has already verified the user's effective access (editor+). +func (r *Resources) UpdateNameByID(resourceID, name string) error { + result, err := r.DB.Exec( + `UPDATE resources SET name = $2, updated_at = NOW() + WHERE resource_id = $1 AND deleted_at IS NULL`, + resourceID, name, + ) + if err != nil && isUniqueViolation(err) { + return ErrNameConflict + } + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + // SyncDelete soft-deletes a resource; absence is NOT an error (idempotent // terminal state for the outbox). func (r *Resources) SyncDelete(ownerID, resourceID string) error { @@ -323,6 +457,16 @@ func (r *Resources) ExistsOwner(ownerID, resourceID string) (bool, error) { return err == nil, err } +// TouchResource bumps updated_at on a non-deleted resource so that the +// grantee's delta snapshot picks it up after a share is created. +func (r *Resources) TouchResource(resourceID string) error { + _, err := r.DB.Exec( + `UPDATE resources SET updated_at = NOW() WHERE resource_id = $1 AND deleted_at IS NULL`, + resourceID, + ) + return err +} + // OwnedRow is a snapshot row: resource identity + freshness. type OwnedRow struct { ID string @@ -378,3 +522,28 @@ func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) { } return folders, rows.Err() } + +// ListRootFoldersVisible returns the user's accessible top-level folders — +// owned (rank 4) or shared (viewer+) at the root. +func (r *Resources) ListRootFoldersVisible(userID string) ([]FolderRow, error) { + rows, err := r.DB.Query(`WITH RECURSIVE `+grantedCTE+` + SELECT resource_id, name, COALESCE(parent_id, '') FROM resources + WHERE type = 'folder' AND parent_id IS NULL AND deleted_at IS NULL + AND resource_id IN (SELECT id FROM granted) + ORDER BY name ASC`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + folders := make([]FolderRow, 0) + for rows.Next() { + var folder FolderRow + if err := rows.Scan(&folder.ID, &folder.Name, &folder.ParentID); err != nil { + return nil, err + } + folders = append(folders, folder) + } + return folders, rows.Err() +} diff --git a/backend/repository/shares.go b/backend/repository/shares.go new file mode 100644 index 0000000..7fe56d3 --- /dev/null +++ b/backend/repository/shares.go @@ -0,0 +1,432 @@ +package repository + +import ( + "database/sql" + "errors" + "time" +) + +// Access ranking — docs/api-v1.md §6.2. +const ( + AccessNone = 0 + AccessViewer = 1 + AccessCommenter = 2 + AccessEditor = 3 + AccessOwner = 4 +) + +// AccessFromString maps contract access levels to numeric ranks. +func AccessFromString(s string) int { + switch s { + case "viewer": + return AccessViewer + case "commenter": + return AccessCommenter + case "editor": + return AccessEditor + case "owner": + return AccessOwner + default: + return AccessNone + } +} + +// AccessToString maps numeric ranks back to the contract string. +func AccessToString(rank int) string { + switch rank { + case AccessViewer: + return "viewer" + case AccessCommenter: + return "commenter" + case AccessEditor: + return "editor" + case AccessOwner: + return "owner" + default: + return "" + } +} + +// EffectivePermission mirrors the full ResourcePermission DTO from +// docs/api-v1.md §6.2 — one row per resource the user can access. +type EffectivePermission struct { + ResourceID string + ResourceType string + EffectiveRank int + OwnerID string + SharedByID *string + ExpiresAt *time.Time + Inherit bool + UpdatedAt time.Time + Name string + ParentID *string +} + +// ResourceAccess is the result of EffectiveAccess — a lightweight check +// for whether the user can act on a given resource. +type ResourceAccess struct { + Accessible bool + IsOwner bool + EffectiveRank int + Inherit bool +} + +// Shares implements share persistence for user-to-user grants. +type Shares struct { + DB *sql.DB +} + +// Upsert inserts or updates an active share. On conflict (active row for +// same resource + grantee) the access, inherit, and expires_at are updated. +func (s *Shares) Upsert(resourceID, granteeUserID, access string, inherit bool, expiresAt *time.Time, createdBy string) error { + var expires any + if expiresAt != nil { + expires = *expiresAt + } + _, err := s.DB.Exec( + `INSERT INTO shares (id, resource_id, grantee_user_id, access, inherit, created_by, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (resource_id, grantee_user_id) WHERE revoked_at IS NULL + DO UPDATE SET access = EXCLUDED.access, + inherit = EXCLUDED.inherit, + expires_at = EXCLUDED.expires_at, + updated_at = NOW()`, + NewID(), resourceID, granteeUserID, access, inherit, createdBy, expires, + ) + return err +} + +// Revoke soft-revokes the active share between a resource and a grantee. +func (s *Shares) Revoke(resourceID, granteeUserID string) error { + result, err := s.DB.Exec( + `UPDATE shares SET revoked_at = NOW(), updated_at = NOW() + WHERE resource_id = $1 AND grantee_user_id = $2 AND revoked_at IS NULL`, + resourceID, granteeUserID, + ) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +// ListEffectivePermissions computes the full permissions snapshot for the +// calling user (docs/api-v1.md §6.2). +// +// Key invariants enforced by the CTE: +// 1. TTL (24h) is NOT applied server-side — it is a client-side cache +// concern. The server returns ALL effective permissions; the client +// downgrades to viewer when cachedAt > 24h. +// 2. Ownership (rank 4) always wins. +// 3. Expired grants (expires_at in the past) are excluded AND do not +// propagate to descendants. +// 4. Revoked shares (revoked_at IS NOT NULL) are excluded AND do not +// propagate. +// 5. Ancestors propagate ONLY when inherit = true on the ancestor grant. +// 6. The exact-node permission is authoritative (§6.2 rule 2): a resource +// with its own owned row or direct grant keeps that rank, which is NOT +// overridden by an ancestor grant. inherit=false on that node only stops +// propagation to descendants. +// 7. Rules 5+6 combine: a node WITHOUT its own seed keeps the highest rank +// among the inherit=true grants up its chain (§6.2 rule 5). inherit=false +// on an intermediate node does NOT block a grant further up from passing +// through it. +func (s *Shares) ListEffectivePermissions(userID string, afterMs int64) ([]EffectivePermission, error) { + rows, err := s.DB.Query(` +WITH RECURSIVE +-- Seeds: owned (rank=4) rows + active grants to this user (rank from the access column). +-- is_seed marks these: their own rank is authoritative on the node. +seeded(id, type, updated_at, parent_id, grant_rank, created_by, expires_at, grant_inherit, name, is_seed) AS ( + SELECT r.resource_id, r.type, r.updated_at, r.parent_id, + 4::int, NULL::text, NULL::timestamptz, FALSE, r.name, TRUE + FROM resources r + WHERE r.user_id = $1 AND r.deleted_at IS NULL + UNION ALL + SELECT r.resource_id, r.type, r.updated_at, r.parent_id, + CASE sh.access WHEN 'viewer' THEN 1 WHEN 'commenter' THEN 2 WHEN 'editor' THEN 3 ELSE 0 END, + sh.created_by, sh.expires_at, sh.inherit, r.name, TRUE + FROM shares sh + JOIN resources r ON r.resource_id = sh.resource_id + WHERE sh.grantee_user_id = $1 AND sh.revoked_at IS NULL + AND (sh.expires_at IS NULL OR sh.expires_at > NOW()) + AND r.deleted_at IS NULL +), +-- Recursive descendants: walk DOWN from each seeded row, following children. +-- A child inherits the rank of an inherit=true grant up the chain; propagated +-- rows are NOT seeds (is_seed = FALSE). inherit=false on an intermediate node +-- does not block a grant further up from passing through it. +descendants(id, type, updated_at, parent_id, grant_rank, created_by, expires_at, grant_inherit, name, is_seed, depth) AS ( + SELECT *, 0 FROM seeded + UNION ALL + SELECT r.resource_id, r.type, r.updated_at, r.parent_id, + d.grant_rank, d.created_by, d.expires_at, TRUE, r.name, FALSE, d.depth + 1 + FROM resources r + JOIN descendants d ON r.parent_id = d.id + WHERE d.grant_inherit AND d.grant_rank BETWEEN 1 AND 3 + AND r.deleted_at IS NULL + AND d.depth < 100 +) +SELECT d.id, d.type, + (EXTRACT(EPOCH FROM d.updated_at) * 1000)::bigint AS updated_at_ms, + COALESCE(r.user_id, d.effective_created_by) AS owner_id, + d.effective_rank, d.effective_created_by, + d.effective_expires_at, d.effective_inherit, + d.name, r.parent_id +FROM ( + SELECT id, type, updated_at, parent_id, name, + -- The node's own seed rank wins when it IS a seed (owned/direct + -- grant, §6.2 rule 2); otherwise the highest inherited rank wins + -- (rule 5). The winning row also supplies created_by / expires_at / + -- inherit. + (ARRAY_AGG(grant_rank ORDER BY is_seed DESC, grant_rank DESC))[1] AS effective_rank, + (ARRAY_AGG(created_by ORDER BY is_seed DESC, grant_rank DESC))[1] AS effective_created_by, + (ARRAY_AGG(expires_at ORDER BY is_seed DESC, grant_rank DESC))[1] AS effective_expires_at, + (ARRAY_AGG(grant_inherit ORDER BY is_seed DESC, grant_rank DESC))[1] AS effective_inherit + FROM descendants + WHERE grant_rank > 0 + GROUP BY id, type, updated_at, parent_id, name +) d +LEFT JOIN resources r ON r.resource_id = d.id +WHERE (EXTRACT(EPOCH FROM r.updated_at) * 1000)::bigint > $2 + OR r.updated_at IS NULL +ORDER BY d.updated_at DESC +LIMIT 10000`, + userID, afterMs, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var perms []EffectivePermission + for rows.Next() { + var ( + p EffectivePermission + updatedAtMs int64 + ownerID sql.NullString + createdBy sql.NullString + expiresAt sql.NullTime + parentID sql.NullString + ) + if err := rows.Scan( + &p.ResourceID, &p.ResourceType, &updatedAtMs, + &ownerID, &p.EffectiveRank, &createdBy, + &expiresAt, &p.Inherit, + &p.Name, &parentID, + ); err != nil { + return nil, err + } + p.UpdatedAt = time.UnixMilli(updatedAtMs) + if ownerID.Valid { + p.OwnerID = ownerID.String + } else { + p.OwnerID = userID + } + if createdBy.Valid { + p.SharedByID = &createdBy.String + } + if expiresAt.Valid { + p.ExpiresAt = &expiresAt.Time + } + if parentID.Valid { + p.ParentID = &parentID.String + } + perms = append(perms, p) + } + return perms, rows.Err() +} + +// EffectiveAccess computes the effective access rank of a user on a specific +// resource, walking ancestors for inherited grants. This is the server-side +// enforcement for individual resource access (e.g. GET /files/:id). +// +// It mirrors the snapshot semantics (§6.2): ownership → owner (rule 4); else +// the exact-node permission is authoritative (rule 2); else the highest rank +// among the inherit=true grants up the ancestor chain wins (rules 3 + 5). +func (s *Shares) EffectiveAccess(userID, resourceID string) (ResourceAccess, error) { + var ra ResourceAccess + err := s.DB.QueryRow(` +WITH RECURSIVE +-- 1. Ownership beats everything. +owned AS ( + SELECT 4::int AS rank + FROM resources WHERE resource_id = $2 AND user_id = $1 AND deleted_at IS NULL +), +-- 2. Direct grants on this exact node (apply regardless of inherit, rule 2). +direct AS ( + SELECT CASE sh.access WHEN 'viewer' THEN 1 WHEN 'commenter' THEN 2 WHEN 'editor' THEN 3 ELSE 0 END AS rank + FROM shares sh + JOIN resources r ON r.resource_id = sh.resource_id + WHERE sh.resource_id = $2 AND sh.grantee_user_id = $1 + AND sh.revoked_at IS NULL + AND (sh.expires_at IS NULL OR sh.expires_at > NOW()) + AND r.deleted_at IS NULL +), +-- 3. Walk UP the ancestors starting at the node's parent. Any ancestor with an +-- active grant AND inherit = true contributes its rank (rule 3); the whole +-- chain is considered and the highest wins (rule 5). inherit=false on an +-- intermediate node does not block a grant further up. +inherited(id, grant_rank, depth) AS ( + SELECT r.parent_id, + COALESCE((SELECT CASE sh.access WHEN 'viewer' THEN 1 WHEN 'commenter' THEN 2 WHEN 'editor' THEN 3 ELSE 0 END + FROM shares sh + WHERE sh.resource_id = r.parent_id AND sh.grantee_user_id = $1 + AND sh.revoked_at IS NULL AND sh.inherit + AND (sh.expires_at IS NULL OR sh.expires_at > NOW())), 0), + 0 + FROM resources r + WHERE r.resource_id = $2 AND r.deleted_at IS NULL AND r.parent_id IS NOT NULL + UNION ALL + SELECT r.parent_id, + COALESCE((SELECT CASE sh.access WHEN 'viewer' THEN 1 WHEN 'commenter' THEN 2 WHEN 'editor' THEN 3 ELSE 0 END + FROM shares sh + WHERE sh.resource_id = ag.id AND sh.grantee_user_id = $1 + AND sh.revoked_at IS NULL AND sh.inherit + AND (sh.expires_at IS NULL OR sh.expires_at > NOW())), 0), + ag.depth + 1 + FROM inherited ag + JOIN resources r ON r.resource_id = ag.id + WHERE r.deleted_at IS NULL AND ag.depth < 100 +) +SELECT EXISTS(SELECT 1 FROM owned) + OR EXISTS(SELECT 1 FROM direct WHERE rank > 0) + OR EXISTS(SELECT 1 FROM inherited WHERE grant_rank > 0), + CASE + WHEN EXISTS(SELECT 1 FROM owned) THEN 4 + WHEN (SELECT COALESCE(MAX(rank), 0) FROM direct) > 0 THEN (SELECT MAX(rank) FROM direct) + ELSE COALESCE((SELECT MAX(grant_rank) FROM inherited), 0) + END, + EXISTS(SELECT 1 FROM owned)`, + userID, resourceID, + ).Scan(&ra.Accessible, &ra.EffectiveRank, &ra.IsOwner) + if err != nil { + return ra, err + } + return ra, nil +} + +// ResourceExists confirms a non-deleted resource exists in the system +// (ownership not checked — used for share operations where the caller +// must own the resource, verified separately). +func (s *Shares) ResourceExists(resourceID string) (bool, error) { + var exists int + err := s.DB.QueryRow( + `SELECT 1 FROM resources WHERE resource_id = $1 AND deleted_at IS NULL`, + resourceID, + ).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +// CreateLink stores a share link (token = id, client-generated 32-hex). +func (s *Shares) CreateLink(token, resourceID, access string, expiresAt *time.Time, createdBy string) error { + var expires any + if expiresAt != nil { + expires = *expiresAt + } + _, err := s.DB.Exec( + `INSERT INTO share_links (id, resource_id, access, created_by, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + token, resourceID, access, createdBy, expires, + ) + return err +} + +// RevokeLink soft-revokes a link by token. Returns ErrNotFound if the link +// does not exist or is already revoked. +func (s *Shares) RevokeLink(token string) error { + result, err := s.DB.Exec( + `UPDATE share_links SET revoked_at = NOW() + WHERE id = $1 AND revoked_at IS NULL`, + token, + ) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +// LinkInfo is the public-facing shape for GET /shares/links/:token. +type LinkInfo struct { + Token string `json:"token"` + ResourceID string `json:"resource_id"` + ResourceType string `json:"resourceType"` + Name string `json:"name"` + Access string `json:"access"` + ExpiresAt *time.Time `json:"expiresAt"` +} + +// GetPublicLink resolves a link token to resource metadata. Returns +// ErrNotFound if the link is revoked, expired, or unknown. +func (s *Shares) GetPublicLink(token string) (LinkInfo, error) { + var info LinkInfo + var expiresAt sql.NullTime + err := s.DB.QueryRow( + `SELECT sl.id, sl.resource_id, r.type, r.name, sl.access, sl.expires_at + FROM share_links sl + JOIN resources r ON r.resource_id = sl.resource_id + WHERE sl.id = $1 AND sl.revoked_at IS NULL + AND (sl.expires_at IS NULL OR sl.expires_at > NOW()) + AND r.deleted_at IS NULL`, + token, + ).Scan(&info.Token, &info.ResourceID, &info.ResourceType, &info.Name, &info.Access, &expiresAt) + if err == sql.ErrNoRows { + return LinkInfo{}, ErrNotFound + } + if err != nil { + return LinkInfo{}, err + } + if expiresAt.Valid { + info.ExpiresAt = &expiresAt.Time + } + return info, nil +} + +// ListActiveLinksForResource returns all non-revoked links for a given resource. +func (s *Shares) ListActiveLinksForResource(resourceID string) ([]LinkInfo, error) { + rows, err := s.DB.Query( + `SELECT sl.id, sl.resource_id, r.type, r.name, sl.access, sl.expires_at + FROM share_links sl + JOIN resources r ON r.resource_id = sl.resource_id + WHERE sl.resource_id = $1 AND sl.revoked_at IS NULL + ORDER BY sl.created_at DESC`, + resourceID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var links []LinkInfo + for rows.Next() { + var ( + info LinkInfo + expiresAt sql.NullTime + ) + if err := rows.Scan(&info.Token, &info.ResourceID, &info.ResourceType, &info.Name, &info.Access, &expiresAt); err != nil { + return nil, err + } + if expiresAt.Valid { + info.ExpiresAt = &expiresAt.Time + } + links = append(links, info) + } + return links, rows.Err() +} + +// Errors specific to shares. +var ErrGranteeNotFound = errors.New("grantee user not found") diff --git a/backend/service/resources.go b/backend/service/resources.go index f9d7306..430e475 100644 --- a/backend/service/resources.go +++ b/backend/service/resources.go @@ -54,7 +54,7 @@ func NewResources(repo *repository.Repository, uploadDir string, maxFileSize int } func (s *Resources) ListFiles(ownerID, folderID string, page, pageSize int, sort, order string) ([]FileDTO, int, error) { - rows, total, err := s.Repo.ListFiles(ownerID, folderID, pageSize, (page-1)*pageSize, sort, order) + rows, total, err := s.Repo.ListFilesVisible(ownerID, folderID, pageSize, (page-1)*pageSize, sort, order) if err != nil { return nil, 0, err } @@ -66,7 +66,7 @@ func (s *Resources) ListFiles(ownerID, folderID string, page, pageSize int, sort } func (s *Resources) GetFile(ownerID, id string) (FileDTO, error) { - row, err := s.Repo.GetFile(ownerID, id) + row, err := s.Repo.GetFileVisible(ownerID, id) if err != nil { return FileDTO{}, err } @@ -78,7 +78,7 @@ func (s *Resources) DeleteFile(ownerID, id string) (string, error) { } func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) { - rows, err := s.Repo.ListRootFolders(ownerID) + rows, err := s.Repo.ListRootFoldersVisible(ownerID) if err != nil { return nil, err } @@ -90,7 +90,7 @@ func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) { } func (s *Resources) SearchFiles(ownerID, q string, page, pageSize int) ([]FileDTO, int, error) { - rows, total, err := s.Repo.SearchFiles(ownerID, q, pageSize, (page-1)*pageSize) + rows, total, err := s.Repo.SearchFilesVisible(ownerID, q, pageSize, (page-1)*pageSize) if err != nil { return nil, 0, err } diff --git a/backend/service/sync.go b/backend/service/sync.go index 9967751..d218601 100644 --- a/backend/service/sync.go +++ b/backend/service/sync.go @@ -11,8 +11,7 @@ import ( var resourceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) -// Ops de l'outbox (docs/api-v1.md §6.1). Les ops partage (share/share_link) -// sont accusées réception mais sans état serveur en V1 (single-owner). +// Ops de l'outbox (docs/api-v1.md §6.1). const ( OpCreateResource = "create_resource" OpUpdateMetadata = "update_metadata" @@ -25,11 +24,6 @@ const ( OpRevokeLink = "revoke_link" ) -var ackOnlyOps = map[string]bool{ - OpShare: true, OpRevokeShare: true, OpUpdateShare: true, - OpCreateLink: true, OpRevokeLink: true, -} - // SyncOperation is one outbox entry (shadow of mobile PendingOperationRow). type SyncOperation struct { OperationID string `json:"operation_id"` @@ -69,12 +63,35 @@ type movePayload struct { ToFolderResourceID string `json:"toFolderResourceId"` } +type sharePayload struct { + GranteeUserID string `json:"granteeUserId"` + Access string `json:"access"` + Inherit *bool `json:"inherit"` + ExpiresAt *int64 `json:"expiresAt"` +} + +type revokeSharePayload struct { + GranteeUserID string `json:"granteeUserId"` +} + +type linkPayload struct { + Token string `json:"token"` + Access string `json:"access"` + ExpiresAt *int64 `json:"expiresAt"` +} + +type revokeLinkPayload struct { + Token string `json:"token"` +} + func classifySyncError(err error) (string, string) { switch { case errors.Is(err, repository.ErrNameConflict): return "NAME_CONFLICT", "a resource with this name already exists here" case errors.Is(err, repository.ErrNotFound): return "NOT_FOUND", "target resource or folder not found" + case errors.Is(err, repository.ErrGranteeNotFound): + return "GRANTEE_NOT_FOUND", "the specified user does not exist" default: return "INVALID_REQUEST", err.Error() } @@ -96,12 +113,6 @@ func (s *Resources) ApplyBatch(userID, deviceID string, ops []SyncOperation) (Sy if already { continue } - if ackOnlyOps[op.Operation] { - if err := s.recordApplied(deviceID, op); err != nil { - return SyncResult{}, err - } - continue - } if err := s.applySyncOp(userID, op); err != nil { code, message := classifySyncError(err) return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: code, Message: message}}, nil @@ -120,14 +131,12 @@ func validateSyncOp(op *SyncOperation) error { if op.Operation == "" { return errors.New("missing operation type") } - if ackOnlyOps[op.Operation] { - return nil - } if !resourceIDPattern.MatchString(derefString(op.ResourceID)) { return errors.New("resource_id must be 32 lowercase hex chars") } switch op.Operation { - case OpCreateResource, OpUpdateMetadata, OpMoveResource, OpDeleteResource: + case OpCreateResource, OpUpdateMetadata, OpMoveResource, OpDeleteResource, + OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink: default: return errors.New("unknown operation " + op.Operation) } @@ -169,12 +178,19 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { return s.Repo.InsertFile(ownerID, resourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension)) case OpUpdateMetadata: - exists, err := s.Repo.ExistsOwner(ownerID, resourceID) + // Owner can always rename. A shared resource can be renamed by an + // editor+ (docs §6.2 effective_access ranking). Below editor, or on + // a resource the caller cannot reach at all, we keep the documented + // terminal-state semantics: absent → no-op. + access, err := s.Repository.Shares.EffectiveAccess(ownerID, resourceID) if err != nil { return err } - if !exists { - return nil + if access.EffectiveRank < repository.AccessEditor { + if !access.Accessible { + return nil + } + return repository.ErrNotFound } var p renamePayload if err := json.Unmarshal(op.Payload, &p); err != nil { @@ -183,7 +199,7 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { if p.Name == "" { return errors.New("payload.name required") } - return s.Repo.UpdateName(ownerID, resourceID, p.Name) + return s.Repo.UpdateNameByID(resourceID, p.Name) case OpMoveResource: exists, err := s.Repo.ExistsOwner(ownerID, resourceID) @@ -205,11 +221,146 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { case OpDeleteResource: return s.Repo.SyncDelete(ownerID, resourceID) + case OpShare, OpUpdateShare: + return s.applyShareOp(ownerID, resourceID, op) + + case OpRevokeShare: + return s.applyRevokeShareOp(ownerID, resourceID, op) + + case OpCreateLink: + return s.applyCreateLinkOp(ownerID, resourceID, op) + + case OpRevokeLink: + return s.applyRevokeLinkOp(ownerID, resourceID, op) + default: return errors.New("unknown operation " + op.Operation) } } +// applyShareOp handles OpShare and OpUpdateShare: upsert a user-to-user grant. +func (s *Resources) applyShareOp(ownerID, resourceID string, op *SyncOperation) error { + exists, err := s.Repo.ExistsOwner(ownerID, resourceID) + if err != nil { + return err + } + if !exists { + return repository.ErrNotFound + } + var p sharePayload + if err := json.Unmarshal(op.Payload, &p); err != nil { + return errors.New("invalid payload: " + err.Error()) + } + if p.GranteeUserID == "" { + return errors.New("payload.granteeUserId required") + } + if !resourceIDPattern.MatchString(p.GranteeUserID) { + return errors.New("payload.granteeUserId must be 32 lowercase hex chars") + } + rank := repository.AccessFromString(p.Access) + if rank < repository.AccessViewer || rank > repository.AccessEditor { + return errors.New("payload.access must be viewer, commenter, or editor") + } + // Validate grantee exists and is active. + if _, err := s.Repository.Users.GetByID(p.GranteeUserID); err != nil { + if errors.Is(err, repository.ErrNotFound) { + return repository.ErrGranteeNotFound + } + return err + } + inherit := true + if p.Inherit != nil { + inherit = *p.Inherit + } + var expiresAt *time.Time + if p.ExpiresAt != nil { + t := time.UnixMilli(*p.ExpiresAt) + expiresAt = &t + } + if err := s.Repository.Shares.Upsert(resourceID, p.GranteeUserID, p.Access, inherit, expiresAt, ownerID); err != nil { + return err + } + // Bump resource updated_at so the grantee's delta snapshot picks it up. + return s.Repo.TouchResource(resourceID) +} + +// applyRevokeShareOp handles OpRevokeShare: soft-revoke a user-to-user grant. +func (s *Resources) applyRevokeShareOp(ownerID, resourceID string, op *SyncOperation) error { + exists, err := s.Repo.ExistsOwner(ownerID, resourceID) + if err != nil { + return err + } + if !exists { + return repository.ErrNotFound + } + var p revokeSharePayload + if err := json.Unmarshal(op.Payload, &p); err != nil { + return errors.New("invalid payload: " + err.Error()) + } + if p.GranteeUserID == "" { + return errors.New("payload.granteeUserId required") + } + if !resourceIDPattern.MatchString(p.GranteeUserID) { + return errors.New("payload.granteeUserId must be 32 lowercase hex chars") + } + // Revoke is idempotent: no-op if already revoked/missing. + _ = s.Repository.Shares.Revoke(resourceID, p.GranteeUserID) + // Bump resource updated_at so the grantee's delta snapshot reflects the change. + return s.Repo.TouchResource(resourceID) +} + +// applyCreateLinkOp handles OpCreateLink: create a public share link. +func (s *Resources) applyCreateLinkOp(ownerID, resourceID string, op *SyncOperation) error { + exists, err := s.Repo.ExistsOwner(ownerID, resourceID) + if err != nil { + return err + } + if !exists { + return repository.ErrNotFound + } + var p linkPayload + if err := json.Unmarshal(op.Payload, &p); err != nil { + return errors.New("invalid payload: " + err.Error()) + } + if !resourceIDPattern.MatchString(p.Token) { + return errors.New("payload.token must be 32 lowercase hex chars") + } + rank := repository.AccessFromString(p.Access) + if rank < repository.AccessViewer || rank > repository.AccessEditor { + return errors.New("payload.access must be viewer, commenter, or editor") + } + var expiresAt *time.Time + if p.ExpiresAt != nil { + t := time.UnixMilli(*p.ExpiresAt) + expiresAt = &t + } + if err := s.Repository.Shares.CreateLink(p.Token, resourceID, p.Access, expiresAt, ownerID); err != nil { + return err + } + return s.Repo.TouchResource(resourceID) +} + +// applyRevokeLinkOp handles OpRevokeLink: soft-revoke a public share link. +func (s *Resources) applyRevokeLinkOp(ownerID, resourceID string, op *SyncOperation) error { + exists, err := s.Repo.ExistsOwner(ownerID, resourceID) + if err != nil { + return err + } + if !exists { + return repository.ErrNotFound + } + var p revokeLinkPayload + if err := json.Unmarshal(op.Payload, &p); err != nil { + return errors.New("invalid payload: " + err.Error()) + } + if !resourceIDPattern.MatchString(p.Token) { + return errors.New("payload.token must be 32 lowercase hex chars") + } + // Revoke is idempotent: no-op if already revoked/missing. + _ = s.Repository.Shares.RevokeLink(p.Token) + return s.Repo.TouchResource(resourceID) +} + func (s *Resources) recordApplied(deviceID string, op *SyncOperation) error { return s.Repository.Operations.Record(deviceID, op.OperationID, op.Operation, derefString(op.RefType), nullableInt64(derefInt64(op.RefID)), derefString(op.ResourceID), op.Payload) } @@ -245,39 +396,47 @@ func nullableInt64(value int64) *int64 { // ResourcePermission is the snapshot shape consumed by canAccess // (docs/api-v1.md §6.2). type ResourcePermission struct { - ResourceID string `json:"resource_id"` - ResourceType string `json:"resourceType"` - EffectiveAccess string `json:"effectiveAccess"` - Inherit bool `json:"inherit"` - OwnerID string `json:"ownerId"` - SharedByID any `json:"sharedById"` - ExpiresAt any `json:"expiresAt"` - CachedAt int64 `json:"cachedAt"` - UpdatedAt int64 `json:"updatedAt"` + ResourceID string `json:"resource_id"` + ResourceType string `json:"resourceType"` + EffectiveAccess string `json:"effectiveAccess"` + Name string `json:"name"` + ParentID *string `json:"parentId"` + Inherit bool `json:"inherit"` + OwnerID string `json:"ownerId"` + SharedByID *string `json:"sharedById"` + ExpiresAt *int64 `json:"expiresAt"` + CachedAt int64 `json:"cachedAt"` + UpdatedAt int64 `json:"updatedAt"` } // Snapshot returns the delta of effective permissions for the user since -// afterMs (epoch ms; 0 = all). V1 : pas encore de partage entre users — toutes -// les ressources appartiennent au user appelant (effective_access = owner). +// afterMs (epoch ms; 0 = all). Uses the recursive CTE computed by +// repository.Shares.ListEffectivePermissions. func (s *Resources) Snapshot(ownerID string, afterMs int64) ([]ResourcePermission, error) { - rows, err := s.Repo.ListOwned(ownerID, afterMs) + perms, err := s.Repository.Shares.ListEffectivePermissions(ownerID, afterMs) if err != nil { return nil, err } now := time.Now().UnixMilli() - perms := make([]ResourcePermission, 0, len(rows)) - for _, row := range rows { - perms = append(perms, ResourcePermission{ - ResourceID: row.ID, - ResourceType: row.Type, - EffectiveAccess: "owner", - Inherit: false, - OwnerID: ownerID, - SharedByID: nil, - ExpiresAt: nil, + out := make([]ResourcePermission, 0, len(perms)) + for _, p := range perms { + rp := ResourcePermission{ + ResourceID: p.ResourceID, + ResourceType: p.ResourceType, + EffectiveAccess: repository.AccessToString(p.EffectiveRank), + Name: p.Name, + ParentID: p.ParentID, + Inherit: p.Inherit, + OwnerID: p.OwnerID, + SharedByID: p.SharedByID, CachedAt: now, - UpdatedAt: row.UpdatedAt.UnixMilli(), - }) + UpdatedAt: p.UpdatedAt.UnixMilli(), + } + if p.ExpiresAt != nil { + ms := p.ExpiresAt.UnixMilli() + rp.ExpiresAt = &ms + } + out = append(out, rp) } - return perms, nil + return out, nil } diff --git a/backend/service/sync_test.go b/backend/service/sync_test.go index 2c55ea0..74246dd 100644 --- a/backend/service/sync_test.go +++ b/backend/service/sync_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "testing" + "time" "github.com/vaultdrop/backend/dbtest" "github.com/vaultdrop/backend/repository" @@ -38,6 +39,9 @@ func mustRegisterDevice(t *testing.T, repo *repository.Repository, deviceID stri return deviceID } +// shareTestDeviceID is a fixed 32-hex device registered by each share test. +const shareTestDeviceID = "a0000000000000000000000000000001" + func strp(v string) *string { return &v } // syncOp builds a SyncOperation with a payload serialized as JSON. @@ -87,17 +91,30 @@ func TestValidateSyncOp(t *testing.T) { } } -func TestValidateSyncOpAckOnlyOpsSkipResourceValidation(t *testing.T) { - // Les ops partage/lien sont accusées réception SANS état serveur : ni - // resource_id ni resource_type ne sont exigés. - for _, opType := range []string{OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink} { +func TestValidateSyncOpShareOpsRequireResourceID(t *testing.T) { + hexID := repository.NewID() + // Share ops are no longer ack-only — they require resource_id and resource_type. + shareOps := []string{OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink} + for _, opType := range shareOps { + // Valid op with resource_id op := SyncOperation{ + OperationID: repository.NewID(), + ResourceID: strp(hexID), + ResourceType: strp("file"), + Operation: opType, + Payload: json.RawMessage(`{}`), + } + if err := validateSyncOp(&op); err != nil { + t.Errorf("%s: op valide rejetée: %v", opType, err) + } + // Missing resource_id → rejected + opNoRes := SyncOperation{ OperationID: repository.NewID(), Operation: opType, Payload: json.RawMessage(`{}`), } - if err := validateSyncOp(&op); err != nil { - t.Errorf("%s: ack-only op rejetée: %v", opType, err) + if err := validateSyncOp(&opNoRes); err == nil { + t.Errorf("%s: resource_id manquant attendu rejeté", opType) } } } @@ -105,7 +122,7 @@ func TestValidateSyncOpAckOnlyOpsSkipResourceValidation(t *testing.T) { func TestApplyBatchRejectsInvalidOperationID(t *testing.T) { s := newServiceStore(t) userID := mustCreateUser(t, s.Repository, "sync-invalid") - deviceID := mustRegisterDevice(t, s.Repository, repository.NewID()) + deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID) result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{ syncOp("NOT-32-HEX", repository.NewID(), OpCreateResource, "file", map[string]any{"name": "x.txt"}), @@ -121,39 +138,10 @@ func TestApplyBatchRejectsInvalidOperationID(t *testing.T) { } } -func TestApplyBatchAckOnlyOpsRecordedWithoutState(t *testing.T) { - s := newServiceStore(t) - userID := mustCreateUser(t, s.Repository, "sync-ack") - deviceID := mustRegisterDevice(t, s.Repository, repository.NewID()) - - ops := []SyncOperation{ - syncOp(repository.NewID(), repository.NewID(), OpShare, "file", map[string]any{}), - syncOp(repository.NewID(), repository.NewID(), OpCreateLink, "file", map[string]any{}), - } - result, err := s.ApplyBatch(userID, deviceID, ops) - if err != nil { - t.Fatalf("ApplyBatch: %v", err) - } - if result.Applied != 2 || result.Failed != nil { - t.Errorf("attendu applied=2, got %+v", result) - } - // Aucune ressource ne doit avoir été créée. - files, total, _ := s.Repo.ListFiles(userID, "", 10, 0, "created_at", "desc") - if len(files) != 0 || total != 0 { - t.Errorf("aucune ressource attendue, got %d", total) - } - - // Rejeu → toujours accusé, jamais visible. - result, err = s.ApplyBatch(userID, deviceID, ops) - if err != nil || result.Applied != 2 { - t.Errorf("rejeu: applied=%d err=%v", result.Applied, err) - } -} - func TestApplyBatchUpdateMetadata(t *testing.T) { s := newServiceStore(t) userID := mustCreateUser(t, s.Repository, "sync-rename") - deviceID := mustRegisterDevice(t, s.Repository, repository.NewID()) + deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID) fileID := repository.NewID() ops := []SyncOperation{ @@ -176,7 +164,7 @@ func TestApplyBatchUpdateMetadata(t *testing.T) { func TestApplyBatchMoveIntoItselfRejected(t *testing.T) { s := newServiceStore(t) userID := mustCreateUser(t, s.Repository, "sync-selfmove") - deviceID := mustRegisterDevice(t, s.Repository, repository.NewID()) + deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID) fileID := repository.NewID() if _, err := s.ApplyBatch(userID, deviceID, []SyncOperation{ @@ -199,7 +187,7 @@ func TestApplyBatchMoveIntoItselfRejected(t *testing.T) { func TestApplyBatchMoveToMissingFolder(t *testing.T) { s := newServiceStore(t) userID := mustCreateUser(t, s.Repository, "sync-move-missing") - deviceID := mustRegisterDevice(t, s.Repository, repository.NewID()) + deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID) fileID := repository.NewID() if _, err := s.ApplyBatch(userID, deviceID, []SyncOperation{ @@ -223,7 +211,7 @@ func TestApplyBatchMoveToMissingFolder(t *testing.T) { func TestApplyBatchRejectsInvalidPayloadJSON(t *testing.T) { s := newServiceStore(t) userID := mustCreateUser(t, s.Repository, "sync-badpayload") - deviceID := mustRegisterDevice(t, s.Repository, repository.NewID()) + deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID) op := syncOp(repository.NewID(), repository.NewID(), OpCreateResource, "file", map[string]any{}) op.Payload = json.RawMessage(`{"name":`) @@ -240,7 +228,7 @@ func TestApplyBatchRejectsInvalidPayloadJSON(t *testing.T) { func TestApplyBatchRejectsMissingName(t *testing.T) { s := newServiceStore(t) userID := mustCreateUser(t, s.Repository, "sync-noname") - deviceID := mustRegisterDevice(t, s.Repository, repository.NewID()) + deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID) result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{ syncOp(repository.NewID(), repository.NewID(), OpCreateResource, "file", map[string]any{}), @@ -256,7 +244,7 @@ func TestApplyBatchRejectsMissingName(t *testing.T) { func TestApplyBatchDeleteIsIdempotent(t *testing.T) { s := newServiceStore(t) userID := mustCreateUser(t, s.Repository, "sync-del") - deviceID := mustRegisterDevice(t, s.Repository, repository.NewID()) + deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID) fileID := repository.NewID() ops := []SyncOperation{ @@ -279,3 +267,947 @@ func TestApplyBatchDeleteIsIdempotent(t *testing.T) { t.Errorf("delete absent: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err) } } + +// --- Share & snapshot tests --- + +func TestApplyShareAndSnapshot(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "share-owner") + granteeID := mustCreateUser(t, s.Repository, "share-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + // Owner creates a file + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "shared.txt"}), + }); err != nil { + t.Fatalf("create: %v", err) + } + + // Owner shares with grantee + result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "viewer", + }), + }) + if err != nil { + t.Fatalf("ApplyBatch: %v", err) + } + if result.Applied != 1 || result.Failed != nil { + t.Fatalf("share: applied=%d failed=%+v", result.Applied, result.Failed) + } + + // Grantee's snapshot includes the shared file + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if len(perms) != 1 { + t.Fatalf("attendu 1 permission, got %d", len(perms)) + } + if perms[0].ResourceID != fileID { + t.Errorf("resource_id = %s, attendu %s", perms[0].ResourceID, fileID) + } + if perms[0].EffectiveAccess != "viewer" { + t.Errorf("effectiveAccess = %s, attendu viewer", perms[0].EffectiveAccess) + } + if perms[0].OwnerID != ownerID { + t.Errorf("ownerId = %s, attendu %s", perms[0].OwnerID, ownerID) + } + if perms[0].SharedByID == nil || *perms[0].SharedByID != ownerID { + t.Errorf("sharedById = %v, attendu %s", perms[0].SharedByID, ownerID) + } + if perms[0].Name != "shared.txt" { + t.Errorf("name = %s, attendu shared.txt", perms[0].Name) + } +} + +func TestApplyShareGranteeNotFound(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "share-nograntee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}), + }); err != nil { + t.Fatalf("create: %v", err) + } + + result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": repository.NewID(), + "access": "viewer", + }), + }) + if err != nil { + t.Fatalf("ApplyBatch: %v", err) + } + if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "GRANTEE_NOT_FOUND" { + t.Errorf("attendu GRANTEE_NOT_FOUND, got %+v", result) + } +} + +func TestApplyShareNotOwner(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "share-owner2") + outsiderID := mustCreateUser(t, s.Repository, "share-outsider") + granteeID := mustCreateUser(t, s.Repository, "share-grantee2") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}), + }); err != nil { + t.Fatalf("create: %v", err) + } + + // Non-owner tries to share → NOT_FOUND (not enumeration) + result, err := s.ApplyBatch(outsiderID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + }), + }) + if err != nil { + t.Fatalf("ApplyBatch: %v", err) + } + if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "NOT_FOUND" { + t.Errorf("non-owner share: attendu NOT_FOUND, got %+v", result) + } +} + +func TestApplyRevokeShareStopsPropagation(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "revoke-owner") + granteeID := mustCreateUser(t, s.Repository, "revoke-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}), + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + }), + }); err != nil { + t.Fatalf("create+share: %v", err) + } + + // Grantee sees it + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if len(perms) != 1 { + t.Fatalf("avant revoke: attendu 1, got %d", len(perms)) + } + + // Owner revokes + result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpRevokeShare, "file", map[string]any{ + "granteeUserId": granteeID, + }), + }) + if err != nil { + t.Fatalf("revoke: %v", err) + } + if result.Applied != 1 || result.Failed != nil { + t.Fatalf("revoke result: %+v", result) + } + + // Grantee no longer sees it + perms, err = s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot après revoke: %v", err) + } + if len(perms) != 0 { + t.Errorf("après revoke: attendu 0, got %d", len(perms)) + } +} + +func TestApplyShareUpdatesLevel(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "upd-owner") + granteeID := mustCreateUser(t, s.Repository, "upd-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}), + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "viewer", + }), + }); err != nil { + t.Fatalf("create+share: %v", err) + } + + // Upgrade to editor + result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpUpdateShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + }), + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if result.Applied != 1 || result.Failed != nil { + t.Fatalf("update result: %+v", result) + } + + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if len(perms) != 1 || perms[0].EffectiveAccess != "editor" { + t.Errorf("après update: attendu editor, got %v", perms) + } +} + +func TestInheritedGrantDownTree(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "inherit-owner") + granteeID := mustCreateUser(t, s.Repository, "inherit-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + // Create folder → subfolder → file + parentID := repository.NewID() + childID := repository.NewID() + fileID := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}), + syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}), + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "file.txt", "parentResourceId": childID}), + }) + if err != nil { + t.Fatalf("create tree: %v", err) + } + + // Share parent with inherit=true → grantee should see parent + child + file + _, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + "inherit": true, + }), + }) + if err != nil { + t.Fatalf("share parent: %v", err) + } + + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + ids := make(map[string]string) + for _, p := range perms { + ids[p.ResourceID] = p.EffectiveAccess + } + if _, ok := ids[parentID]; !ok { + t.Error("parent absent du snapshot") + } + if _, ok := ids[childID]; !ok { + t.Error("child absent du snapshot (inherit)") + } + if _, ok := ids[fileID]; !ok { + t.Error("file absent du snapshot (inherit)") + } +} + +func TestInheritFalseBlocksPropagation(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "noinherit-owner") + granteeID := mustCreateUser(t, s.Repository, "noinherit-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + parentID := repository.NewID() + childID := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}), + syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}), + }) + if err != nil { + t.Fatalf("create tree: %v", err) + } + + // Share parent with inherit=false → only parent visible + inherit := false + _, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + "inherit": inherit, + }), + }) + if err != nil { + t.Fatalf("share parent: %v", err) + } + + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if len(perms) != 1 || perms[0].ResourceID != parentID { + t.Errorf("inherit=false: attendu juste parent, got %d permissions", len(perms)) + } +} + +func TestDirectGrantOnChildOverrides(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "direct-owner") + granteeID := mustCreateUser(t, s.Repository, "direct-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + parentID := repository.NewID() + childID := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}), + syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}), + }) + if err != nil { + t.Fatalf("create tree: %v", err) + } + + // Share parent with viewer + inherit=true → child gets viewer via inherit + _, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{ + "granteeUserId": granteeID, + "access": "viewer", + "inherit": true, + }), + }) + if err != nil { + t.Fatalf("share parent: %v", err) + } + + // Directly grant child with editor (higher) → child should show editor + _, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), childID, OpShare, "folder", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + "inherit": false, + }), + }) + if err != nil { + t.Fatalf("share child: %v", err) + } + + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + byID := make(map[string]string) + for _, p := range perms { + byID[p.ResourceID] = p.EffectiveAccess + } + if byID[parentID] != "viewer" { + t.Errorf("parent: attendu viewer, got %s", byID[parentID]) + } + if byID[childID] != "editor" { + t.Errorf("child: attendu editor (direct > inherited), got %s", byID[childID]) + } +} + +func TestExpiredGrantIgnored(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "expired-owner") + granteeID := mustCreateUser(t, s.Repository, "expired-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}), + }); err != nil { + t.Fatalf("create: %v", err) + } + + // Share with an expiry in the past + pastMs := time.Now().Add(-1 * time.Hour).UnixMilli() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "viewer", + "expiresAt": pastMs, + }), + }) + if err != nil { + t.Fatalf("share: %v", err) + } + + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if len(perms) != 0 { + t.Errorf("grant expiré: attendu 0 permissions, got %d", len(perms)) + } +} + +func TestExpiredGrantDoesNotPropagate(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "expire-prop-owner") + granteeID := mustCreateUser(t, s.Repository, "expire-prop-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + parentID := repository.NewID() + childID := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}), + syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}), + }) + if err != nil { + t.Fatalf("create tree: %v", err) + } + + pastMs := time.Now().Add(-1 * time.Hour).UnixMilli() + _, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + "inherit": true, + "expiresAt": pastMs, + }), + }) + if err != nil { + t.Fatalf("share: %v", err) + } + + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + // Neither parent nor child should appear (expired grant blocks propagation) + if len(perms) != 0 { + t.Errorf("grant expiré + inherit: attendu 0, got %d", len(perms)) + } +} + +func TestSnapshotDelta(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "delta-owner") + granteeID := mustCreateUser(t, s.Repository, "delta-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + // Full snapshot (afterMs=0) → empty + perms, err := s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if len(perms) != 0 { + t.Fatalf("attendu 0, got %d", len(perms)) + } + + // Share a file + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "delta.txt"}), + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "viewer", + }), + }); err != nil { + t.Fatalf("create+share: %v", err) + } + + // Delta with afterMs in the far future → empty (resource not that new) + futureMs := time.Now().Add(1 * time.Hour).UnixMilli() + perms, err = s.Snapshot(granteeID, futureMs) + if err != nil { + t.Fatalf("Snapshot future: %v", err) + } + if len(perms) != 0 { + t.Errorf("delta future: attendu 0, got %d", len(perms)) + } + + // Delta with afterMs=0 → includes the file + perms, err = s.Snapshot(granteeID, 0) + if err != nil { + t.Fatalf("Snapshot full: %v", err) + } + if len(perms) != 1 { + t.Errorf("delta full: attendu 1, got %d", len(perms)) + } +} + +func TestShareLink(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "link-owner") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "linked.txt"}), + }); err != nil { + t.Fatalf("create: %v", err) + } + + token := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateLink, "file", map[string]any{ + "token": token, + "access": "viewer", + }), + }) + if err != nil { + t.Fatalf("create link: %v", err) + } + + // Resolve the link + link, err := s.Repository.Shares.GetPublicLink(token) + if err != nil { + t.Fatalf("GetPublicLink: %v", err) + } + if link.ResourceID != fileID || link.Access != "viewer" || link.Name != "linked.txt" { + t.Errorf("link: %+v", link) + } +} + +func TestRevokeLink(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "revoke-link-owner") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}), + }); err != nil { + t.Fatalf("create: %v", err) + } + + token := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateLink, "file", map[string]any{ + "token": token, + "access": "viewer", + }), + }) + if err != nil { + t.Fatalf("create link: %v", err) + } + + // Revoke + _, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpRevokeLink, "file", map[string]any{ + "token": token, + }), + }) + if err != nil { + t.Fatalf("revoke link: %v", err) + } + + // No longer resolvable + _, err = s.Repository.Shares.GetPublicLink(token) + if !errors.Is(err, repository.ErrNotFound) { + t.Errorf("après revoke: attendu ErrNotFound, got %v", err) + } +} + +func TestShareInvalidAccessLevel(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "share-invalid-access") + granteeID := mustCreateUser(t, s.Repository, "share-invalid-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}), + }); err != nil { + t.Fatalf("create: %v", err) + } + + result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "owner", + }), + }) + if err != nil { + t.Fatalf("ApplyBatch: %v", err) + } + if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" { + t.Errorf("access=owner: attendu INVALID_REQUEST, got %+v", result) + } +} + +func TestRevokeShareIsIdempotent(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "revoke-idempotent") + granteeID := mustCreateUser(t, s.Repository, "revoke-idempotent-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}), + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "viewer", + }), + }); err != nil { + t.Fatalf("create+share: %v", err) + } + + // First revoke + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpRevokeShare, "file", map[string]any{ + "granteeUserId": granteeID, + }), + }) + if err != nil { + t.Fatalf("revoke 1: %v", err) + } + + // Second revoke (different operation_id) — idempotent, should succeed + result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpRevokeShare, "file", map[string]any{ + "granteeUserId": granteeID, + }), + }) + if err != nil { + t.Fatalf("revoke 2: %v", err) + } + if result.Applied != 1 || result.Failed != nil { + t.Errorf("revoke idempotent: applied=%d failed=%+v", result.Applied, result.Failed) + } +} + +func TestOwnerDoesNotSeeSharesInSnapshot(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "own-snap-owner") + granteeID := mustCreateUser(t, s.Repository, "own-snap-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "own.txt"}), + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{ + "granteeUserId": granteeID, + "access": "viewer", + }), + }); err != nil { + t.Fatalf("create+share: %v", err) + } + + // Owner snapshot: only 1 (their own file, not duplicated by the share) + perms, err := s.Snapshot(ownerID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if len(perms) != 1 { + t.Fatalf("owner: attendu 1, got %d", len(perms)) + } + if perms[0].EffectiveAccess != "owner" { + t.Errorf("owner access = %s, attendu owner", perms[0].EffectiveAccess) + } +} + +// --- Phase 2 : enforcement lecture partagée + rename editor+ --- + +func snapshotAccess(t *testing.T, s *Resources, userID string) map[string]string { + t.Helper() + perms, err := s.Snapshot(userID, 0) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + byID := make(map[string]string, len(perms)) + for _, p := range perms { + byID[p.ResourceID] = p.EffectiveAccess + } + return byID +} + +func TestVisibleReadsIncludeSharedContent(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "vis-owner") + granteeID := mustCreateUser(t, s.Repository, "vis-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + sharedRoot := repository.NewID() + sharedSub := repository.NewID() + sharedFile := repository.NewID() + privateRoot := repository.NewID() + privateFile := repository.NewID() + + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), sharedRoot, OpCreateResource, "folder", map[string]any{"name": "shared-root"}), + syncOp(repository.NewID(), sharedSub, OpCreateResource, "folder", map[string]any{"name": "sub", "parentResourceId": sharedRoot}), + syncOp(repository.NewID(), sharedFile, OpCreateResource, "file", map[string]any{"name": "shared-file.txt", "parentResourceId": sharedSub}), + syncOp(repository.NewID(), privateRoot, OpCreateResource, "folder", map[string]any{"name": "private-root"}), + syncOp(repository.NewID(), privateFile, OpCreateResource, "file", map[string]any{"name": "secret.txt", "parentResourceId": privateRoot}), + syncOp(repository.NewID(), sharedRoot, OpShare, "folder", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + "inherit": true, + }), + }) + if err != nil { + t.Fatalf("create tree + share: %v", err) + } + + // Grantee sees the shared root folder, not the private one. + roots, err := s.Repo.ListRootFoldersVisible(granteeID) + if err != nil { + t.Fatalf("ListRootFoldersVisible: %v", err) + } + rootIDs := make(map[string]bool) + for _, f := range roots { + rootIDs[f.ID] = true + } + if !rootIDs[sharedRoot] || rootIDs[privateRoot] { + t.Errorf("dossiers racines visibles: %v, attendu {shared-root} seulement", rootIDs) + } + + // Grantee lists the shared file inside the shared subfolder. + files, total, err := s.Repo.ListFilesVisible(granteeID, sharedSub, 50, 0, "created_at", "desc") + if err != nil { + t.Fatalf("ListFilesVisible: %v", err) + } + if total != 1 || len(files) != 1 || files[0].ID != sharedFile { + t.Errorf("ListFilesVisible(sharedSub): total=%d files=%+v", total, files) + } + + // Grantee does NOT see the private file inside the private folder. + _, total, err = s.Repo.ListFilesVisible(granteeID, privateRoot, 50, 0, "created_at", "desc") + if err != nil { + t.Fatalf("ListFilesVisible(private): %v", err) + } + if total != 0 { + t.Errorf("dossier privé: total=%d, attendu 0", total) + } + + // GetFileVisible: shared ok, private → ErrNotFound. + if f, err := s.Repo.GetFileVisible(granteeID, sharedFile); err != nil || f.Name != "shared-file.txt" { + t.Errorf("GetFileVisible(shared): %+v err=%v", f, err) + } + if _, err := s.Repo.GetFileVisible(granteeID, privateFile); !errors.Is(err, repository.ErrNotFound) { + t.Errorf("GetFileVisible(private): attendu ErrNotFound, got %v", err) + } + + // SearchFilesVisible: shared matches, private does not. + search, total, err := s.Repo.SearchFilesVisible(granteeID, "shared-file", 50, 0) + if err != nil { + t.Fatalf("SearchFilesVisible: %v", err) + } + if total != 1 || len(search) != 1 || search[0].ID != sharedFile { + t.Errorf("SearchFilesVisible('shared-file'): total=%d files=%+v", total, search) + } + _, total, err = s.Repo.SearchFilesVisible(granteeID, "secret", 50, 0) + if err != nil { + t.Fatalf("SearchFilesVisible secret: %v", err) + } + if total != 0 { + t.Errorf("recherche 'secret': total=%d, attendu 0", total) + } +} + +func TestEffectiveAccessInheritedFromParent(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "eff-owner") + granteeID := mustCreateUser(t, s.Repository, "eff-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + parentID := repository.NewID() + childID := repository.NewID() + privateID := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}), + syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}), + syncOp(repository.NewID(), privateID, OpCreateResource, "file", map[string]any{"name": "secret.txt"}), + syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{ + "granteeUserId": granteeID, + "access": "editor", + "inherit": true, + }), + }) + if err != nil { + t.Fatalf("create + share: %v", err) + } + + // Child (no direct grant) inherits editor from the parent grant. + ra, err := s.Repository.Shares.EffectiveAccess(granteeID, childID) + if err != nil { + t.Fatalf("EffectiveAccess child: %v", err) + } + if !ra.Accessible || ra.EffectiveRank != repository.AccessEditor { + t.Errorf("child: attendu editor accessible, got %+v", ra) + } + + // Private file: not accessible. + ra, err = s.Repository.Shares.EffectiveAccess(granteeID, privateID) + if err != nil { + t.Fatalf("EffectiveAccess private: %v", err) + } + if ra.Accessible || ra.EffectiveRank != 0 { + t.Errorf("private: attendu inaccessible, got %+v", ra) + } + + // Direct viewer grant on the child is authoritative over the inherited editor. + _, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), childID, OpShare, "folder", map[string]any{ + "granteeUserId": granteeID, + "access": "viewer", + }), + }) + if err != nil { + t.Fatalf("share child viewer: %v", err) + } + ra, err = s.Repository.Shares.EffectiveAccess(granteeID, childID) + if err != nil { + t.Fatalf("EffectiveAccess child 2: %v", err) + } + if !ra.Accessible || ra.EffectiveRank != repository.AccessViewer { + t.Errorf("child après grant direct viewer: attendu viewer, got %+v", ra) + } +} + +func TestEffectiveAccessMaxInheritedChain(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "chain-owner") + granteeID := mustCreateUser(t, s.Repository, "chain-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + g := repository.NewID() + a := repository.NewID() + b := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), g, OpCreateResource, "folder", map[string]any{"name": "G"}), + syncOp(repository.NewID(), a, OpCreateResource, "folder", map[string]any{"name": "A", "parentResourceId": g}), + syncOp(repository.NewID(), b, OpCreateResource, "folder", map[string]any{"name": "B", "parentResourceId": a}), + // G: editor, inherit. A: viewer, inherit (exact-node authoritative). + syncOp(repository.NewID(), g, OpShare, "folder", map[string]any{"granteeUserId": granteeID, "access": "editor", "inherit": true}), + syncOp(repository.NewID(), a, OpShare, "folder", map[string]any{"granteeUserId": granteeID, "access": "viewer", "inherit": true}), + }) + if err != nil { + t.Fatalf("create + shares: %v", err) + } + + // A keeps its own viewer (rule 2); B, with no direct grant, keeps the + // highest inherited rank (rule 5): editor from G. + for id, want := range map[string]int{g: repository.AccessEditor, a: repository.AccessViewer, b: repository.AccessEditor} { + ra, err := s.Repository.Shares.EffectiveAccess(granteeID, id) + if err != nil { + t.Fatalf("EffectiveAccess %s: %v", id, err) + } + if !ra.Accessible || ra.EffectiveRank != want { + t.Errorf("EffectiveAccess(%s) = %+v, attendu rank %d", id, ra, want) + } + } + + // The snapshot matches the per-resource check. + byID := snapshotAccess(t, s, granteeID) + if byID[g] != "editor" || byID[a] != "viewer" || byID[b] != "editor" { + t.Errorf("snapshot chain = %v, attendu G=editor A=viewer B=editor", byID) + } +} + +func TestUpdateMetadataRequiresEditor(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "ren-owner") + editorID := mustCreateUser(t, s.Repository, "ren-editor") + viewerID := mustCreateUser(t, s.Repository, "ren-viewer") + strangerID := mustCreateUser(t, s.Repository, "ren-stranger") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "orig.txt"}), + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{"granteeUserId": editorID, "access": "editor"}), + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{"granteeUserId": viewerID, "access": "viewer"}), + }) + if err != nil { + t.Fatalf("create + shares: %v", err) + } + + // Editor renames successfully. + result, err := s.ApplyBatch(editorID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "renamed.txt"}), + }) + if err != nil { + t.Fatalf("editor update: %v", err) + } + if result.Applied != 1 || result.Failed != nil { + t.Fatalf("editor update: applied=%d failed=%+v", result.Applied, result.Failed) + } + row, err := s.Repo.GetFile(ownerID, fileID) + if err != nil || row.Name != "renamed.txt" { + t.Errorf("après rename editor: %+v err=%v", row, err) + } + + // Viewer rename is rejected (NOT_FOUND) and stops the batch. + result, err = s.ApplyBatch(viewerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "hijack.txt"}), + }) + if err != nil { + t.Fatalf("viewer update: %v", err) + } + if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "NOT_FOUND" { + t.Errorf("viewer update: attendu applied=0 NOT_FOUND, got %+v", result) + } + + // Stranger (no access) → no-op terminal (absent semantics), no failure. + result, err = s.ApplyBatch(strangerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "sneak.txt"}), + }) + if err != nil { + t.Fatalf("stranger update: %v", err) + } + if result.Applied != 1 || result.Failed != nil { + t.Errorf("stranger update: attendu applied=1 aucun échec, got %+v", result) + } + row, err = s.Repo.GetFile(ownerID, fileID) + if err != nil || row.Name != "renamed.txt" { + t.Errorf("nom altéré par un tiers: %+v err=%v", row, err) + } +} + +func TestMoveAndDeleteOwnerOnly(t *testing.T) { + s := newServiceStore(t) + ownerID := mustCreateUser(t, s.Repository, "ownmove-owner") + granteeID := mustCreateUser(t, s.Repository, "ownmove-grantee") + mustRegisterDevice(t, s.Repository, shareTestDeviceID) + + fileID := repository.NewID() + targetID := repository.NewID() + _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "protected.txt"}), + syncOp(repository.NewID(), targetID, OpCreateResource, "folder", map[string]any{"name": "target"}), + syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{"granteeUserId": granteeID, "access": "editor"}), + }) + if err != nil { + t.Fatalf("create + share: %v", err) + } + + // Editor tries to move the shared file → no-op (owner-only), nothing moves. + result, err := s.ApplyBatch(granteeID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpMoveResource, "file", map[string]any{"toFolderResourceId": targetID}), + }) + if err != nil { + t.Fatalf("grantee move: %v", err) + } + if result.Applied != 1 || result.Failed != nil { + t.Fatalf("grantee move: applied=%d failed=%+v", result.Applied, result.Failed) + } + row, err := s.Repo.GetFile(ownerID, fileID) + if err != nil { + t.Fatalf("GetFile: %v", err) + } + if row.FolderID != "" { + t.Errorf("fichier déplacé par un non-owner: folderId=%q, attendu racine", row.FolderID) + } + + // Editor tries to delete the shared file → no-op, still present for owner. + result, err = s.ApplyBatch(granteeID, shareTestDeviceID, []SyncOperation{ + syncOp(repository.NewID(), fileID, OpDeleteResource, "file", map[string]any{}), + }) + if err != nil { + t.Fatalf("grantee delete: %v", err) + } + if result.Applied != 1 || result.Failed != nil { + t.Fatalf("grantee delete: applied=%d failed=%+v", result.Applied, result.Failed) + } + if _, err := s.Repo.GetFile(ownerID, fileID); err != nil { + t.Errorf("fichier supprimé par un non-owner: %v", err) + } +} diff --git a/docs/api-v1.md b/docs/api-v1.md index bd0ebf4..867bff3 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -32,6 +32,7 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync) | GET | `/health` | — | `{ "status": "healthy" }` | — | | POST | `/devices` | `{ "deviceId": "…32-hex" }` (client-generated) | `{ "deviceId": "…32-hex" }` — **aucun token** (V1 finale) | `INVALID_DEVICE_ID` | | POST | `/auth/login` | `{ "username", "password", "device_id" }` | `{ "token", "expires_at" (ms), "user": { "id", "username", "is_admin" } }` | `UNAUTHORIZED` / `INVALID_DEVICE_ID` | +| GET | `/shares/links/:token` | — (public, sans auth) | `{ "token", "resource_id", "resourceType", "name", "access", "expiresAt" }` | `NOT_FOUND` (inconnu/révoqué/expiré) | | GET | `/users/resolve` | query `username` (obligatoire) | `{ "id", "username" }` | `NOT_FOUND` | | PATCH | `/users/me/password` | `{ "current_password", "new_password" }` | `{ "id" }` | `INVALID_PASSWORD` (403) | | GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — | @@ -45,6 +46,8 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync) | POST | `/sync/ops` | voir §6 | voir §6 | — | | GET | `/sync/permissions` | query `after?` (cached_at ms) | `ResourcePermission[]` | — | +> **Lecture partagée** : `GET /files`, `GET /files/:id`, `GET /files/search` et `GET /files/folders` couvrent les ressources **possédées ET partagées** (accès `viewer+`, §6.2). Le rename d'une ressource partagée passe par l'outbox `update_metadata` (nécessite `editor+`, §6.1). `DELETE /files/:id` reste owner-only. + ### DTOs (copie conforme de `mobile/api/types.ts`) ```ts @@ -100,13 +103,18 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: - **Identifiants** : `operation_id` est un **TEXT 32-hex** généré par le client (`^[0-9a-f]{32}$`, CHECK-enforced depuis la migration `000008`), distinct de `resource_id`. L'idempotence outbox reste **par device** : `UNIQUE(device_id, operation_id)` (la réinscription d'un device avec un login différent ne réutilise pas l'historique outbox d'un autre compte). Pour chaque op : si déjà traitée → **no-op** (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide. - **Ordre** : les opérations sont appliquées **séquentiellement**, dans l'ordre du batch. Le serveur **s'arrête à la première erreur non-idempotente** et renvoie l'index atteint — le client reprend à cet index. - Réponse : `2xx` avec `{ "applied": int, "failed": { "operation_id": "…32-hex", "code": string, "message": string } | null }` (`applied` = index de la prochaine op à envoyer). -- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; **dead-letter immédiat** sur erreur 4xx non-idempotente (`failed`, non resélectionné ; `attempts` reste un compteur diagnostic, pas un seuil) — seul le transitoire (`NETWORK_ERROR`/5xx) est rejoué avec backoff. **Côté serveur, les ops `share | revoke_share | update_share | create_link | revoke_link` sont accusées réception mais ne créent aucun état** (V1 single-owner, pas de table shares serveur) — la dérivation du pushStatus reste purement client. +- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; **dead-letter immédiat** sur erreur 4xx non-idempotente (`failed`, non resélectionné ; `attempts` reste un compteur diagnostic, pas un seuil) — seul le transitoire (`NETWORK_ERROR`/5xx) est rejoué avec backoff. **Côté serveur, les ops partage créent un état réel** (tables `shares`/`share_links`, migration `000009`) — l'outbox est le seul chemin d'écriture des droits. - Sémantique d'application (côté serveur) : - `create_resource` : crée la ressource ; **déjà présente → no-op** (rejeu idempotent). `payload.name` obligatoire ; `payload.parentResourceId` (32-hex, optionnel) = dossier parent — absent → racine. **Parent inexistant → `NOT_FOUND`** (cohérent avec `move_resource`). Ordre garanti par construction client : le walk SAF émet les `create` des dossiers (ordre préfixe) avant ceux des fichiers, dans la même transaction Room → `id ASC` = parent avant enfant. - - `update_metadata` / `move_resource` : ressource absente → **no-op** (état terminal atteint) ; dossier cible de `move_resource` absent → `NOT_FOUND` ; déplacement dans soi-même → `INVALID_REQUEST`. + - `update_metadata` : ressource absente → **no-op** (état terminal atteint) ; **ressource partagée : nécessite `editor+`** (§6.2) — sinon `NOT_FOUND` (une ressource visible mais sans droit de mutation n'est pas renommée) ; une ressource sans relation applicable (ni owner, ni grant) reste un no-op (pas d'énumération). `move_resource` / `delete_resource` restent **owner-only** (scoping par `user_id`, les ops d'un non-owner sont des no-op). - `delete_resource` : **idempotent** — suppression d'une ressource absente = succès. + - **Partage** (`share` / `update_share`, même sémantique d'upsert) : `payload = { "granteeUserId": "…32-hex", "access": "viewer"|"commenter"|"editor", "inherit"?: bool, "expiresAt"?: ms }`. La ressource doit **appartenir** au user appelant (sinon `NOT_FOUND`) ; le grantee doit exister (sinon `GRANTEE_NOT_FOUND`) ; accès déjà partagé → mis à jour (idempotent). Privilégier `viewer` < `commenter` < `editor` (< `owner` réservé à l'ownership, non partageable → `INVALID_REQUEST`). + - `revoke_share` : `payload = { "granteeUserId": "…32-hex" }`. **Idempotent** (déjà révoqué/absent → no-op). + - `create_link` : `payload = { "token": "…32-hex", "access": "viewer"|"commenter"|"editor", "expiresAt"?: ms }`. Le token est **généré côté client** (`GenerateId`), c'est l'identifiant du lien (colonne `id` de `share_links`). + - `revoke_link` : `payload = { "token": "…32-hex" }`. **Idempotent**. - Validation (deuxième champ `operation_id`, hex32 pour `resource_id` **et** `operation_id`, enum `operation`) → échec `INVALID_REQUEST` avec arrêt du batch. - Nom déjà pris (même parent, ou à la racine) → échec `NAME_CONFLICT`.** +- **Lien public** : `GET /shares/links/:token` est **sans authentification** (résolution publique d'un lien) et renvoie `{ "token", "resource_id", "resourceType", "name", "access", "expiresAt" }`. `NOT_FOUND` si le lien est inconnu, révoqué ou expiré. ### 6.2 Snapshot — `GET /sync/permissions?after=` @@ -117,6 +125,8 @@ type ResourcePermission = { resource_id: string; // 32-hex resourceType: 'folder' | 'file'; effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner'; + name: string; // nom de la ressource (hydratation mobile) + parentId: string | null; // parent 32-hex, null = racine (hydratation mobile) inherit: boolean; ownerId: string | null; // ownership USER si applicable sharedById: string | null; @@ -128,10 +138,11 @@ type ResourcePermission = { - **Calcul de `effective_access`** (le serveur est la source de vérité) : 1. Rang : `viewer = 1 < commenter = 2 < editor = 3 < owner = 4`. - 2. La permission **exacte sur le nœud** est autoritaire (elle n'est pas annulée par son propre `inherit=false`). + 2. La permission **exacte sur le nœud** est autoritaire — elle n'est pas annulée par son propre `inherit=false` et n'est pas écrasée par un ancêtre de rang supérieur. 3. Les ancêtres propagent **uniquement si leur relation a `inherit = true`** ; une relation expirée (`expires_at` passé) est ignorée **et ne propage pas**. 4. `owner_id` == le user appelant → `owner` (fallback, quel que soit le niveau remonté). - 5. Le **rang le plus élevé** l'emporte ; sans relation applicable et sans ownership → la ressource n'est pas dans le snapshot. + 5. Le **rang le plus élevé** l'emporte pour les nœuds **sans permission propre** ; sans relation applicable et sans ownership → la ressource n'est pas dans le snapshot. `inherit = false` sur un nœud intermédiaire stoppe uniquement la propagation de **sa propre relation** ; une relation `inherit = true` plus haut continue de traverser. +- **Delta** : `after` filtre sur `resource.updated_at` **et** l'`updated_at` de la relation gagnante — une ressource nouvellement partagée apparaît dans le delta du grantee dès sa création. **Révoquée/expirée, la ressource disparaît du snapshot** : la convergence côté client passe par les pulls complets (`after=0`, à chaque login et quand le cache dépasse le TTL 24h). - **TTL / stale** : après `PERMISSION_TTL_MS` (= 24h) sans reseed, `canAccess` **downgrade en lecture seule** (`viewer`) vers le cache. ### 6.3 Placements @@ -142,6 +153,7 @@ type ResourcePermission = { - Authentification : `UNAUTHORIZED` (**401** — token manquant/invalide/expiré, compte supprimé, OU identifiants de login erronés : **indistinguables par design**, même code+message), `INVALID_DEVICE_ID` (**400** — device non enregistré au login). - Ressources : `NOT_FOUND` (404), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`, **ou à la racine**, index partiel `(user_id, name) WHERE parent_id IS NULL`), `FILE_TOO_LARGE` (413), `INVALID_PASSWORD` (403 sur `PATCH /users/me/password`). +- Partage : `GRANTEE_NOT_FOUND` (404 — grantee inexistant sur une op share), `NOT_FOUND` (404 — ressource non possédée sur une op share : scoping, pas d'énumération). - Client-only : `NETWORK_ERROR`, `INVALID_RESPONSE` (2xx mais corps d'enveloppe invalide), `HTTP_` (fallback). Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé. - **V1 finale : toutes les routes sont réelles** (pas de 501 restant). diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt index 7be6b89..014dd69 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/PendingOperationDao.kt @@ -58,4 +58,31 @@ interface PendingOperationDao { @Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'") suspend fun countPending(): Int + + /** + * Une op `move_resource` encore en attente de push existe-t-elle pour cette + * ressource ? L'antichambre outbox est alors la source de vérité du + * placement : ni le refresh serveur ni le walk SAF ne doivent écraser le + * `folder_resource_id` local avant que le serveur ait accusé le move. + */ + @Query(""" + SELECT COUNT(*) FROM pending_operations + WHERE resource_id = :resourceId + AND operation = 'move_resource' + AND status = 'pending' + """) + suspend fun countPendingMoveOperations(resourceId: String): Int + + /** + * Un `move_resource` (pending ou synced) existe-t-il pour cette ressource ? + * La réconciliation du walk ne doit jamais masquer (`exists = 0`) une ligne + * en cours de transition vers une autre arborescence physique. + */ + @Query(""" + SELECT COUNT(*) FROM pending_operations + WHERE resource_id = :resourceId + AND operation = 'move_resource' + AND status IN ('pending', 'synced') + """) + suspend fun countMoveOperations(resourceId: String): Int } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt index 2ea9382..217286a 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt @@ -9,6 +9,7 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.SyncOpDto import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest @@ -67,6 +68,10 @@ class ApiClient @Inject constructor( suspend fun syncOps(operations: List): SyncOpsResult = unwrap({ apiService.syncOps(SyncOpsRequest(operations)) }) + /** Résout un destinataire par username EXACT — 404 si inconnu. */ + suspend fun resolveUser(username: String): ResolvedUserDto = + unwrap({ apiService.resolveUser(username.trim()) }) + /** Snapshot des permissions effectives (delta si `after` ms fourni). */ suspend fun syncPermissions(after: Long? = null): List = unwrap({ apiService.syncPermissions(after) }) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt index 1d89dca..966e8fd 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt @@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto import com.vaultdrop.mobile.data.remote.dto.FolderDto import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult @@ -51,6 +52,12 @@ interface ApiService { @Body body: SyncOpsRequest, ): Response> + /** Résolution d'un destinataire par username EXACT (jamais d'énumération). */ + @GET("users/resolve") + suspend fun resolveUser( + @Query("username") username: String, + ): Response> + /** Snapshot des permissions effectives (delta si `after` fourni, ms epoch). */ @GET("sync/permissions") suspend fun syncPermissions( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt index 0a8c9fc..3a39ad1 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt @@ -68,6 +68,12 @@ data class UserDto( @Json(name = "is_admin") val isAdmin: Boolean = false, ) +/** Réponse de `GET /users/resolve` — `{ id, username }` uniquement. */ +data class ResolvedUserDto( + @Json(name = "id") val id: String, + @Json(name = "username") val username: String, +) + /** Miroir de `api/types.ts` — `LoginRequest`. */ data class LoginRequestDto( @Json(name = "username") val username: String, @@ -121,4 +127,6 @@ data class ResourcePermissionDto( @Json(name = "expiresAt") val expiresAt: Any? = null, @Json(name = "cachedAt") val cachedAt: Long, @Json(name = "updatedAt") val updatedAt: Long, + @Json(name = "name") val name: String = "", + @Json(name = "parentId") val parentId: String? = null, ) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt index 0b0cb50..c9d692d 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt @@ -130,12 +130,18 @@ class FileRepository @Inject constructor( val category = computeCategory(input.mimeType, input.extension).dbValue + // Un `move_resource` encore en attente fait de la cible outbox la + // source de vérité du placement : un snapshot SAF périmé (walk lancé + // avant le move) ne doit pas ré-attribuer le dossier à l'ancien parent. + val movePending = existing != null && outboxRepository.hasPendingMoveOperation(existing.resourceId) + val effectiveFolder = if (movePending) existing.folderResourceId else folderResourceId + val entity = FileEntity( id = existing?.id ?: 0L, resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(), uri = input.uri, name = input.name, - folderResourceId = folderResourceId, + folderResourceId = effectiveFolder, extension = input.extension ?: existing?.extension, size = input.size, mimeType = input.mimeType, @@ -192,12 +198,21 @@ class FileRepository @Inject constructor( /** * `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de * chaque fichier. Ne supprime jamais de lignes locales. + * + * Garde-fou outbox : un fichier dont le `move_resource` n'a pas encore été + * poussé garde son placement local. Le serveur renvoie encore l'ancien + * dossier tant que l'op est pendante — écraser la ligne la ferait disparaître + * du dossier cible (et réapparaître dans l'ancien). */ suspend fun refreshFromServer(folderResourceId: String) { val files = apiClient.listFiles(folderId = folderResourceId, pageSize = PAGE_SIZE) if (files.isEmpty()) return val now = System.currentTimeMillis() files.forEach { dto -> + if (outboxRepository.hasPendingMoveOperation(dto.id)) { + // L'antichambre outbox fait foi tant que le move n'est pas synced. + return@forEach + } fileDao.upsert(toEntity(dto, folderResourceId, now)) } } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt index 1c97d1f..6791b07 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/OutboxRepository.kt @@ -103,6 +103,41 @@ class OutboxRepository @Inject constructor( resourceType = resourceType, ) + /** + * Raccourci `share` — payload `{ granteeUserId, access }` (voir + * docs/api-v1.md §6.1). `access`: viewer | commenter | editor. + */ + suspend fun enqueueShare( + resourceId: String, + resourceType: String, + granteeUserId: String, + access: String, + inherit: Boolean = true, + expiresAt: Long? = null, + ): String = enqueue( + operation = PendingOperationType.SHARE, + resourceId = resourceId, + resourceType = resourceType, + payload = buildMap { + put("granteeUserId", granteeUserId) + put("access", access) + put("inherit", inherit) + expiresAt?.let { put("expiresAt", it) } + }, + ) + + /** Raccourci `revoke_share` — payload `{ granteeUserId }`. */ + suspend fun enqueueRevokeShare( + resourceId: String, + resourceType: String, + granteeUserId: String, + ): String = enqueue( + operation = PendingOperationType.REVOKE_SHARE, + resourceId = resourceId, + resourceType = resourceType, + payload = mapOf("granteeUserId" to granteeUserId), + ) + /** Nombre d'ops en attente de push (stats UI optionnelles). */ suspend fun countPending(): Int = pendingOperationDao.countPending() @@ -113,4 +148,21 @@ class OutboxRepository @Inject constructor( */ suspend fun hasCreateOperation(resourceId: String): Boolean = pendingOperationDao.countCreateOperations(resourceId) > 0 + + /** + * Un `move_resource` est-il encore en attente de push ? Tant que l'op est + * pendante, le `folder_resource_id` local reflète la cible future : ni le + * refresh serveur (qui renvoie l'ancien dossier) ni le walk SAF (snapshot + * périmé) ne doivent l'écraser. + */ + suspend fun hasPendingMoveOperation(resourceId: String): Boolean = + pendingOperationDao.countPendingMoveOperations(resourceId) > 0 + + /** + * Un `move_resource` (pending ou synced) a-t-il jamais été journalisé ? + * Utilisé par la réconciliation du walk : une ligne en transition physique + * ne doit pas être masquée (`exists = 0`) avant convergence. + */ + suspend fun hasMoveOperation(resourceId: String): Boolean = + pendingOperationDao.countMoveOperations(resourceId) > 0 } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/DeviceSync.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/DeviceSync.kt index 501399a..2fb3b32 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/DeviceSync.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/DeviceSync.kt @@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.local.AppDatabase import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.repository.FileRepository import com.vaultdrop.mobile.data.repository.FolderRepository +import com.vaultdrop.mobile.data.repository.OutboxRepository import com.vaultdrop.mobile.data.repository.SaveFileInput import com.vaultdrop.mobile.data.repository.SaveFolderInput import com.vaultdrop.mobile.domain.DeviceIdentity @@ -34,6 +35,7 @@ class DeviceSync @Inject constructor( private val scanner: SafScanner, private val folderRepository: FolderRepository, private val fileRepository: FileRepository, + private val outboxRepository: OutboxRepository, private val deviceIdentity: DeviceIdentity, ) { @@ -119,7 +121,13 @@ class DeviceSync @Inject constructor( } for (file in fileRepository.getAll()) { val fileUri = file.uri - if (file.exists != 0 && fileUri != null && isChildOf(fileUri, rootUri) && fileUri !in seen) { + // Un `move_resource` (pending ou synced) fait de l'outbox la + // source de vérité du placement : on ne masque jamais une ligne + // en transition vers une autre arborescence physique (le + // snapshot peut être périmé par rapport au move en cours). + if (file.exists != 0 && fileUri != null && isChildOf(fileUri, rootUri) && fileUri !in seen && + !outboxRepository.hasMoveOperation(file.resourceId) + ) { fileRepository.markMissing(file.resourceId, now) missing++ } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt index b7c100e..54d12aa 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt @@ -10,6 +10,7 @@ import com.vaultdrop.mobile.data.local.entity.PendingOpStatus import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.SaveFolderInput +import com.vaultdrop.mobile.data.repository.ShareRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException @@ -46,6 +47,7 @@ class SyncViewModel @Inject constructor( private val deviceSync: DeviceSync, private val folderRepository: FolderRepository, private val pendingOperationDao: PendingOperationDao, + private val shareRepository: ShareRepository, @ApplicationContext private val appContext: Context, ) : ViewModel() { @@ -107,6 +109,10 @@ class SyncViewModel @Inject constructor( OutboxSyncWorker.enqueue(appContext) } .onFailure { e -> Timber.w(e, "syncAll failed, retrying later") } + // Hydrate les ressources partagées depuis le snapshot serveur. + // Échec réseau toléré : le prochain tick réessaiera. + runCatching { shareRepository.syncSnapshot() } + .onFailure { e -> Timber.d("syncSnapshot failed, retrying later: %s", e.message) } _walkInProgress.value = false delay(INTERVAL_MS) } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt index da9c292..fa5d849 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt @@ -7,6 +7,7 @@ import com.vaultdrop.mobile.auth.SessionManager import com.vaultdrop.mobile.data.remote.ApiClient import com.vaultdrop.mobile.data.remote.ApiException import com.vaultdrop.mobile.data.repository.AuthRepository +import com.vaultdrop.mobile.data.repository.ShareRepository import com.vaultdrop.mobile.features.sync.OutboxSyncWorker import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -25,6 +26,7 @@ class AuthViewModel @Inject constructor( private val authRepository: AuthRepository, private val sessionManager: SessionManager, private val apiClient: ApiClient, + private val shareRepository: ShareRepository, @ApplicationContext private val appContext: Context, ) : ViewModel() { @@ -49,6 +51,11 @@ class AuthViewModel @Inject constructor( authRepository.registerDevice() // Session restaurée → drainer l'outbox laissée en attente. OutboxSyncWorker.enqueue(appContext) + if (session != null) { + // Snapshot complet des permissions partagées (convergence). + runCatching { shareRepository.syncSnapshot() } + .onFailure { e -> Timber.d("syncSnapshot on restore failed: %s", e.message) } + } } } @@ -66,6 +73,9 @@ class AuthViewModel @Inject constructor( _authState.value = AuthState.SignedIn(response.user) // Connexion réussie → pousser les mutations locales en attente. OutboxSyncWorker.enqueue(appContext) + // Snapshot complet des permissions partagées (convergence). + runCatching { shareRepository.syncSnapshot() } + .onFailure { e -> Timber.d("syncSnapshot on login failed: %s", e.message) } } .onFailure { e -> val error = when (e) { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt index 5aed662..405acce 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -65,6 +66,8 @@ import com.vaultdrop.mobile.ui.components.DeleteReview import com.vaultdrop.mobile.ui.components.DeleteWarningDialog import com.vaultdrop.mobile.ui.components.reviewDelete import com.vaultdrop.mobile.ui.navigation.SelectionNavBar +import com.vaultdrop.mobile.ui.share.ShareBottomSheet +import com.vaultdrop.mobile.ui.share.ShareViewModel import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @@ -78,14 +81,17 @@ fun FolderDetailScreen( connectionStatusViewModel: ConnectionStatusViewModel, syncViewModel: SyncViewModel, viewModel: FolderDetailViewModel = hiltViewModel(), + shareViewModel: ShareViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() + val shareState by shareViewModel.uiState.collectAsStateWithLifecycle() val selection = rememberSelectionState() var showCreateDialog by remember { mutableStateOf(false) } var showMoveDialog by remember { mutableStateOf(false) } var showDeleteWarning by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } + var showShareSheet by remember { mutableStateOf(false) } var pendingDeleteMode by remember { mutableStateOf(null) } var pendingDeleteReview by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } @@ -172,6 +178,12 @@ fun FolderDetailScreen( status = connectionStatus, onClick = connectionStatusViewModel::checkNow, ) + IconButton(onClick = { showShareSheet = true }) { + Icon( + Icons.Filled.Share, + contentDescription = stringResource(R.string.share_content_description), + ) + } IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh)) } @@ -280,6 +292,22 @@ fun FolderDetailScreen( }, ) } + + if (showShareSheet) { + ShareBottomSheet( + resourceName = uiState.folder?.name ?: stringResource(R.string.folder), + onDismiss = { + showShareSheet = false + shareViewModel.reset() + }, + onShare = { username, access -> + shareViewModel.share(folderResourceId, "folder", username, access) + }, + sharing = shareState.sharing, + error = shareState.error, + enqueued = shareState.enqueued, + ) + } } @Composable diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index 28c1ac1..9c9e407 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -305,6 +305,7 @@ fun FolderListScreen( atRoot = uiState.browseFolderId == null, browseFolderName = uiState.browseFolderName, subFolders = uiState.browseSubFolders, + browseFiles = uiState.browseFiles, sections = uiState.sections, isImporting = importState.isImporting, error = uiState.error ?: importState.error, @@ -467,6 +468,7 @@ private fun HomeViewContent( atRoot: Boolean, browseFolderName: String?, subFolders: List, + browseFiles: List, sections: List, isImporting: Boolean, error: String?, @@ -491,11 +493,14 @@ private fun HomeViewContent( atRoot = atRoot, browseFolderName = browseFolderName, subFolders = subFolders, + browseFiles = browseFiles, isImporting = isImporting, error = error, + selection = selection, onBrowseUp = onBrowseUp, onOpenBrowseFolder = onOpenBrowseFolder, onCreateFolder = onCreateFolder, + onOpenDocument = onOpenDocument, modifier = modifier, ) } @@ -592,13 +597,17 @@ private fun FolderBrowserContent( atRoot: Boolean, browseFolderName: String?, subFolders: List, + browseFiles: List, isImporting: Boolean, error: String?, + selection: SelectionState, onBrowseUp: () -> Unit, onOpenBrowseFolder: (String) -> Unit, onCreateFolder: () -> Unit, + onOpenDocument: (String) -> Unit, modifier: Modifier = Modifier, ) { + val empty = subFolders.isEmpty() && (moveMode || browseFiles.isEmpty()) && !isImporting LazyColumn( modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp), @@ -677,7 +686,20 @@ private fun FolderBrowserContent( FolderRow(folder, onClick = { onOpenBrowseFolder(folder.resourceId) }) } - if (subFolders.isEmpty() && !isImporting) { + // En mode déplacement, le navigateur sert au choix de la cible : les + // fichiers du dossier courant ne sont pas affichés (reste un pur + // explorateur de dossiers). + if (!moveMode) { + items(browseFiles, key = { it.resourceId }) { file -> + BrowseFileRow( + file = file, + selection = selection, + onOpenDocument = { onOpenDocument(file.resourceId) }, + ) + } + } + + if (empty) { item(key = "empty") { Text( text = stringResource(if (atRoot) R.string.no_folders_yet else R.string.empty_folder), @@ -856,6 +878,67 @@ private fun FileCard( } } +/** Carte fichier dans l'explorateur Dossiers — clic pour ouvrir, clic long pour la sélection. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun BrowseFileRow( + file: FileEntity, + selection: SelectionState, + onOpenDocument: () -> Unit, +) { + val selected = file.resourceId in selection.ids + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp) + .combinedClickable( + onClick = { + if (selection.active) selection.toggle(file.resourceId) else onOpenDocument() + }, + onLongClick = { + if (!selection.active) selection.start(file.resourceId) + }, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FileCategoryIcon(file = file, size = 26.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium, + ) + Text( + text = formatSize(file.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (selection.active) { + SelectionStatusIcon(selected = selected) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilePendingReviewBadge(file = file) + FileSyncStatusIcon(file = file, size = 20.dp) + } + } + } + } +} + /** Miroir de `formatSize` (app/index.tsx). */ @Composable private fun formatSize(bytes: Long): String { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt index 02a466d..227c3eb 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt @@ -37,6 +37,8 @@ data class FolderListUiState( val browseFolderName: String? = null, /** Sous-dossiers visibles du dossier courant de l'explorateur. */ val browseSubFolders: List = emptyList(), + /** Fichiers visibles du dossier courant de l'explorateur. */ + val browseFiles: List = emptyList(), val sections: List = emptyList(), val isRefreshing: Boolean = false, val error: String? = null, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt index c2c4187..e75a665 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -147,6 +147,16 @@ class FolderListViewModel @Inject constructor( _uiState.update { it.copy(browseSubFolders = subFolders) } } } + viewModelScope.launch { + _defaultRootId.combine(_browseFolderId) { root, browse -> browse ?: root } + .flatMapLatest { parentId -> + if (parentId == null) flowOf(emptyList()) + else fileRepository.observeFiles(parentId) + } + .collect { files -> + _uiState.update { it.copy(browseFiles = files) } + } + } } /** Descend dans l'explorateur Dossiers (aussi en mode déplacement). */ diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 6e30e21..4258373 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -229,4 +229,17 @@ Confirmer Annuler Erreur lors de la suppression + + + Partager avec un utilisateur + Nom d\'utilisateur du destinataire + Niveau d\'accès + Lecture (viewer) + Commentaire (commenter) + Édition (editor) + Partager + Annuler + Aucun utilisateur connu sous ce nom. + Impossible de partager pour le moment. + Partager ce dossier \ No newline at end of file