🔍 Created WebFinger HTTP delivery
This commit is contained in:
parent
90eff9a812
commit
d4a7d8061d
|
@ -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"}]}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue