From d4a7d8061d9f0b99a1d94e7f32d86cf5d45fc235 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Wed, 14 Feb 2024 12:14:33 +0600 Subject: [PATCH] :mag: Created WebFinger HTTP delivery --- .../testdata/TestHandler_ServeHTTP.golden | 1 + .../webfinger/delivery/http/webfinger_http.go | 167 ++++++++++++++++++ .../delivery/http/webfinger_http_test.go | 59 +++++++ 3 files changed, 227 insertions(+) create mode 100755 internal/webfinger/delivery/http/testdata/TestHandler_ServeHTTP.golden create mode 100644 internal/webfinger/delivery/http/webfinger_http.go create mode 100644 internal/webfinger/delivery/http/webfinger_http_test.go diff --git a/internal/webfinger/delivery/http/testdata/TestHandler_ServeHTTP.golden b/internal/webfinger/delivery/http/testdata/TestHandler_ServeHTTP.golden new file mode 100755 index 0000000..368f2aa --- /dev/null +++ b/internal/webfinger/delivery/http/testdata/TestHandler_ServeHTTP.golden @@ -0,0 +1 @@ +{"subject":"acct:user@example.com","links":[{"rel":"self","type":"application/activity+json","href":"https://example.com/"},{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://example.com/"},{"rel":"http://webfinger.net/rel/avatar","type":"image/jpeg","href":"https://example.com/logo.jpg"}]} diff --git a/internal/webfinger/delivery/http/webfinger_http.go b/internal/webfinger/delivery/http/webfinger_http.go new file mode 100644 index 0000000..7a82e2c --- /dev/null +++ b/internal/webfinger/delivery/http/webfinger_http.go @@ -0,0 +1,167 @@ +package http + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "source.toby3d.me/toby3d/home/internal/common" + "source.toby3d.me/toby3d/home/internal/domain" + "source.toby3d.me/toby3d/home/internal/webfinger" +) + +type ( + Handler struct { + webFingers webfinger.UseCase + } + + Request struct { + Resource *Resource + Rel string // optional + } + + Response struct { + Properties map[string]string `json:"properties,omitempty"` + Subject string `json:"subject"` + Aliases []string `json:"aliases,omitempty"` + Links []Link `json:"links,omitempty"` + } + + Link struct { + Titles map[string]string `json:"titles,omitempty"` + Properties map[string]string `json:"properties,omitempty"` + Rel string `json:"rel"` + Type string `json:"type,omitempty"` + Href string `json:"href,omitempty"` + } + + Resource struct { + User string + Host string + IsEmail bool + } +) + +var ErrResource error = errors.New("'resource' URL must be absolute, or an email address") + +func NewHandler(webFingers webfinger.UseCase) *Handler { + return &Handler{webFingers: webFingers} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set(common.HeaderAccessControlAllowOrigin, "*") + + if r.TLS == nil { + http.Error(w, "webfinger usage is https only", http.StatusBadRequest) + + return + } + + if r.Method != "" && r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + + return + } + + req := new(Request) + if err := req.bind(r.URL.Query()); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + + site, err := h.webFingers.Do(r.Context(), strings.TrimPrefix(req.Resource.String(), "acct:")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + resp := NewResponse(req.Resource.String()) + // TODO(toby3d): fill resp.Aliases with profile syndications links. + // NOTE(toby3d): rel="self" link is a Mastodon requirement for + // ActivityPub support. + resp.Links = append(resp.Links, Link{ + Titles: make(map[string]string), + Properties: make(map[string]string), + Rel: "self", + Type: common.MIMEApplicationActivityJSON, + Href: site.BaseURL.String(), + }, Link{ + Titles: make(map[string]string), + Properties: make(map[string]string), + Rel: "http://webfinger.net/rel/profile-page", + Type: "text/html", + Href: site.BaseURL.String(), + }) + + // TODO(toby3d): explicitly control which resource is to be used. + if photo := site.Resources.GetType(domain.ResourceTypeImage).GetMatch("logo"); photo != nil { + resp.Links = append(resp.Links, Link{ + Titles: make(map[string]string), + Properties: make(map[string]string), + Rel: "http://webfinger.net/rel/avatar", + Type: photo.MediaType().String(), + Href: site.BaseURL.JoinPath(photo.File.Path()).String(), + }) + } + + w.Header().Set(common.HeaderContentType, common.MIMEApplicationJRDKJSONCharsetUTF8) + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (r *Request) bind(req url.Values) error { + if !req.Has("resource") { + return ErrResource + } + + var err error + if r.Resource, err = ParseResource(req.Get("resource")); err != nil { + return fmt.Errorf("cannot parse 'resource' query as URI: %w", err) + } + + return nil +} + +func NewResponse(subject string) *Response { + return &Response{ + Properties: make(map[string]string), + Subject: subject, + Aliases: make([]string, 0), + Links: make([]Link, 0), + } +} + +func ParseResource(raw string) (*Resource, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("cannot parse 'resource' query value as URL or email address: %w", err) + } + + var parts []string + switch { + case u.Path != "": + parts = strings.Split(u.Path, "@") + case u.Opaque != "": + parts = strings.Split(u.Opaque, "@") + } + + return &Resource{ + IsEmail: u.Scheme != "acct", + User: parts[0], + Host: parts[len(parts)-1], + }, nil +} + +func (r Resource) String() string { + if !r.IsEmail { + return "acct:" + r.User + "@" + r.Host + } + + return r.User + "@" + r.Host +} diff --git a/internal/webfinger/delivery/http/webfinger_http_test.go b/internal/webfinger/delivery/http/webfinger_http_test.go new file mode 100644 index 0000000..899d202 --- /dev/null +++ b/internal/webfinger/delivery/http/webfinger_http_test.go @@ -0,0 +1,59 @@ +package http_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + + "source.toby3d.me/toby3d/home/internal/common" + "source.toby3d.me/toby3d/home/internal/domain" + sitestubrepo "source.toby3d.me/toby3d/home/internal/site/repository/stub" + "source.toby3d.me/toby3d/home/internal/testutil" + delivery "source.toby3d.me/toby3d/home/internal/webfinger/delivery/http" + webfingerucase "source.toby3d.me/toby3d/home/internal/webfinger/usecase" +) + +func TestHandler_ServeHTTP(t *testing.T) { + t.Parallel() + + testSite := domain.TestSite(t) + u := testSite.BaseURL.JoinPath(".well-known", "webfinger") + u.RawQuery = "resource=acct:user@" + testSite.BaseURL.Hostname() + + req := httptest.NewRequest(http.MethodGet, u.String(), nil) + req.Header.Set(common.HeaderAccept, common.MIMEApplicationJRDKJSON+", "+common.MIMEApplicationJSON) + w := httptest.NewRecorder() + + delivery.NewHandler(webfingerucase.NewWebFingerUseCase(sitestubrepo.NewStubSiteRepository(testSite, nil))). + ServeHTTP(w, req) + testutil.GoldenEqual(t, w.Result().Body) +} + +func TestParseResource(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + input string + expect delivery.Resource + }{ + "profile": {"acct:user@example.com", delivery.Resource{"user", "example.com", false}}, + "email": {"user@example.com", delivery.Resource{"user", "example.com", true}}, + } { + name, tc := name, tc + + t.Run(name, func(t *testing.T) { + t.Parallel() + + result, err := delivery.ParseResource(tc.input) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expect, *result); diff != "" { + t.Error(diff) + } + }) + } +}