home/internal/webfinger/delivery/http/webfinger_http.go

168 lines
4.2 KiB
Go

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
}