diff --git a/internal/api/client/search/searchget_test.go b/internal/api/client/search/searchget_test.go index 76a1b86ec..9adc7a9d2 100644 --- a/internal/api/client/search/searchget_test.go +++ b/internal/api/client/search/searchget_test.go @@ -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 diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index ccf7aaa46..56d46a232 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -27,9 +27,11 @@ "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(account *gtsmodel.Account) error { } 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 { diff --git a/internal/db/bundb/domain.go b/internal/db/bundb/domain.go index b9d03e98f..5c92645de 100644 --- a/internal/db/bundb/domain.go +++ b/internal/db/bundb/domain.go @@ -20,14 +20,13 @@ 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 } diff --git a/internal/processing/instance.go b/internal/processing/instance.go index 88c5c7b67..a9d849fa1 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -27,6 +27,7 @@ "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, + }) } } diff --git a/internal/regexes/regexes.go b/internal/regexes/regexes.go index d3a40e587..fe44980c5 100644 --- a/internal/regexes/regexes.go +++ b/internal/regexes/regexes.go @@ -19,7 +19,6 @@ import ( "bytes" - "fmt" "regexp" "sync" @@ -39,15 +38,42 @@ 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 @@ 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. diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 74b061fb0..88646c311 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -19,6 +19,7 @@ import ( "context" + "errors" "fmt" "math" "strconv" @@ -26,6 +27,7 @@ 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, }, } diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 1fb601260..c8ab5a8e1 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -70,10 +70,12 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() { } func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() { - testAccount := suite.testAccounts["local_account_1"] // take zork for this test + testAccount := >smodel.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"] diff --git a/internal/uris/uri.go b/internal/uris/uri.go index e0b72f7de..8a8968f38 100644 --- a/internal/uris/uri.go +++ b/internal/uris/uri.go @@ -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) diff --git a/internal/util/punycode.go b/internal/util/punycode.go new file mode 100644 index 000000000..4a595a281 --- /dev/null +++ b/internal/util/punycode.go @@ -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 . + +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 +} diff --git a/internal/validate/formvalidation_test.go b/internal/validate/formvalidation_test.go index 7face3359..fa59977b9 100644 --- a/internal/validate/formvalidation_test.go +++ b/internal/validate/formvalidation_test.go @@ -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() { diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 6638acbde..9d7654dd5 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -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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",