182 lines
5.1 KiB
Go
182 lines
5.1 KiB
Go
package hold
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/bluesky-social/indigo/atproto/identity"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
)
|
|
|
|
// isAuthorizedRead checks if a DID can read from this hold
|
|
// Authorization:
|
|
// - Public hold: allow anonymous (empty DID) or any authenticated user
|
|
// - Private hold: require authentication (any user with sailor.profile)
|
|
func (s *HoldService) isAuthorizedRead(did string) bool {
|
|
// Check hold public flag
|
|
isPublic, err := s.isHoldPublic()
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to check hold public flag: %v", err)
|
|
// Fail secure - deny access on error
|
|
return false
|
|
}
|
|
|
|
if isPublic {
|
|
// Public hold - allow anyone (even anonymous)
|
|
return true
|
|
}
|
|
|
|
// Private hold - require authentication
|
|
// Any authenticated user with sailor.profile can read
|
|
if did == "" {
|
|
// Anonymous user trying to access private hold
|
|
return false
|
|
}
|
|
|
|
// For MVP: assume DID presence means they have sailor.profile
|
|
// Future: could query PDS to verify sailor.profile exists
|
|
return true
|
|
}
|
|
|
|
// isAuthorizedWrite checks if a DID can write to this hold
|
|
// Authorization: must be hold owner OR crew member
|
|
func (s *HoldService) isAuthorizedWrite(did string) bool {
|
|
if did == "" {
|
|
// Anonymous writes not allowed
|
|
return false
|
|
}
|
|
|
|
// Check if DID is the hold owner
|
|
ownerDID := s.config.Registration.OwnerDID
|
|
if ownerDID == "" {
|
|
log.Printf("ERROR: Hold owner DID not configured")
|
|
return false
|
|
}
|
|
|
|
if did == ownerDID {
|
|
// Owner always has write access
|
|
return true
|
|
}
|
|
|
|
// Check if DID is a crew member
|
|
isCrew, err := s.isCrewMember(did)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to check crew membership: %v", err)
|
|
return false
|
|
}
|
|
|
|
return isCrew
|
|
}
|
|
|
|
// isHoldPublic checks if this hold allows public (anonymous) reads
|
|
func (s *HoldService) isHoldPublic() (bool, error) {
|
|
// Use cached config value for now
|
|
// Future: could query PDS for hold record to get live value
|
|
return s.config.Server.Public, nil
|
|
}
|
|
|
|
// isCrewMember checks if a DID is a crew member of this hold
|
|
// Supports both explicit DID matching and pattern-based matching (wildcards, handle globs)
|
|
func (s *HoldService) isCrewMember(did string) (bool, error) {
|
|
ownerDID := s.config.Registration.OwnerDID
|
|
if ownerDID == "" {
|
|
return false, fmt.Errorf("hold owner DID not configured")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Resolve owner's PDS endpoint using indigo
|
|
directory := identity.DefaultDirectory()
|
|
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid owner DID: %w", err)
|
|
}
|
|
|
|
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
|
|
}
|
|
|
|
pdsEndpoint := ident.PDSEndpoint()
|
|
if pdsEndpoint == "" {
|
|
return false, fmt.Errorf("no PDS endpoint found for owner")
|
|
}
|
|
|
|
// Build this hold's URI for filtering
|
|
publicURL := s.config.Server.PublicURL
|
|
if publicURL == "" {
|
|
return false, fmt.Errorf("hold public URL not configured")
|
|
}
|
|
holdName, err := extractHostname(publicURL)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to extract hold name: %w", err)
|
|
}
|
|
holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
|
|
|
|
// Create unauthenticated client to read public records
|
|
client := atproto.NewClient(pdsEndpoint, ownerDID, "")
|
|
|
|
// List crew records for this hold
|
|
// Crew records are public, so we can read them without auth
|
|
records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to list crew records: %w", err)
|
|
}
|
|
|
|
// Resolve handle once for pattern matching (lazily, only if needed)
|
|
var handle string
|
|
var handleResolved bool
|
|
|
|
// Check crew records for both explicit DID and pattern matches
|
|
for _, record := range records {
|
|
var crewRecord atproto.HoldCrewRecord
|
|
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Only check crew records for THIS hold (prevents cross-hold access)
|
|
if crewRecord.Hold != holdURI {
|
|
continue
|
|
}
|
|
|
|
// Check expiration (if set)
|
|
if crewRecord.ExpiresAt != nil && time.Now().After(*crewRecord.ExpiresAt) {
|
|
continue // Skip expired membership
|
|
}
|
|
|
|
// Check explicit DID match
|
|
if crewRecord.Member != nil && *crewRecord.Member == did {
|
|
// Found explicit crew membership
|
|
return true, nil
|
|
}
|
|
|
|
// Check pattern match (if pattern is set)
|
|
if crewRecord.MemberPattern != nil && *crewRecord.MemberPattern != "" {
|
|
// Lazy handle resolution - only resolve if we encounter a pattern
|
|
if !handleResolved {
|
|
handle, err = resolveHandle(did)
|
|
if err != nil {
|
|
log.Printf("Warning: failed to resolve handle for DID %s: %v", did, err)
|
|
// Continue checking explicit DIDs even if handle resolution fails
|
|
handleResolved = true // Mark as attempted (don't retry)
|
|
handle = "" // Empty handle won't match patterns
|
|
} else {
|
|
handleResolved = true
|
|
}
|
|
}
|
|
|
|
// If we have a handle, check pattern match
|
|
if handle != "" && matchPattern(*crewRecord.MemberPattern, handle) {
|
|
// Found pattern-based crew membership
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|