package repoident import ( "cmp" "errors" "fmt" "net/url" "strings" "github.com/bluesky-social/indigo/atproto/identity" "github.com/samber/lo" ) const ( KnotServiceID = "tangled_knot" KnotServiceType = "TangledKnot" LegacyKnotServiceID = "atproto_pds" LegacyKnotServiceType = "AtprotoPersonalDataServer" ) type SchemePolicy int const ( RequireHTTPS SchemePolicy = iota AllowHTTP ) func SchemeFor(allowHTTP bool) SchemePolicy { return lo.Ternary(allowHTTP, AllowHTTP, RequireHTTPS) } var ( ErrNilIdentity = errors.New("nil identity has no knot service endpoint") ErrNoKnotService = errors.New("DID document declares no " + KnotServiceID + " or " + LegacyKnotServiceID + " service endpoint") ErrZeroKnotURL = errors.New("zero KnotURL has no base URL to encode") ) var defaultPorts = map[string]string{"https": "443", "http": "80"} type KnotURL struct { scheme string host string } func (k KnotURL) IsZero() bool { return k.host == "" } func (k KnotURL) Host() string { return k.host } func (k KnotURL) url() *url.URL { return &url.URL{Scheme: k.scheme, Host: k.host} } func (k KnotURL) String() string { if k.IsZero() { return "" } return k.url().String() } func (k KnotURL) JoinPath(elem ...string) string { return k.url().JoinPath(elem...).String() } func (k KnotURL) MarshalText() ([]byte, error) { if k.IsZero() { return nil, ErrZeroKnotURL } return []byte(k.String()), nil } func (k *KnotURL) UnmarshalText(text []byte) error { parsed, err := ParseKnotURL(string(text), AllowHTTP) if err != nil { return err } *k = parsed return nil } type pathMode int const ( rejectPath pathMode = iota stripPath ) func ParseKnotURL(raw string, policy SchemePolicy) (KnotURL, error) { return parseBase(raw, policy, rejectPath) } func KnotURLFromIdentity(ident *identity.Identity, policy SchemePolicy) (KnotURL, error) { if ident == nil { return KnotURL{}, ErrNilIdentity } raw := cmp.Or( typedEndpoint(ident, KnotServiceID, KnotServiceType), typedEndpoint(ident, LegacyKnotServiceID, LegacyKnotServiceType), ) if raw == "" { return KnotURL{}, ErrNoKnotService } return parseBase(raw, policy, stripPath) } func typedEndpoint(ident *identity.Identity, id, serviceType string) string { service := ident.Services[id] return lo.Ternary(service.Type == serviceType, service.URL, "") } func parseBase(raw string, policy SchemePolicy, paths pathMode) (KnotURL, error) { if raw == "" { return KnotURL{}, errors.New("empty knot URL") } u, err := url.Parse(raw) if err != nil { return KnotURL{}, fmt.Errorf("invalid knot URL %q: %w", raw, err) } if u.Hostname() == "" { return KnotURL{}, fmt.Errorf("knot URL %q has no host", raw) } if u.User != nil { return KnotURL{}, fmt.Errorf("knot URL %q has userinfo", raw) } if u.RawQuery != "" || u.Fragment != "" { return KnotURL{}, fmt.Errorf("knot URL %q has a query or fragment", raw) } if paths == rejectPath && u.Path != "" && u.Path != "/" { return KnotURL{}, fmt.Errorf("knot URL %q has a path", raw) } switch u.Scheme { case "https": case "http": if policy != AllowHTTP { return KnotURL{}, fmt.Errorf("knot URL %q must use https", raw) } default: return KnotURL{}, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme) } return KnotURL{scheme: u.Scheme, host: canonicalHost(u)}, nil } func canonicalHost(u *url.URL) string { host := strings.ToLower(u.Host) switch port := u.Port(); port { case "": return strings.TrimSuffix(host, ":") case defaultPorts[u.Scheme]: return strings.TrimSuffix(host, ":"+port) default: return host } }