feat(activitypub): use structure @PreferredUsername@host.tld:port for actors (#9254)

This modifies usernames of ActivityPub accounts to use the @example@example.tld
format with an additional optional port component (e.g. @user@example.tld:42).
This allows accounts from ActivityPub servers with more relaxed username
requirements than those of Forgejo's to interact with Forgejo. Forgejo would
also follow a "de facto" standard of ActivityPub implementations.

By separating different information using @'s, we also gain future
opportunities to store more information about ActivityPub accounts internally,
so that we won't have to rely on e.g. the amount of dashes in a username as
my migration currently does.

Continuation of Aravinth's work: https://codeberg.org/forgejo/forgejo/pulls/4778

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9254
Reviewed-by: jerger <jerger@noreply.codeberg.org>
Reviewed-by: Ellen Εμιλία Άννα Zscheile <fogti@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Panagiotis "Ivory" Vasilopoulos <git@n0toose.net>
Co-committed-by: Panagiotis "Ivory" Vasilopoulos <git@n0toose.net>
This commit is contained in:
Panagiotis "Ivory" Vasilopoulos 2026-01-30 23:45:11 +01:00 committed by Gusted
parent 90b3352ed5
commit 81601eab85
13 changed files with 422 additions and 19 deletions

View file

@ -71,12 +71,13 @@ func (id PersonID) AsLoginName() string {
return result
}
// HostSuffix returns the host part of a handle, i.e. @host.tld (if port is supplemented) or @host.tld:1234
func (id PersonID) HostSuffix() string {
var result string
if !id.IsPortSupplemented {
result = fmt.Sprintf("-%s-%d", strings.ToLower(id.Host), id.HostPort)
result = fmt.Sprintf("@%s:%d", strings.ToLower(id.Host), id.HostPort)
} else {
result = fmt.Sprintf("-%s", strings.ToLower(id.Host))
result = fmt.Sprintf("@%s", strings.ToLower(id.Host))
}
return result
}

View file

@ -246,8 +246,19 @@ func TestForgePersonValidation(t *testing.T) {
func TestAsloginName(t *testing.T) {
sut, _ := forgefed.NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo")
assert.Equal(t, "12345-codeberg.org", sut.AsLoginName())
assert.Equal(t, "12345@codeberg.org", sut.AsLoginName())
sut, _ = forgefed.NewPersonID("https://codeberg.org:443/api/v1/activitypub/user-id/12345", "forgejo")
assert.Equal(t, "12345-codeberg.org-443", sut.AsLoginName())
assert.Equal(t, "12345@codeberg.org:443", sut.AsLoginName())
}
func TestHostSuffix(t *testing.T) {
sut, _ := forgefed.NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo")
sut.Host = "forgejo.example.tld"
sut.HostPort = 80
// sut.IsPortSupplemented is true by default at time of writing.
assert.Equal(t, "@forgejo.example.tld", sut.HostSuffix())
sut.IsPortSupplemented = false
assert.Equal(t, "@forgejo.example.tld:80", sut.HostSuffix())
}

View file

@ -102,6 +102,21 @@ var (
// No consecutive or trailing non-alphanumeric chars, catches both cases
invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`)
// This is intended to accept any character, in any language, with accent symbols,
// as well as an arbitrary amount of subdomains and an optional port number defined
// through `:12345`.
//
// This is intended to cover username cases from distant servers in the fediverse, which
// can have much laxer requirements than those of Forgejo. It is not intended to check for
// invalid, non-standard compliant domains.
//
// For instance, the following should work:
// @user.όνομαß_21__@subdomain1.subdomain2.example.tld:65536
// @42@42.example.tld
// @user@example.tld:99999 (presumed to be an impossible case)
// @-@-.tld (also impossible)
validFediverseUsernamePattern = regexp.MustCompile(`^(@[\p{L}\p{M}0-9_\.\-]{1,})(@[\p{L}\p{M}0-9_\.\-]{1,})(:[1-9][0-9]{0,4})?$`)
)
// IsValidUsername checks if username is valid
@ -114,3 +129,11 @@ func IsValidUsername(name string) bool {
return validUsernamePatternWithoutDots.MatchString(name) && !invalidUsernamePattern.MatchString(name)
}
// IsValidActivityPubUsername checks whether the username can be a valid ActivityPub handle.
//
// Username refers to the Forgejo user account's username for consistency, and not
// e.g. "username" in @username@example.tld.
func IsValidActivityPubUsername(name string) bool {
return validFediverseUsernamePattern.MatchString(name)
}

View file

@ -1,4 +1,5 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package validation
@ -213,3 +214,109 @@ func TestIsValidUsernameBanDots(t *testing.T) {
})
}
}
func TestIsValidActivityPubUsername(t *testing.T) {
cases := []struct {
description string
username string
valid bool
}{
{
description: "Username without domain",
username: "@user",
valid: false,
},
{
description: "Username with domain",
username: "@user@example.tld",
valid: true,
},
{
description: "Numeric username with subdomain",
username: "@42@42.example.tld",
valid: true,
},
{
description: "Username with two subdomains",
username: "@user@forgejo.activitypub.example.tld",
valid: true,
},
{
description: "Username with domain and without port",
username: "@user@social.example.tld:",
valid: false,
},
{
description: "Username with domain and invalid port 0",
username: "@user@social.example.tld:0",
valid: false,
},
{
// We do not validate the port and assume that federationHost.HostPort
// cannot present such invalid ports. That also makes the previous case
// (port: 0) redundant, but it doesn't hurt.
description: "Username with domain and valid port",
username: "@user@social.example.tld:65536",
valid: true,
},
{
description: "Username with Latin letters and special symbols",
username: "@$username$@example.tld",
valid: false,
},
{
description: "Strictly numeric handle, domain, TLD",
username: "@0123456789@0123456789.0123456789.123",
valid: true,
},
{
description: "Handle with Latin characters and dashes",
username: "@0-O@O-O.tld",
valid: true,
},
// This is an impossible case, but we assume that this will never happen
// to begin with.
{
description: "Handle that only has dashes",
username: "@-@-.-",
valid: true,
},
{
description: "Username with a mix of Latin and non-Latin letters containing accents",
username: "@usernäme.όνομαß_21__@example.tld",
valid: true,
},
// Note: Our regex should accept any character, in any language and with accent symbols.
// The list is neither exhaustive, nor does it represent all possible cases.
// I chose some TLDs from https://en.wikipedia.org/wiki/Country_code_top-level_domain,
// although only one test case should suffice in theory. Nevertheless, to play it safe,
// I included four from different geographic regions whose scripts were legible using my
// IDE's default font to play it safe.
{
description: "Username, domain and ccTLD in Greek",
username: "@ευ@ευ.ευ",
valid: true,
},
{
description: "Username, domain and ccTLD in Georgian (Mkhedruli)",
username: "@გე@გე.გე",
valid: true,
},
{
description: "Username, domain and ccTLD of Malaysia (Arabic Jawi)",
username: "@مليسيا@ລمليسيا.مليسيا",
valid: true,
},
{
description: "Username, domain and ccTLD of China (Simplified)",
username: "@中国@中国.中国",
valid: true,
},
}
for _, testCase := range cases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(t, testCase.valid, IsValidActivityPubUsername(testCase.username))
})
}
}