[bugfix] Punycode fixes (#1743)

Co-authored-by: kim <grufwub@gmail.com>
Co-authored-by: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
This commit is contained in:
tobi 2023-05-07 19:53:21 +02:00 committed by GitHub
parent b7dd32da42
commit 37b4d9d179
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 409 additions and 211 deletions

View file

@ -142,6 +142,36 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringNoResolve()
suite.Len(searchResult.Accounts, 0)
}
func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringSpecialChars() {
query := "@üser@ëxample.org"
resolve := false
searchResult, err := suite.testSearch(query, resolve, http.StatusOK)
if err != nil {
suite.FailNow(err.Error())
}
if l := len(searchResult.Accounts); l != 1 {
suite.FailNow("", "expected %d accounts, got %d", 1, l)
}
suite.Equal("üser@ëxample.org", searchResult.Accounts[0].Acct)
}
func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringSpecialCharsPunycode() {
query := "@üser@xn--xample-ova.org"
resolve := false
searchResult, err := suite.testSearch(query, resolve, http.StatusOK)
if err != nil {
suite.FailNow(err.Error())
}
if l := len(searchResult.Accounts); l != 1 {
suite.FailNow("", "expected %d accounts, got %d", 1, l)
}
suite.Equal("üser@ëxample.org", searchResult.Accounts[0].Acct)
}
func (suite *SearchGetTestSuite) TestSearchLocalAccountByNamestring() {
query := "@the_mighty_zork"
resolve := false

View file

@ -27,9 +27,11 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
@ -82,6 +84,15 @@ func (a *accountDB) GetAccountByURL(ctx context.Context, url string) (*gtsmodel.
}
func (a *accountDB) GetAccountByUsernameDomain(ctx context.Context, username string, domain string) (*gtsmodel.Account, db.Error) {
if domain != "" {
// Normalize the domain as punycode
var err error
domain, err = util.Punify(domain)
if err != nil {
return nil, err
}
}
return a.getAccount(
ctx,
"Username.Domain",
@ -220,7 +231,10 @@ func (a *accountDB) getAccount(ctx context.Context, lookup string, dbQuery func(
}
func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Account) error {
var err error
var (
err error
errs = make(gtserror.MultiError, 0, 3)
)
if account.AvatarMediaAttachment == nil && account.AvatarMediaAttachmentID != "" {
// Account avatar attachment is not set, fetch from database.
@ -229,7 +243,7 @@ func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Accou
account.AvatarMediaAttachmentID,
)
if err != nil {
return fmt.Errorf("error populating account avatar: %w", err)
errs.Append(fmt.Errorf("error populating account avatar: %w", err))
}
}
@ -240,7 +254,7 @@ func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Accou
account.HeaderMediaAttachmentID,
)
if err != nil {
return fmt.Errorf("error populating account header: %w", err)
errs.Append(fmt.Errorf("error populating account header: %w", err))
}
}
@ -251,11 +265,11 @@ func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Accou
account.EmojiIDs,
)
if err != nil {
return fmt.Errorf("error populating account emojis: %w", err)
errs.Append(fmt.Errorf("error populating account emojis: %w", err))
}
}
return nil
return errs.Combine()
}
func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) db.Error {

View file

@ -20,14 +20,13 @@ package bundb
import (
"context"
"net/url"
"strings"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
"golang.org/x/net/idna"
)
type domainDB struct {
@ -35,22 +34,10 @@ type domainDB struct {
state *state.State
}
// normalizeDomain converts the given domain to lowercase
// then to punycode (for international domain names).
//
// Returns the resulting domain or an error if the
// punycode conversion fails.
func normalizeDomain(domain string) (out string, err error) {
out = strings.ToLower(domain)
out, err = idna.ToASCII(out)
return out, err
}
func (d *domainDB) CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) db.Error {
var err error
// Normalize the domain as punycode
block.Domain, err = normalizeDomain(block.Domain)
var err error
block.Domain, err = util.Punify(block.Domain)
if err != nil {
return err
}
@ -69,10 +56,8 @@ func (d *domainDB) CreateDomainBlock(ctx context.Context, block *gtsmodel.Domain
}
func (d *domainDB) GetDomainBlock(ctx context.Context, domain string) (*gtsmodel.DomainBlock, db.Error) {
var err error
// Normalize the domain as punycode
domain, err = normalizeDomain(domain)
domain, err := util.Punify(domain)
if err != nil {
return nil, err
}
@ -98,9 +83,8 @@ func (d *domainDB) GetDomainBlock(ctx context.Context, domain string) (*gtsmodel
}
func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) db.Error {
var err error
domain, err = normalizeDomain(domain)
// Normalize the domain as punycode
domain, err := util.Punify(domain)
if err != nil {
return err
}
@ -121,7 +105,7 @@ func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) db.Erro
func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, db.Error) {
// Normalize the domain as punycode
domain, err := normalizeDomain(domain)
domain, err := util.Punify(domain)
if err != nil {
return false, err
}

View file

@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
@ -79,8 +80,15 @@ func (p *Processor) InstancePeersGet(ctx context.Context, includeSuspended bool,
}
for _, i := range instances {
domain := &apimodel.Domain{Domain: i.Domain}
domains = append(domains, domain)
// Domain may be in Punycode,
// de-punify it just in case.
d, err := util.DePunify(i.Domain)
if err != nil {
log.Errorf(ctx, "couldn't depunify domain %s: %s", i.Domain, err)
continue
}
domains = append(domains, &apimodel.Domain{Domain: d})
}
}
@ -90,17 +98,25 @@ func (p *Processor) InstancePeersGet(ctx context.Context, includeSuspended bool,
return nil, gtserror.NewErrorInternalError(err)
}
for _, d := range domainBlocks {
if *d.Obfuscate {
d.Domain = obfuscate(d.Domain)
for _, domainBlock := range domainBlocks {
// Domain may be in Punycode,
// de-punify it just in case.
d, err := util.DePunify(domainBlock.Domain)
if err != nil {
log.Errorf(ctx, "couldn't depunify domain %s: %s", domainBlock.Domain, err)
continue
}
domain := &apimodel.Domain{
Domain: d.Domain,
SuspendedAt: util.FormatISO8601(d.CreatedAt),
PublicComment: d.PublicComment,
if *domainBlock.Obfuscate {
// Obfuscate the de-punified version.
d = obfuscate(d)
}
domains = append(domains, domain)
domains = append(domains, &apimodel.Domain{
Domain: d,
SuspendedAt: util.FormatISO8601(domainBlock.CreatedAt),
PublicComment: domainBlock.PublicComment,
})
}
}

View file

@ -19,7 +19,6 @@ package regexes
import (
"bytes"
"fmt"
"regexp"
"sync"
@ -39,15 +38,42 @@ const (
follow = "follow"
blocks = "blocks"
reports = "reports"
)
const (
maximumUsernameLength = 64
maximumEmojiShortcodeLength = 30
schemes = `(http|https)://` // Allowed URI protocols for parsing links in text.
alphaNumeric = `\p{L}\p{M}*|\p{N}` // A single number or script character in any language, including chars with accents.
usernameGrp = `(?:` + alphaNumeric + `|\.|\-|\_)` // Non-capturing group that matches against a single valid username character.
domainGrp = `(?:` + alphaNumeric + `|\.|\-|\:)` // Non-capturing group that matches against a single valid domain character.
mentionName = `^@(` + usernameGrp + `+)(?:@(` + domainGrp + `+))?$` // Extract parts of one mention, maybe including domain.
mentionFinder = `(?:^|\s)(@` + usernameGrp + `+(?:@` + domainGrp + `+)?)` // Extract all mentions from a text, each mention may include domain.
emojiShortcode = `\w{2,30}` // Pattern for emoji shortcodes. maximumEmojiShortcodeLength = 30
emojiFinder = `(?:\b)?:(` + emojiShortcode + `):(?:\b)?` // Extract all emoji shortcodes from a text.
usernameStrict = `^[a-z0-9_]{2,64}$` // Pattern for usernames on THIS instance. maximumUsernameLength = 64
usernameRelaxed = `[a-z0-9_\.]{2,}` // Relaxed version of username that can match instance accounts too.
misskeyReportNotesFinder = `(?m)(?:^Note: ((?:http|https):\/\/.*)$)` // Extract reported Note URIs from the text of a Misskey report/flag.
ulid = `[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}` // Pattern for ULID.
ulidValidate = `^` + ulid + `$` // Validate one ULID.
/*
Path parts / capture.
*/
userPathPrefix = `^/?` + users + `/(` + usernameRelaxed + `)`
userPath = userPathPrefix + `$`
publicKeyPath = userPathPrefix + `/` + publicKey + `$`
inboxPath = userPathPrefix + `/` + inbox + `$`
outboxPath = userPathPrefix + `/` + outbox + `$`
followersPath = userPathPrefix + `/` + followers + `$`
followingPath = userPathPrefix + `/` + following + `$`
likedPath = userPathPrefix + `/` + liked + `$`
followPath = userPathPrefix + `/` + follow + `/(` + ulid + `)$`
likePath = userPathPrefix + `/` + liked + `/(` + ulid + `)$`
statusesPath = userPathPrefix + `/` + statuses + `/(` + ulid + `)$`
blockPath = userPathPrefix + `/` + blocks + `/(` + ulid + `)$`
reportPath = `^/?` + reports + `/(` + ulid + `)$`
filePath = `^/?(` + ulid + `)/([a-z]+)/([a-z]+)/(` + ulid + `)\.([a-z]+)$`
)
var (
schemes = `(http|https)://`
// LinkScheme captures http/https schemes in URLs.
LinkScheme = func() *regexp.Regexp {
rgx, err := xurls.StrictMatchingScheme(schemes)
@ -57,107 +83,80 @@ var (
return rgx
}()
mentionName = `^@([\w\-\.]+)(?:@([\w\-\.:]+))?$`
// MentionName captures the username and domain part from a mention string
// such as @whatever_user@example.org, returning whatever_user and example.org (without the @ symbols)
// MentionName captures the username and domain part from
// a mention string such as @whatever_user@example.org,
// returning whatever_user and example.org (without the @ symbols).
// Will also work for characters with umlauts and other accents.
// See: https://regex101.com/r/9tjNUy/1 for explanation and examples.
MentionName = regexp.MustCompile(mentionName)
// mention regex can be played around with here: https://regex101.com/r/P0vpYG/1
mentionFinder = `(?:^|\s)(@\w+(?:@[a-zA-Z0-9_\-\.]+)?)`
// MentionFinder extracts mentions from a piece of text.
// MentionFinder extracts whole mentions from a piece of text.
MentionFinder = regexp.MustCompile(mentionFinder)
emojiShortcode = fmt.Sprintf(`\w{2,%d}`, maximumEmojiShortcodeLength)
// EmojiShortcode validates an emoji name.
EmojiShortcode = regexp.MustCompile(fmt.Sprintf("^%s$", emojiShortcode))
EmojiShortcode = regexp.MustCompile(emojiShortcode)
// emoji regex can be played with here: https://regex101.com/r/478XGM/1
emojiFinderString = fmt.Sprintf(`(?:\b)?:(%s):(?:\b)?`, emojiShortcode)
// EmojiFinder extracts emoji strings from a piece of text.
EmojiFinder = regexp.MustCompile(emojiFinderString)
// See: https://regex101.com/r/478XGM/1
EmojiFinder = regexp.MustCompile(emojiFinder)
// usernameString defines an acceptable username for a new account on this instance
usernameString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumUsernameLength)
// Username can be used to validate usernames of new signups
Username = regexp.MustCompile(fmt.Sprintf(`^%s$`, usernameString))
// Username can be used to validate usernames of new signups on this instance.
Username = regexp.MustCompile(usernameStrict)
// usernameStringRelaxed is like usernameString, but also allows the '.' character,
// so it can also be used to match the instance account, which will have a username
// like 'example.org', and it has no upper length limit, so will work for long domains.
usernameStringRelaxed = `[a-z0-9_\.]{2,}`
// MisskeyReportNotes captures a list of Note URIs from report content created by Misskey.
// See: https://regex101.com/r/EnTOBV/1
MisskeyReportNotes = regexp.MustCompile(misskeyReportNotesFinder)
userPathString = fmt.Sprintf(`^/?%s/(%s)$`, users, usernameStringRelaxed)
// UserPath parses a path that validates and captures the username part from eg /users/example_username
UserPath = regexp.MustCompile(userPathString)
// UserPath validates and captures the username part from eg /users/example_username.
UserPath = regexp.MustCompile(userPath)
publicKeyPath = fmt.Sprintf(`^/?%s/(%s)/%s`, users, usernameStringRelaxed, publicKey)
// PublicKeyPath parses a path that validates and captures the username part from eg /users/example_username/main-key
PublicKeyPath = regexp.MustCompile(publicKeyPath)
inboxPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, inbox)
// InboxPath parses a path that validates and captures the username part from eg /users/example_username/inbox
InboxPath = regexp.MustCompile(inboxPath)
outboxPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, outbox)
// OutboxPath parses a path that validates and captures the username part from eg /users/example_username/outbox
OutboxPath = regexp.MustCompile(outboxPath)
actorPath = fmt.Sprintf(`^/?%s/(%s)$`, actors, usernameStringRelaxed)
// ActorPath parses a path that validates and captures the username part from eg /actors/example_username
ActorPath = regexp.MustCompile(actorPath)
followersPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, followers)
// FollowersPath parses a path that validates and captures the username part from eg /users/example_username/followers
FollowersPath = regexp.MustCompile(followersPath)
followingPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, following)
// FollowingPath parses a path that validates and captures the username part from eg /users/example_username/following
FollowingPath = regexp.MustCompile(followingPath)
followPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameStringRelaxed, follow, ulid)
// LikedPath parses a path that validates and captures the username part from eg /users/example_username/liked
LikedPath = regexp.MustCompile(likedPath)
// ULID parses and validate a ULID.
ULID = regexp.MustCompile(ulidValidate)
// FollowPath parses a path that validates and captures the username part and the ulid part
// from eg /users/example_username/follow/01F7XT5JZW1WMVSW1KADS8PVDH
FollowPath = regexp.MustCompile(followPath)
ulid = `[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}`
// ULID parses and validate a ULID.
ULID = regexp.MustCompile(fmt.Sprintf(`^%s$`, ulid))
likedPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, liked)
// LikedPath parses a path that validates and captures the username part from eg /users/example_username/liked
LikedPath = regexp.MustCompile(likedPath)
likePath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameStringRelaxed, liked, ulid)
// LikePath parses a path that validates and captures the username part and the ulid part
// from eg /users/example_username/like/01F7XT5JZW1WMVSW1KADS8PVDH
// from eg /users/example_username/liked/01F7XT5JZW1WMVSW1KADS8PVDH
LikePath = regexp.MustCompile(likePath)
statusesPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameStringRelaxed, statuses, ulid)
// StatusesPath parses a path that validates and captures the username part and the ulid part
// from eg /users/example_username/statuses/01F7XT5JZW1WMVSW1KADS8PVDH
// The regex can be played with here: https://regex101.com/r/G9zuxQ/1
StatusesPath = regexp.MustCompile(statusesPath)
blockPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameStringRelaxed, blocks, ulid)
// BlockPath parses a path that validates and captures the username part and the ulid part
// from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH
BlockPath = regexp.MustCompile(blockPath)
reportPath = fmt.Sprintf(`^/?%s/(%s)$`, reports, ulid)
// ReportPath parses a path that validates and captures the ulid part
// from eg /reports/01GP3AWY4CRDVRNZKW0TEAMB5R
ReportPath = regexp.MustCompile(reportPath)
filePath = fmt.Sprintf(`^(%s)/([a-z]+)/([a-z]+)/(%s)\.([a-z]+)$`, ulid, ulid)
// FilePath parses a file storage path of the form [ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]
// eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg
// It captures the account id, media type, media size, file name, and file extension, eg
// `01F8MH1H7YV1Z7D2C8K2730QBF`, `attachment`, `small`, `01F8MH8RMYQ6MSNY3JM2XT1CQ5`, `jpeg`.
FilePath = regexp.MustCompile(filePath)
// MisskeyReportNotes captures a list of Note URIs from report content created by Misskey.
// https://regex101.com/r/EnTOBV/1
MisskeyReportNotes = regexp.MustCompile(`(?m)(?:^Note: ((?:http|https):\/\/.*)$)`)
)
// bufpool is a memory pool of byte buffers for use in our regex utility functions.

