package atp import ( "fmt" "regexp" "strings" "github.com/bluesky-social/indigo/atproto/syntax" "golang.org/x/net/idna" ) // URI holds the parsed components of an AT Protocol URI (at://did/collection/rkey). type URI struct { DID string Collection string RKey string } // String reconstructs the AT-URI from its components. func (u *URI) String() string { return BuildATURI(u.DID, u.Collection, u.RKey) } // BuildATURI constructs an AT-URI string from its components. func BuildATURI(did, collection, rkey string) string { return fmt.Sprintf("at://%s/%s/%s", did, collection, rkey) } // ParseATURI parses an AT-URI and returns its components. func ParseATURI(uri string) (*URI, error) { atURI, err := syntax.ParseATURI(uri) if err != nil { return nil, fmt.Errorf("invalid AT-URI %q: %w", uri, err) } return &URI{ DID: atURI.Authority().String(), Collection: atURI.Collection().String(), RKey: atURI.RecordKey().String(), }, nil } // RKeyFromURI extracts the record key from an AT-URI. Returns empty string on error. func RKeyFromURI(uri string) string { u, err := ParseATURI(uri) if err != nil { return "" } return u.RKey } // MaxRKeyLength is the maximum allowed length for a record key. const MaxRKeyLength = 512 // rkeyRegex validates AT Protocol record keys (rkeys). // Valid rkeys contain only alphanumeric characters, hyphens, underscores, colons, and periods. // They must start with an alphanumeric character and be 1-512 characters long. // TIDs are the most common format: 13 lowercase base32 characters (e.g., "3kfk4slgu6s2h"). var rkeyRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,511}$`) // ValidateRKey checks if an rkey is valid according to AT Protocol spec. // Returns true if valid, false otherwise. func ValidateRKey(rkey string) bool { if rkey == "" || len(rkey) > MaxRKeyLength { return false } // Reserved rkeys that should not be used if rkey == "." || rkey == ".." { return false } return rkeyRegex.MatchString(rkey) } // NormalizeHandle converts a handle to its ASCII (punycode) form for // resolution and storage. Idempotent on plain ASCII input. Returns the // lowercased input unchanged if IDN conversion fails. func NormalizeHandle(handle string) string { h := strings.TrimPrefix(handle, "@") h = strings.ToLower(strings.TrimSpace(h)) if h == "" { return "" } ascii, err := idna.Lookup.ToASCII(h) if err != nil { return h } return ascii } // DisplayHandle converts a handle to its Unicode form for display. If the // input is already Unicode (or plain ASCII with no xn-- labels), it is // returned unchanged. Falls back to the input on conversion error. func DisplayHandle(handle string) string { if handle == "" { return "" } unicode, err := idna.Display.ToUnicode(handle) if err != nil { return handle } return unicode }