This repository has no description
1package repoident
2
3import (
4 "cmp"
5 "errors"
6 "fmt"
7 "net/url"
8 "strings"
9
10 "github.com/bluesky-social/indigo/atproto/identity"
11 "github.com/samber/lo"
12)
13
14const (
15 KnotServiceID = "tangled_knot"
16 KnotServiceType = "TangledKnot"
17 LegacyKnotServiceID = "atproto_pds"
18 LegacyKnotServiceType = "AtprotoPersonalDataServer"
19)
20
21type SchemePolicy int
22
23const (
24 RequireHTTPS SchemePolicy = iota
25 AllowHTTP
26)
27
28func SchemeFor(allowHTTP bool) SchemePolicy {
29 return lo.Ternary(allowHTTP, AllowHTTP, RequireHTTPS)
30}
31
32var (
33 ErrNilIdentity = errors.New("nil identity has no knot service endpoint")
34 ErrNoKnotService = errors.New("DID document declares no " + KnotServiceID + " or " + LegacyKnotServiceID + " service endpoint")
35 ErrZeroKnotURL = errors.New("zero KnotURL has no base URL to encode")
36)
37
38var defaultPorts = map[string]string{"https": "443", "http": "80"}
39
40type KnotURL struct {
41 scheme string
42 host string
43}
44
45func (k KnotURL) IsZero() bool { return k.host == "" }
46
47func (k KnotURL) Host() string { return k.host }
48
49func (k KnotURL) url() *url.URL { return &url.URL{Scheme: k.scheme, Host: k.host} }
50
51func (k KnotURL) String() string {
52 if k.IsZero() {
53 return ""
54 }
55 return k.url().String()
56}
57
58func (k KnotURL) JoinPath(elem ...string) string {
59 return k.url().JoinPath(elem...).String()
60}
61
62func (k KnotURL) MarshalText() ([]byte, error) {
63 if k.IsZero() {
64 return nil, ErrZeroKnotURL
65 }
66 return []byte(k.String()), nil
67}
68
69func (k *KnotURL) UnmarshalText(text []byte) error {
70 parsed, err := ParseKnotURL(string(text), AllowHTTP)
71 if err != nil {
72 return err
73 }
74 *k = parsed
75 return nil
76}
77
78type pathMode int
79
80const (
81 rejectPath pathMode = iota
82 stripPath
83)
84
85func ParseKnotURL(raw string, policy SchemePolicy) (KnotURL, error) {
86 return parseBase(raw, policy, rejectPath)
87}
88
89func KnotURLFromIdentity(ident *identity.Identity, policy SchemePolicy) (KnotURL, error) {
90 if ident == nil {
91 return KnotURL{}, ErrNilIdentity
92 }
93 raw := cmp.Or(
94 typedEndpoint(ident, KnotServiceID, KnotServiceType),
95 typedEndpoint(ident, LegacyKnotServiceID, LegacyKnotServiceType),
96 )
97 if raw == "" {
98 return KnotURL{}, ErrNoKnotService
99 }
100 return parseBase(raw, policy, stripPath)
101}
102
103func typedEndpoint(ident *identity.Identity, id, serviceType string) string {
104 service := ident.Services[id]
105 return lo.Ternary(service.Type == serviceType, service.URL, "")
106}
107
108func parseBase(raw string, policy SchemePolicy, paths pathMode) (KnotURL, error) {
109 if raw == "" {
110 return KnotURL{}, errors.New("empty knot URL")
111 }
112 u, err := url.Parse(raw)
113 if err != nil {
114 return KnotURL{}, fmt.Errorf("invalid knot URL %q: %w", raw, err)
115 }
116 if u.Hostname() == "" {
117 return KnotURL{}, fmt.Errorf("knot URL %q has no host", raw)
118 }
119 if u.User != nil {
120 return KnotURL{}, fmt.Errorf("knot URL %q has userinfo", raw)
121 }
122 if u.RawQuery != "" || u.Fragment != "" {
123 return KnotURL{}, fmt.Errorf("knot URL %q has a query or fragment", raw)
124 }
125 if paths == rejectPath && u.Path != "" && u.Path != "/" {
126 return KnotURL{}, fmt.Errorf("knot URL %q has a path", raw)
127 }
128 switch u.Scheme {
129 case "https":
130 case "http":
131 if policy != AllowHTTP {
132 return KnotURL{}, fmt.Errorf("knot URL %q must use https", raw)
133 }
134 default:
135 return KnotURL{}, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme)
136 }
137 return KnotURL{scheme: u.Scheme, host: canonicalHost(u)}, nil
138}
139
140func canonicalHost(u *url.URL) string {
141 host := strings.ToLower(u.Host)
142 switch port := u.Port(); port {
143 case "":
144 return strings.TrimSuffix(host, ":")
145 case defaultPorts[u.Scheme]:
146 return strings.TrimSuffix(host, ":"+port)
147 default:
148 return host
149 }
150}