mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
240 lines
7.9 KiB
Go
240 lines
7.9 KiB
Go
package atproto
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
)
|
|
|
|
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL.
|
|
// For DIDs (both did:web and did:plc), resolves via the indigo identity directory
|
|
// which caches results (24h TTL). Prefers the #atcr_hold service endpoint,
|
|
// falls back to #atproto_pds.
|
|
//
|
|
// Supported formats:
|
|
// - URL: https://hold.example.com → passthrough
|
|
// - DID: did:web:hold01.atcr.io → resolved via /.well-known/did.json
|
|
// - DID: did:plc:abc123 → resolved via PLC directory
|
|
func ResolveHoldURL(ctx context.Context, holdIdentifier string) (string, error) {
|
|
// If it's already a URL (has scheme), return as-is
|
|
if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
|
|
return holdIdentifier, nil
|
|
}
|
|
|
|
// If it's a DID, resolve via identity directory
|
|
if strings.HasPrefix(holdIdentifier, "did:") {
|
|
return ResolveHoldDIDToURL(ctx, holdIdentifier)
|
|
}
|
|
|
|
// Fallback: assume it's a hostname and use HTTPS
|
|
return "https://" + holdIdentifier, nil
|
|
}
|
|
|
|
// ResolveHoldDID resolves a hold identifier (DID, URL, or hostname) to its actual DID.
|
|
// If the input is already a DID, it is returned as-is.
|
|
// If the input is a URL or hostname, the hold's /.well-known/atproto-did endpoint is
|
|
// fetched to discover the real DID (which may be did:web or did:plc).
|
|
func ResolveHoldDID(ctx context.Context, holdIdentifier string) (string, error) {
|
|
if holdIdentifier == "" {
|
|
return "", fmt.Errorf("empty hold identifier")
|
|
}
|
|
|
|
// If already a DID, return as-is
|
|
if IsDID(holdIdentifier) {
|
|
return holdIdentifier, nil
|
|
}
|
|
|
|
// Normalize to a full URL
|
|
holdURL := holdIdentifier
|
|
if !strings.HasPrefix(holdURL, "http://") && !strings.HasPrefix(holdURL, "https://") {
|
|
holdURL = "https://" + holdURL
|
|
}
|
|
holdURL = strings.TrimSuffix(holdURL, "/")
|
|
|
|
// Fetch /.well-known/atproto-did to discover the hold's actual DID
|
|
req, err := http.NewRequestWithContext(ctx, "GET", holdURL+"/.well-known/atproto-did", nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create request for hold DID resolution: %w", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to fetch hold DID from %s: %w", holdURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("hold at %s returned status %d for DID resolution", holdURL, resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read hold DID response: %w", err)
|
|
}
|
|
|
|
did := strings.TrimSpace(string(body))
|
|
if !IsDID(did) {
|
|
return "", fmt.Errorf("hold at %s returned invalid DID: %q", holdURL, did)
|
|
}
|
|
|
|
return did, nil
|
|
}
|
|
|
|
// ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint.
|
|
// Prefers the #atcr_hold service endpoint, falls back to #atproto_pds.
|
|
// Uses the shared identity directory with cache TTL and event-driven invalidation.
|
|
func ResolveHoldDIDToURL(ctx context.Context, did string) (string, error) {
|
|
directory := GetDirectory()
|
|
didParsed, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid hold DID %q: %w", did, err)
|
|
}
|
|
|
|
ident, err := directory.LookupDID(ctx, didParsed)
|
|
if err != nil {
|
|
// In test mode, fall back to deriving URL directly from did:web.
|
|
// The indigo directory hardcodes HTTPS and rejects IPs/ports,
|
|
// so local dev (HTTP, IP:port) always needs this fallback.
|
|
if testMode && strings.HasPrefix(did, "did:web:") {
|
|
return didWebToURL(did), nil
|
|
}
|
|
return "", fmt.Errorf("failed to resolve hold DID %s: %w", did, err)
|
|
}
|
|
|
|
// Prefer #atcr_hold service (hold-specific endpoint)
|
|
if url := ident.GetServiceEndpoint("atcr_hold"); url != "" {
|
|
return url, nil
|
|
}
|
|
|
|
// Fall back to #atproto_pds (hold publishes both with same URL)
|
|
if url := ident.PDSEndpoint(); url != "" {
|
|
return url, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("no hold or PDS service endpoint found for DID %s", did)
|
|
}
|
|
|
|
// NormalizeDID ensures did:web DIDs use %3A encoding for port separators
|
|
// per the did:web spec. Other DID methods are returned as-is.
|
|
// e.g., "did:web:172.28.0.3:8080" → "did:web:172.28.0.3%3A8080"
|
|
func NormalizeDID(did string) string {
|
|
if !strings.HasPrefix(did, "did:web:") {
|
|
return did
|
|
}
|
|
host := strings.TrimPrefix(did, "did:web:")
|
|
// Only fix bare colons — skip if already percent-encoded
|
|
if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
|
|
host = strings.Replace(host, ":", "%3A", 1)
|
|
}
|
|
return "did:web:" + host
|
|
}
|
|
|
|
// didWebToURL converts a did:web DID to its base URL.
|
|
// did:web:example.com → https://example.com
|
|
// did:web:172.28.0.3%3A8080 → http://172.28.0.3:8080
|
|
func didWebToURL(did string) string {
|
|
host := strings.TrimPrefix(did, "did:web:")
|
|
host = strings.ReplaceAll(host, "%3A", ":")
|
|
scheme := "https"
|
|
if strings.Contains(host, ":") {
|
|
scheme = "http"
|
|
}
|
|
return scheme + "://" + host
|
|
}
|
|
|
|
// ResolveDIDToPDS resolves a DID to its PDS endpoint.
|
|
// Uses the shared identity directory with cache TTL and event-driven invalidation.
|
|
func ResolveDIDToPDS(ctx context.Context, did string) (string, error) {
|
|
directory := GetDirectory()
|
|
didParsed, err := syntax.ParseDID(did)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid DID: %w", err)
|
|
}
|
|
|
|
ident, err := directory.LookupDID(ctx, didParsed)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve DID: %w", err)
|
|
}
|
|
|
|
pdsEndpoint := ident.PDSEndpoint()
|
|
if pdsEndpoint == "" {
|
|
return "", fmt.Errorf("no PDS endpoint found for DID")
|
|
}
|
|
|
|
return pdsEndpoint, nil
|
|
}
|
|
|
|
// ResolveIdentity resolves an ATProto identifier (handle or DID) to DID, handle, and PDS endpoint.
|
|
// Uses the shared identity directory with cache TTL and event-driven invalidation.
|
|
//
|
|
// If the handle is invalid (handle.invalid), it returns the DID as the handle for display purposes.
|
|
// Returns: did, handle, pdsEndpoint, error
|
|
func ResolveIdentity(ctx context.Context, identifier string) (string, string, string, error) {
|
|
directory := GetDirectory()
|
|
atID, err := syntax.ParseAtIdentifier(identifier)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
|
|
}
|
|
|
|
ident, err := directory.Lookup(ctx, atID)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
|
|
}
|
|
|
|
did := ident.DID.String()
|
|
handle := ident.Handle.String()
|
|
pdsEndpoint := ident.PDSEndpoint()
|
|
|
|
// If handle is invalid, use DID as display name
|
|
if handle == "handle.invalid" || handle == "" {
|
|
handle = did
|
|
}
|
|
|
|
// PDS endpoint is required for XRPC calls
|
|
if pdsEndpoint == "" {
|
|
return "", "", "", fmt.Errorf("no PDS endpoint found for identifier %q", identifier)
|
|
}
|
|
|
|
return did, handle, pdsEndpoint, nil
|
|
}
|
|
|
|
// ResolveHandleToDID resolves a handle or DID to just the DID.
|
|
// Uses the shared identity directory with cache TTL and event-driven invalidation.
|
|
// This is useful when you only need the DID and don't care about handle/PDS.
|
|
func ResolveHandleToDID(ctx context.Context, identifier string) (string, error) {
|
|
directory := GetDirectory()
|
|
atID, err := syntax.ParseAtIdentifier(identifier)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid identifier: %w", err)
|
|
}
|
|
|
|
ident, err := directory.Lookup(ctx, atID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return ident.DID.String(), nil
|
|
}
|
|
|
|
// InvalidateIdentity purges cached identity data for a DID or handle.
|
|
// This should be called when identity changes are detected (e.g., via Jetstream events)
|
|
// to ensure the cache is refreshed on the next lookup.
|
|
//
|
|
// Use cases:
|
|
// - Handle changes (identity events from Jetstream)
|
|
// - Account deactivation/migration (account events from Jetstream)
|
|
// - PDS migrations (deactivation followed by reactivation at new PDS)
|
|
func InvalidateIdentity(ctx context.Context, identifier string) error {
|
|
directory := GetDirectory()
|
|
atID, err := syntax.ParseAtIdentifier(identifier)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid identifier for cache invalidation: %w", err)
|
|
}
|
|
|
|
return directory.Purge(ctx, atID)
|
|
}
|