248 lines
8.2 KiB
Go
248 lines
8.2 KiB
Go
|
|
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}
|
||
|
|
}
|