diff options
| author | Xe <me@christine.website> | 2022-11-21 08:34:21 -0500 |
|---|---|---|
| committer | Xe <me@christine.website> | 2022-11-21 08:34:30 -0500 |
| commit | d4cde2dca21c09ecfa7392661574dc92200dd79e (patch) | |
| tree | 48e8f3bad6bea04c8e53939bffbfafbc05d62b54 /web/nodeinfo | |
| parent | 2e2b6fa099a463666396b80048cb23af0e5a8fd9 (diff) | |
| download | x-d4cde2dca21c09ecfa7392661574dc92200dd79e.tar.xz x-d4cde2dca21c09ecfa7392661574dc92200dd79e.zip | |
web: add nodeinfo package to read nodeinfo metadata
Signed-off-by: Xe <me@christine.website>
Diffstat (limited to 'web/nodeinfo')
| -rw-r--r-- | web/nodeinfo/nodeinfo.go | 146 | ||||
| -rw-r--r-- | web/nodeinfo/nodeinfo_test.go | 37 |
2 files changed, 183 insertions, 0 deletions
diff --git a/web/nodeinfo/nodeinfo.go b/web/nodeinfo/nodeinfo.go new file mode 100644 index 0000000..7c77c0e --- /dev/null +++ b/web/nodeinfo/nodeinfo.go @@ -0,0 +1,146 @@ +// Package nodeinfo contains types and a simple client for reading the standard NodeInfo protocol described by Diaspora[1]. +// +// This package supports version 2.0[2] because that is the one most commonly used. +// +// [1]: http://nodeinfo.diaspora.software/ +// [2]: http://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0#$$expand +package nodeinfo + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "within.website/x/web" + "within.website/x/web/useragent" +) + +const schema2point0 = "http://nodeinfo.diaspora.software/ns/schema/2.0" +const twoMebibytes = 1024 * 1024 * 2 + +// Node is the node information you are looking for. +type Node struct { + Version string `json:"version"` + Software Software `json:"software"` + Protocols []string `json:"protocols"` + Services Services `json:"services"` + OpenRegistration bool `json:"openRegistration"` + Usage Usage `json:"usage"` + Metadata any `json:"metadata"` +} + +// Software contains metadata about the server software in use. +type Software struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// Services lists the third party services that this server can connect to with their application API. +type Services struct { + Inbound []string `json:"inbound"` + Outbound []string `json:"outbound"` +} + +// Usage statistics for this server. +type Usage struct { + Users Users `json:"users"` + LocalPosts int64 `json:"localPosts"` +} + +// Users contains statistics about the users of this server. +type Users struct { + Total int64 `json:"total"` + ActiveHalfYear int64 `json:"activeHalfYear"` + ActiveMonth int64 `json:"activeMonth"` +} + +type wellKnownLinks struct { + Links []wellKnownLink `json:"links"` +} + +type wellKnownLink struct { + Rel string `json:"rel"` + Href string `json:"href"` +} + +// FetchWithClient uses the provided HTTP client to fetch node information. +func FetchWithClient(ctx context.Context, cli *http.Client, nodeURL string) (*Node, error) { + u, err := url.Parse(nodeURL) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't parse nodeURL: %w", err) + } + + u.Path = "/.well-known/nodeinfo" + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't create HTTP request: %w", err) + } + + resp, err := cli.Do(req) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't read HTTP response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, web.NewError(http.StatusOK, resp) + } + + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, twoMebibytes)) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't read from HTTP response body: %w", err) + } + + var niw wellKnownLinks + err = json.Unmarshal(data, &niw) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't unmarshal discovery metadata: %w", err) + } + + var targetURL string + + for _, link := range niw.Links { + if link.Rel == schema2point0 { + targetURL = link.Href + } + } + + if targetURL == "" { + return nil, fmt.Errorf("nodeinfo: can't find schema 2.0 nodeinfo for %s", nodeURL) + } + + req, err = http.NewRequest(http.MethodGet, targetURL, nil) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't create HTTP request: %w", err) + } + + resp, err = cli.Do(req) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't read HTTP response: %w", err) + } + + data, err = io.ReadAll(io.LimitReader(resp.Body, twoMebibytes)) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't read from HTTP response body: %w", err) + } + + var result Node + err = json.Unmarshal(data, &result) + if err != nil { + return nil, fmt.Errorf("nodeinfo: can't unmarshal nodeinfo: %w", err) + } + + return &result, nil +} + +// Fetch uses the standard library HTTP client to fetch node information. +func Fetch(ctx context.Context, nodeURL string) (*Node, error) { + cli := &http.Client{ + Transport: useragent.Transport("github.com/Xe/x/web/nodeinfo", "https://within.website/.x.botinfo", http.DefaultTransport), + } + return FetchWithClient(ctx, cli, nodeURL) +} diff --git a/web/nodeinfo/nodeinfo_test.go b/web/nodeinfo/nodeinfo_test.go new file mode 100644 index 0000000..3241142 --- /dev/null +++ b/web/nodeinfo/nodeinfo_test.go @@ -0,0 +1,37 @@ +package nodeinfo + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const nodeInfo2point0 = `{"version":"2.0","software":{"name":"mastodon","version":"4.0.2"},"protocols":["activitypub"],"services":{"outbound":[],"inbound":[]},"usage":{"users":{"total":255,"activeMonth":163,"activeHalfyear":166},"localPosts":12107},"openRegistrations":true,"metadata":{}}` + +func TestNodeInfo(t *testing.T) { + mux := http.NewServeMux() + s := httptest.NewServer(mux) + mux.HandleFunc("/.well-known/nodeinfo", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(wellKnownLinks{Links: []wellKnownLink{ + { + Rel: schema2point0, + Href: s.URL + "/nodeinfo/2.0", + }, + }}) + }) + mux.HandleFunc("/nodeinfo/2.0", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, nodeInfo2point0) + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, err := FetchWithClient(ctx, s.Client(), s.URL) + if err != nil { + t.Fatal(err) + } +} |
