gotosocial/internal/typeutils/internaltofrontend.go

1237 lines
41 KiB
Go
Raw Normal View History

/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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 typeutils
import (
"context"
"fmt"
"math"
"strconv"
"strings"
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"
[chore] use our own logging implementation (#716) * first commit Signed-off-by: kim <grufwub@gmail.com> * replace logging with our own log library Signed-off-by: kim <grufwub@gmail.com> * fix imports Signed-off-by: kim <grufwub@gmail.com> * fix log imports Signed-off-by: kim <grufwub@gmail.com> * add license text Signed-off-by: kim <grufwub@gmail.com> * fix package import cycle between config and log package Signed-off-by: kim <grufwub@gmail.com> * fix empty kv.Fields{} being passed to WithFields() Signed-off-by: kim <grufwub@gmail.com> * fix uses of log.WithFields() with whitespace issues and empty slices Signed-off-by: kim <grufwub@gmail.com> * *linter related grumbling* Signed-off-by: kim <grufwub@gmail.com> * gofmt the codebase! also fix more log.WithFields() formatting issues Signed-off-by: kim <grufwub@gmail.com> * update testrig code to match new changes Signed-off-by: kim <grufwub@gmail.com> * fix error wrapping in non fmt.Errorf function Signed-off-by: kim <grufwub@gmail.com> * add benchmarking of log.Caller() vs non-cached Signed-off-by: kim <grufwub@gmail.com> * fix syslog tests, add standard build tags to test runner to ensure consistency Signed-off-by: kim <grufwub@gmail.com> * make syslog tests more robust Signed-off-by: kim <grufwub@gmail.com> * fix caller depth arithmatic (is that how you spell it?) Signed-off-by: kim <grufwub@gmail.com> * update to use unkeyed fields in kv.Field{} instances Signed-off-by: kim <grufwub@gmail.com> * update go-kv library Signed-off-by: kim <grufwub@gmail.com> * update libraries list Signed-off-by: kim <grufwub@gmail.com> * fuck you linter get nerfed Signed-off-by: kim <grufwub@gmail.com> Co-authored-by: tobi <31960611+tsmethurst@users.noreply.github.com>
2022-07-19 08:47:55 +00:00
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
const (
instanceStatusesCharactersReservedPerURL = 25
instanceMediaAttachmentsImageMatrixLimit = 16777216 // width * height
instanceMediaAttachmentsVideoMatrixLimit = 16777216 // width * height
instanceMediaAttachmentsVideoFrameRateLimit = 60
instancePollsMinExpiration = 300 // seconds
instancePollsMaxExpiration = 2629746 // seconds
instanceAccountsMaxFeaturedTags = 10
instanceSourceURL = "https://github.com/superseriousbusiness/gotosocial"
)
var instanceStatusesSupportedMimeTypes = []string{
string(apimodel.StatusContentTypePlain),
string(apimodel.StatusContentTypeMarkdown),
}
func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) {
// we can build this sensitive account easily by first getting the public account....
apiAccount, err := c.AccountToAPIAccountPublic(ctx, a)
if err != nil {
return nil, err
}
// then adding the Source object to it...
// check pending follow requests aimed at this account
frs, err := c.db.GetAccountFollowRequests(ctx, a.ID)
if err != nil {
if err != db.ErrNoEntries {
return nil, fmt.Errorf("error getting follow requests: %s", err)
}
}
var frc int
if frs != nil {
frc = len(frs)
}
statusContentType := string(apimodel.StatusContentTypeDefault)
if a.StatusContentType != "" {
statusContentType = a.StatusContentType
}
apiAccount.Source = &apimodel.Source{
Privacy: c.VisToAPIVis(ctx, a.Privacy),
Sensitive: *a.Sensitive,
Language: a.Language,
StatusContentType: statusContentType,
[bugfix] Fix existing bio text showing as HTML (#531) * fix existing bio text showing as HTML - updated replaced mentions to include instance - strips HTML from account source note in Verify handler - update text formatter to use buffers for string writes Signed-off-by: kim <grufwub@gmail.com> * go away linter Signed-off-by: kim <grufwub@gmail.com> * change buf reset location, change html mention tags Signed-off-by: kim <grufwub@gmail.com> * reduce FindLinks code complexity Signed-off-by: kim <grufwub@gmail.com> * fix HTML to text conversion Signed-off-by: kim <grufwub@gmail.com> * Update internal/regexes/regexes.go Co-authored-by: Mina Galić <mina.galic@puppet.com> * use improved html2text lib with more options Signed-off-by: kim <grufwub@gmail.com> * fix to produce actual plaintext from html Signed-off-by: kim <grufwub@gmail.com> * fix span tags instead written as space Signed-off-by: kim <grufwub@gmail.com> * performance improvements to regex replacements, fix link replace logic for un-html-ing in the future Signed-off-by: kim <grufwub@gmail.com> * fix tag/mention replacements to use input string, fix link replace to not include scheme Signed-off-by: kim <grufwub@gmail.com> * use matched input string for link replace href text Signed-off-by: kim <grufwub@gmail.com> * remove unused code (to appease linter :sobs:) Signed-off-by: kim <grufwub@gmail.com> * improve hashtagFinger regex to be more compliant Signed-off-by: kim <grufwub@gmail.com> * update breakReplacer to include both unix and windows line endings Signed-off-by: kim <grufwub@gmail.com> * add NoteRaw field to Account to store plaintext account bio, add migration for this, set for sensitive accounts Signed-off-by: kim <grufwub@gmail.com> * drop unnecessary code Signed-off-by: kim <grufwub@gmail.com> * update text package tests to fix logic changes Signed-off-by: kim <grufwub@gmail.com> * add raw note content testing to account update and account verify Signed-off-by: kim <grufwub@gmail.com> * remove unused modules Signed-off-by: kim <grufwub@gmail.com> * fix emoji regex Signed-off-by: kim <grufwub@gmail.com> * fix replacement of hashtags Signed-off-by: kim <grufwub@gmail.com> * update code comment Signed-off-by: kim <grufwub@gmail.com> Co-authored-by: Mina Galić <mina.galic@puppet.com>
2022-05-07 15:55:27 +00:00
Note: a.NoteRaw,
Fields: apiAccount.Fields,
FollowRequestsCount: frc,
}
return apiAccount, nil
}
func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) {
// count followers
followersCount, err := c.db.CountAccountFollowedBy(ctx, a.ID, false)
if err != nil {
return nil, fmt.Errorf("error counting followers: %s", err)
}
// count following
followingCount, err := c.db.CountAccountFollows(ctx, a.ID, false)
if err != nil {
return nil, fmt.Errorf("error counting following: %s", err)
}
// count statuses
statusesCount, err := c.db.CountAccountStatuses(ctx, a.ID)
if err != nil {
return nil, fmt.Errorf("error counting statuses: %s", 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
}
2022-01-24 12:12:17 +00:00
// 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
}
2022-01-25 11:03:25 +00:00
if a.AvatarMediaAttachment != nil {
aviURL = a.AvatarMediaAttachment.URL
aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL
}
}
2022-01-24 12:12:17 +00:00
// 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
}
2022-01-25 11:03:25 +00:00
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
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
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
if a.Domain != "" {
// this is a remote user
acct = a.Username + "@" + a.Domain
} 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}
}
}
var suspended bool
if !a.SuspendedAt.IsZero() {
suspended = true
}
accountFrontend := &apimodel.Account{
ID: a.ID,
Username: a.Username,
Acct: acct,
DisplayName: a.DisplayName,
Locked: *a.Locked,
Discoverable: *a.Discoverable,
Bot: *a.Bot,
CreatedAt: util.FormatISO8601(a.CreatedAt),
Note: a.Note,
URL: a.URL,
Avatar: aviURL,
AvatarStatic: aviURLStatic,
Header: headerURL,
HeaderStatic: headerURLStatic,
FollowersCount: followersCount,
FollowingCount: followingCount,
StatusesCount: statusesCount,
LastStatusAt: lastStatusAt,
Emojis: apiEmojis,
Fields: fields,
Suspended: suspended,
CustomCSS: a.CustomCSS,
EnableRSS: *a.EnableRSS,
Role: role,
}
c.ensureAvatar(accountFrontend)
c.ensureHeader(accountFrontend)
return accountFrontend, nil
}
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 suspended bool
if !a.SuspendedAt.IsZero() {
suspended = true
}
return &apimodel.Account{
ID: a.ID,
Username: a.Username,
Acct: acct,
DisplayName: a.DisplayName,
Bot: *a.Bot,
CreatedAt: util.FormatISO8601(a.CreatedAt),
URL: a.URL,
Suspended: suspended,
}, nil
}
func (c *converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Account) (*apimodel.AdminAccountInfo, error) {
var (
email string
ip *string
domain *string
locale string
confirmed bool
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.Domain != "" {
domain = &a.Domain
} else {
user, err := c.db.GetUserByAccountID(ctx, a.ID)
if err != nil {
return nil, fmt.Errorf("AccountToAdminAPIAccount: error getting user from database for account id %s: %w", a.ID, err)
}
if user.Email != "" {
email = user.Email
} else {
email = user.UnconfirmedEmail
}
if i := user.CurrentSignInIP.String(); i != "<nil>" {
ip = &i
}
locale = user.Locale
inviteRequest = &user.Account.Reason
if *user.Admin {
role.Name = apimodel.AccountRoleAdmin
} else if *user.Moderator {
role.Name = apimodel.AccountRoleModerator
}
confirmed = !user.ConfirmedAt.IsZero()
approved = *user.Approved
disabled = *user.Disabled
silenced = !user.Account.SilencedAt.IsZero()
suspended = !user.Account.SuspendedAt.IsZero()
createdByApplicationID = user.CreatedByApplicationID
}
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)
}
return &apimodel.AdminAccountInfo{
ID: a.ID,
Username: a.Username,
Domain: domain,
CreatedAt: util.FormatISO8601(a.CreatedAt),
Email: email,
IP: ip,
IPs: []interface{}{}, // not implemented,
Locale: locale,
InviteRequest: inviteRequest,
Role: role,
Confirmed: confirmed,
Approved: approved,
Disabled: disabled,
Silenced: silenced,
Suspended: suspended,
Account: apiAccount,
CreatedByApplicationID: createdByApplicationID,
InvitedByAccountID: "", // not implemented (yet)
}, nil
}
func (c *converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Application) (*apimodel.Application, error) {
return &apimodel.Application{
ID: a.ID,
Name: a.Name,
Website: a.Website,
RedirectURI: a.RedirectURI,
ClientID: a.ClientID,
ClientSecret: a.ClientSecret,
}, nil
}
func (c *converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Application) (*apimodel.Application, error) {
return &apimodel.Application{
Name: a.Name,
Website: a.Website,
}, nil
}
func (c *converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (apimodel.Attachment, error) {
apiAttachment := apimodel.Attachment{
ID: a.ID,
Type: strings.ToLower(string(a.Type)),
TextURL: a.URL,
PreviewURL: a.Thumbnail.URL,
Meta: apimodel.MediaMeta{
Original: apimodel.MediaDimensions{
Width: a.FileMeta.Original.Width,
Height: a.FileMeta.Original.Height,
},
Small: apimodel.MediaDimensions{
Width: a.FileMeta.Small.Width,
Height: a.FileMeta.Small.Height,
Size: strconv.Itoa(a.FileMeta.Small.Width) + "x" + strconv.Itoa(a.FileMeta.Small.Height),
Aspect: float32(a.FileMeta.Small.Aspect),
},
},
Blurhash: a.Blurhash,
}
// nullable fields
if i := a.URL; i != "" {
apiAttachment.URL = &i
}
if i := a.RemoteURL; i != "" {
apiAttachment.RemoteURL = &i
}
if i := a.Thumbnail.RemoteURL; i != "" {
apiAttachment.PreviewRemoteURL = &i
}
if i := a.Description; i != "" {
apiAttachment.Description = &i
}
// type specific fields
switch a.Type {
case gtsmodel.FileTypeImage:
apiAttachment.Meta.Original.Size = strconv.Itoa(a.FileMeta.Original.Width) + "x" + strconv.Itoa(a.FileMeta.Original.Height)
apiAttachment.Meta.Original.Aspect = float32(a.FileMeta.Original.Aspect)
apiAttachment.Meta.Focus = &apimodel.MediaFocus{
X: a.FileMeta.Focus.X,
Y: a.FileMeta.Focus.Y,
}
case gtsmodel.FileTypeVideo:
if i := a.FileMeta.Original.Duration; i != nil {
apiAttachment.Meta.Original.Duration = *i
}
if i := a.FileMeta.Original.Framerate; i != nil {
// the masto api expects this as a string in
// the format `integer/1`, so 30fps is `30/1`
round := math.Round(float64(*i))
fr := strconv.FormatInt(int64(round), 10)
apiAttachment.Meta.Original.FrameRate = fr + "/1"
}
if i := a.FileMeta.Original.Bitrate; i != nil {
apiAttachment.Meta.Original.Bitrate = int(*i)
}
}
return apiAttachment, nil
}
func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention) (apimodel.Mention, error) {
if m.TargetAccount == nil {
targetAccount, err := c.db.GetAccountByID(ctx, m.TargetAccountID)
if err != nil {
return apimodel.Mention{}, err
}
m.TargetAccount = targetAccount
}
var local bool
if m.TargetAccount.Domain == "" {
local = true
}
var acct string
if local {
acct = m.TargetAccount.Username
} else {
acct = fmt.Sprintf("%s@%s", m.TargetAccount.Username, m.TargetAccount.Domain)
}
return apimodel.Mention{
ID: m.TargetAccount.ID,
Username: m.TargetAccount.Username,
URL: m.TargetAccount.URL,
Acct: acct,
}, nil
}
func (c *converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (apimodel.Emoji, error) {
var category string
if e.CategoryID != "" {
if e.Category == nil {
var err error
e.Category, err = c.db.GetEmojiCategory(ctx, e.CategoryID)
if err != nil {
return apimodel.Emoji{}, err
}
}
category = e.Category.Name
}
return apimodel.Emoji{
Shortcode: e.Shortcode,
URL: e.ImageURL,
StaticURL: e.ImageStaticURL,
VisibleInPicker: *e.VisibleInPicker,
Category: category,
}, nil
}
func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*apimodel.AdminEmoji, error) {
emoji, err := c.EmojiToAPIEmoji(ctx, e)
if err != nil {
return nil, err
}
return &apimodel.AdminEmoji{
Emoji: emoji,
ID: e.ID,
Disabled: *e.Disabled,
Domain: e.Domain,
UpdatedAt: util.FormatISO8601(e.UpdatedAt),
TotalFileSize: e.ImageFileSize + e.ImageStaticFileSize,
ContentType: e.ImageContentType,
URI: e.URI,
}, nil
}
func (c *converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, category *gtsmodel.EmojiCategory) (*apimodel.EmojiCategory, error) {
return &apimodel.EmojiCategory{
ID: category.ID,
Name: category.Name,
}, nil
}
func (c *converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (apimodel.Tag, error) {
return apimodel.Tag{
Name: t.Name,
URL: t.URL,
}, nil
}
func (c *converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) {
repliesCount, err := c.db.CountStatusReplies(ctx, s)
if err != nil {
return nil, fmt.Errorf("error counting replies: %s", err)
}
reblogsCount, err := c.db.CountStatusReblogs(ctx, s)
if err != nil {
return nil, fmt.Errorf("error counting reblogs: %s", err)
}
favesCount, err := c.db.CountStatusFaves(ctx, s)
if err != nil {
return nil, fmt.Errorf("error counting faves: %s", err)
}
var apiRebloggedStatus *apimodel.Status
2021-05-08 13:16:24 +00:00
if s.BoostOfID != "" {
// the boosted status might have been set on this struct already so check first before doing db calls
if s.BoostOf == nil {
2021-05-08 13:16:24 +00:00
// it's not set so fetch it from the db
bs, err := c.db.GetStatusByID(ctx, s.BoostOfID)
if err != nil {
2021-05-08 13:16:24 +00:00
return nil, fmt.Errorf("error getting boosted status with id %s: %s", s.BoostOfID, err)
}
s.BoostOf = bs
2021-05-08 13:16:24 +00:00
}
// the boosted account might have been set on this struct already or passed as a param so check first before doing db calls
if s.BoostOfAccount == nil {
2021-05-08 13:16:24 +00:00
// it's not set so fetch it from the db
ba, err := c.db.GetAccountByID(ctx, s.BoostOf.AccountID)
if err != nil {
return nil, fmt.Errorf("error getting boosted account %s from status with id %s: %s", s.BoostOf.AccountID, s.BoostOfID, err)
2021-05-08 13:16:24 +00:00
}
s.BoostOfAccount = ba
s.BoostOf.Account = ba
2021-05-08 13:16:24 +00:00
}
apiRebloggedStatus, err = c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount)
if err != nil {
return nil, fmt.Errorf("error converting boosted status to apitype: %s", err)
2021-05-08 13:16:24 +00:00
}
}
var apiApplication *apimodel.Application
if s.CreatedWithApplicationID != "" {
gtsApplication := &gtsmodel.Application{}
if err := c.db.GetByID(ctx, s.CreatedWithApplicationID, gtsApplication); err != nil {
return nil, fmt.Errorf("error fetching application used to create status: %s", err)
}
apiApplication, err = c.AppToAPIAppPublic(ctx, gtsApplication)
if err != nil {
return nil, fmt.Errorf("error parsing application used to create status: %s", err)
}
}
if s.Account == nil {
a, err := c.db.GetAccountByID(ctx, s.AccountID)
if err != nil {
return nil, fmt.Errorf("error getting status author: %s", err)
}
s.Account = a
}
apiAuthorAccount, err := c.AccountToAPIAccountPublic(ctx, s.Account)
if err != nil {
return nil, fmt.Errorf("error parsing account of status author: %s", err)
}
// convert status gts model attachments to frontend api model attachments
apiAttachments, err := c.convertAttachmentsToAPIAttachments(ctx, s.Attachments, s.AttachmentIDs)
if err != nil {
log.Errorf(ctx, "error converting status attachments: %v", err)
}
// convert status gts model mentions to frontend api model mentions
apiMentions, err := c.convertMentionsToAPIMentions(ctx, s.Mentions, s.MentionIDs)
if err != nil {
log.Errorf(ctx, "error converting status mentions: %v", err)
}
// convert status gts model tags to frontend api model tags
apiTags, err := c.convertTagsToAPITags(ctx, s.Tags, s.TagIDs)
if err != nil {
log.Errorf(ctx, "error converting status tags: %v", err)
}
// convert status gts model emojis to frontend api model emojis
apiEmojis, err := c.convertEmojisToAPIEmojis(ctx, s.Emojis, s.EmojiIDs)
if err != nil {
log.Errorf(ctx, "error converting status emojis: %v", err)
}
// Fetch status interaction flags for acccount
interacts, err := c.interactionsWithStatusForAccount(ctx, s, requestingAccount)
if err != nil {
log.Errorf(ctx, "error getting interactions for status %s for account %s: %v", s.ID, requestingAccount.ID, err)
// Ensure a non nil object
interacts = &statusInteractions{}
}
var language *string
if s.Language != "" {
language = &s.Language
}
apiStatus := &apimodel.Status{
ID: s.ID,
CreatedAt: util.FormatISO8601(s.CreatedAt),
InReplyToID: nil,
InReplyToAccountID: nil,
Sensitive: *s.Sensitive,
SpoilerText: s.ContentWarning,
Visibility: c.VisToAPIVis(ctx, s.Visibility),
Language: language,
URI: s.URI,
URL: s.URL,
RepliesCount: repliesCount,
ReblogsCount: reblogsCount,
FavouritesCount: favesCount,
Favourited: interacts.Faved,
Bookmarked: interacts.Bookmarked,
Muted: interacts.Muted,
Reblogged: interacts.Reblogged,
Pinned: interacts.Pinned,
Content: s.Content,
Reblog: nil,
Application: apiApplication,
Account: apiAuthorAccount,
MediaAttachments: apiAttachments,
Mentions: apiMentions,
Tags: apiTags,
Emojis: apiEmojis,
Card: nil, // TODO: implement cards
Poll: nil, // TODO: implement polls
Text: s.Text,
}
// nullable fields
if s.InReplyToID != "" {
i := s.InReplyToID
apiStatus.InReplyToID = &i
}
if s.InReplyToAccountID != "" {
i := s.InReplyToAccountID
apiStatus.InReplyToAccountID = &i
}
if apiRebloggedStatus != nil {
apiStatus.Reblog = &apimodel.StatusReblogged{Status: apiRebloggedStatus}
}
return apiStatus, nil
}
// VisToapi converts a gts visibility into its api equivalent
func (c *converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility {
switch m {
case gtsmodel.VisibilityPublic:
return apimodel.VisibilityPublic
case gtsmodel.VisibilityUnlocked:
return apimodel.VisibilityUnlisted
case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
return apimodel.VisibilityPrivate
case gtsmodel.VisibilityDirect:
return apimodel.VisibilityDirect
}
return ""
}
func (c *converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error) {
instance := &apimodel.InstanceV1{
2021-05-09 18:34:27 +00:00
URI: i.URI,
AccountDomain: config.GetAccountDomain(),
2021-05-09 18:34:27 +00:00
Title: i.Title,
Description: i.Description,
ShortDescription: i.ShortDescription,
2021-05-09 18:34:27 +00:00
Email: i.ContactEmail,
Version: config.GetSoftwareVersion(),
Languages: []string{}, // todo: not supported yet
Registrations: config.GetAccountsRegistrationOpen(),
ApprovalRequired: config.GetAccountsApprovalRequired(),
InvitesEnabled: false, // todo: not supported yet
MaxTootChars: uint(config.GetStatusesMaxChars()),
}
// configuration
instance.Configuration.Statuses.MaxCharacters = config.GetStatusesMaxChars()
instance.Configuration.Statuses.MaxMediaAttachments = config.GetStatusesMediaMaxFiles()
instance.Configuration.Statuses.CharactersReservedPerURL = instanceStatusesCharactersReservedPerURL
instance.Configuration.Statuses.SupportedMimeTypes = instanceStatusesSupportedMimeTypes
instance.Configuration.MediaAttachments.SupportedMimeTypes = media.SupportedMIMETypes
instance.Configuration.MediaAttachments.ImageSizeLimit = int(config.GetMediaImageMaxSize())
instance.Configuration.MediaAttachments.ImageMatrixLimit = instanceMediaAttachmentsImageMatrixLimit
instance.Configuration.MediaAttachments.VideoSizeLimit = int(config.GetMediaVideoMaxSize())
instance.Configuration.MediaAttachments.VideoFrameRateLimit = instanceMediaAttachmentsVideoFrameRateLimit
instance.Configuration.MediaAttachments.VideoMatrixLimit = instanceMediaAttachmentsVideoMatrixLimit
instance.Configuration.Polls.MaxOptions = config.GetStatusesPollMaxOptions()
instance.Configuration.Polls.MaxCharactersPerOption = config.GetStatusesPollOptionMaxChars()
instance.Configuration.Polls.MinExpiration = instancePollsMinExpiration
instance.Configuration.Polls.MaxExpiration = instancePollsMaxExpiration
instance.Configuration.Accounts.AllowCustomCSS = config.GetAccountsAllowCustomCSS()
instance.Configuration.Accounts.MaxFeaturedTags = instanceAccountsMaxFeaturedTags
instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize())
// URLs
instance.URLs.StreamingAPI = "wss://" + i.Domain
// statistics
stats := make(map[string]int, 3)
userCount, err := c.db.CountInstanceUsers(ctx, i.Domain)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting counting instance users: %w", err)
}
stats["user_count"] = userCount
statusCount, err := c.db.CountInstanceStatuses(ctx, i.Domain)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting counting instance statuses: %w", err)
}
stats["status_count"] = statusCount
domainCount, err := c.db.CountInstanceDomains(ctx, i.Domain)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting counting instance domains: %w", err)
}
stats["domain_count"] = domainCount
instance.Stats = stats
// thumbnail
iAccount, err := c.db.GetInstanceAccount(ctx, "")
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting instance account: %w", err)
}
if iAccount.AvatarMediaAttachmentID != "" {
if iAccount.AvatarMediaAttachment == nil {
avi, err := c.db.GetAttachmentByID(ctx, iAccount.AvatarMediaAttachmentID)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIInstance: error getting instance avatar attachment with id %s: %w", iAccount.AvatarMediaAttachmentID, err)
}
iAccount.AvatarMediaAttachment = avi
}
instance.Thumbnail = iAccount.AvatarMediaAttachment.URL
instance.ThumbnailType = iAccount.AvatarMediaAttachment.File.ContentType
instance.ThumbnailDescription = iAccount.AvatarMediaAttachment.Description
} else {
instance.Thumbnail = config.GetProtocol() + "://" + i.Domain + "/assets/logo.png" // default thumb
}
// contact account
if i.ContactAccountID != "" {
if i.ContactAccount == nil {
contactAccount, err := c.db.GetAccountByID(ctx, i.ContactAccountID)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting instance contact account %s: %w", i.ContactAccountID, err)
}
i.ContactAccount = contactAccount
}
account, err := c.AccountToAPIAccountPublic(ctx, i.ContactAccount)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: error converting instance contact account %s: %w", i.ContactAccountID, err)
}
instance.ContactAccount = account
}
return instance, nil
}
func (c *converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV2, error) {
instance := &apimodel.InstanceV2{
Domain: i.Domain,
AccountDomain: config.GetAccountDomain(),
Title: i.Title,
Version: config.GetSoftwareVersion(),
SourceURL: instanceSourceURL,
Description: i.Description,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: []string{}, // todo: not implemented
Rules: []interface{}{}, // todo: not implemented
}
// thumbnail
thumbnail := apimodel.InstanceV2Thumbnail{}
iAccount, err := c.db.GetInstanceAccount(ctx, "")
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV2Instance: db error getting instance account: %w", err)
}
if iAccount.AvatarMediaAttachmentID != "" {
if iAccount.AvatarMediaAttachment == nil {
avi, err := c.db.GetAttachmentByID(ctx, iAccount.AvatarMediaAttachmentID)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV2Instance: error getting instance avatar attachment with id %s: %w", iAccount.AvatarMediaAttachmentID, err)
}
iAccount.AvatarMediaAttachment = avi
}
thumbnail.URL = iAccount.AvatarMediaAttachment.URL
thumbnail.Type = iAccount.AvatarMediaAttachment.File.ContentType
thumbnail.Description = iAccount.AvatarMediaAttachment.Description
thumbnail.Blurhash = iAccount.AvatarMediaAttachment.Blurhash
} else {
thumbnail.URL = config.GetProtocol() + "://" + i.Domain + "/assets/logo.png" // default thumb
}
instance.Thumbnail = thumbnail
// configuration
instance.Configuration.URLs.Streaming = "wss://" + i.Domain
instance.Configuration.Statuses.MaxCharacters = config.GetStatusesMaxChars()
instance.Configuration.Statuses.MaxMediaAttachments = config.GetStatusesMediaMaxFiles()
instance.Configuration.Statuses.CharactersReservedPerURL = instanceStatusesCharactersReservedPerURL
instance.Configuration.Statuses.SupportedMimeTypes = instanceStatusesSupportedMimeTypes
instance.Configuration.MediaAttachments.SupportedMimeTypes = media.SupportedMIMETypes
instance.Configuration.MediaAttachments.ImageSizeLimit = int(config.GetMediaImageMaxSize())
instance.Configuration.MediaAttachments.ImageMatrixLimit = instanceMediaAttachmentsImageMatrixLimit
instance.Configuration.MediaAttachments.VideoSizeLimit = int(config.GetMediaVideoMaxSize())
instance.Configuration.MediaAttachments.VideoFrameRateLimit = instanceMediaAttachmentsVideoFrameRateLimit
instance.Configuration.MediaAttachments.VideoMatrixLimit = instanceMediaAttachmentsVideoMatrixLimit
instance.Configuration.Polls.MaxOptions = config.GetStatusesPollMaxOptions()
instance.Configuration.Polls.MaxCharactersPerOption = config.GetStatusesPollOptionMaxChars()
instance.Configuration.Polls.MinExpiration = instancePollsMinExpiration
instance.Configuration.Polls.MaxExpiration = instancePollsMaxExpiration
instance.Configuration.Accounts.AllowCustomCSS = config.GetAccountsAllowCustomCSS()
instance.Configuration.Accounts.MaxFeaturedTags = instanceAccountsMaxFeaturedTags
instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize())
// registrations
instance.Registrations.Enabled = config.GetAccountsRegistrationOpen()
instance.Registrations.ApprovalRequired = config.GetAccountsApprovalRequired()
instance.Registrations.Message = nil // todo: not implemented
// contact
instance.Contact.Email = i.ContactEmail
if i.ContactAccountID != "" {
if i.ContactAccount == nil {
contactAccount, err := c.db.GetAccountByID(ctx, i.ContactAccountID)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV2Instance: db error getting instance contact account %s: %w", i.ContactAccountID, err)
}
i.ContactAccount = contactAccount
}
account, err := c.AccountToAPIAccountPublic(ctx, i.ContactAccount)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV2Instance: error converting instance contact account %s: %w", i.ContactAccountID, err)
}
instance.Contact.Account = account
}
return instance, nil
}
func (c *converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error) {
return &apimodel.Relationship{
ID: r.ID,
Following: r.Following,
ShowingReblogs: r.ShowingReblogs,
Notifying: r.Notifying,
FollowedBy: r.FollowedBy,
Blocking: r.Blocking,
BlockedBy: r.BlockedBy,
Muting: r.Muting,
MutingNotifications: r.MutingNotifications,
Requested: r.Requested,
DomainBlocking: r.DomainBlocking,
Endorsed: r.Endorsed,
Note: r.Note,
}, nil
}
func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) {
if n.TargetAccount == nil {
tAccount, err := c.db.GetAccountByID(ctx, n.TargetAccountID)
if err != nil {
return nil, fmt.Errorf("NotificationToapi: error getting target account with id %s from the db: %s", n.TargetAccountID, err)
}
n.TargetAccount = tAccount
}
if n.OriginAccount == nil {
ogAccount, err := c.db.GetAccountByID(ctx, n.OriginAccountID)
if err != nil {
return nil, fmt.Errorf("NotificationToapi: error getting origin account with id %s from the db: %s", n.OriginAccountID, err)
}
n.OriginAccount = ogAccount
}
apiAccount, err := c.AccountToAPIAccountPublic(ctx, n.OriginAccount)
if err != nil {
return nil, fmt.Errorf("NotificationToapi: error converting account to api: %s", err)
}
var apiStatus *apimodel.Status
if n.StatusID != "" {
if n.Status == nil {
status, err := c.db.GetStatusByID(ctx, n.StatusID)
if err != nil {
return nil, fmt.Errorf("NotificationToapi: error getting status with id %s from the db: %s", n.StatusID, err)
}
n.Status = status
}
if n.Status.Account == nil {
if n.Status.AccountID == n.TargetAccount.ID {
n.Status.Account = n.TargetAccount
} else if n.Status.AccountID == n.OriginAccount.ID {
n.Status.Account = n.OriginAccount
}
}
var err error
apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount)
if err != nil {
return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", err)
}
}
if apiStatus != nil && apiStatus.Reblog != nil {
// use the actual reblog status for the notifications endpoint
apiStatus = apiStatus.Reblog.Status
}
return &apimodel.Notification{
ID: n.ID,
Type: string(n.NotificationType),
CreatedAt: util.FormatISO8601(n.CreatedAt),
Account: apiAccount,
Status: apiStatus,
}, nil
}
func (c *converter) DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) {
domainBlock := &apimodel.DomainBlock{
Domain: apimodel.Domain{
Domain: b.Domain,
PublicComment: b.PublicComment,
},
}
// if we're exporting a domain block, return it with minimal information attached
if !export {
domainBlock.ID = b.ID
domainBlock.Obfuscate = *b.Obfuscate
domainBlock.PrivateComment = b.PrivateComment
domainBlock.SubscriptionID = b.SubscriptionID
domainBlock.CreatedBy = b.CreatedByAccountID
domainBlock.CreatedAt = util.FormatISO8601(b.CreatedAt)
}
return domainBlock, nil
}
func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) {
report := &apimodel.Report{
ID: r.ID,
CreatedAt: util.FormatISO8601(r.CreatedAt),
ActionTaken: !r.ActionTakenAt.IsZero(),
Category: "other", // todo: only support default 'other' category right now
Comment: r.Comment,
Forwarded: *r.Forwarded,
StatusIDs: r.StatusIDs,
RuleIDs: []int{}, // todo: not supported yet
}
if !r.ActionTakenAt.IsZero() {
actionTakenAt := util.FormatISO8601(r.ActionTakenAt)
report.ActionTakenAt = &actionTakenAt
}
if actionComment := r.ActionTaken; actionComment != "" {
report.ActionTakenComment = &actionComment
}
if r.TargetAccount == nil {
tAccount, err := c.db.GetAccountByID(ctx, r.TargetAccountID)
if err != nil {
return nil, fmt.Errorf("ReportToAPIReport: error getting target account with id %s from the db: %s", r.TargetAccountID, err)
}
r.TargetAccount = tAccount
}
apiAccount, err := c.AccountToAPIAccountPublic(ctx, r.TargetAccount)
if err != nil {
return nil, fmt.Errorf("ReportToAPIReport: error converting target account to api: %s", err)
}
report.TargetAccount = apiAccount
return report, nil
}
func (c *converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Report, requestingAccount *gtsmodel.Account) (*apimodel.AdminReport, error) {
var (
err error
actionTakenAt *string
actionTakenComment *string
actionTakenByAccount *apimodel.AdminAccountInfo
)
if !r.ActionTakenAt.IsZero() {
ata := util.FormatISO8601(r.ActionTakenAt)
actionTakenAt = &ata
}
if r.Account == nil {
r.Account, err = c.db.GetAccountByID(ctx, r.AccountID)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting account with id %s from the db: %w", r.AccountID, err)
}
}
account, err := c.AccountToAdminAPIAccount(ctx, r.Account)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting account with id %s to adminAPIAccount: %w", r.AccountID, err)
}
if r.TargetAccount == nil {
r.TargetAccount, err = c.db.GetAccountByID(ctx, r.TargetAccountID)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting target account with id %s from the db: %w", r.TargetAccountID, err)
}
}
targetAccount, err := c.AccountToAdminAPIAccount(ctx, r.TargetAccount)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting target account with id %s to adminAPIAccount: %w", r.TargetAccountID, err)
}
if r.ActionTakenByAccountID != "" {
if r.ActionTakenByAccount == nil {
r.ActionTakenByAccount, err = c.db.GetAccountByID(ctx, r.ActionTakenByAccountID)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting action taken by account with id %s from the db: %w", r.ActionTakenByAccountID, err)
}
}
actionTakenByAccount, err = c.AccountToAdminAPIAccount(ctx, r.ActionTakenByAccount)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting action taken by account with id %s to adminAPIAccount: %w", r.ActionTakenByAccountID, err)
}
}
statuses := make([]*apimodel.Status, 0, len(r.StatusIDs))
if len(r.StatusIDs) != 0 && len(r.Statuses) == 0 {
r.Statuses, err = c.db.GetStatuses(ctx, r.StatusIDs)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting statuses from the db: %w", err)
}
}
for _, s := range r.Statuses {
status, err := c.StatusToAPIStatus(ctx, s, requestingAccount)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
}
statuses = append(statuses, status)
}
if ac := r.ActionTaken; ac != "" {
actionTakenComment = &ac
}
return &apimodel.AdminReport{
ID: r.ID,
ActionTaken: !r.ActionTakenAt.IsZero(),
ActionTakenAt: actionTakenAt,
Category: "other", // todo: only support default 'other' category right now
Comment: r.Comment,
Forwarded: *r.Forwarded,
CreatedAt: util.FormatISO8601(r.CreatedAt),
UpdatedAt: util.FormatISO8601(r.UpdatedAt),
Account: account,
TargetAccount: targetAccount,
AssignedAccount: actionTakenByAccount,
ActionTakenByAccount: actionTakenByAccount,
ActionTakenComment: actionTakenComment,
Statuses: statuses,
Rules: []interface{}{}, // not implemented
}, nil
}
// convertAttachmentsToAPIAttachments will convert a slice of GTS model attachments to frontend API model attachments, falling back to IDs if no GTS models supplied.
func (c *converter) convertAttachmentsToAPIAttachments(ctx context.Context, attachments []*gtsmodel.MediaAttachment, attachmentIDs []string) ([]apimodel.Attachment, error) {
var errs gtserror.MultiError
if len(attachments) == 0 {
// GTS model attachments were not populated
// Preallocate expected GTS slice
attachments = make([]*gtsmodel.MediaAttachment, 0, len(attachmentIDs))
// Fetch GTS models for attachment IDs
for _, id := range attachmentIDs {
attachment, err := c.db.GetAttachmentByID(ctx, id)
if err != nil {
errs.Appendf("error fetching attachment %s from database: %v", id, err)
continue
}
attachments = append(attachments, attachment)
}
}
// Preallocate expected frontend slice
apiAttachments := make([]apimodel.Attachment, 0, len(attachments))
// Convert GTS models to frontend models
for _, attachment := range attachments {
apiAttachment, err := c.AttachmentToAPIAttachment(ctx, attachment)
if err != nil {
errs.Appendf("error converting attchment %s to api attachment: %v", attachment.ID, err)
continue
}
apiAttachments = append(apiAttachments, apiAttachment)
}
return apiAttachments, errs.Combine()
}
// convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied.
func (c *converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsmodel.Emoji, emojiIDs []string) ([]apimodel.Emoji, error) {
var errs gtserror.MultiError
if len(emojis) == 0 {
// GTS model attachments were not populated
// Preallocate expected GTS slice
emojis = make([]*gtsmodel.Emoji, 0, len(emojiIDs))
// Fetch GTS models for emoji IDs
for _, id := range emojiIDs {
emoji, err := c.db.GetEmojiByID(ctx, id)
if err != nil {
errs.Appendf("error fetching emoji %s from database: %v", id, err)
continue
}
emojis = append(emojis, emoji)
}
}
// Preallocate expected frontend slice
apiEmojis := make([]apimodel.Emoji, 0, len(emojis))
// Convert GTS models to frontend models
for _, emoji := range emojis {
apiEmoji, err := c.EmojiToAPIEmoji(ctx, emoji)
if err != nil {
errs.Appendf("error converting emoji %s to api emoji: %v", emoji.ID, err)
continue
}
apiEmojis = append(apiEmojis, apiEmoji)
}
return apiEmojis, errs.Combine()
}
// convertMentionsToAPIMentions will convert a slice of GTS model mentions to frontend API model mentions, falling back to IDs if no GTS models supplied.
func (c *converter) convertMentionsToAPIMentions(ctx context.Context, mentions []*gtsmodel.Mention, mentionIDs []string) ([]apimodel.Mention, error) {
var errs gtserror.MultiError
if len(mentions) == 0 {
var err error
// GTS model mentions were not populated
//
// Fetch GTS models for mention IDs
mentions, err = c.db.GetMentions(ctx, mentionIDs)
if err != nil {
errs.Appendf("error fetching mentions from database: %v", err)
}
}
// Preallocate expected frontend slice
apiMentions := make([]apimodel.Mention, 0, len(mentions))
// Convert GTS models to frontend models
for _, mention := range mentions {
apiMention, err := c.MentionToAPIMention(ctx, mention)
if err != nil {
errs.Appendf("error converting mention %s to api mention: %v", mention.ID, err)
continue
}
apiMentions = append(apiMentions, apiMention)
}
return apiMentions, errs.Combine()
}
// convertTagsToAPITags will convert a slice of GTS model tags to frontend API model tags, falling back to IDs if no GTS models supplied.
func (c *converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.Tag, tagIDs []string) ([]apimodel.Tag, error) {
var errs gtserror.MultiError
if len(tags) == 0 {
// GTS model tags were not populated
// Preallocate expected GTS slice
tags = make([]*gtsmodel.Tag, 0, len(tagIDs))
// Fetch GTS models for tag IDs
for _, id := range tagIDs {
tag := new(gtsmodel.Tag)
if err := c.db.GetByID(ctx, id, tag); err != nil {
errs.Appendf("error fetching tag %s from database: %v", id, err)
continue
}
tags = append(tags, tag)
}
}
// Preallocate expected frontend slice
apiTags := make([]apimodel.Tag, 0, len(tags))
// Convert GTS models to frontend models
for _, tag := range tags {
apiTag, err := c.TagToAPITag(ctx, tag)
if err != nil {
errs.Appendf("error converting tag %s to api tag: %v", tag.ID, err)
continue
}
apiTags = append(apiTags, apiTag)
}
return apiTags, errs.Combine()
}