Skip to content

Commit 3da9daf

Browse files
authored
Add Webfinger endpoint (#19462)
This adds the [Webfinger](https://webfinger.net/) endpoint for federation. Supported schemes are `acct` and `mailto`. The profile and avatar url are returned as metadata.
1 parent a61a47f commit 3da9daf

File tree

3 files changed

+189
-2
lines changed

3 files changed

+189
-2
lines changed

integrations/webfinger_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package integrations
6+
7+
import (
8+
"fmt"
9+
"net/http"
10+
"net/url"
11+
"testing"
12+
13+
"code.gitea.io/gitea/models/unittest"
14+
user_model "code.gitea.io/gitea/models/user"
15+
"code.gitea.io/gitea/modules/setting"
16+
17+
"github.com/stretchr/testify/assert"
18+
)
19+
20+
func TestWebfinger(t *testing.T) {
21+
defer prepareTestEnv(t)()
22+
23+
setting.Federation.Enabled = true
24+
defer func() {
25+
setting.Federation.Enabled = false
26+
}()
27+
28+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User)
29+
30+
appURL, _ := url.Parse(setting.AppURL)
31+
32+
type webfingerLink struct {
33+
Rel string `json:"rel,omitempty"`
34+
Type string `json:"type,omitempty"`
35+
Href string `json:"href,omitempty"`
36+
Titles map[string]string `json:"titles,omitempty"`
37+
Properties map[string]interface{} `json:"properties,omitempty"`
38+
}
39+
40+
type webfingerJRD struct {
41+
Subject string `json:"subject,omitempty"`
42+
Aliases []string `json:"aliases,omitempty"`
43+
Properties map[string]interface{} `json:"properties,omitempty"`
44+
Links []*webfingerLink `json:"links,omitempty"`
45+
}
46+
47+
session := loginUser(t, "user1")
48+
49+
req := NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, appURL.Host))
50+
resp := MakeRequest(t, req, http.StatusOK)
51+
52+
var jrd webfingerJRD
53+
DecodeJSON(t, resp, &jrd)
54+
assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject)
55+
assert.ElementsMatch(t, []string{user.HTMLURL()}, jrd.Aliases)
56+
57+
req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host"))
58+
MakeRequest(t, req, http.StatusBadRequest)
59+
60+
req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", "user31", appURL.Host))
61+
MakeRequest(t, req, http.StatusNotFound)
62+
63+
req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", "user31", appURL.Host))
64+
session.MakeRequest(t, req, http.StatusOK)
65+
66+
req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=mailto:%s", user.Email))
67+
MakeRequest(t, req, http.StatusNotFound)
68+
}

routers/web/web.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,16 +282,24 @@ func RegisterRoutes(m *web.Route) {
282282
}
283283
}
284284

285+
federationEnabled := func(ctx *context.Context) {
286+
if !setting.Federation.Enabled {
287+
ctx.Error(http.StatusNotFound)
288+
return
289+
}
290+
}
291+
285292
// FIXME: not all routes need go through same middleware.
286293
// Especially some AJAX requests, we can reduce middleware number to improve performance.
287294
// Routers.
288295
// for health check
289296
m.Get("/", Home)
290297
m.Group("/.well-known", func() {
291298
m.Get("/openid-configuration", auth.OIDCWellKnown)
292-
if setting.Federation.Enabled {
299+
m.Group("", func() {
293300
m.Get("/nodeinfo", NodeInfoLinks)
294-
}
301+
m.Get("/webfinger", WebfingerQuery)
302+
}, federationEnabled)
295303
m.Get("/change-password", func(w http.ResponseWriter, req *http.Request) {
296304
http.Redirect(w, req, "/user/settings/account", http.StatusTemporaryRedirect)
297305
})

routers/web/webfinger.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package web
6+
7+
import (
8+
"fmt"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
13+
user_model "code.gitea.io/gitea/models/user"
14+
"code.gitea.io/gitea/modules/context"
15+
"code.gitea.io/gitea/modules/log"
16+
"code.gitea.io/gitea/modules/setting"
17+
)
18+
19+
// https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-webfinger-14#section-4.4
20+
21+
type webfingerJRD struct {
22+
Subject string `json:"subject,omitempty"`
23+
Aliases []string `json:"aliases,omitempty"`
24+
Properties map[string]interface{} `json:"properties,omitempty"`
25+
Links []*webfingerLink `json:"links,omitempty"`
26+
}
27+
28+
type webfingerLink struct {
29+
Rel string `json:"rel,omitempty"`
30+
Type string `json:"type,omitempty"`
31+
Href string `json:"href,omitempty"`
32+
Titles map[string]string `json:"titles,omitempty"`
33+
Properties map[string]interface{} `json:"properties,omitempty"`
34+
}
35+
36+
// WebfingerQuery returns informations about a resource
37+
// https://datatracker.ietf.org/doc/html/rfc7565
38+
func WebfingerQuery(ctx *context.Context) {
39+
appURL, _ := url.Parse(setting.AppURL)
40+
41+
resource, err := url.Parse(ctx.FormTrim("resource"))
42+
if err != nil {
43+
ctx.Error(http.StatusBadRequest)
44+
return
45+
}
46+
47+
var u *user_model.User
48+
49+
switch resource.Scheme {
50+
case "acct":
51+
// allow only the current host
52+
parts := strings.SplitN(resource.Opaque, "@", 2)
53+
if len(parts) != 2 {
54+
ctx.Error(http.StatusBadRequest)
55+
return
56+
}
57+
if parts[1] != appURL.Host {
58+
ctx.Error(http.StatusBadRequest)
59+
return
60+
}
61+
62+
u, err = user_model.GetUserByNameCtx(ctx, parts[0])
63+
case "mailto":
64+
u, err = user_model.GetUserByEmailContext(ctx, resource.Opaque)
65+
if u != nil && u.KeepEmailPrivate {
66+
err = user_model.ErrUserNotExist{}
67+
}
68+
default:
69+
ctx.Error(http.StatusBadRequest)
70+
return
71+
}
72+
if err != nil {
73+
if user_model.IsErrUserNotExist(err) {
74+
ctx.Error(http.StatusNotFound)
75+
} else {
76+
log.Error("Error getting user: %s Error: %v", resource.Opaque, err)
77+
ctx.Error(http.StatusInternalServerError)
78+
}
79+
return
80+
}
81+
82+
if !user_model.IsUserVisibleToViewer(u, ctx.Doer) {
83+
ctx.Error(http.StatusNotFound)
84+
return
85+
}
86+
87+
aliases := []string{
88+
u.HTMLURL(),
89+
}
90+
if !u.KeepEmailPrivate {
91+
aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email))
92+
}
93+
94+
links := []*webfingerLink{
95+
{
96+
Rel: "http://webfinger.net/rel/profile-page",
97+
Type: "text/html",
98+
Href: u.HTMLURL(),
99+
},
100+
{
101+
Rel: "http://webfinger.net/rel/avatar",
102+
Href: u.AvatarLink(),
103+
},
104+
}
105+
106+
ctx.JSON(http.StatusOK, &webfingerJRD{
107+
Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host),
108+
Aliases: aliases,
109+
Links: links,
110+
})
111+
}

0 commit comments

Comments
 (0)