diff --git a/config/config.go b/config/config.go index 3e7d6f8..e8f22ed 100644 --- a/config/config.go +++ b/config/config.go @@ -1,5 +1,10 @@ package config +import ( + "net/url" + "strings" +) + type Config struct { Core CoreConfig `toml:"core"` Web WebConfig `toml:"web"` @@ -9,11 +14,34 @@ type Config struct { type WebConfig struct { // Domain should be the instance's full domain, including https:// but without the trailing slash. Domain string `toml:"domain"` + // WebFingerDomain should be the instance's WebFinger domain, used for usernames. + // .well-known/webfinger *must* listen on this domain. + // It should only be the bare domain, i.e. `mercury.localhost` + WebFingerDomain string `toml:"webfinger_domain"` // Port is the port the server should listen on. Port int `toml:"port"` } +// WebFingerDomains returns the domains valid for WebFinger requests. +// The first one is always the canonical domain. +// This function is guaranteed to return at least one domain. +func (c WebConfig) WebFingerDomains() []string { + domains := make([]string, 0, 2) + + if c.WebFingerDomain != "" { + domains = append(domains, c.WebFingerDomain) + } + + u, err := url.Parse(c.Domain) + if err != nil { + return append(domains, strings.TrimPrefix( + strings.TrimPrefix(c.Domain, "http://"), "https://")) + } + + return append(domains, u.Host) +} + type CoreConfig struct { Postgres string `toml:"postgres"` Dev bool `toml:"dev"` diff --git a/web/routes.go b/web/routes.go index 0792ceb..c0b99d4 100644 --- a/web/routes.go +++ b/web/routes.go @@ -11,6 +11,7 @@ import ( "git.sleepycat.moe/sam/mercury/web/app" "git.sleepycat.moe/sam/mercury/web/auth" "git.sleepycat.moe/sam/mercury/web/frontend" + "git.sleepycat.moe/sam/mercury/web/wellknown" "github.com/go-chi/chi/v5" ) @@ -25,6 +26,13 @@ func Routes(app *app.App) { r.Post("/sign_up", auth.PostSignup) }) + // .well-known handlers + app.Router.Route("/.well-known", func(r chi.Router) { + wellknown := wellknown.New(app) + + r.Get("/webfinger", api.WrapHandlerT(wellknown.WebFinger)) + }) + // APIv1 handlers app.Router.Route("/api/v1", func(r chi.Router) { unauthedAccess := !app.AppConfig.Security.RestrictAPI diff --git a/web/wellknown/module.go b/web/wellknown/module.go new file mode 100644 index 0000000..2909260 --- /dev/null +++ b/web/wellknown/module.go @@ -0,0 +1,13 @@ +package wellknown + +import "git.sleepycat.moe/sam/mercury/web/app" + +type App struct { + *app.App +} + +func New(app *app.App) *App { + return &App{ + App: app, + } +} diff --git a/web/wellknown/webfinger.go b/web/wellknown/webfinger.go new file mode 100644 index 0000000..1172845 --- /dev/null +++ b/web/wellknown/webfinger.go @@ -0,0 +1,84 @@ +package wellknown + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "git.sleepycat.moe/sam/mercury/internal/database/sql" + "git.sleepycat.moe/sam/mercury/web/api" + "github.com/rs/zerolog/log" +) + +func (app *App) WebFinger(w http.ResponseWriter, r *http.Request) (any, error) { + ctx := r.Context() + validDomains := app.AppConfig.Web.WebFingerDomains() + + resource, err := url.QueryUnescape(r.FormValue("resource")) + if err != nil { + return nil, api.Error{Code: api.ErrBadRequest} + } + + if !strings.HasPrefix(resource, "acct:") { + return nil, api.Error{Code: api.ErrBadRequest, Details: "WebFinger only supports `acct:` queries"} + } + resource = strings.TrimPrefix(resource, "acct:") + username, domain, ok := strings.Cut(resource, "@") + if !ok { + return nil, api.Error{Code: api.ErrBadRequest} + } + if !anyMatches(validDomains, domain) { + return nil, api.Error{Code: api.ErrBadRequest, Details: "Not a local user on this instance"} + } + + blog, err := app.Blog().ByName(ctx, username, "") + if err != nil { + if err == sql.ErrNotFound { + return nil, api.Error{Code: api.ErrNotFound} + } + + log.Err(err).Str("username", username).Msg("looking up user for webfinger request") + return nil, err + } + + webFingerHref := fmt.Sprintf("%s/@%s", app.AppConfig.Web.Domain, blog.Name) + + return WebFinger{ + Subject: fmt.Sprintf("acct:%s@%s", blog.Name, validDomains[0]), + Aliases: []string{webFingerHref}, + Links: []WebFingerLink{ + { + Rel: "http://webfinger.net/rel/profile-page", + Type: "text/html", + Href: webFingerHref, + }, + { + Rel: "self", + Type: "application/activity+json", + Href: webFingerHref, + }, + }, + }, nil +} + +type WebFinger struct { + Subject string `json:"subject"` + Aliases []string `json:"aliases"` + Links []WebFingerLink `json:"links"` +} + +type WebFingerLink struct { + Rel string `json:"rel"` + Type string `json:"type"` + Href string `json:"href"` +} + +func anyMatches[T comparable](slice []T, t T) bool { + for _, entry := range slice { + if entry == t { + return true + } + } + return false +}