mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 12:02:26 -05:00 
			
		
		
		
	[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:
		
					parent
					
						
							
								b7dd32da42
							
						
					
				
			
			
				commit
				
					
						37b4d9d179
					
				
			
		
					 11 changed files with 409 additions and 211 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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 | ||||
| 	} | ||||
|  |  | |||
|  | @ -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, | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -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. | ||||
|  |  | |||
|  | @ -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 !lastPosted.IsZero() { | ||||
| 		lastStatusAt = func() *string { t := util.FormatISO8601(lastPosted); return &t }() | ||||
| 	} | ||||
| 
 | ||||
| 	// 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 | ||||
| 	} | ||||
| 	} | ||||
| 
 | ||||
| 	// 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 | ||||
| 	} | ||||
| 	} | ||||
| 
 | ||||
| 	// preallocate frontend fields slice | ||||
| 	fields := make([]apimodel.Field, len(a.Fields)) | ||||
| 
 | ||||
| 	// 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 | ||||
| 	) | ||||
| 
 | ||||
| 	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) | ||||
| 		} | ||||
| 
 | ||||
| 	var suspended bool | ||||
| 	if !a.SuspendedAt.IsZero() { | ||||
| 		suspended = true | ||||
| 		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, | ||||
| 		}, | ||||
| 	} | ||||
|  |  | |||
|  | @ -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"] | ||||
|  |  | |||
|  | @ -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
									
								
							
							
						
						
									
										44
									
								
								internal/util/punycode.go
									
										
									
									
									
										Normal 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 | ||||
| } | ||||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -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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", | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue