share folders and files
This commit is contained in:
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user