share folders and files
This commit is contained in:
@@ -10,6 +10,7 @@ var expectedRoutes = []string{
|
|||||||
"GET /api/v1/health",
|
"GET /api/v1/health",
|
||||||
"POST /api/v1/devices",
|
"POST /api/v1/devices",
|
||||||
"POST /api/v1/auth/login",
|
"POST /api/v1/auth/login",
|
||||||
|
"GET /api/v1/shares/links/:token",
|
||||||
|
|
||||||
"GET /api/v1/files",
|
"GET /api/v1/files",
|
||||||
"GET /api/v1/files/:id",
|
"GET /api/v1/files/:id",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS share_links;
|
||||||
|
DROP TABLE IF EXISTS shares;
|
||||||
@@ -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);
|
||||||
@@ -221,6 +221,55 @@ func TestMigrationsUpDown(t *testing.T) {
|
|||||||
t.Error("contrainte UNIQUE(device_id, operation_id) manquante sur operations")
|
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 {
|
if err := MigrateDownDatabase(url); err != nil {
|
||||||
t.Fatalf("migrate down: %v", err)
|
t.Fatalf("migrate down: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RegisterRoutes wires the full /api/v1 surface (public + protected).
|
// RegisterRoutes wires the full /api/v1 surface (public + protected).
|
||||||
// Public: /health, /devices, /auth/login. Everything else requires a user
|
// Public: /health, /devices, /auth/login, /shares/links/:token (résolution de
|
||||||
// bearer token (subject = user_id, claim = device_id, cf. docs/api-v1.md §2).
|
// 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) {
|
func RegisterRoutes(r *gin.Engine) {
|
||||||
public := r.Group("/api/v1")
|
public := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
public.GET("/health", Health)
|
public.GET("/health", Health)
|
||||||
public.POST("/devices", DevicesRegister)
|
public.POST("/devices", DevicesRegister)
|
||||||
public.POST("/auth/login", AuthLogin)
|
public.POST("/auth/login", AuthLogin)
|
||||||
|
public.GET("/shares/links/:token", ShareLinkGet)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected := r.Group("/api/v1")
|
protected := r.Group("/api/v1")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ func writeError(c *gin.Context, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, repository.ErrNotFound):
|
case errors.Is(err, repository.ErrNotFound):
|
||||||
api.Error(c, 404, "NOT_FOUND", "resource not found")
|
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):
|
case errors.Is(err, repository.ErrNameConflict):
|
||||||
api.Error(c, 409, "NAME_CONFLICT", "a resource with this name already exists here")
|
api.Error(c, 409, "NAME_CONFLICT", "a resource with this name already exists here")
|
||||||
case errors.Is(err, service.FileTooLargeError):
|
case errors.Is(err, service.FileTooLargeError):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Repository struct {
|
|||||||
Operations *Operations
|
Operations *Operations
|
||||||
OcrJobs *OcrJobs
|
OcrJobs *OcrJobs
|
||||||
Users *Users
|
Users *Users
|
||||||
|
Shares *Shares
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(conn *sql.DB) *Repository {
|
func NewRepository(conn *sql.DB) *Repository {
|
||||||
@@ -20,5 +21,6 @@ func NewRepository(conn *sql.DB) *Repository {
|
|||||||
Operations: &Operations{DB: conn},
|
Operations: &Operations{DB: conn},
|
||||||
OcrJobs: &OcrJobs{DB: conn},
|
OcrJobs: &OcrJobs{DB: conn},
|
||||||
Users: &Users{DB: conn},
|
Users: &Users{DB: conn},
|
||||||
|
Shares: &Shares{DB: conn},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,39 @@ func sortClause(sort, order string) (string, string) {
|
|||||||
return column, direction
|
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) {
|
func (r *Resources) folderExists(ownerID, folderID string) (bool, error) {
|
||||||
var exists int
|
var exists int
|
||||||
err := r.DB.QueryRow(
|
err := r.DB.QueryRow(
|
||||||
@@ -161,6 +194,38 @@ func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset in
|
|||||||
return files, total, rows.Err()
|
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) {
|
func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
|
||||||
row := r.DB.QueryRow(
|
row := r.DB.QueryRow(
|
||||||
`SELECT `+fileColumns+` FROM resources
|
`SELECT `+fileColumns+` FROM resources
|
||||||
@@ -174,6 +239,21 @@ func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
|
|||||||
return file, err
|
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.
|
// DeleteFile soft-deletes the file (deleted_at), returning its id.
|
||||||
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
||||||
result, err := r.DB.Exec(
|
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()
|
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 {
|
func escapeLike(q string) string {
|
||||||
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||||
return replacer.Replace(q)
|
return replacer.Replace(q)
|
||||||
@@ -299,6 +409,30 @@ func (r *Resources) UpdateName(ownerID, resourceID, name string) error {
|
|||||||
return nil
|
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
|
// SyncDelete soft-deletes a resource; absence is NOT an error (idempotent
|
||||||
// terminal state for the outbox).
|
// terminal state for the outbox).
|
||||||
func (r *Resources) SyncDelete(ownerID, resourceID string) error {
|
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
|
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.
|
// OwnedRow is a snapshot row: resource identity + freshness.
|
||||||
type OwnedRow struct {
|
type OwnedRow struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -378,3 +522,28 @@ func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) {
|
|||||||
}
|
}
|
||||||
return folders, rows.Err()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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) {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return FileDTO{}, err
|
return FileDTO{}, err
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ func (s *Resources) DeleteFile(ownerID, id string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) {
|
func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) {
|
||||||
rows, err := s.Repo.ListRootFolders(ownerID)
|
rows, err := s.Repo.ListRootFoldersVisible(ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
+205
-46
@@ -11,8 +11,7 @@ import (
|
|||||||
|
|
||||||
var resourceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
var resourceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||||
|
|
||||||
// Ops de l'outbox (docs/api-v1.md §6.1). Les ops partage (share/share_link)
|
// Ops de l'outbox (docs/api-v1.md §6.1).
|
||||||
// sont accusées réception mais sans état serveur en V1 (single-owner).
|
|
||||||
const (
|
const (
|
||||||
OpCreateResource = "create_resource"
|
OpCreateResource = "create_resource"
|
||||||
OpUpdateMetadata = "update_metadata"
|
OpUpdateMetadata = "update_metadata"
|
||||||
@@ -25,11 +24,6 @@ const (
|
|||||||
OpRevokeLink = "revoke_link"
|
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).
|
// SyncOperation is one outbox entry (shadow of mobile PendingOperationRow).
|
||||||
type SyncOperation struct {
|
type SyncOperation struct {
|
||||||
OperationID string `json:"operation_id"`
|
OperationID string `json:"operation_id"`
|
||||||
@@ -69,12 +63,35 @@ type movePayload struct {
|
|||||||
ToFolderResourceID string `json:"toFolderResourceId"`
|
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) {
|
func classifySyncError(err error) (string, string) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, repository.ErrNameConflict):
|
case errors.Is(err, repository.ErrNameConflict):
|
||||||
return "NAME_CONFLICT", "a resource with this name already exists here"
|
return "NAME_CONFLICT", "a resource with this name already exists here"
|
||||||
case errors.Is(err, repository.ErrNotFound):
|
case errors.Is(err, repository.ErrNotFound):
|
||||||
return "NOT_FOUND", "target resource or folder not found"
|
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:
|
default:
|
||||||
return "INVALID_REQUEST", err.Error()
|
return "INVALID_REQUEST", err.Error()
|
||||||
}
|
}
|
||||||
@@ -96,12 +113,6 @@ func (s *Resources) ApplyBatch(userID, deviceID string, ops []SyncOperation) (Sy
|
|||||||
if already {
|
if already {
|
||||||
continue
|
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 {
|
if err := s.applySyncOp(userID, op); err != nil {
|
||||||
code, message := classifySyncError(err)
|
code, message := classifySyncError(err)
|
||||||
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: code, Message: message}}, nil
|
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 == "" {
|
if op.Operation == "" {
|
||||||
return errors.New("missing operation type")
|
return errors.New("missing operation type")
|
||||||
}
|
}
|
||||||
if ackOnlyOps[op.Operation] {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !resourceIDPattern.MatchString(derefString(op.ResourceID)) {
|
if !resourceIDPattern.MatchString(derefString(op.ResourceID)) {
|
||||||
return errors.New("resource_id must be 32 lowercase hex chars")
|
return errors.New("resource_id must be 32 lowercase hex chars")
|
||||||
}
|
}
|
||||||
switch op.Operation {
|
switch op.Operation {
|
||||||
case OpCreateResource, OpUpdateMetadata, OpMoveResource, OpDeleteResource:
|
case OpCreateResource, OpUpdateMetadata, OpMoveResource, OpDeleteResource,
|
||||||
|
OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink:
|
||||||
default:
|
default:
|
||||||
return errors.New("unknown operation " + op.Operation)
|
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))
|
return s.Repo.InsertFile(ownerID, resourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension))
|
||||||
|
|
||||||
case OpUpdateMetadata:
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !exists {
|
if access.EffectiveRank < repository.AccessEditor {
|
||||||
return nil
|
if !access.Accessible {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return repository.ErrNotFound
|
||||||
}
|
}
|
||||||
var p renamePayload
|
var p renamePayload
|
||||||
if err := json.Unmarshal(op.Payload, &p); err != nil {
|
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 == "" {
|
if p.Name == "" {
|
||||||
return errors.New("payload.name required")
|
return errors.New("payload.name required")
|
||||||
}
|
}
|
||||||
return s.Repo.UpdateName(ownerID, resourceID, p.Name)
|
return s.Repo.UpdateNameByID(resourceID, p.Name)
|
||||||
|
|
||||||
case OpMoveResource:
|
case OpMoveResource:
|
||||||
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
|
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
|
||||||
@@ -205,11 +221,146 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
|
|||||||
case OpDeleteResource:
|
case OpDeleteResource:
|
||||||
return s.Repo.SyncDelete(ownerID, resourceID)
|
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:
|
default:
|
||||||
return errors.New("unknown operation " + op.Operation)
|
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 {
|
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)
|
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
|
// ResourcePermission is the snapshot shape consumed by canAccess
|
||||||
// (docs/api-v1.md §6.2).
|
// (docs/api-v1.md §6.2).
|
||||||
type ResourcePermission struct {
|
type ResourcePermission struct {
|
||||||
ResourceID string `json:"resource_id"`
|
ResourceID string `json:"resource_id"`
|
||||||
ResourceType string `json:"resourceType"`
|
ResourceType string `json:"resourceType"`
|
||||||
EffectiveAccess string `json:"effectiveAccess"`
|
EffectiveAccess string `json:"effectiveAccess"`
|
||||||
Inherit bool `json:"inherit"`
|
Name string `json:"name"`
|
||||||
OwnerID string `json:"ownerId"`
|
ParentID *string `json:"parentId"`
|
||||||
SharedByID any `json:"sharedById"`
|
Inherit bool `json:"inherit"`
|
||||||
ExpiresAt any `json:"expiresAt"`
|
OwnerID string `json:"ownerId"`
|
||||||
CachedAt int64 `json:"cachedAt"`
|
SharedByID *string `json:"sharedById"`
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
ExpiresAt *int64 `json:"expiresAt"`
|
||||||
|
CachedAt int64 `json:"cachedAt"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns the delta of effective permissions for the user since
|
// Snapshot returns the delta of effective permissions for the user since
|
||||||
// afterMs (epoch ms; 0 = all). V1 : pas encore de partage entre users — toutes
|
// afterMs (epoch ms; 0 = all). Uses the recursive CTE computed by
|
||||||
// les ressources appartiennent au user appelant (effective_access = owner).
|
// repository.Shares.ListEffectivePermissions.
|
||||||
func (s *Resources) Snapshot(ownerID string, afterMs int64) ([]ResourcePermission, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
perms := make([]ResourcePermission, 0, len(rows))
|
out := make([]ResourcePermission, 0, len(perms))
|
||||||
for _, row := range rows {
|
for _, p := range perms {
|
||||||
perms = append(perms, ResourcePermission{
|
rp := ResourcePermission{
|
||||||
ResourceID: row.ID,
|
ResourceID: p.ResourceID,
|
||||||
ResourceType: row.Type,
|
ResourceType: p.ResourceType,
|
||||||
EffectiveAccess: "owner",
|
EffectiveAccess: repository.AccessToString(p.EffectiveRank),
|
||||||
Inherit: false,
|
Name: p.Name,
|
||||||
OwnerID: ownerID,
|
ParentID: p.ParentID,
|
||||||
SharedByID: nil,
|
Inherit: p.Inherit,
|
||||||
ExpiresAt: nil,
|
OwnerID: p.OwnerID,
|
||||||
|
SharedByID: p.SharedByID,
|
||||||
CachedAt: now,
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+974
-42
File diff suppressed because it is too large
Load Diff
+16
-4
@@ -32,6 +32,7 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync)
|
|||||||
| GET | `/health` | — | `{ "status": "healthy" }` | — |
|
| GET | `/health` | — | `{ "status": "healthy" }` | — |
|
||||||
| POST | `/devices` | `{ "deviceId": "…32-hex" }` (client-generated) | `{ "deviceId": "…32-hex" }` — **aucun token** (V1 finale) | `INVALID_DEVICE_ID` |
|
| 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` |
|
| 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` |
|
| GET | `/users/resolve` | query `username` (obligatoire) | `{ "id", "username" }` | `NOT_FOUND` |
|
||||||
| PATCH | `/users/me/password` | `{ "current_password", "new_password" }` | `{ "id" }` | `INVALID_PASSWORD` (403) |
|
| PATCH | `/users/me/password` | `{ "current_password", "new_password" }` | `{ "id" }` | `INVALID_PASSWORD` (403) |
|
||||||
| GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — |
|
| 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 | — |
|
| POST | `/sync/ops` | voir §6 | voir §6 | — |
|
||||||
| GET | `/sync/permissions` | query `after?` (cached_at ms) | `ResourcePermission[]` | — |
|
| 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`)
|
### DTOs (copie conforme de `mobile/api/types.ts`)
|
||||||
|
|
||||||
```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.
|
- **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.
|
- **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).
|
- 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) :
|
- 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.
|
- `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.
|
- `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.
|
- 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`.**
|
- 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=<cached_at_ms>`
|
### 6.2 Snapshot — `GET /sync/permissions?after=<cached_at_ms>`
|
||||||
|
|
||||||
@@ -117,6 +125,8 @@ type ResourcePermission = {
|
|||||||
resource_id: string; // 32-hex
|
resource_id: string; // 32-hex
|
||||||
resourceType: 'folder' | 'file';
|
resourceType: 'folder' | 'file';
|
||||||
effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner';
|
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;
|
inherit: boolean;
|
||||||
ownerId: string | null; // ownership USER si applicable
|
ownerId: string | null; // ownership USER si applicable
|
||||||
sharedById: string | null;
|
sharedById: string | null;
|
||||||
@@ -128,10 +138,11 @@ type ResourcePermission = {
|
|||||||
|
|
||||||
- **Calcul de `effective_access`** (le serveur est la source de vérité) :
|
- **Calcul de `effective_access`** (le serveur est la source de vérité) :
|
||||||
1. Rang : `viewer = 1 < commenter = 2 < editor = 3 < owner = 4`.
|
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**.
|
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é).
|
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.
|
- **TTL / stale** : après `PERMISSION_TTL_MS` (= 24h) sans reseed, `canAccess` **downgrade en lecture seule** (`viewer`) vers le cache.
|
||||||
|
|
||||||
### 6.3 Placements
|
### 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).
|
- 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`).
|
- 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_<status>` (fallback). Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé.
|
- Client-only : `NETWORK_ERROR`, `INVALID_RESPONSE` (2xx mais corps d'enveloppe invalide), `HTTP_<status>` (fallback). Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé.
|
||||||
- **V1 finale : toutes les routes sont réelles** (pas de 501 restant).
|
- **V1 finale : toutes les routes sont réelles** (pas de 501 restant).
|
||||||
|
|
||||||
|
|||||||
+27
@@ -58,4 +58,31 @@ interface PendingOperationDao {
|
|||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'")
|
@Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'")
|
||||||
suspend fun countPending(): Int
|
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
|
||||||
}
|
}
|
||||||
@@ -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.FolderDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
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.ResourcePermissionDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpDto
|
import com.vaultdrop.mobile.data.remote.dto.SyncOpDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
|
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
|
||||||
@@ -67,6 +68,10 @@ class ApiClient @Inject constructor(
|
|||||||
suspend fun syncOps(operations: List<SyncOpDto>): SyncOpsResult =
|
suspend fun syncOps(operations: List<SyncOpDto>): SyncOpsResult =
|
||||||
unwrap({ apiService.syncOps(SyncOpsRequest(operations)) })
|
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). */
|
/** Snapshot des permissions effectives (delta si `after` ms fourni). */
|
||||||
suspend fun syncPermissions(after: Long? = null): List<ResourcePermissionDto> =
|
suspend fun syncPermissions(after: Long? = null): List<ResourcePermissionDto> =
|
||||||
unwrap({ apiService.syncPermissions(after) })
|
unwrap({ apiService.syncPermissions(after) })
|
||||||
|
|||||||
@@ -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.FolderDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
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.ResourcePermissionDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
|
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
|
||||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult
|
import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult
|
||||||
@@ -51,6 +52,12 @@ interface ApiService {
|
|||||||
@Body body: SyncOpsRequest,
|
@Body body: SyncOpsRequest,
|
||||||
): Response<ApiEnvelope<SyncOpsResult>>
|
): Response<ApiEnvelope<SyncOpsResult>>
|
||||||
|
|
||||||
|
/** Résolution d'un destinataire par username EXACT (jamais d'énumération). */
|
||||||
|
@GET("users/resolve")
|
||||||
|
suspend fun resolveUser(
|
||||||
|
@Query("username") username: String,
|
||||||
|
): Response<ApiEnvelope<ResolvedUserDto>>
|
||||||
|
|
||||||
/** Snapshot des permissions effectives (delta si `after` fourni, ms epoch). */
|
/** Snapshot des permissions effectives (delta si `after` fourni, ms epoch). */
|
||||||
@GET("sync/permissions")
|
@GET("sync/permissions")
|
||||||
suspend fun syncPermissions(
|
suspend fun syncPermissions(
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ data class UserDto(
|
|||||||
@Json(name = "is_admin") val isAdmin: Boolean = false,
|
@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`. */
|
/** Miroir de `api/types.ts` — `LoginRequest`. */
|
||||||
data class LoginRequestDto(
|
data class LoginRequestDto(
|
||||||
@Json(name = "username") val username: String,
|
@Json(name = "username") val username: String,
|
||||||
@@ -121,4 +127,6 @@ data class ResourcePermissionDto(
|
|||||||
@Json(name = "expiresAt") val expiresAt: Any? = null,
|
@Json(name = "expiresAt") val expiresAt: Any? = null,
|
||||||
@Json(name = "cachedAt") val cachedAt: Long,
|
@Json(name = "cachedAt") val cachedAt: Long,
|
||||||
@Json(name = "updatedAt") val updatedAt: Long,
|
@Json(name = "updatedAt") val updatedAt: Long,
|
||||||
|
@Json(name = "name") val name: String = "",
|
||||||
|
@Json(name = "parentId") val parentId: String? = null,
|
||||||
)
|
)
|
||||||
+16
-1
@@ -130,12 +130,18 @@ class FileRepository @Inject constructor(
|
|||||||
|
|
||||||
val category = computeCategory(input.mimeType, input.extension).dbValue
|
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(
|
val entity = FileEntity(
|
||||||
id = existing?.id ?: 0L,
|
id = existing?.id ?: 0L,
|
||||||
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
|
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
|
||||||
uri = input.uri,
|
uri = input.uri,
|
||||||
name = input.name,
|
name = input.name,
|
||||||
folderResourceId = folderResourceId,
|
folderResourceId = effectiveFolder,
|
||||||
extension = input.extension ?: existing?.extension,
|
extension = input.extension ?: existing?.extension,
|
||||||
size = input.size,
|
size = input.size,
|
||||||
mimeType = input.mimeType,
|
mimeType = input.mimeType,
|
||||||
@@ -192,12 +198,21 @@ class FileRepository @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de
|
* `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de
|
||||||
* chaque fichier. Ne supprime jamais de lignes locales.
|
* 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) {
|
suspend fun refreshFromServer(folderResourceId: String) {
|
||||||
val files = apiClient.listFiles(folderId = folderResourceId, pageSize = PAGE_SIZE)
|
val files = apiClient.listFiles(folderId = folderResourceId, pageSize = PAGE_SIZE)
|
||||||
if (files.isEmpty()) return
|
if (files.isEmpty()) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
files.forEach { dto ->
|
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))
|
fileDao.upsert(toEntity(dto, folderResourceId, now))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -103,6 +103,41 @@ class OutboxRepository @Inject constructor(
|
|||||||
resourceType = resourceType,
|
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). */
|
/** Nombre d'ops en attente de push (stats UI optionnelles). */
|
||||||
suspend fun countPending(): Int = pendingOperationDao.countPending()
|
suspend fun countPending(): Int = pendingOperationDao.countPending()
|
||||||
|
|
||||||
@@ -113,4 +148,21 @@ class OutboxRepository @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
suspend fun hasCreateOperation(resourceId: String): Boolean =
|
suspend fun hasCreateOperation(resourceId: String): Boolean =
|
||||||
pendingOperationDao.countCreateOperations(resourceId) > 0
|
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
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.local.AppDatabase
|
|||||||
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
import com.vaultdrop.mobile.data.repository.FileRepository
|
import com.vaultdrop.mobile.data.repository.FileRepository
|
||||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
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.SaveFileInput
|
||||||
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
||||||
import com.vaultdrop.mobile.domain.DeviceIdentity
|
import com.vaultdrop.mobile.domain.DeviceIdentity
|
||||||
@@ -34,6 +35,7 @@ class DeviceSync @Inject constructor(
|
|||||||
private val scanner: SafScanner,
|
private val scanner: SafScanner,
|
||||||
private val folderRepository: FolderRepository,
|
private val folderRepository: FolderRepository,
|
||||||
private val fileRepository: FileRepository,
|
private val fileRepository: FileRepository,
|
||||||
|
private val outboxRepository: OutboxRepository,
|
||||||
private val deviceIdentity: DeviceIdentity,
|
private val deviceIdentity: DeviceIdentity,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -119,7 +121,13 @@ class DeviceSync @Inject constructor(
|
|||||||
}
|
}
|
||||||
for (file in fileRepository.getAll()) {
|
for (file in fileRepository.getAll()) {
|
||||||
val fileUri = file.uri
|
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)
|
fileRepository.markMissing(file.resourceId, now)
|
||||||
missing++
|
missing++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.local.entity.PendingOperationEntity
|
||||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||||
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
||||||
|
import com.vaultdrop.mobile.data.repository.ShareRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -46,6 +47,7 @@ class SyncViewModel @Inject constructor(
|
|||||||
private val deviceSync: DeviceSync,
|
private val deviceSync: DeviceSync,
|
||||||
private val folderRepository: FolderRepository,
|
private val folderRepository: FolderRepository,
|
||||||
private val pendingOperationDao: PendingOperationDao,
|
private val pendingOperationDao: PendingOperationDao,
|
||||||
|
private val shareRepository: ShareRepository,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -107,6 +109,10 @@ class SyncViewModel @Inject constructor(
|
|||||||
OutboxSyncWorker.enqueue(appContext)
|
OutboxSyncWorker.enqueue(appContext)
|
||||||
}
|
}
|
||||||
.onFailure { e -> Timber.w(e, "syncAll failed, retrying later") }
|
.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
|
_walkInProgress.value = false
|
||||||
delay(INTERVAL_MS)
|
delay(INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.vaultdrop.mobile.auth.SessionManager
|
|||||||
import com.vaultdrop.mobile.data.remote.ApiClient
|
import com.vaultdrop.mobile.data.remote.ApiClient
|
||||||
import com.vaultdrop.mobile.data.remote.ApiException
|
import com.vaultdrop.mobile.data.remote.ApiException
|
||||||
import com.vaultdrop.mobile.data.repository.AuthRepository
|
import com.vaultdrop.mobile.data.repository.AuthRepository
|
||||||
|
import com.vaultdrop.mobile.data.repository.ShareRepository
|
||||||
import com.vaultdrop.mobile.features.sync.OutboxSyncWorker
|
import com.vaultdrop.mobile.features.sync.OutboxSyncWorker
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -25,6 +26,7 @@ class AuthViewModel @Inject constructor(
|
|||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val apiClient: ApiClient,
|
private val apiClient: ApiClient,
|
||||||
|
private val shareRepository: ShareRepository,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -49,6 +51,11 @@ class AuthViewModel @Inject constructor(
|
|||||||
authRepository.registerDevice()
|
authRepository.registerDevice()
|
||||||
// Session restaurée → drainer l'outbox laissée en attente.
|
// Session restaurée → drainer l'outbox laissée en attente.
|
||||||
OutboxSyncWorker.enqueue(appContext)
|
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)
|
_authState.value = AuthState.SignedIn(response.user)
|
||||||
// Connexion réussie → pousser les mutations locales en attente.
|
// Connexion réussie → pousser les mutations locales en attente.
|
||||||
OutboxSyncWorker.enqueue(appContext)
|
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 ->
|
.onFailure { e ->
|
||||||
val error = when (e) {
|
val error = when (e) {
|
||||||
|
|||||||
+28
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.DeleteWarningDialog
|
||||||
import com.vaultdrop.mobile.ui.components.reviewDelete
|
import com.vaultdrop.mobile.ui.components.reviewDelete
|
||||||
import com.vaultdrop.mobile.ui.navigation.SelectionNavBar
|
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
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -78,14 +81,17 @@ fun FolderDetailScreen(
|
|||||||
connectionStatusViewModel: ConnectionStatusViewModel,
|
connectionStatusViewModel: ConnectionStatusViewModel,
|
||||||
syncViewModel: SyncViewModel,
|
syncViewModel: SyncViewModel,
|
||||||
viewModel: FolderDetailViewModel = hiltViewModel(),
|
viewModel: FolderDetailViewModel = hiltViewModel(),
|
||||||
|
shareViewModel: ShareViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
||||||
|
val shareState by shareViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val selection = rememberSelectionState()
|
val selection = rememberSelectionState()
|
||||||
var showCreateDialog by remember { mutableStateOf(false) }
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
var showMoveDialog by remember { mutableStateOf(false) }
|
var showMoveDialog by remember { mutableStateOf(false) }
|
||||||
var showDeleteWarning by remember { mutableStateOf(false) }
|
var showDeleteWarning by remember { mutableStateOf(false) }
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showShareSheet by remember { mutableStateOf(false) }
|
||||||
var pendingDeleteMode by remember { mutableStateOf<FileDeleter.DeleteMode?>(null) }
|
var pendingDeleteMode by remember { mutableStateOf<FileDeleter.DeleteMode?>(null) }
|
||||||
var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) }
|
var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -172,6 +178,12 @@ fun FolderDetailScreen(
|
|||||||
status = connectionStatus,
|
status = connectionStatus,
|
||||||
onClick = connectionStatusViewModel::checkNow,
|
onClick = connectionStatusViewModel::checkNow,
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = { showShareSheet = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Share,
|
||||||
|
contentDescription = stringResource(R.string.share_content_description),
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = viewModel::refresh) {
|
IconButton(onClick = viewModel::refresh) {
|
||||||
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.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
|
@Composable
|
||||||
|
|||||||
+84
-1
@@ -305,6 +305,7 @@ fun FolderListScreen(
|
|||||||
atRoot = uiState.browseFolderId == null,
|
atRoot = uiState.browseFolderId == null,
|
||||||
browseFolderName = uiState.browseFolderName,
|
browseFolderName = uiState.browseFolderName,
|
||||||
subFolders = uiState.browseSubFolders,
|
subFolders = uiState.browseSubFolders,
|
||||||
|
browseFiles = uiState.browseFiles,
|
||||||
sections = uiState.sections,
|
sections = uiState.sections,
|
||||||
isImporting = importState.isImporting,
|
isImporting = importState.isImporting,
|
||||||
error = uiState.error ?: importState.error,
|
error = uiState.error ?: importState.error,
|
||||||
@@ -467,6 +468,7 @@ private fun HomeViewContent(
|
|||||||
atRoot: Boolean,
|
atRoot: Boolean,
|
||||||
browseFolderName: String?,
|
browseFolderName: String?,
|
||||||
subFolders: List<FolderEntity>,
|
subFolders: List<FolderEntity>,
|
||||||
|
browseFiles: List<FileEntity>,
|
||||||
sections: List<FileSection>,
|
sections: List<FileSection>,
|
||||||
isImporting: Boolean,
|
isImporting: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
@@ -491,11 +493,14 @@ private fun HomeViewContent(
|
|||||||
atRoot = atRoot,
|
atRoot = atRoot,
|
||||||
browseFolderName = browseFolderName,
|
browseFolderName = browseFolderName,
|
||||||
subFolders = subFolders,
|
subFolders = subFolders,
|
||||||
|
browseFiles = browseFiles,
|
||||||
isImporting = isImporting,
|
isImporting = isImporting,
|
||||||
error = error,
|
error = error,
|
||||||
|
selection = selection,
|
||||||
onBrowseUp = onBrowseUp,
|
onBrowseUp = onBrowseUp,
|
||||||
onOpenBrowseFolder = onOpenBrowseFolder,
|
onOpenBrowseFolder = onOpenBrowseFolder,
|
||||||
onCreateFolder = onCreateFolder,
|
onCreateFolder = onCreateFolder,
|
||||||
|
onOpenDocument = onOpenDocument,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -592,13 +597,17 @@ private fun FolderBrowserContent(
|
|||||||
atRoot: Boolean,
|
atRoot: Boolean,
|
||||||
browseFolderName: String?,
|
browseFolderName: String?,
|
||||||
subFolders: List<FolderEntity>,
|
subFolders: List<FolderEntity>,
|
||||||
|
browseFiles: List<FileEntity>,
|
||||||
isImporting: Boolean,
|
isImporting: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
|
selection: SelectionState,
|
||||||
onBrowseUp: () -> Unit,
|
onBrowseUp: () -> Unit,
|
||||||
onOpenBrowseFolder: (String) -> Unit,
|
onOpenBrowseFolder: (String) -> Unit,
|
||||||
onCreateFolder: () -> Unit,
|
onCreateFolder: () -> Unit,
|
||||||
|
onOpenDocument: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val empty = subFolders.isEmpty() && (moveMode || browseFiles.isEmpty()) && !isImporting
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
@@ -677,7 +686,20 @@ private fun FolderBrowserContent(
|
|||||||
FolderRow(folder, onClick = { onOpenBrowseFolder(folder.resourceId) })
|
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") {
|
item(key = "empty") {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(if (atRoot) R.string.no_folders_yet else R.string.empty_folder),
|
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). */
|
/** Miroir de `formatSize` (app/index.tsx). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun formatSize(bytes: Long): String {
|
private fun formatSize(bytes: Long): String {
|
||||||
|
|||||||
+2
@@ -37,6 +37,8 @@ data class FolderListUiState(
|
|||||||
val browseFolderName: String? = null,
|
val browseFolderName: String? = null,
|
||||||
/** Sous-dossiers visibles du dossier courant de l'explorateur. */
|
/** Sous-dossiers visibles du dossier courant de l'explorateur. */
|
||||||
val browseSubFolders: List<FolderEntity> = emptyList(),
|
val browseSubFolders: List<FolderEntity> = emptyList(),
|
||||||
|
/** Fichiers visibles du dossier courant de l'explorateur. */
|
||||||
|
val browseFiles: List<FileEntity> = emptyList(),
|
||||||
val sections: List<FileSection> = emptyList(),
|
val sections: List<FileSection> = emptyList(),
|
||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
|||||||
+10
@@ -147,6 +147,16 @@ class FolderListViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(browseSubFolders = subFolders) }
|
_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). */
|
/** Descend dans l'explorateur Dossiers (aussi en mode déplacement). */
|
||||||
|
|||||||
@@ -229,4 +229,17 @@
|
|||||||
<string name="delete_confirm_button">Confirmer</string>
|
<string name="delete_confirm_button">Confirmer</string>
|
||||||
<string name="delete_cancel">Annuler</string>
|
<string name="delete_cancel">Annuler</string>
|
||||||
<string name="delete_error">Erreur lors de la suppression</string>
|
<string name="delete_error">Erreur lors de la suppression</string>
|
||||||
|
|
||||||
|
<!-- Partage (share grants) -->
|
||||||
|
<string name="share_title">Partager avec un utilisateur</string>
|
||||||
|
<string name="share_username_hint">Nom d\'utilisateur du destinataire</string>
|
||||||
|
<string name="share_access_label">Niveau d\'accès</string>
|
||||||
|
<string name="share_access_viewer">Lecture (viewer)</string>
|
||||||
|
<string name="share_access_commenter">Commentaire (commenter)</string>
|
||||||
|
<string name="share_access_editor">Édition (editor)</string>
|
||||||
|
<string name="share_submit">Partager</string>
|
||||||
|
<string name="share_cancel">Annuler</string>
|
||||||
|
<string name="share_error_not_found">Aucun utilisateur connu sous ce nom.</string>
|
||||||
|
<string name="share_error_generic">Impossible de partager pour le moment.</string>
|
||||||
|
<string name="share_content_description">Partager ce dossier</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user