diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/api/client/search/searchget_test.go | 30 | ||||
-rw-r--r-- | internal/db/bundb/account.go | 24 | ||||
-rw-r--r-- | internal/db/bundb/domain.go | 30 | ||||
-rw-r--r-- | internal/processing/instance.go | 36 | ||||
-rw-r--r-- | internal/regexes/regexes.go | 111 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend.go | 226 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend_test.go | 40 | ||||
-rw-r--r-- | internal/uris/uri.go | 5 | ||||
-rw-r--r-- | internal/util/punycode.go | 44 | ||||
-rw-r--r-- | internal/validate/formvalidation_test.go | 32 |
10 files changed, 369 insertions, 209 deletions
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 @@ 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 { 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 @@ 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 } 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 @@ 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, + }) } } 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 @@ 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) - // 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) + // LikedPath parses a path that validates and captures the username part from eg /users/example_username/liked + LikedPath = regexp.MustCompile(likedPath) - ulid = `[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}` // ULID parses and validate a ULID. - ULID = regexp.MustCompile(fmt.Sprintf(`^%s$`, ulid)) + ULID = regexp.MustCompile(ulidValidate) - 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) + // 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) - 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 @@ 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. + + 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) + } - if a.Domain != "" { - // this is a remote user - acct = a.Username + "@" + a.Domain + 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) + 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 - } - var suspended bool - if !a.SuspendedAt.IsZero() { - suspended = true + 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 <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 +} 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() { |