View file

@ -19,6 +19,7 @@ package typeutils
import (
"context"
"errors"
"fmt"
"math"
"strconv"
@ -26,6 +27,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -83,99 +85,110 @@ func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode
}
func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) {
// count followers
if err := c.db.PopulateAccount(ctx, a); err != nil {
log.Errorf(ctx, "error(s) populating account, will continue: %s", err)
}
// Basic account stats:
// - Followers count
// - Following count
// - Statuses count
// - Last status time
followersCount, err := c.db.CountAccountFollowers(ctx, a.ID)
if err != nil {
return nil, fmt.Errorf("error counting followers: %s", err)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting followers: %w", err)
}
// count following
followingCount, err := c.db.CountAccountFollows(ctx, a.ID)
if err != nil {
return nil, fmt.Errorf("error counting following: %s", err)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting following: %w", err)
}
// count statuses
statusesCount, err := c.db.CountAccountStatuses(ctx, a.ID)
if err != nil {
return nil, fmt.Errorf("error counting statuses: %s", err)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting statuses: %w", err)
}
// check when the last status was
var lastStatusAt *string
lastPosted, err := c.db.GetAccountLastPosted(ctx, a.ID, false)
if err == nil && !lastPosted.IsZero() {
lastStatusAtTemp := util.FormatISO8601(lastPosted)
lastStatusAt = &lastStatusAtTemp
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting statuses: %w", err)
}
// set account avatar fields if available
var aviURL string
var aviURLStatic string
if a.AvatarMediaAttachmentID != "" {
if a.AvatarMediaAttachment == nil {
avi, err := c.db.GetAttachmentByID(ctx, a.AvatarMediaAttachmentID)
if err != nil {
log.Errorf(ctx, "error getting Avatar with id %s: %s", a.AvatarMediaAttachmentID, err)
}
a.AvatarMediaAttachment = avi
}
if a.AvatarMediaAttachment != nil {
aviURL = a.AvatarMediaAttachment.URL
aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL
}
if !lastPosted.IsZero() {
lastStatusAt = func() *string { t := util.FormatISO8601(lastPosted); return &t }()
}
// set account header fields if available
var headerURL string
var headerURLStatic string
if a.HeaderMediaAttachmentID != "" {
if a.HeaderMediaAttachment == nil {
avi, err := c.db.GetAttachmentByID(ctx, a.HeaderMediaAttachmentID)
if err != nil {
log.Errorf(ctx, "error getting Header with id %s: %s", a.HeaderMediaAttachmentID, err)
}
a.HeaderMediaAttachment = avi
}
if a.HeaderMediaAttachment != nil {
headerURL = a.HeaderMediaAttachment.URL
headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL
}
// Profile media + nice extras:
// - Avatar
// - Header
// - Fields
// - Emojis
var (
aviURL string
aviURLStatic string
headerURL string
headerURLStatic string
fields = make([]apimodel.Field, len(a.Fields))
)
if a.AvatarMediaAttachment != nil {
aviURL = a.AvatarMediaAttachment.URL
aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL
}
// preallocate frontend fields slice
fields := make([]apimodel.Field, len(a.Fields))
if a.HeaderMediaAttachment != nil {
headerURL = a.HeaderMediaAttachment.URL
headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL
}
// Convert account GTS model fields to frontend
// GTS model fields -> frontend.
for i, field := range a.Fields {
mField := apimodel.Field{
Name: field.Name,
Value: field.Value,
}
if !field.VerifiedAt.IsZero() {
mField.VerifiedAt = util.FormatISO8601(field.VerifiedAt)
}
fields[i] = mField
}
// convert account gts model emojis to frontend api model emojis
// GTS model emojis -> frontend.
apiEmojis, err := c.convertEmojisToAPIEmojis(ctx, a.Emojis, a.EmojiIDs)
if err != nil {
log.Errorf(ctx, "error converting account emojis: %v", err)
}
var acct string
var role *apimodel.AccountRole
// Bits that vary between remote + local accounts:
// - Account (acct) string.
// - Role.
if a.Domain != "" {
// this is a remote user
acct = a.Username + "@" + a.Domain
var (
acct string
role *apimodel.AccountRole
)
if a.IsRemote() {
// Domain may be in Punycode,
// de-punify it just in case.
d, err := util.DePunify(a.Domain)
if err != nil {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error de-punifying domain %s for account id %s: %w", a.Domain, a.ID, err)
}
acct = a.Username + "@" + d
} else {
// this is a local user
// This is a local user.
acct = a.Username
user, err := c.db.GetUserByAccountID(ctx, a.ID)
if err != nil {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error getting user from database for account id %s: %s", a.ID, err)
return nil, fmt.Errorf("AccountToAPIAccountPublic: error getting user from database for account id %s: %w", a.ID, err)
}
switch {
@ -188,10 +201,8 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
}
}
var suspended bool
if !a.SuspendedAt.IsZero() {
suspended = true
}
// Remaining properties are simple and
// can be populated directly below.
accountFrontend := &apimodel.Account{
ID: a.ID,
@ -214,12 +225,14 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
LastStatusAt: lastStatusAt,
Emojis: apiEmojis,
Fields: fields,
Suspended: suspended,
Suspended: !a.SuspendedAt.IsZero(),
CustomCSS: a.CustomCSS,
EnableRSS: *a.EnableRSS,
Role: role,
}
// Bodge default avatar + header in,
// if we didn't have one already.
c.ensureAvatar(accountFrontend)
c.ensureHeader(accountFrontend)
@ -227,18 +240,37 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
}
func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) {
var acct string
if a.Domain != "" {
// this is a remote user
acct = fmt.Sprintf("%s@%s", a.Username, a.Domain)
} else {
// this is a local user
acct = a.Username
}
var (
acct string
role *apimodel.AccountRole
)
var suspended bool
if !a.SuspendedAt.IsZero() {
suspended = true
if a.IsRemote() {
// Domain may be in Punycode,
// de-punify it just in case.
d, err := util.DePunify(a.Domain)
if err != nil {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error de-punifying domain %s for account id %s: %w", a.Domain, a.ID, err)
}
acct = a.Username + "@" + d
} else {
// This is a local user.
acct = a.Username
user, err := c.db.GetUserByAccountID(ctx, a.ID)
if err != nil {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error getting user from database for account id %s: %s", a.ID, err)
}
switch {
case *user.Admin:
role = &apimodel.AccountRole{Name: apimodel.AccountRoleAdmin}
case *user.Moderator:
role = &apimodel.AccountRole{Name: apimodel.AccountRoleModerator}
default:
role = &apimodel.AccountRole{Name: apimodel.AccountRoleUser}
}
}
return &apimodel.Account{
@ -249,7 +281,8 @@ func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel.
Bot: *a.Bot,
CreatedAt: util.FormatISO8601(a.CreatedAt),
URL: a.URL,
Suspended: suspended,
Suspended: !a.SuspendedAt.IsZero(),
Role: role,
}, nil
}
@ -263,15 +296,20 @@ func (c *converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac
inviteRequest *string
approved bool
disabled bool
silenced bool
suspended bool
role = apimodel.AccountRole{Name: apimodel.AccountRoleUser} // assume user by default
createdByApplicationID string
)
// take user-level information if possible
if a.IsRemote() {
domain = &a.Domain
// Domain may be in Punycode,
// de-punify it just in case.
d, err := util.DePunify(a.Domain)
if err != nil {
return nil, fmt.Errorf("AccountToAdminAPIAccount: error de-punifying domain %s for account id %s: %w", a.Domain, a.ID, err)
}
domain = &d
} else {
user, err := c.db.GetUserByAccountID(ctx, a.ID)
if err != nil {
@ -303,9 +341,6 @@ func (c *converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac
createdByApplicationID = user.CreatedByApplicationID
}
silenced = !a.SilencedAt.IsZero()
suspended = !a.SuspendedAt.IsZero()
apiAccount, err := c.AccountToAPIAccountPublic(ctx, a)
if err != nil {
return nil, fmt.Errorf("AccountToAdminAPIAccount: error converting account to api account for account id %s: %w", a.ID, err)
@ -325,8 +360,8 @@ func (c *converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac
Confirmed: confirmed,
Approved: approved,
Disabled: disabled,
Silenced: silenced,
Suspended: suspended,
Silenced: !a.SilencedAt.IsZero(),
Suspended: !a.SuspendedAt.IsZero(),
Account: apiAccount,
CreatedByApplicationID: createdByApplicationID,
InvitedByAccountID: "", // not implemented (yet)
@ -428,16 +463,19 @@ func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention
m.TargetAccount = targetAccount
}
var local bool
if m.TargetAccount.Domain == "" {
local = true
}
var acct string
if local {
if m.TargetAccount.IsLocal() {
acct = m.TargetAccount.Username
} else {
acct = fmt.Sprintf("%s@%s", m.TargetAccount.Username, m.TargetAccount.Domain)
// Domain may be in Punycode,
// de-punify it just in case.
d, err := util.DePunify(m.TargetAccount.Domain)
if err != nil {
err = fmt.Errorf("MentionToAPIMention: error de-punifying domain %s for account id %s: %w", m.TargetAccount.Domain, m.TargetAccountID, err)
return apimodel.Mention{}, err
}
acct = m.TargetAccount.Username + "@" + d
}
return apimodel.Mention{
@ -476,6 +514,17 @@ func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji)
return nil, err
}
if e.Domain != "" {
// Domain may be in Punycode,
// de-punify it just in case.
var err error
e.Domain, err = util.DePunify(e.Domain)
if err != nil {
err = fmt.Errorf("EmojiToAdminAPIEmoji: error de-punifying domain %s for emoji id %s: %w", e.Domain, e.ID, err)
return nil, err
}
}
return &apimodel.AdminEmoji{
Emoji: emoji,
ID: e.ID,
@ -942,9 +991,16 @@ func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmod
}
func (c *converter) DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) {
// Domain may be in Punycode,
// de-punify it just in case.
d, err := util.DePunify(b.Domain)
if err != nil {
return nil, fmt.Errorf("DomainBlockToAPIDomainBlock: error de-punifying domain %s: %w", b.Domain, err)
}
domainBlock := &apimodel.DomainBlock{
Domain: apimodel.Domain{
Domain: b.Domain,
Domain: d,
PublicComment: b.PublicComment,
},
}

View file

@ -70,10 +70,12 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() {
}
func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() {
testAccount := suite.testAccounts["local_account_1"] // take zork for this test
testAccount := &gtsmodel.Account{}
*testAccount = *suite.testAccounts["local_account_1"] // take zork for this test
testEmoji := suite.testEmojis["rainbow"]
testAccount.Emojis = []*gtsmodel.Emoji{testEmoji}
testAccount.EmojiIDs = []string{testEmoji.ID}
apiAccount, err := suite.typeconverter.AccountToAPIAccountPublic(context.Background(), testAccount)
suite.NoError(err)
@ -210,6 +212,42 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() {
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestAccountToFrontendPublicPunycode() {
testAccount := suite.testAccounts["remote_account_4"]
apiAccount, err := suite.typeconverter.AccountToAPIAccountPublic(context.Background(), testAccount)
suite.NoError(err)
suite.NotNil(apiAccount)
b, err := json.MarshalIndent(apiAccount, "", " ")
suite.NoError(err)
// Even though account domain is stored in
// punycode, it should be served in its
// unicode representation in the 'acct' field.
suite.Equal(`{
"id": "07GZRBAEMBNKGZ8Z9VSKSXKR98",
"username": "üser",
"acct": "üser@ëxample.org",
"display_name": "",
"locked": false,
"discoverable": false,
"bot": false,
"created_at": "2020-08-10T12:13:28.000Z",
"note": "",
"url": "https://xn--xample-ova.org/users/@%C3%BCser",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"last_status_at": null,
"emojis": [],
"fields": []
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
requestingAccount := suite.testAccounts["local_account_1"]

View file

@ -193,11 +193,6 @@ func IsOutboxPath(id *url.URL) bool {
return regexes.OutboxPath.MatchString(id.Path)
}
// IsInstanceActorPath returns true if the given URL path corresponds to eg /actors/example_username
func IsInstanceActorPath(id *url.URL) bool {
return regexes.ActorPath.MatchString(id.Path)
}
// IsFollowersPath returns true if the given URL path corresponds to eg /users/example_username/followers
func IsFollowersPath(id *url.URL) bool {
return regexes.FollowersPath.MatchString(id.Path)

44
internal/util/punycode.go Normal file
View file

@ -0,0 +1,44 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package util
import (
"strings"
"golang.org/x/net/idna"
)
// Punify converts the given domain to lowercase
// then to punycode (for international domain names).
//
// Returns the resulting domain or an error if the
// punycode conversion fails.
func Punify(domain string) (string, error) {
domain = strings.ToLower(domain)
return idna.ToASCII(domain)
}
// DePunify converts the given punycode string
// to its original unicode representation (lowercased).
// Noop if the domain is (already) not puny.
//
// Returns an error if conversion fails.
func DePunify(domain string) (string, error) {
out, err := idna.ToUnicode(domain)
return strings.ToLower(out), err
}

View file

@ -96,44 +96,28 @@ func (suite *ValidationTestSuite) TestValidateUsername() {
var err error
err = validate.Username(empty)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("no username provided"), err)
}
suite.EqualError(err, "no username provided")
err = validate.Username(tooLong)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", tooLong), err)
}
suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", tooLong))
err = validate.Username(withSpaces)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", withSpaces), err)
}
suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", withSpaces))
err = validate.Username(weirdChars)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", weirdChars), err)
}
suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", weirdChars))
err = validate.Username(leadingSpace)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", leadingSpace), err)
}
suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", leadingSpace))
err = validate.Username(trailingSpace)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", trailingSpace), err)
}
suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", trailingSpace))
err = validate.Username(newlines)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", newlines), err)
}
suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", newlines))
err = validate.Username(goodUsername)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
suite.NoError(err)
}
func (suite *ValidationTestSuite) TestValidateEmail() {

View file

@ -617,6 +617,43 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
SuspensionOrigin: "",
HeaderMediaAttachmentID: "01PFPMWK2FF0D9WMHEJHR07C3R",
},
"remote_account_4": {
ID: "07GZRBAEMBNKGZ8Z9VSKSXKR98",
Username: "üser",
Domain: "xn--xample-ova.org",
DisplayName: "",
Note: "",
Memorial: FalseBool(),
MovedToAccountID: "",
CreatedAt: TimeMustParse("2020-08-10T14:13:28+02:00"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Bot: FalseBool(),
Locked: FalseBool(),
Discoverable: FalseBool(),
Sensitive: FalseBool(),
Language: "de",
URI: "https://xn--xample-ova.org/users/%C3%BCser",
URL: "https://xn--xample-ova.org/users/@%C3%BCser",
FetchedAt: time.Time{},
InboxURI: "https://xn--xample-ova.org/users/%C3%BCser/inbox",
SharedInboxURI: StringPtr(""),
OutboxURI: "https://xn--xample-ova.org/users/%C3%BCser/outbox",
FollowersURI: "https://xn--xample-ova.org/users/%C3%BCser/followers",
FollowingURI: "https://xn--xample-ova.org/users/%C3%BCser/following",
FeaturedCollectionURI: "https://xn--xample-ova.org/users/%C3%BCser/collections/featured",
ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{},
PublicKeyURI: "https://xn--xample-ova.org/users/%C3%BCser#main-key",
SensitizedAt: time.Time{},
SilencedAt: time.Time{},
SuspendedAt: time.Time{},
HideCollections: FalseBool(),
SuspensionOrigin: "",
HeaderMediaAttachmentID: "",
EnableRSS: FalseBool(),
},
}
var accountsSorted []*gtsmodel.Account
@ -629,6 +666,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
})
preserializedKeys := []string{
"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",