[feature] Implement /api/v2/instance endpoint (#1409)

* interim: start adding /api/v2/instance

* finish up
This commit is contained in:
tobi 2023-02-02 14:08:13 +01:00 committed by GitHub
commit 382512a5a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 1660 additions and 944 deletions

View file

@ -79,8 +79,10 @@ type TypeConverter interface {
StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error)
// VisToAPIVis converts a gts visibility into its api equivalent
VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility
// InstanceToAPIInstance converts a gts instance into its api equivalent for serving at /api/v1/instance
InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.Instance, error)
// InstanceToAPIV1Instance converts a gts instance into its api equivalent for serving at /api/v1/instance
InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error)
// InstanceToAPIV2Instance converts a gts instance into its api equivalent for serving at /api/v2/instance
InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV2, error)
// RelationshipToAPIRelationship converts a gts relationship into its api equivalent for serving in various places
RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error)
// NotificationToAPINotification converts a gts notification into a api notification

View file

@ -20,7 +20,6 @@ package typeutils
import (
"context"
"errors"
"fmt"
"math"
"strconv"
@ -43,6 +42,8 @@ const (
instanceMediaAttachmentsVideoFrameRateLimit = 60
instancePollsMinExpiration = 300 // seconds
instancePollsMaxExpiration = 2629746 // seconds
instanceAccountsMaxFeaturedTags = 10
instanceSourceURL = "https://github.com/superseriousbusiness/gotosocial"
)
func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) {
@ -675,113 +676,189 @@ func (c *converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apim
return ""
}
func (c *converter) InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.Instance, error) {
mi := &apimodel.Instance{
func (c *converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error) {
instance := &apimodel.InstanceV1{
URI: i.URI,
AccountDomain: config.GetAccountDomain(),
Title: i.Title,
Description: i.Description,
ShortDescription: i.ShortDescription,
Email: i.ContactEmail,
Version: i.Version,
Stats: make(map[string]int),
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()),
}
// if the requested instance is *this* instance, we can add some extra information
if host := config.GetHost(); i.Domain == host {
mi.AccountDomain = config.GetAccountDomain()
// configuration
instance.Configuration.Statuses.MaxCharacters = config.GetStatusesMaxChars()
instance.Configuration.Statuses.MaxMediaAttachments = config.GetStatusesMediaMaxFiles()
instance.Configuration.Statuses.CharactersReservedPerURL = instanceStatusesCharactersReservedPerURL
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())
if ia, err := c.db.GetInstanceAccount(ctx, ""); err == nil {
// assume default logo
mi.Thumbnail = config.GetProtocol() + "://" + host + "/assets/logo.png"
// URLs
instance.URLs.StreamingAPI = "wss://" + i.Domain
// take instance account avatar as instance thumbnail if we can
if ia.AvatarMediaAttachmentID != "" {
if ia.AvatarMediaAttachment == nil {
avi, err := c.db.GetAttachmentByID(ctx, ia.AvatarMediaAttachmentID)
if err == nil {
ia.AvatarMediaAttachment = avi
} else if !errors.Is(err, db.ErrNoEntries) {
log.Errorf("InstanceToAPIInstance: error getting instance avatar attachment with id %s: %s", ia.AvatarMediaAttachmentID, err)
}
}
// 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
if ia.AvatarMediaAttachment != nil {
mi.Thumbnail = ia.AvatarMediaAttachment.URL
mi.ThumbnailType = ia.AvatarMediaAttachment.File.ContentType
mi.ThumbnailDescription = ia.AvatarMediaAttachment.Description
}
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
}
userCount, err := c.db.CountInstanceUsers(ctx, host)
if err == nil {
mi.Stats["user_count"] = userCount
}
statusCount, err := c.db.CountInstanceStatuses(ctx, host)
if err == nil {
mi.Stats["status_count"] = statusCount
}
domainCount, err := c.db.CountInstanceDomains(ctx, host)
if err == nil {
mi.Stats["domain_count"] = domainCount
}
mi.Registrations = config.GetAccountsRegistrationOpen()
mi.ApprovalRequired = config.GetAccountsApprovalRequired()
mi.InvitesEnabled = false // TODO
mi.MaxTootChars = uint(config.GetStatusesMaxChars())
mi.URLS = &apimodel.InstanceURLs{
StreamingAPI: "wss://" + host,
}
mi.Version = config.GetSoftwareVersion()
// todo: remove hardcoded values and put them in config somewhere
mi.Configuration = &apimodel.InstanceConfiguration{
Statuses: &apimodel.InstanceConfigurationStatuses{
MaxCharacters: config.GetStatusesMaxChars(),
MaxMediaAttachments: config.GetStatusesMediaMaxFiles(),
CharactersReservedPerURL: instanceStatusesCharactersReservedPerURL,
},
MediaAttachments: &apimodel.InstanceConfigurationMediaAttachments{
SupportedMimeTypes: media.SupportedMIMETypes,
ImageSizeLimit: int(config.GetMediaImageMaxSize()), // bytes
ImageMatrixLimit: instanceMediaAttachmentsImageMatrixLimit, // height*width
VideoSizeLimit: int(config.GetMediaVideoMaxSize()), // bytes
VideoFrameRateLimit: instanceMediaAttachmentsVideoFrameRateLimit,
VideoMatrixLimit: instanceMediaAttachmentsVideoMatrixLimit, // height*width
},
Polls: &apimodel.InstanceConfigurationPolls{
MaxOptions: config.GetStatusesPollMaxOptions(),
MaxCharactersPerOption: config.GetStatusesPollOptionMaxChars(),
MinExpiration: instancePollsMinExpiration, // seconds
MaxExpiration: instancePollsMaxExpiration, // seconds
},
Accounts: &apimodel.InstanceConfigurationAccounts{
AllowCustomCSS: config.GetAccountsAllowCustomCSS(),
},
Emojis: &apimodel.InstanceConfigurationEmojis{
EmojiSizeLimit: int(config.GetMediaEmojiLocalMaxSize()), // bytes
},
}
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 is optional but let's try to get it
// contact account
if i.ContactAccountID != "" {
if i.ContactAccount == nil {
contactAccount, err := c.db.GetAccountByID(ctx, i.ContactAccountID)
if err == nil {
i.ContactAccount = contactAccount
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting instance contact account %s: %w", i.ContactAccountID, err)
}
i.ContactAccount = contactAccount
}
ma, err := c.AccountToAPIAccountPublic(ctx, i.ContactAccount)
if err == nil {
mi.ContactAccount = ma
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 mi, nil
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.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) {

View file

@ -24,8 +24,9 @@ import (
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type InternalToFrontendTestSuite struct {
@ -454,93 +455,207 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() {
testInstance := &gtsmodel.Instance{
CreatedAt: testrig.TimeMustParse("2021-10-20T11:36:45Z"),
UpdatedAt: testrig.TimeMustParse("2021-10-20T11:36:45Z"),
Domain: "example.org",
Title: "example instance",
URI: "https://example.org",
ShortDescription: "a little description",
Description: "a much longer description",
ContactEmail: "someone@example.org",
Version: "software-from-hell 0.666",
func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
ctx := context.Background()
i := &gtsmodel.Instance{}
if err := suite.db.GetWhere(ctx, []db.Where{{Key: "domain", Value: config.GetHost()}}, i); err != nil {
suite.FailNow(err.Error())
}
apiInstance, err := suite.typeconverter.InstanceToAPIInstance(context.Background(), testInstance)
suite.NoError(err)
instance, err := suite.typeconverter.InstanceToAPIV1Instance(ctx, i)
if err != nil {
suite.FailNow(err.Error())
}
b, err := json.MarshalIndent(apiInstance, "", " ")
b, err := json.MarshalIndent(instance, "", " ")
suite.NoError(err)
suite.Equal(`{
"uri": "https://example.org",
"title": "example instance",
"description": "a much longer description",
"short_description": "a little description",
"email": "someone@example.org",
"version": "software-from-hell 0.666",
"registrations": false,
"approval_required": false,
"uri": "http://localhost:8080",
"account_domain": "localhost:8080",
"title": "GoToSocial Testrig Instance",
"description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",
"short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",
"email": "admin@example.org",
"version": "0.0.0-testrig",
"registrations": true,
"approval_required": true,
"invites_enabled": false,
"thumbnail": "",
"max_toot_chars": 0
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestInstanceToFrontendWithAdminAccount() {
testInstance := &gtsmodel.Instance{
CreatedAt: testrig.TimeMustParse("2021-10-20T11:36:45Z"),
UpdatedAt: testrig.TimeMustParse("2021-10-20T11:36:45Z"),
Domain: "example.org",
Title: "example instance",
URI: "https://example.org",
ShortDescription: "a little description",
Description: "a much longer description",
ContactEmail: "someone@example.org",
ContactAccountID: suite.testAccounts["remote_account_2"].ID,
Version: "software-from-hell 0.666",
}
apiInstance, err := suite.typeconverter.InstanceToAPIInstance(context.Background(), testInstance)
suite.NoError(err)
b, err := json.MarshalIndent(apiInstance, "", " ")
suite.NoError(err)
suite.Equal(`{
"uri": "https://example.org",
"title": "example instance",
"description": "a much longer description",
"short_description": "a little description",
"email": "someone@example.org",
"version": "software-from-hell 0.666",
"registrations": false,
"approval_required": false,
"invites_enabled": false,
"thumbnail": "",
"configuration": {
"statuses": {
"max_characters": 5000,
"max_media_attachments": 6,
"characters_reserved_per_url": 25
},
"media_attachments": {
"supported_mime_types": [
"image/jpeg",
"image/gif",
"image/png",
"image/webp",
"video/mp4"
],
"image_size_limit": 10485760,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
"video_matrix_limit": 16777216
},
"polls": {
"max_options": 6,
"max_characters_per_option": 50,
"min_expiration": 300,
"max_expiration": 2629746
},
"accounts": {
"allow_custom_css": true,
"max_featured_tags": 10
},
"emojis": {
"emoji_size_limit": 51200
}
},
"urls": {
"streaming_api": "wss://localhost:8080"
},
"stats": {
"domain_count": 2,
"status_count": 16,
"user_count": 4
},
"thumbnail": "http://localhost:8080/assets/logo.png",
"contact_account": {
"id": "01FHMQX3GAABWSM0S2VZEC2SWC",
"username": "Some_User",
"acct": "Some_User@example.org",
"display_name": "some user",
"locked": true,
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"bot": false,
"created_at": "2020-08-10T12:13:28.000Z",
"note": "i'm a real son of a gun",
"url": "http://example.org/@Some_User",
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"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,
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": []
"fields": [],
"enable_rss": true,
"role": "admin"
},
"max_toot_chars": 0
"max_toot_chars": 5000
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
ctx := context.Background()
i := &gtsmodel.Instance{}
if err := suite.db.GetWhere(ctx, []db.Where{{Key: "domain", Value: config.GetHost()}}, i); err != nil {
suite.FailNow(err.Error())
}
instance, err := suite.typeconverter.InstanceToAPIV2Instance(ctx, i)
if err != nil {
suite.FailNow(err.Error())
}
b, err := json.MarshalIndent(instance, "", " ")
suite.NoError(err)
suite.Equal(`{
"domain": "localhost:8080",
"account_domain": "localhost:8080",
"title": "GoToSocial Testrig Instance",
"version": "0.0.0-testrig",
"source_url": "https://github.com/superseriousbusiness/gotosocial",
"description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",
"usage": {
"users": {
"active_month": 0
}
},
"thumbnail": {
"url": "http://localhost:8080/assets/logo.png"
},
"languages": [],
"configuration": {
"urls": {
"streaming": "wss://localhost:8080"
},
"accounts": {
"allow_custom_css": true,
"max_featured_tags": 10
},
"statuses": {
"max_characters": 5000,
"max_media_attachments": 6,
"characters_reserved_per_url": 25
},
"media_attachments": {
"supported_mime_types": [
"image/jpeg",
"image/gif",
"image/png",
"image/webp",
"video/mp4"
],
"image_size_limit": 10485760,
"image_matrix_limit": 16777216,
"video_size_limit": 41943040,
"video_frame_rate_limit": 60,
"video_matrix_limit": 16777216
},
"polls": {
"max_options": 6,
"max_characters_per_option": 50,
"min_expiration": 300,
"max_expiration": 2629746
},
"translation": {
"enabled": false
},
"emojis": {
"emoji_size_limit": 51200
}
},
"registrations": {
"enabled": true,
"approval_required": true,
"message": null
},
"contact": {
"email": "admin@example.org",
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"role": "admin"
}
},
"rules": []
}`, string(b))
}