share folders and files

This commit is contained in:
m
2026-09-14 14:09:41 +02:00
parent 09669a1e7b
commit e9c85febca
29 changed files with 2289 additions and 101 deletions
+1
View File
@@ -10,6 +10,7 @@ var expectedRoutes = []string{
"GET /api/v1/health",
"POST /api/v1/devices",
"POST /api/v1/auth/login",
"GET /api/v1/shares/links/:token",
"GET /api/v1/files",
"GET /api/v1/files/:id",
@@ -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);
+49
View File
@@ -221,6 +221,55 @@ func TestMigrationsUpDown(t *testing.T) {
t.Error("contrainte UNIQUE(device_id, operation_id) manquante sur operations")
}
// --- 000009 : shares + share_links ------------------------------------
assertTables(t, conn, "users", "devices", "resources", "operations", "ocr_jobs", "shares", "share_links", "schema_migrations")
assertHexCheck(t, conn, "shares", "id")
assertHexCheck(t, conn, "share_links", "id")
// shares access CHECK
var sharesAccessCheck int
err = conn.QueryRow(`
SELECT COUNT(*) FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'shares'
AND pg_get_constraintdef(c.oid) LIKE '%viewer%'
AND pg_get_constraintdef(c.oid) LIKE '%editor%'`).Scan(&sharesAccessCheck)
if err != nil {
t.Fatalf("check shares access: %v", err)
}
if sharesAccessCheck == 0 {
t.Error("CHECK (access IN ('viewer','commenter','editor')) manquant sur shares")
}
// shares unique partiel (resource_id, grantee_user_id) WHERE revoked_at IS NULL
var sharesActiveIdx int
err = conn.QueryRow(`
SELECT COUNT(*) FROM pg_index i
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = 'shares' AND i.indisunique
AND i.indpred IS NOT NULL`).Scan(&sharesActiveIdx)
if err != nil {
t.Fatalf("shares active index: %v", err)
}
if sharesActiveIdx == 0 {
t.Error("index unique partiel (resource_id, grantee_user_id) WHERE revoked_at IS NULL manquant sur shares")
}
// share_links token unique partiel
var linksTokenIdx int
err = conn.QueryRow(`
SELECT COUNT(*) FROM pg_index i
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = 'share_links' AND i.indisunique
AND i.indpred IS NOT NULL`).Scan(&linksTokenIdx)
if err != nil {
t.Fatalf("share_links token index: %v", err)
}
if linksTokenIdx == 0 {
t.Error("index unique partiel (id) WHERE revoked_at IS NULL manquant sur share_links")
}
if err := MigrateDownDatabase(url); err != nil {
t.Fatalf("migrate down: %v", err)
}
+4 -2
View File
@@ -10,14 +10,16 @@ import (
)
// RegisterRoutes wires the full /api/v1 surface (public + protected).
// Public: /health, /devices, /auth/login. Everything else requires a user
// bearer token (subject = user_id, claim = device_id, cf. docs/api-v1.md §2).
// Public: /health, /devices, /auth/login, /shares/links/:token (résolution de
// lien anonyme). Everything else requires a user bearer token (subject =
// user_id, claim = device_id, cf. docs/api-v1.md §2).
func RegisterRoutes(r *gin.Engine) {
public := r.Group("/api/v1")
{
public.GET("/health", Health)
public.POST("/devices", DevicesRegister)
public.POST("/auth/login", AuthLogin)
public.GET("/shares/links/:token", ShareLinkGet)
}
protected := r.Group("/api/v1")
+31
View File
@@ -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)
}
+87
View File
@@ -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")
}
+2
View File
@@ -21,6 +21,8 @@ func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, repository.ErrNotFound):
api.Error(c, 404, "NOT_FOUND", "resource not found")
case errors.Is(err, repository.ErrGranteeNotFound):
api.Error(c, 404, "GRANTEE_NOT_FOUND", "the specified user does not exist")
case errors.Is(err, repository.ErrNameConflict):
api.Error(c, 409, "NAME_CONFLICT", "a resource with this name already exists here")
case errors.Is(err, service.FileTooLargeError):
+2
View File
@@ -11,6 +11,7 @@ type Repository struct {
Operations *Operations
OcrJobs *OcrJobs
Users *Users
Shares *Shares
}
func NewRepository(conn *sql.DB) *Repository {
@@ -20,5 +21,6 @@ func NewRepository(conn *sql.DB) *Repository {
Operations: &Operations{DB: conn},
OcrJobs: &OcrJobs{DB: conn},
Users: &Users{DB: conn},
Shares: &Shares{DB: conn},
}
}
+169
View File
@@ -74,6 +74,39 @@ func sortClause(sort, order string) (string, string) {
return column, direction
}
// grantedCTE expands, for the user bound to $1, the set of accessible
// resource ids — owned rows (each its own seed) + active direct grants +
// every node reached by an inherit=true grant up its chain (passing through
// intermediate nodes, per docs/api-v1.md §6.2 rule 5). Only the visibility
// SET matters here (which resources the user can read, viewer+); the effective
// rank is validated by repository.Shares.EffectiveAccess. Walk bounded at
// depth < 100. Append a query that references
// `resource_id IN (SELECT id FROM granted)`.
const grantedCTE = `
seeded(id, type, rank, inherit, depth) AS (
SELECT r.resource_id, r.type, 4::int, FALSE, 0
FROM resources r
WHERE r.user_id = $1 AND r.deleted_at IS NULL
UNION ALL
SELECT r.resource_id, r.type,
CASE sh.access WHEN 'viewer' THEN 1 WHEN 'commenter' THEN 2 WHEN 'editor' THEN 3 ELSE 0 END,
sh.inherit, 0
FROM shares sh
JOIN resources r ON r.resource_id = sh.resource_id
WHERE sh.grantee_user_id = $1 AND sh.revoked_at IS NULL
AND (sh.expires_at IS NULL OR sh.expires_at > NOW())
AND r.deleted_at IS NULL
),
granted(id, rank, inherit, depth) AS (
SELECT id, rank, inherit, 0 FROM seeded
UNION ALL
SELECT r.resource_id, g.rank, TRUE, g.depth + 1
FROM resources r
JOIN granted g ON r.parent_id = g.id
WHERE g.inherit AND g.rank BETWEEN 1 AND 3
AND r.deleted_at IS NULL AND g.depth < 100
)`
func (r *Resources) folderExists(ownerID, folderID string) (bool, error) {
var exists int
err := r.DB.QueryRow(
@@ -161,6 +194,38 @@ func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset in
return files, total, rows.Err()
}
// ListFilesVisible returns the user's accessible files (owned or shared,
// viewer+) within a folder (or at the root when folderResourceID is empty).
func (r *Resources) ListFilesVisible(userID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) {
column, direction := sortClause(sort, order)
where := `type = 'file' AND deleted_at IS NULL AND resource_id IN (SELECT id FROM granted)
AND ($2::text = '' AND parent_id IS NULL OR parent_id = $2)`
var total int
if err := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+` SELECT COUNT(*) FROM resources WHERE `+where, userID, folderResourceID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.DB.Query(
fmt.Sprintf(`WITH RECURSIVE %s SELECT %s FROM resources WHERE %s ORDER BY %s %s LIMIT $3 OFFSET $4`,
grantedCTE, fileColumns, where, column, direction),
userID, folderResourceID, limit, offset,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
files := make([]FileRow, 0)
for rows.Next() {
row, err := r.scanFile(rows.Scan)
if err != nil {
return nil, 0, err
}
files = append(files, row)
}
return files, total, rows.Err()
}
// GetFile returns a file owned by the device (no-rows → ErrNotFound).
func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
row := r.DB.QueryRow(
`SELECT `+fileColumns+` FROM resources
@@ -174,6 +239,21 @@ func (r *Resources) GetFile(ownerID, resourceID string) (FileRow, error) {
return file, err
}
// GetFileVisible returns a file the user can access (owned or shared, viewer+).
func (r *Resources) GetFileVisible(userID, resourceID string) (FileRow, error) {
row := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+`
SELECT `+fileColumns+` FROM resources
WHERE type = 'file' AND deleted_at IS NULL AND resource_id = $2
AND resource_id IN (SELECT id FROM granted)`,
userID, resourceID,
)
file, err := r.scanFile(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return FileRow{}, ErrNotFound
}
return file, err
}
// DeleteFile soft-deletes the file (deleted_at), returning its id.
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
result, err := r.DB.Exec(
@@ -224,6 +304,36 @@ func (r *Resources) SearchFiles(ownerID, q string, limit, offset int) ([]FileRow
return files, total, rows.Err()
}
// SearchFilesVisible returns the user's accessible files (owned or shared,
// viewer+) whose name matches q (case-insensitive substring).
func (r *Resources) SearchFilesVisible(userID, q string, limit, offset int) ([]FileRow, int, error) {
pattern := `%` + escapeLike(q) + `%`
where := `type = 'file' AND deleted_at IS NULL AND resource_id IN (SELECT id FROM granted) AND name ILIKE $2 ESCAPE '\'`
var total int
if err := r.DB.QueryRow(`WITH RECURSIVE `+grantedCTE+` SELECT COUNT(*) FROM resources WHERE `+where, userID, pattern).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.DB.Query(
fmt.Sprintf(`WITH RECURSIVE %s SELECT %s FROM resources WHERE %s ORDER BY name ASC LIMIT $3 OFFSET $4`,
grantedCTE, fileColumns, where),
userID, pattern, limit, offset,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
files := make([]FileRow, 0)
for rows.Next() {
row, err := r.scanFile(rows.Scan)
if err != nil {
return nil, 0, err
}
files = append(files, row)
}
return files, total, rows.Err()
}
func escapeLike(q string) string {
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return replacer.Replace(q)
@@ -299,6 +409,30 @@ func (r *Resources) UpdateName(ownerID, resourceID, name string) error {
return nil
}
// UpdateNameByID renames a resource without owner scoping — the caller
// (service) has already verified the user's effective access (editor+).
func (r *Resources) UpdateNameByID(resourceID, name string) error {
result, err := r.DB.Exec(
`UPDATE resources SET name = $2, updated_at = NOW()
WHERE resource_id = $1 AND deleted_at IS NULL`,
resourceID, name,
)
if err != nil && isUniqueViolation(err) {
return ErrNameConflict
}
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
// SyncDelete soft-deletes a resource; absence is NOT an error (idempotent
// terminal state for the outbox).
func (r *Resources) SyncDelete(ownerID, resourceID string) error {
@@ -323,6 +457,16 @@ func (r *Resources) ExistsOwner(ownerID, resourceID string) (bool, error) {
return err == nil, err
}
// TouchResource bumps updated_at on a non-deleted resource so that the
// grantee's delta snapshot picks it up after a share is created.
func (r *Resources) TouchResource(resourceID string) error {
_, err := r.DB.Exec(
`UPDATE resources SET updated_at = NOW() WHERE resource_id = $1 AND deleted_at IS NULL`,
resourceID,
)
return err
}
// OwnedRow is a snapshot row: resource identity + freshness.
type OwnedRow struct {
ID string
@@ -378,3 +522,28 @@ func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) {
}
return folders, rows.Err()
}
// ListRootFoldersVisible returns the user's accessible top-level folders —
// owned (rank 4) or shared (viewer+) at the root.
func (r *Resources) ListRootFoldersVisible(userID string) ([]FolderRow, error) {
rows, err := r.DB.Query(`WITH RECURSIVE `+grantedCTE+`
SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
WHERE type = 'folder' AND parent_id IS NULL AND deleted_at IS NULL
AND resource_id IN (SELECT id FROM granted)
ORDER BY name ASC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
folders := make([]FolderRow, 0)
for rows.Next() {
var folder FolderRow
if err := rows.Scan(&folder.ID, &folder.Name, &folder.ParentID); err != nil {
return nil, err
}
folders = append(folders, folder)
}
return folders, rows.Err()
}
+432
View File
@@ -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")
+4 -4
View File
@@ -54,7 +54,7 @@ func NewResources(repo *repository.Repository, uploadDir string, maxFileSize int
}
func (s *Resources) ListFiles(ownerID, folderID string, page, pageSize int, sort, order string) ([]FileDTO, int, error) {
rows, total, err := s.Repo.ListFiles(ownerID, folderID, pageSize, (page-1)*pageSize, sort, order)
rows, total, err := s.Repo.ListFilesVisible(ownerID, folderID, pageSize, (page-1)*pageSize, sort, order)
if err != nil {
return nil, 0, err
}
@@ -66,7 +66,7 @@ func (s *Resources) ListFiles(ownerID, folderID string, page, pageSize int, sort
}
func (s *Resources) GetFile(ownerID, id string) (FileDTO, error) {
row, err := s.Repo.GetFile(ownerID, id)
row, err := s.Repo.GetFileVisible(ownerID, id)
if err != nil {
return FileDTO{}, err
}
@@ -78,7 +78,7 @@ func (s *Resources) DeleteFile(ownerID, id string) (string, error) {
}
func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) {
rows, err := s.Repo.ListRootFolders(ownerID)
rows, err := s.Repo.ListRootFoldersVisible(ownerID)
if err != nil {
return nil, err
}
@@ -90,7 +90,7 @@ func (s *Resources) ListRootFolders(ownerID string) ([]FolderDTO, error) {
}
func (s *Resources) SearchFiles(ownerID, q string, page, pageSize int) ([]FileDTO, int, error) {
rows, total, err := s.Repo.SearchFiles(ownerID, q, pageSize, (page-1)*pageSize)
rows, total, err := s.Repo.SearchFilesVisible(ownerID, q, pageSize, (page-1)*pageSize)
if err != nil {
return nil, 0, err
}
+205 -46
View File
@@ -11,8 +11,7 @@ import (
var resourceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
// Ops de l'outbox (docs/api-v1.md §6.1). Les ops partage (share/share_link)
// sont accusées réception mais sans état serveur en V1 (single-owner).
// Ops de l'outbox (docs/api-v1.md §6.1).
const (
OpCreateResource = "create_resource"
OpUpdateMetadata = "update_metadata"
@@ -25,11 +24,6 @@ const (
OpRevokeLink = "revoke_link"
)
var ackOnlyOps = map[string]bool{
OpShare: true, OpRevokeShare: true, OpUpdateShare: true,
OpCreateLink: true, OpRevokeLink: true,
}
// SyncOperation is one outbox entry (shadow of mobile PendingOperationRow).
type SyncOperation struct {
OperationID string `json:"operation_id"`
@@ -69,12 +63,35 @@ type movePayload struct {
ToFolderResourceID string `json:"toFolderResourceId"`
}
type sharePayload struct {
GranteeUserID string `json:"granteeUserId"`
Access string `json:"access"`
Inherit *bool `json:"inherit"`
ExpiresAt *int64 `json:"expiresAt"`
}
type revokeSharePayload struct {
GranteeUserID string `json:"granteeUserId"`
}
type linkPayload struct {
Token string `json:"token"`
Access string `json:"access"`
ExpiresAt *int64 `json:"expiresAt"`
}
type revokeLinkPayload struct {
Token string `json:"token"`
}
func classifySyncError(err error) (string, string) {
switch {
case errors.Is(err, repository.ErrNameConflict):
return "NAME_CONFLICT", "a resource with this name already exists here"
case errors.Is(err, repository.ErrNotFound):
return "NOT_FOUND", "target resource or folder not found"
case errors.Is(err, repository.ErrGranteeNotFound):
return "GRANTEE_NOT_FOUND", "the specified user does not exist"
default:
return "INVALID_REQUEST", err.Error()
}
@@ -96,12 +113,6 @@ func (s *Resources) ApplyBatch(userID, deviceID string, ops []SyncOperation) (Sy
if already {
continue
}
if ackOnlyOps[op.Operation] {
if err := s.recordApplied(deviceID, op); err != nil {
return SyncResult{}, err
}
continue
}
if err := s.applySyncOp(userID, op); err != nil {
code, message := classifySyncError(err)
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: code, Message: message}}, nil
@@ -120,14 +131,12 @@ func validateSyncOp(op *SyncOperation) error {
if op.Operation == "" {
return errors.New("missing operation type")
}
if ackOnlyOps[op.Operation] {
return nil
}
if !resourceIDPattern.MatchString(derefString(op.ResourceID)) {
return errors.New("resource_id must be 32 lowercase hex chars")
}
switch op.Operation {
case OpCreateResource, OpUpdateMetadata, OpMoveResource, OpDeleteResource:
case OpCreateResource, OpUpdateMetadata, OpMoveResource, OpDeleteResource,
OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink:
default:
return errors.New("unknown operation " + op.Operation)
}
@@ -169,12 +178,19 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
return s.Repo.InsertFile(ownerID, resourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension))
case OpUpdateMetadata:
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
// Owner can always rename. A shared resource can be renamed by an
// editor+ (docs §6.2 effective_access ranking). Below editor, or on
// a resource the caller cannot reach at all, we keep the documented
// terminal-state semantics: absent → no-op.
access, err := s.Repository.Shares.EffectiveAccess(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return nil
if access.EffectiveRank < repository.AccessEditor {
if !access.Accessible {
return nil
}
return repository.ErrNotFound
}
var p renamePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
@@ -183,7 +199,7 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
if p.Name == "" {
return errors.New("payload.name required")
}
return s.Repo.UpdateName(ownerID, resourceID, p.Name)
return s.Repo.UpdateNameByID(resourceID, p.Name)
case OpMoveResource:
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
@@ -205,11 +221,146 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
case OpDeleteResource:
return s.Repo.SyncDelete(ownerID, resourceID)
case OpShare, OpUpdateShare:
return s.applyShareOp(ownerID, resourceID, op)
case OpRevokeShare:
return s.applyRevokeShareOp(ownerID, resourceID, op)
case OpCreateLink:
return s.applyCreateLinkOp(ownerID, resourceID, op)
case OpRevokeLink:
return s.applyRevokeLinkOp(ownerID, resourceID, op)
default:
return errors.New("unknown operation " + op.Operation)
}
}
// applyShareOp handles OpShare and OpUpdateShare: upsert a user-to-user grant.
func (s *Resources) applyShareOp(ownerID, resourceID string, op *SyncOperation) error {
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return repository.ErrNotFound
}
var p sharePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.GranteeUserID == "" {
return errors.New("payload.granteeUserId required")
}
if !resourceIDPattern.MatchString(p.GranteeUserID) {
return errors.New("payload.granteeUserId must be 32 lowercase hex chars")
}
rank := repository.AccessFromString(p.Access)
if rank < repository.AccessViewer || rank > repository.AccessEditor {
return errors.New("payload.access must be viewer, commenter, or editor")
}
// Validate grantee exists and is active.
if _, err := s.Repository.Users.GetByID(p.GranteeUserID); err != nil {
if errors.Is(err, repository.ErrNotFound) {
return repository.ErrGranteeNotFound
}
return err
}
inherit := true
if p.Inherit != nil {
inherit = *p.Inherit
}
var expiresAt *time.Time
if p.ExpiresAt != nil {
t := time.UnixMilli(*p.ExpiresAt)
expiresAt = &t
}
if err := s.Repository.Shares.Upsert(resourceID, p.GranteeUserID, p.Access, inherit, expiresAt, ownerID); err != nil {
return err
}
// Bump resource updated_at so the grantee's delta snapshot picks it up.
return s.Repo.TouchResource(resourceID)
}
// applyRevokeShareOp handles OpRevokeShare: soft-revoke a user-to-user grant.
func (s *Resources) applyRevokeShareOp(ownerID, resourceID string, op *SyncOperation) error {
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return repository.ErrNotFound
}
var p revokeSharePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.GranteeUserID == "" {
return errors.New("payload.granteeUserId required")
}
if !resourceIDPattern.MatchString(p.GranteeUserID) {
return errors.New("payload.granteeUserId must be 32 lowercase hex chars")
}
// Revoke is idempotent: no-op if already revoked/missing.
_ = s.Repository.Shares.Revoke(resourceID, p.GranteeUserID)
// Bump resource updated_at so the grantee's delta snapshot reflects the change.
return s.Repo.TouchResource(resourceID)
}
// applyCreateLinkOp handles OpCreateLink: create a public share link.
func (s *Resources) applyCreateLinkOp(ownerID, resourceID string, op *SyncOperation) error {
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return repository.ErrNotFound
}
var p linkPayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if !resourceIDPattern.MatchString(p.Token) {
return errors.New("payload.token must be 32 lowercase hex chars")
}
rank := repository.AccessFromString(p.Access)
if rank < repository.AccessViewer || rank > repository.AccessEditor {
return errors.New("payload.access must be viewer, commenter, or editor")
}
var expiresAt *time.Time
if p.ExpiresAt != nil {
t := time.UnixMilli(*p.ExpiresAt)
expiresAt = &t
}
if err := s.Repository.Shares.CreateLink(p.Token, resourceID, p.Access, expiresAt, ownerID); err != nil {
return err
}
return s.Repo.TouchResource(resourceID)
}
// applyRevokeLinkOp handles OpRevokeLink: soft-revoke a public share link.
func (s *Resources) applyRevokeLinkOp(ownerID, resourceID string, op *SyncOperation) error {
exists, err := s.Repo.ExistsOwner(ownerID, resourceID)
if err != nil {
return err
}
if !exists {
return repository.ErrNotFound
}
var p revokeLinkPayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if !resourceIDPattern.MatchString(p.Token) {
return errors.New("payload.token must be 32 lowercase hex chars")
}
// Revoke is idempotent: no-op if already revoked/missing.
_ = s.Repository.Shares.RevokeLink(p.Token)
return s.Repo.TouchResource(resourceID)
}
func (s *Resources) recordApplied(deviceID string, op *SyncOperation) error {
return s.Repository.Operations.Record(deviceID, op.OperationID, op.Operation, derefString(op.RefType), nullableInt64(derefInt64(op.RefID)), derefString(op.ResourceID), op.Payload)
}
@@ -245,39 +396,47 @@ func nullableInt64(value int64) *int64 {
// ResourcePermission is the snapshot shape consumed by canAccess
// (docs/api-v1.md §6.2).
type ResourcePermission struct {
ResourceID string `json:"resource_id"`
ResourceType string `json:"resourceType"`
EffectiveAccess string `json:"effectiveAccess"`
Inherit bool `json:"inherit"`
OwnerID string `json:"ownerId"`
SharedByID any `json:"sharedById"`
ExpiresAt any `json:"expiresAt"`
CachedAt int64 `json:"cachedAt"`
UpdatedAt int64 `json:"updatedAt"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resourceType"`
EffectiveAccess string `json:"effectiveAccess"`
Name string `json:"name"`
ParentID *string `json:"parentId"`
Inherit bool `json:"inherit"`
OwnerID string `json:"ownerId"`
SharedByID *string `json:"sharedById"`
ExpiresAt *int64 `json:"expiresAt"`
CachedAt int64 `json:"cachedAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// Snapshot returns the delta of effective permissions for the user since
// afterMs (epoch ms; 0 = all). V1 : pas encore de partage entre users — toutes
// les ressources appartiennent au user appelant (effective_access = owner).
// afterMs (epoch ms; 0 = all). Uses the recursive CTE computed by
// repository.Shares.ListEffectivePermissions.
func (s *Resources) Snapshot(ownerID string, afterMs int64) ([]ResourcePermission, error) {
rows, err := s.Repo.ListOwned(ownerID, afterMs)
perms, err := s.Repository.Shares.ListEffectivePermissions(ownerID, afterMs)
if err != nil {
return nil, err
}
now := time.Now().UnixMilli()
perms := make([]ResourcePermission, 0, len(rows))
for _, row := range rows {
perms = append(perms, ResourcePermission{
ResourceID: row.ID,
ResourceType: row.Type,
EffectiveAccess: "owner",
Inherit: false,
OwnerID: ownerID,
SharedByID: nil,
ExpiresAt: nil,
out := make([]ResourcePermission, 0, len(perms))
for _, p := range perms {
rp := ResourcePermission{
ResourceID: p.ResourceID,
ResourceType: p.ResourceType,
EffectiveAccess: repository.AccessToString(p.EffectiveRank),
Name: p.Name,
ParentID: p.ParentID,
Inherit: p.Inherit,
OwnerID: p.OwnerID,
SharedByID: p.SharedByID,
CachedAt: now,
UpdatedAt: row.UpdatedAt.UnixMilli(),
})
UpdatedAt: p.UpdatedAt.UnixMilli(),
}
if p.ExpiresAt != nil {
ms := p.ExpiresAt.UnixMilli()
rp.ExpiresAt = &ms
}
out = append(out, rp)
}
return perms, nil
return out, nil
}
File diff suppressed because it is too large Load Diff