mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 14:22:25 -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) | 	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() { | func (suite *SearchGetTestSuite) TestSearchLocalAccountByNamestring() { | ||||||
| 	query := "@the_mighty_zork" | 	query := "@the_mighty_zork" | ||||||
| 	resolve := false | 	resolve := false | ||||||
|  |  | ||||||
|  | @ -27,9 +27,11 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/config" | 	"github.com/superseriousbusiness/gotosocial/internal/config" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/db" | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/state" | 	"github.com/superseriousbusiness/gotosocial/internal/state" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
| 	"github.com/uptrace/bun" | 	"github.com/uptrace/bun" | ||||||
| 	"github.com/uptrace/bun/dialect" | 	"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) { | 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( | 	return a.getAccount( | ||||||
| 		ctx, | 		ctx, | ||||||
| 		"Username.Domain", | 		"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 { | 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 != "" { | 	if account.AvatarMediaAttachment == nil && account.AvatarMediaAttachmentID != "" { | ||||||
| 		// Account avatar attachment is not set, fetch from database. | 		// 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, | 			account.AvatarMediaAttachmentID, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		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, | 			account.HeaderMediaAttachmentID, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		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, | 			account.EmojiIDs, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		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 { | func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) db.Error { | ||||||
|  |  | ||||||
|  | @ -20,14 +20,13 @@ package bundb | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"net/url" | 	"net/url" | ||||||
| 	"strings" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/config" | 	"github.com/superseriousbusiness/gotosocial/internal/config" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/db" | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/state" | 	"github.com/superseriousbusiness/gotosocial/internal/state" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
| 	"github.com/uptrace/bun" | 	"github.com/uptrace/bun" | ||||||
| 	"golang.org/x/net/idna" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type domainDB struct { | type domainDB struct { | ||||||
|  | @ -35,22 +34,10 @@ type domainDB struct { | ||||||
| 	state *state.State | 	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 { | func (d *domainDB) CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) db.Error { | ||||||
| 	var err error |  | ||||||
| 
 |  | ||||||
| 	// Normalize the domain as punycode | 	// Normalize the domain as punycode | ||||||
| 	block.Domain, err = normalizeDomain(block.Domain) | 	var err error | ||||||
|  | 	block.Domain, err = util.Punify(block.Domain) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		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) { | func (d *domainDB) GetDomainBlock(ctx context.Context, domain string) (*gtsmodel.DomainBlock, db.Error) { | ||||||
| 	var err error |  | ||||||
| 
 |  | ||||||
| 	// Normalize the domain as punycode | 	// Normalize the domain as punycode | ||||||
| 	domain, err = normalizeDomain(domain) | 	domain, err := util.Punify(domain) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		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 { | func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) db.Error { | ||||||
| 	var err error | 	// Normalize the domain as punycode | ||||||
| 
 | 	domain, err := util.Punify(domain) | ||||||
| 	domain, err = normalizeDomain(domain) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		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) { | func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, db.Error) { | ||||||
| 	// Normalize the domain as punycode | 	// Normalize the domain as punycode | ||||||
| 	domain, err := normalizeDomain(domain) | 	domain, err := util.Punify(domain) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return false, err | 		return false, err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -27,6 +27,7 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/db" | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/text" | 	"github.com/superseriousbusiness/gotosocial/internal/text" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/util" | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/validate" | 	"github.com/superseriousbusiness/gotosocial/internal/validate" | ||||||
|  | @ -79,8 +80,15 @@ func (p *Processor) InstancePeersGet(ctx context.Context, includeSuspended bool, | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		for _, i := range instances { | 		for _, i := range instances { | ||||||
| 			domain := &apimodel.Domain{Domain: i.Domain} | 			// Domain may be in Punycode, | ||||||
| 			domains = append(domains, domain) | 			// 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) | 			return nil, gtserror.NewErrorInternalError(err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		for _, d := range domainBlocks { | 		for _, domainBlock := range domainBlocks { | ||||||
| 			if *d.Obfuscate { | 			// Domain may be in Punycode, | ||||||
| 				d.Domain = obfuscate(d.Domain) | 			// 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{ | 			if *domainBlock.Obfuscate { | ||||||
| 				Domain:        d.Domain, | 				// Obfuscate the de-punified version. | ||||||
| 				SuspendedAt:   util.FormatISO8601(d.CreatedAt), | 				d = obfuscate(d) | ||||||
| 				PublicComment: d.PublicComment, |  | ||||||
| 			} | 			} | ||||||
| 			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 ( | import ( | ||||||
| 	"bytes" | 	"bytes" | ||||||
| 	"fmt" |  | ||||||
| 	"regexp" | 	"regexp" | ||||||
| 	"sync" | 	"sync" | ||||||
| 
 | 
 | ||||||
|  | @ -39,15 +38,42 @@ const ( | ||||||
| 	follow    = "follow" | 	follow    = "follow" | ||||||
| 	blocks    = "blocks" | 	blocks    = "blocks" | ||||||
| 	reports   = "reports" | 	reports   = "reports" | ||||||
| ) |  | ||||||
| 
 | 
 | ||||||
| const ( | 	schemes                  = `(http|https)://`                                         // Allowed URI protocols for parsing links in text. | ||||||
| 	maximumUsernameLength       = 64 | 	alphaNumeric             = `\p{L}\p{M}*|\p{N}`                                       // A single number or script character in any language, including chars with accents. | ||||||
| 	maximumEmojiShortcodeLength = 30 | 	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 ( | var ( | ||||||
| 	schemes = `(http|https)://` |  | ||||||
| 	// LinkScheme captures http/https schemes in URLs. | 	// LinkScheme captures http/https schemes in URLs. | ||||||
| 	LinkScheme = func() *regexp.Regexp { | 	LinkScheme = func() *regexp.Regexp { | ||||||
| 		rgx, err := xurls.StrictMatchingScheme(schemes) | 		rgx, err := xurls.StrictMatchingScheme(schemes) | ||||||
|  | @ -57,107 +83,80 @@ var ( | ||||||
| 		return rgx | 		return rgx | ||||||
| 	}() | 	}() | ||||||
| 
 | 
 | ||||||
| 	mentionName = `^@([\w\-\.]+)(?:@([\w\-\.:]+))?$` | 	// MentionName captures the username and domain part from | ||||||
| 	// MentionName captures the username and domain part from a mention string | 	// a mention string such as @whatever_user@example.org, | ||||||
| 	// such as @whatever_user@example.org, returning whatever_user and example.org (without the @ symbols) | 	// 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) | 	MentionName = regexp.MustCompile(mentionName) | ||||||
| 
 | 
 | ||||||
| 	// mention regex can be played around with here: https://regex101.com/r/P0vpYG/1 | 	// MentionFinder extracts whole mentions from a piece of text. | ||||||
| 	mentionFinder = `(?:^|\s)(@\w+(?:@[a-zA-Z0-9_\-\.]+)?)` |  | ||||||
| 	// MentionFinder extracts mentions from a piece of text. |  | ||||||
| 	MentionFinder = regexp.MustCompile(mentionFinder) | 	MentionFinder = regexp.MustCompile(mentionFinder) | ||||||
| 
 | 
 | ||||||
| 	emojiShortcode = fmt.Sprintf(`\w{2,%d}`, maximumEmojiShortcodeLength) |  | ||||||
| 	// EmojiShortcode validates an emoji name. | 	// 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 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 | 	// Username can be used to validate usernames of new signups on this instance. | ||||||
| 	usernameString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumUsernameLength) | 	Username = regexp.MustCompile(usernameStrict) | ||||||
| 	// Username can be used to validate usernames of new signups |  | ||||||
| 	Username = regexp.MustCompile(fmt.Sprintf(`^%s$`, usernameString)) |  | ||||||
| 
 | 
 | ||||||
| 	// usernameStringRelaxed is like usernameString, but also allows the '.' character, | 	// MisskeyReportNotes captures a list of Note URIs from report content created by Misskey. | ||||||
| 	// so it can also be used to match the instance account, which will have a username | 	// See: https://regex101.com/r/EnTOBV/1 | ||||||
| 	// like 'example.org', and it has no upper length limit, so will work for long domains. | 	MisskeyReportNotes = regexp.MustCompile(misskeyReportNotesFinder) | ||||||
| 	usernameStringRelaxed = `[a-z0-9_\.]{2,}` |  | ||||||
| 
 | 
 | ||||||
| 	userPathString = fmt.Sprintf(`^/?%s/(%s)$`, users, usernameStringRelaxed) | 	// UserPath validates and captures the username part from eg /users/example_username. | ||||||
| 	// UserPath parses a path that validates and captures the username part from eg /users/example_username | 	UserPath = regexp.MustCompile(userPath) | ||||||
| 	UserPath = regexp.MustCompile(userPathString) |  | ||||||
| 
 | 
 | ||||||
| 	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 parses a path that validates and captures the username part from eg /users/example_username/main-key | ||||||
| 	PublicKeyPath = regexp.MustCompile(publicKeyPath) | 	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 parses a path that validates and captures the username part from eg /users/example_username/inbox | ||||||
| 	InboxPath = regexp.MustCompile(inboxPath) | 	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 parses a path that validates and captures the username part from eg /users/example_username/outbox | ||||||
| 	OutboxPath = regexp.MustCompile(outboxPath) | 	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 parses a path that validates and captures the username part from eg /users/example_username/followers | ||||||
| 	FollowersPath = regexp.MustCompile(followersPath) | 	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 parses a path that validates and captures the username part from eg /users/example_username/following | ||||||
| 	FollowingPath = regexp.MustCompile(followingPath) | 	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 | 	// FollowPath parses a path that validates and captures the username part and the ulid part | ||||||
| 	// from eg /users/example_username/follow/01F7XT5JZW1WMVSW1KADS8PVDH | 	// from eg /users/example_username/follow/01F7XT5JZW1WMVSW1KADS8PVDH | ||||||
| 	FollowPath = regexp.MustCompile(followPath) | 	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 | 	// 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) | 	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 | 	// StatusesPath parses a path that validates and captures the username part and the ulid part | ||||||
| 	// from eg /users/example_username/statuses/01F7XT5JZW1WMVSW1KADS8PVDH | 	// from eg /users/example_username/statuses/01F7XT5JZW1WMVSW1KADS8PVDH | ||||||
| 	// The regex can be played with here: https://regex101.com/r/G9zuxQ/1 | 	// The regex can be played with here: https://regex101.com/r/G9zuxQ/1 | ||||||
| 	StatusesPath = regexp.MustCompile(statusesPath) | 	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 | 	// BlockPath parses a path that validates and captures the username part and the ulid part | ||||||
| 	// from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH | 	// from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH | ||||||
| 	BlockPath = regexp.MustCompile(blockPath) | 	BlockPath = regexp.MustCompile(blockPath) | ||||||
| 
 | 
 | ||||||
| 	reportPath = fmt.Sprintf(`^/?%s/(%s)$`, reports, ulid) |  | ||||||
| 	// ReportPath parses a path that validates and captures the ulid part | 	// ReportPath parses a path that validates and captures the ulid part | ||||||
| 	// from eg /reports/01GP3AWY4CRDVRNZKW0TEAMB5R | 	// from eg /reports/01GP3AWY4CRDVRNZKW0TEAMB5R | ||||||
| 	ReportPath = regexp.MustCompile(reportPath) | 	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] | 	// FilePath parses a file storage path of the form [ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME] | ||||||
| 	// eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg | 	// eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg | ||||||
| 	// It captures the account id, media type, media size, file name, and file extension, eg | 	// It captures the account id, media type, media size, file name, and file extension, eg | ||||||
| 	// `01F8MH1H7YV1Z7D2C8K2730QBF`, `attachment`, `small`, `01F8MH8RMYQ6MSNY3JM2XT1CQ5`, `jpeg`. | 	// `01F8MH1H7YV1Z7D2C8K2730QBF`, `attachment`, `small`, `01F8MH8RMYQ6MSNY3JM2XT1CQ5`, `jpeg`. | ||||||
| 	FilePath = regexp.MustCompile(filePath) | 	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. | // bufpool is a memory pool of byte buffers for use in our regex utility functions. | ||||||
|  |  | ||||||
|  | @ -19,6 +19,7 @@ package typeutils | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"math" | 	"math" | ||||||
| 	"strconv" | 	"strconv" | ||||||
|  | @ -26,6 +27,7 @@ import ( | ||||||
| 
 | 
 | ||||||
| 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/config" | 	"github.com/superseriousbusiness/gotosocial/internal/config" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | 	"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) { | 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) | 	followersCount, err := c.db.CountAccountFollowers(ctx, a.ID) | ||||||
| 	if err != nil { | 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
| 		return nil, fmt.Errorf("error counting followers: %s", err) | 		return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting followers: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// count following |  | ||||||
| 	followingCount, err := c.db.CountAccountFollows(ctx, a.ID) | 	followingCount, err := c.db.CountAccountFollows(ctx, a.ID) | ||||||
| 	if err != nil { | 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
| 		return nil, fmt.Errorf("error counting following: %s", err) | 		return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting following: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// count statuses |  | ||||||
| 	statusesCount, err := c.db.CountAccountStatuses(ctx, a.ID) | 	statusesCount, err := c.db.CountAccountStatuses(ctx, a.ID) | ||||||
| 	if err != nil { | 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
| 		return nil, fmt.Errorf("error counting statuses: %s", err) | 		return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting statuses: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// check when the last status was |  | ||||||
| 	var lastStatusAt *string | 	var lastStatusAt *string | ||||||
| 	lastPosted, err := c.db.GetAccountLastPosted(ctx, a.ID, false) | 	lastPosted, err := c.db.GetAccountLastPosted(ctx, a.ID, false) | ||||||
| 	if err == nil && !lastPosted.IsZero() { | 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
| 		lastStatusAtTemp := util.FormatISO8601(lastPosted) | 		return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting statuses: %w", err) | ||||||
| 		lastStatusAt = &lastStatusAtTemp |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// set account avatar fields if available | 	if !lastPosted.IsZero() { | ||||||
| 	var aviURL string | 		lastStatusAt = func() *string { t := util.FormatISO8601(lastPosted); return &t }() | ||||||
| 	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 |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// set account header fields if available | 	// Profile media + nice extras: | ||||||
| 	var headerURL string | 	//   - Avatar | ||||||
| 	var headerURLStatic string | 	//   - Header | ||||||
| 	if a.HeaderMediaAttachmentID != "" { | 	//   - Fields | ||||||
| 		if a.HeaderMediaAttachment == nil { | 	//   - Emojis | ||||||
| 			avi, err := c.db.GetAttachmentByID(ctx, a.HeaderMediaAttachmentID) | 
 | ||||||
| 			if err != nil { | 	var ( | ||||||
| 				log.Errorf(ctx, "error getting Header with id %s: %s", a.HeaderMediaAttachmentID, err) | 		aviURL          string | ||||||
| 			} | 		aviURLStatic    string | ||||||
| 			a.HeaderMediaAttachment = avi | 		headerURL       string | ||||||
| 		} | 		headerURLStatic string | ||||||
| 		if a.HeaderMediaAttachment != nil { | 		fields          = make([]apimodel.Field, len(a.Fields)) | ||||||
| 			headerURL = a.HeaderMediaAttachment.URL | 	) | ||||||
| 			headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL | 
 | ||||||
| 		} | 	if a.AvatarMediaAttachment != nil { | ||||||
|  | 		aviURL = a.AvatarMediaAttachment.URL | ||||||
|  | 		aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// preallocate frontend fields slice | 	if a.HeaderMediaAttachment != nil { | ||||||
| 	fields := make([]apimodel.Field, len(a.Fields)) | 		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 { | 	for i, field := range a.Fields { | ||||||
| 		mField := apimodel.Field{ | 		mField := apimodel.Field{ | ||||||
| 			Name:  field.Name, | 			Name:  field.Name, | ||||||
| 			Value: field.Value, | 			Value: field.Value, | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
| 		if !field.VerifiedAt.IsZero() { | 		if !field.VerifiedAt.IsZero() { | ||||||
| 			mField.VerifiedAt = util.FormatISO8601(field.VerifiedAt) | 			mField.VerifiedAt = util.FormatISO8601(field.VerifiedAt) | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
| 		fields[i] = mField | 		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) | 	apiEmojis, err := c.convertEmojisToAPIEmojis(ctx, a.Emojis, a.EmojiIDs) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Errorf(ctx, "error converting account emojis: %v", err) | 		log.Errorf(ctx, "error converting account emojis: %v", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var acct string | 	// Bits that vary between remote + local accounts: | ||||||
| 	var role *apimodel.AccountRole | 	//   - Account (acct) string. | ||||||
|  | 	//   - Role. | ||||||
| 
 | 
 | ||||||
| 	if a.Domain != "" { | 	var ( | ||||||
| 		// this is a remote user | 		acct string | ||||||
| 		acct = a.Username + "@" + a.Domain | 		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 { | 	} else { | ||||||
| 		// this is a local user | 		// This is a local user. | ||||||
| 		acct = a.Username | 		acct = a.Username | ||||||
|  | 
 | ||||||
| 		user, err := c.db.GetUserByAccountID(ctx, a.ID) | 		user, err := c.db.GetUserByAccountID(ctx, a.ID) | ||||||
| 		if err != nil { | 		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 { | 		switch { | ||||||
|  | @ -188,10 +201,8 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var suspended bool | 	// Remaining properties are simple and | ||||||
| 	if !a.SuspendedAt.IsZero() { | 	// can be populated directly below. | ||||||
| 		suspended = true |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	accountFrontend := &apimodel.Account{ | 	accountFrontend := &apimodel.Account{ | ||||||
| 		ID:             a.ID, | 		ID:             a.ID, | ||||||
|  | @ -214,12 +225,14 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A | ||||||
| 		LastStatusAt:   lastStatusAt, | 		LastStatusAt:   lastStatusAt, | ||||||
| 		Emojis:         apiEmojis, | 		Emojis:         apiEmojis, | ||||||
| 		Fields:         fields, | 		Fields:         fields, | ||||||
| 		Suspended:      suspended, | 		Suspended:      !a.SuspendedAt.IsZero(), | ||||||
| 		CustomCSS:      a.CustomCSS, | 		CustomCSS:      a.CustomCSS, | ||||||
| 		EnableRSS:      *a.EnableRSS, | 		EnableRSS:      *a.EnableRSS, | ||||||
| 		Role:           role, | 		Role:           role, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Bodge default avatar + header in, | ||||||
|  | 	// if we didn't have one already. | ||||||
| 	c.ensureAvatar(accountFrontend) | 	c.ensureAvatar(accountFrontend) | ||||||
| 	c.ensureHeader(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) { | func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { | ||||||
| 	var acct string | 	var ( | ||||||
| 	if a.Domain != "" { | 		acct string | ||||||
| 		// this is a remote user | 		role *apimodel.AccountRole | ||||||
| 		acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) | 	) | ||||||
| 	} else { |  | ||||||
| 		// this is a local user |  | ||||||
| 		acct = a.Username |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	var suspended bool | 	if a.IsRemote() { | ||||||
| 	if !a.SuspendedAt.IsZero() { | 		// Domain may be in Punycode, | ||||||
| 		suspended = true | 		// 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{ | 	return &apimodel.Account{ | ||||||
|  | @ -249,7 +281,8 @@ func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel. | ||||||
| 		Bot:         *a.Bot, | 		Bot:         *a.Bot, | ||||||
| 		CreatedAt:   util.FormatISO8601(a.CreatedAt), | 		CreatedAt:   util.FormatISO8601(a.CreatedAt), | ||||||
| 		URL:         a.URL, | 		URL:         a.URL, | ||||||
| 		Suspended:   suspended, | 		Suspended:   !a.SuspendedAt.IsZero(), | ||||||
|  | 		Role:        role, | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -263,15 +296,20 @@ func (c *converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac | ||||||
| 		inviteRequest          *string | 		inviteRequest          *string | ||||||
| 		approved               bool | 		approved               bool | ||||||
| 		disabled               bool | 		disabled               bool | ||||||
| 		silenced               bool |  | ||||||
| 		suspended              bool |  | ||||||
| 		role                   = apimodel.AccountRole{Name: apimodel.AccountRoleUser} // assume user by default | 		role                   = apimodel.AccountRole{Name: apimodel.AccountRoleUser} // assume user by default | ||||||
| 		createdByApplicationID string | 		createdByApplicationID string | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	// take user-level information if possible | 	// take user-level information if possible | ||||||
| 	if a.IsRemote() { | 	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 { | 	} else { | ||||||
| 		user, err := c.db.GetUserByAccountID(ctx, a.ID) | 		user, err := c.db.GetUserByAccountID(ctx, a.ID) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | @ -303,9 +341,6 @@ func (c *converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac | ||||||
| 		createdByApplicationID = user.CreatedByApplicationID | 		createdByApplicationID = user.CreatedByApplicationID | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	silenced = !a.SilencedAt.IsZero() |  | ||||||
| 	suspended = !a.SuspendedAt.IsZero() |  | ||||||
| 
 |  | ||||||
| 	apiAccount, err := c.AccountToAPIAccountPublic(ctx, a) | 	apiAccount, err := c.AccountToAPIAccountPublic(ctx, a) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, fmt.Errorf("AccountToAdminAPIAccount: error converting account to api account for account id %s: %w", a.ID, err) | 		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, | 		Confirmed:              confirmed, | ||||||
| 		Approved:               approved, | 		Approved:               approved, | ||||||
| 		Disabled:               disabled, | 		Disabled:               disabled, | ||||||
| 		Silenced:               silenced, | 		Silenced:               !a.SilencedAt.IsZero(), | ||||||
| 		Suspended:              suspended, | 		Suspended:              !a.SuspendedAt.IsZero(), | ||||||
| 		Account:                apiAccount, | 		Account:                apiAccount, | ||||||
| 		CreatedByApplicationID: createdByApplicationID, | 		CreatedByApplicationID: createdByApplicationID, | ||||||
| 		InvitedByAccountID:     "", // not implemented (yet) | 		InvitedByAccountID:     "", // not implemented (yet) | ||||||
|  | @ -428,16 +463,19 @@ func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention | ||||||
| 		m.TargetAccount = targetAccount | 		m.TargetAccount = targetAccount | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var local bool |  | ||||||
| 	if m.TargetAccount.Domain == "" { |  | ||||||
| 		local = true |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	var acct string | 	var acct string | ||||||
| 	if local { | 	if m.TargetAccount.IsLocal() { | ||||||
| 		acct = m.TargetAccount.Username | 		acct = m.TargetAccount.Username | ||||||
| 	} else { | 	} 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{ | 	return apimodel.Mention{ | ||||||
|  | @ -476,6 +514,17 @@ func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) | ||||||
| 		return nil, err | 		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{ | 	return &apimodel.AdminEmoji{ | ||||||
| 		Emoji:         emoji, | 		Emoji:         emoji, | ||||||
| 		ID:            e.ID, | 		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) { | 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{ | 	domainBlock := &apimodel.DomainBlock{ | ||||||
| 		Domain: apimodel.Domain{ | 		Domain: apimodel.Domain{ | ||||||
| 			Domain:        b.Domain, | 			Domain:        d, | ||||||
| 			PublicComment: b.PublicComment, | 			PublicComment: b.PublicComment, | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -70,10 +70,12 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() { | 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"] | 	testEmoji := suite.testEmojis["rainbow"] | ||||||
| 
 | 
 | ||||||
| 	testAccount.Emojis = []*gtsmodel.Emoji{testEmoji} | 	testAccount.Emojis = []*gtsmodel.Emoji{testEmoji} | ||||||
|  | 	testAccount.EmojiIDs = []string{testEmoji.ID} | ||||||
| 
 | 
 | ||||||
| 	apiAccount, err := suite.typeconverter.AccountToAPIAccountPublic(context.Background(), testAccount) | 	apiAccount, err := suite.typeconverter.AccountToAPIAccountPublic(context.Background(), testAccount) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
|  | @ -210,6 +212,42 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { | ||||||
| }`, string(b)) | }`, 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() { | func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { | ||||||
| 	testStatus := suite.testStatuses["admin_account_status_1"] | 	testStatus := suite.testStatuses["admin_account_status_1"] | ||||||
| 	requestingAccount := suite.testAccounts["local_account_1"] | 	requestingAccount := suite.testAccounts["local_account_1"] | ||||||
|  |  | ||||||
|  | @ -193,11 +193,6 @@ func IsOutboxPath(id *url.URL) bool { | ||||||
| 	return regexes.OutboxPath.MatchString(id.Path) | 	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 | // IsFollowersPath returns true if the given URL path corresponds to eg /users/example_username/followers | ||||||
| func IsFollowersPath(id *url.URL) bool { | func IsFollowersPath(id *url.URL) bool { | ||||||
| 	return regexes.FollowersPath.MatchString(id.Path) | 	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 | 	var err error | ||||||
| 
 | 
 | ||||||
| 	err = validate.Username(empty) | 	err = validate.Username(empty) | ||||||
| 	if assert.Error(suite.T(), err) { | 	suite.EqualError(err, "no username provided") | ||||||
| 		assert.Equal(suite.T(), errors.New("no username provided"), err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	err = validate.Username(tooLong) | 	err = validate.Username(tooLong) | ||||||
| 	if assert.Error(suite.T(), err) { | 	suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", tooLong)) | ||||||
| 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", tooLong), err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	err = validate.Username(withSpaces) | 	err = validate.Username(withSpaces) | ||||||
| 	if assert.Error(suite.T(), err) { | 	suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", withSpaces)) | ||||||
| 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", withSpaces), err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	err = validate.Username(weirdChars) | 	err = validate.Username(weirdChars) | ||||||
| 	if assert.Error(suite.T(), err) { | 	suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", weirdChars)) | ||||||
| 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", weirdChars), err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	err = validate.Username(leadingSpace) | 	err = validate.Username(leadingSpace) | ||||||
| 	if assert.Error(suite.T(), err) { | 	suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", leadingSpace)) | ||||||
| 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", leadingSpace), err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	err = validate.Username(trailingSpace) | 	err = validate.Username(trailingSpace) | ||||||
| 	if assert.Error(suite.T(), err) { | 	suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", trailingSpace)) | ||||||
| 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", trailingSpace), err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	err = validate.Username(newlines) | 	err = validate.Username(newlines) | ||||||
| 	if assert.Error(suite.T(), err) { | 	suite.EqualError(err, fmt.Sprintf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", newlines)) | ||||||
| 		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max 64 characters", newlines), err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	err = validate.Username(goodUsername) | 	err = validate.Username(goodUsername) | ||||||
| 	if assert.NoError(suite.T(), err) { | 	suite.NoError(err) | ||||||
| 		assert.Equal(suite.T(), nil, err) |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *ValidationTestSuite) TestValidateEmail() { | func (suite *ValidationTestSuite) TestValidateEmail() { | ||||||
|  |  | ||||||
|  | @ -617,6 +617,43 @@ func NewTestAccounts() map[string]*gtsmodel.Account { | ||||||
| 			SuspensionOrigin:        "", | 			SuspensionOrigin:        "", | ||||||
| 			HeaderMediaAttachmentID: "01PFPMWK2FF0D9WMHEJHR07C3R", | 			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 | 	var accountsSorted []*gtsmodel.Account | ||||||
|  | @ -629,6 +666,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account { | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	preserializedKeys := []string{ | 	preserializedKeys := []string{ | ||||||
|  | 		"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC/4BHKpxI7X+d6MKKnZtfi8F46sujkBS4HVXP/T/HfMqnwyeTOJMkSfJEbjSeJSyqxjrWKtaeO1vnduddPAgSj9kwaZ9Drf1KZA1zBCJp4ZPqQBQCUdQrWJHw87cCEGuFXObhgCvi8mM8gfBzmF5wz/K8USy/t3GCuAWgUwupAhN40Br1SgSwMv/LI2z04yZJN98SAxKDI8aRHEWd9LnyKSR08r581JEFTcqnqR14RvhC+3nXEYzU3HMND8QLsRXQFDmjeEpwiFPSo55iOToA/fLw0OC2v1v5OwUtuwjMr1mxMGG/QPPhCT5xKxTeIEvNtCcSBO2as3yAfYrJYL/T7AgMBAAECggEBALXCitgQAANizCJB5DL0B1ohHQI57Mfj6EBmQKYAkz09/yHr/uUQj7EFc2hIBMXYAK+GYo7tmbaECtpxa3aakM7JSDpTUeNkD1iHiNwLTFj0Py8irfP0E7nbgh0tk4sQ85nvQaspeYserkc1iyKkBwJwQWHV/6cxdhwflPrl0YYfM2TiSVauB+e/H+M/TzJMCKXMiN6bavJcsJT8m6b3sI1gGFdM+vylacGmrJ0PDroiE5LkjefYe8aGr1Gi+u8yl9n4c2qAR9TltUNV2SgC02J70B+IeS12xeLXKht8ayaAOpZcmggNAOATpEAUZ3qXnWYdu8rMChoNMnwUVJx0XiECgYEA2KgoA721ORR3AyWgVyc/ByyMFS/DGMOLXKBTsiH4Tt65bA7c2UKzcHtrmGbOcEHTD8h/FKoQ8TKhPFqAERyUZ1gwy6E6yuNDZOff5+4aPOszhNwW8ty0O0SrWTOVHyXnBYFAWCbzoKrGNsfxG6T6ZXzf1IYZZuyCc+lwz+Nb++MCgYEA4rfgz3+JwUga2jwWEKiQ+Oz2vuHh8lHRtjKTLvZePKBI5lFjS5PHNhs3JfN8kzhyh87CzcHpBFyeNPmc1WYr0hOuhoVk/8NC97BKvtxokafEXDhRbFlkNsgWb+gqkYZOAih6OL8FkC3yO6hqmLyX+zbN5ke3c0b3fHI4T/3qngkCgYBTS3L23TyLEV8gCps2ZpRIwcupaY9sOeGeXtVOqti4GdDXxm8J6Cbsm8al9QBxEB2A9+hDnY6d7IUomvKZoY88nB9GalocHnuOk8b1eAkGWraX4bXA8TEpiCEITliKfRvwddyzB2aq4n0KGpyLsEXENtom7tddRphwz9LbWeHHWQKBgFuJ/LYq+5bToyvsSMhvFyG6o6HMmCr7yB21a+HxTXlTCjwcLmhMgYmiEXE8T1ct2mhlHhhvq8K8FpCzHBS5jQXkNnpQD8iIsVhKkNNhMMNmpozJnG6P5TuNLCoA5ncdcA/FAhw5XGirdHuL84Y5129x4E6TNEnSJIjVoVEC56DpAoGBAMqetUxfzx57TlZeBegIlaWYhDczB22s6YAiCurWBKOdwhGfZfUuYt5wkrfy3zi6oH2f9kxh4mq+yk7Pc8oXktk6Z1GahTjNuhHI5ESh9cX12L2RbypJwUWWfe4EfRDOdVlaOLI3ECAi8rFpoAUaZIIKzcJF46Ve9Frm+L82eH91", | ||||||
| 		"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", | ||||||
| 		"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", | ||||||
| 		"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", | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue