aboutsummaryrefslogtreecommitdiff
path: root/web/nodeinfo
diff options
context:
space:
mode:
authorXe <me@christine.website>2022-11-21 08:34:21 -0500
committerXe <me@christine.website>2022-11-21 08:34:30 -0500
commitd4cde2dca21c09ecfa7392661574dc92200dd79e (patch)
tree48e8f3bad6bea04c8e53939bffbfafbc05d62b54 /web/nodeinfo
parent2e2b6fa099a463666396b80048cb23af0e5a8fd9 (diff)
downloadx-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.go146
-rw-r--r--web/nodeinfo/nodeinfo_test.go37
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)
+ }
+}