feat(server): add project sharing access control

This commit is contained in:
2026-07-10 14:46:18 +08:00
parent 60130b3d9b
commit 9d47cb9bbd
41 changed files with 3069 additions and 17 deletions
@@ -0,0 +1,247 @@
package sharing
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
type PostgresStore struct {
pool *pgxpool.Pool
}
func NewPostgresStore(ctx context.Context, dataSource string) (*PostgresStore, error) {
pool, err := pgxpool.New(ctx, dataSource)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &PostgresStore{pool: pool}, nil
}
func (s *PostgresStore) GetPolicyByProject(ctx context.Context, projectID string) (PolicyRecord, error) {
return scanPolicy(s.pool.QueryRow(ctx, `
SELECT project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
FROM project_share_policies
WHERE project_id = $1
`, projectID))
}
func (s *PostgresStore) GetPolicyByTokenHash(ctx context.Context, tokenHash []byte) (PolicyRecord, error) {
return scanPolicy(s.pool.QueryRow(ctx, `
SELECT project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
FROM project_share_policies
WHERE token_hash = $1
`, tokenHash))
}
func (s *PostgresStore) CreatePolicy(ctx context.Context, policy PolicyRecord) (PolicyRecord, error) {
created, err := scanPolicy(s.pool.QueryRow(ctx, `
INSERT INTO project_share_policies (
project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (project_id) DO NOTHING
RETURNING project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
`, policy.ProjectID, toPGUUID(policy.OwnerID), policy.TokenHash, policy.TokenCiphertext, string(policy.LinkPermission), policy.Version, policy.CreatedAt, policy.UpdatedAt))
if err == nil {
return created, nil
}
if errors.Is(err, ErrNotFound) {
return s.GetPolicyByProject(ctx, policy.ProjectID)
}
return PolicyRecord{}, err
}
func (s *PostgresStore) UpdatePolicyPermission(ctx context.Context, projectID string, ownerID string, permission Permission, updatedAt time.Time) (PolicyRecord, error) {
return scanPolicy(s.pool.QueryRow(ctx, `
UPDATE project_share_policies
SET link_permission = $3, policy_version = policy_version + 1, updated_at = $4
WHERE project_id = $1 AND owner_id = $2
RETURNING project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
`, projectID, toPGUUID(ownerID), string(permission), updatedAt))
}
func (s *PostgresStore) DeleteAll(ctx context.Context, projectID string, ownerID string) error {
result, err := s.pool.Exec(ctx, `DELETE FROM project_share_policies WHERE project_id = $1 AND owner_id = $2`, projectID, toPGUUID(ownerID))
if err != nil {
return err
}
if result.RowsAffected() == 0 {
if _, getErr := s.GetPolicyByProject(ctx, projectID); getErr == nil {
return ErrForbidden
} else if !errors.Is(getErr, ErrNotFound) {
return getErr
}
}
return nil
}
func (s *PostgresStore) ListMembers(ctx context.Context, projectID string) ([]MemberRecord, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at
FROM project_share_members
WHERE project_id = $1
ORDER BY created_at ASC
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]MemberRecord, 0)
for rows.Next() {
member, scanErr := scanMember(rows)
if scanErr != nil {
return nil, scanErr
}
items = append(items, member)
}
return items, rows.Err()
}
func (s *PostgresStore) CreateMember(ctx context.Context, member MemberRecord) (MemberRecord, error) {
created, err := scanMember(s.pool.QueryRow(ctx, `
INSERT INTO project_share_members (
id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at
`, toPGUUID(member.ID), member.ProjectID, member.IdentifierType, member.IdentifierHash, member.IdentifierCiphertext, string(member.Permission), member.CreatedAt, member.UpdatedAt))
if err != nil {
var postgresErr *pgconn.PgError
if errors.As(err, &postgresErr) && postgresErr.Code == "23505" {
return MemberRecord{}, ErrMemberExists
}
return MemberRecord{}, err
}
return created, nil
}
func (s *PostgresStore) UpdateMember(ctx context.Context, projectID string, memberID string, permission Permission, updatedAt time.Time) (MemberRecord, error) {
return scanMember(s.pool.QueryRow(ctx, `
UPDATE project_share_members
SET permission = $3, updated_at = $4
WHERE project_id = $1 AND id = $2
RETURNING id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at
`, projectID, toPGUUID(memberID), string(permission), updatedAt))
}
func (s *PostgresStore) DeleteMember(ctx context.Context, projectID string, memberID string) error {
result, err := s.pool.Exec(ctx, `DELETE FROM project_share_members WHERE project_id = $1 AND id = $2`, projectID, toPGUUID(memberID))
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *PostgresStore) CountVisitors(ctx context.Context, projectID string) (int64, error) {
var count int64
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM project_share_visits WHERE project_id = $1`, projectID).Scan(&count)
return count, err
}
func (s *PostgresStore) RecordVisit(ctx context.Context, visit VisitRecord) error {
var userID any
if visit.UserID != "" {
userID = toPGUUID(visit.UserID)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO project_share_visits (
id, project_id, visitor_hash, user_id, visit_count, first_seen_at, last_seen_at
)
VALUES ($1, $2, $3, $4, 1, $5, $6)
ON CONFLICT (project_id, visitor_hash) DO UPDATE SET
user_id = COALESCE(EXCLUDED.user_id, project_share_visits.user_id),
visit_count = project_share_visits.visit_count + 1,
last_seen_at = EXCLUDED.last_seen_at
`, visit.ID, visit.ProjectID, visit.VisitorHash, userID, visit.FirstSeenAt, visit.LastSeenAt)
return err
}
func (s *PostgresStore) ListVisitors(ctx context.Context, projectID string, limit int64) ([]VisitRecord, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, project_id, visitor_hash, user_id, visit_count, first_seen_at, last_seen_at
FROM project_share_visits
WHERE project_id = $1
ORDER BY last_seen_at DESC
LIMIT $2
`, projectID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]VisitRecord, 0)
for rows.Next() {
var visit VisitRecord
var userID pgtype.UUID
if err := rows.Scan(&visit.ID, &visit.ProjectID, &visit.VisitorHash, &userID, &visit.VisitCount, &visit.FirstSeenAt, &visit.LastSeenAt); err != nil {
return nil, err
}
if userID.Valid {
visit.UserID = uuid.UUID(userID.Bytes).String()
}
items = append(items, visit)
}
return items, rows.Err()
}
func (s *PostgresStore) Close() error {
if s != nil && s.pool != nil {
s.pool.Close()
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanPolicy(row rowScanner) (PolicyRecord, error) {
var policy PolicyRecord
var ownerID pgtype.UUID
if err := row.Scan(&policy.ProjectID, &ownerID, &policy.TokenHash, &policy.TokenCiphertext, &policy.LinkPermission, &policy.Version, &policy.CreatedAt, &policy.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return PolicyRecord{}, ErrNotFound
}
return PolicyRecord{}, err
}
if ownerID.Valid {
policy.OwnerID = uuid.UUID(ownerID.Bytes).String()
}
return policy, nil
}
func scanMember(row rowScanner) (MemberRecord, error) {
var member MemberRecord
var id pgtype.UUID
if err := row.Scan(&id, &member.ProjectID, &member.IdentifierType, &member.IdentifierHash, &member.IdentifierCiphertext, &member.Permission, &member.CreatedAt, &member.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return MemberRecord{}, ErrNotFound
}
return MemberRecord{}, err
}
if id.Valid {
member.ID = uuid.UUID(id.Bytes).String()
}
return member, nil
}
func toPGUUID(value string) pgtype.UUID {
parsed, err := uuid.Parse(value)
if err != nil {
return pgtype.UUID{}
}
return pgtype.UUID{Bytes: parsed, Valid: true}
}