Skip to content

Commit

Permalink
Instance settings updates (#59)
Browse files Browse the repository at this point in the history
Allow admins to set instance settings through a PATCH to /api/v1/instance

Update templates to reflect some of the new fields
  • Loading branch information
tsmethurst committed Jun 23, 2021
1 parent fd57cca commit 8c9a853
Show file tree
Hide file tree
Showing 13 changed files with 340 additions and 29 deletions.
1 change: 1 addition & 0 deletions internal/api/client/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ func New(config *config.Config, processor processing.Processor, log *logrus.Logg
// Route satisfies the ClientModule interface
func (m *Module) Route(s router.Router) error {
s.AttachHandler(http.MethodGet, InstanceInformationPath, m.InstanceInformationGETHandler)
s.AttachHandler(http.MethodPatch, InstanceInformationPath, m.InstanceUpdatePATCHHandler)
return nil
}
50 changes: 50 additions & 0 deletions internal/api/client/instance/instancepatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package instance

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)

func (m *Module) InstanceUpdatePATCHHandler(c *gin.Context) {
l := m.log.WithField("func", "InstanceUpdatePATCHHandler")
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
l.Debugf("couldn't auth: %s", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}

// only admins can update instance settings
if !authed.User.Admin {
l.Debug("user is not an admin so cannot update instance settings")
c.JSON(http.StatusUnauthorized, gin.H{"error": "not an admin"})
return
}

l.Debugf("parsing request form %s", c.Request.Form)
form := &model.InstanceSettingsUpdateRequest{}
if err := c.ShouldBind(&form); err != nil || form == nil {
l.Debugf("could not parse form from request: %s", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// if everything on the form is nil, then nothing has been set and we shouldn't continue
if form.SiteTitle == nil && form.SiteContactUsername == nil && form.SiteContactEmail == nil && form.SiteShortDescription == nil && form.SiteDescription == nil && form.SiteTerms == nil && form.Avatar == nil && form.Header == nil {
l.Debugf("could not parse form from request")
c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"})
return
}

i, errWithCode := m.processor.InstancePatch(form)
if errWithCode != nil {
l.Debugf("error with instance patch request: %s", errWithCode.Error())
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
return
}

c.JSON(http.StatusOK, i)
}
16 changes: 15 additions & 1 deletion internal/api/model/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

package model

import "mime/multipart"

// Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/
type Instance struct {
// REQUIRED
Expand Down Expand Up @@ -45,7 +47,7 @@ type Instance struct {
// URLs of interest for clients apps.
URLS *InstanceURLs `json:"urls,omitempty"`
// Statistics about how much information the instance contains.
Stats *InstanceStats `json:"stats,omitempty"`
Stats map[string]int `json:"stats,omitempty"`
// Banner image for the website.
Thumbnail string `json:"thumbnail"`
// A user that can be contacted, as an alternative to email.
Expand All @@ -70,3 +72,15 @@ type InstanceStats struct {
// Domains federated with this instance.
DomainCount int `json:"domain_count"`
}

// InstanceSettingsUpdateRequest is the form to be parsed on a PATCH to /api/v1/instance
type InstanceSettingsUpdateRequest struct {
SiteTitle *string `form:"site_title" json:"site_title" xml:"site_title"`
SiteContactUsername *string `form:"site_contact_username" json:"site_contact_username" xml:"site_contact_username"`
SiteContactEmail *string `form:"site_contact_email" json:"site_contact_email" xml:"site_contact_email"`
SiteShortDescription *string `form:"site_short_description" json:"site_short_description" xml:"site_short_description"`
SiteDescription *string `form:"site_description" json:"site_description" xml:"site_description"`
SiteTerms *string `form:"site_terms" json:"site_terms" xml:"site_terms"`
Avatar *multipart.FileHeader `form:"avatar" json:"avatar" xml:"avatar"`
Header *multipart.FileHeader `form:"header" json:"header" xml:"header"`
}
8 changes: 8 additions & 0 deletions internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,14 @@ type DB interface {
// GetNotificationsForAccount returns a list of notifications that pertain to the given accountID.
GetNotificationsForAccount(accountID string, limit int, maxID string, sinceID string) ([]*gtsmodel.Notification, error)

// GetUserCountForInstance returns the number of known accounts registered with the given domain.
GetUserCountForInstance(domain string) (int, error)

// GetStatusCountForInstance returns the number of known statuses posted from the given domain.
GetStatusCountForInstance(domain string) (int, error)

// GetDomainCountForInstance returns the number of known instances known that the given domain federates with.
GetDomainCountForInstance(domain string) (int, error)
/*
USEFUL CONVERSION FUNCTIONS
*/
Expand Down
52 changes: 52 additions & 0 deletions internal/db/pg/instancestats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package pg

import (
"github.com/go-pg/pg/v10"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)

func (ps *postgresService) GetUserCountForInstance(domain string) (int, error) {
q := ps.conn.Model(&[]*gtsmodel.Account{})

if domain == ps.config.Host {
// if the domain is *this* domain, just count where the domain field is null
q = q.Where("? IS NULL", pg.Ident("domain"))
} else {
q = q.Where("domain = ?", domain)
}

// don't count the instance account or suspended users
q = q.Where("username != ?", domain).Where("? IS NULL", pg.Ident("suspended_at"))

return q.Count()
}

func (ps *postgresService) GetStatusCountForInstance(domain string) (int, error) {
q := ps.conn.Model(&[]*gtsmodel.Status{})

if domain == ps.config.Host {
// if the domain is *this* domain, just count where local is true
q = q.Where("local = ?", true)
} else {
// join on the domain of the account
q = q.Join("JOIN accounts AS account ON account.id = status.account_id").
Where("account.domain = ?", domain)
}

return q.Count()
}

func (ps *postgresService) GetDomainCountForInstance(domain string) (int, error) {
q := ps.conn.Model(&[]*gtsmodel.Instance{})

if domain == ps.config.Host {
// if the domain is *this* domain, just count other instances it knows about
// TODO: exclude domains that are blocked or silenced
q = q.Where("domain != ?", domain)
} else {
// TODO: implement federated domain counting properly for remote domains
return 0, nil
}

return q.Count()
}
2 changes: 2 additions & 0 deletions internal/gtsmodel/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type Instance struct {
ShortDescription string
// Longer description of this instance
Description string
// Terms and conditions of this instance
Terms string
// Contact email address for this instance
ContactEmail string
// Contact account ID in the database for this instance
Expand Down
116 changes: 116 additions & 0 deletions internal/processing/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)

func (p *processor) InstanceGet(domain string) (*apimodel.Instance, gtserror.WithCode) {
Expand All @@ -40,3 +41,118 @@ func (p *processor) InstanceGet(domain string) (*apimodel.Instance, gtserror.Wit

return ai, nil
}

func (p *processor) InstancePatch(form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.Instance, gtserror.WithCode) {
// fetch the instance entry from the db for processing
i := &gtsmodel.Instance{}
if err := p.db.GetWhere([]db.Where{{Key: "domain", Value: p.config.Host}}, i); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance %s: %s", p.config.Host, err))
}

// fetch the instance account from the db for processing
ia := &gtsmodel.Account{}
if err := p.db.GetLocalAccountByUsername(p.config.Host, ia); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance account %s: %s", p.config.Host, err))
}

// validate & update site title if it's set on the form
if form.SiteTitle != nil {
if err := util.ValidateSiteTitle(*form.SiteTitle); err != nil {
return nil, gtserror.NewErrorBadRequest(err, fmt.Sprintf("site title invalid: %s", err))
}
i.Title = *form.SiteTitle
}

// validate & update site contact account if it's set on the form
if form.SiteContactUsername != nil {
// make sure the account with the given username exists in the db
contactAccount := &gtsmodel.Account{}
if err := p.db.GetLocalAccountByUsername(*form.SiteContactUsername, contactAccount); err != nil {
return nil, gtserror.NewErrorBadRequest(err, fmt.Sprintf("account with username %s not retrievable", *form.SiteContactUsername))
}
// make sure it has a user associated with it
contactUser := &gtsmodel.User{}
if err := p.db.GetWhere([]db.Where{{Key: "account_id", Value: contactAccount.ID}}, contactUser); err != nil {
return nil, gtserror.NewErrorBadRequest(err, fmt.Sprintf("user for account with username %s not retrievable", *form.SiteContactUsername))
}
// suspended accounts cannot be contact accounts
if !contactAccount.SuspendedAt.IsZero() {
err := fmt.Errorf("selected contact account %s is suspended", contactAccount.Username)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// unconfirmed or unapproved users cannot be contacts
if contactUser.ConfirmedAt.IsZero() {
err := fmt.Errorf("user of selected contact account %s is not confirmed", contactAccount.Username)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
if !contactUser.Approved {
err := fmt.Errorf("user of selected contact account %s is not approved", contactAccount.Username)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// contact account user must be admin or moderator otherwise what's the point of contacting them
if !contactUser.Admin && !contactUser.Moderator {
err := fmt.Errorf("user of selected contact account %s is neither admin nor moderator", contactAccount.Username)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
i.ContactAccountID = contactAccount.ID
}

// validate & update site contact email if it's set on the form
if form.SiteContactEmail != nil {
if err := util.ValidateEmail(*form.SiteContactEmail); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
i.ContactEmail = *form.SiteContactEmail
}

// validate & update site short description if it's set on the form
if form.SiteShortDescription != nil {
if err := util.ValidateSiteShortDescription(*form.SiteShortDescription); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
i.ShortDescription = *form.SiteShortDescription
}

// validate & update site description if it's set on the form
if form.SiteDescription != nil {
if err := util.ValidateSiteDescription(*form.SiteDescription); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
i.Description = *form.SiteDescription
}

// validate & update site terms if it's set on the form
if form.SiteTerms != nil {
if err := util.ValidateSiteTerms(*form.SiteTerms); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
i.Terms = *form.SiteTerms
}

// process avatar if provided
if form.Avatar != nil && form.Avatar.Size != 0 {
_, err := p.updateAccountAvatar(form.Avatar, ia.ID)
if err != nil {
return nil, gtserror.NewErrorBadRequest(err, "error processing avatar")
}
}

// process header if provided
if form.Header != nil && form.Header.Size != 0 {
_, err := p.updateAccountHeader(form.Header, ia.ID)
if err != nil {
return nil, gtserror.NewErrorBadRequest(err, "error processing header")
}
}

if err := p.db.UpdateByID(i.ID, i); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error updating instance %s: %s", p.config.Host, err))
}

ai, err := p.tc.InstanceToMasto(i)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting instance to api representation: %s", err))
}

return ai, nil
}
4 changes: 4 additions & 0 deletions internal/processing/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ type Processor interface {

// InstanceGet retrieves instance information for serving at api/v1/instance
InstanceGet(domain string) (*apimodel.Instance, gtserror.WithCode)
// InstancePatch updates this instance according to the given form.
//
// It should already be ascertained that the requesting account is authenticated and an admin.
InstancePatch(form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.Instance, gtserror.WithCode)

// MediaCreate handles the creation of a media attachment, using the given form.
MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error)
Expand Down
33 changes: 33 additions & 0 deletions internal/typeutils/internaltofrontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,31 @@ func (c *converter) InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, erro
ShortDescription: i.ShortDescription,
Email: i.ContactEmail,
Version: i.Version,
Stats: make(map[string]int),
ContactAccount: &model.Account{},
}

// if the requested instance is *this* instance, we can add some extra information
if i.Domain == c.config.Host {
userCountKey := "user_count"
statusCountKey := "status_count"
domainCountKey := "domain_count"

userCount, err := c.db.GetUserCountForInstance(c.config.Host)
if err == nil {
mi.Stats[userCountKey] = userCount
}

statusCount, err := c.db.GetStatusCountForInstance(c.config.Host)
if err == nil {
mi.Stats[statusCountKey] = statusCount
}

domainCount, err := c.db.GetDomainCountForInstance(c.config.Host)
if err == nil {
mi.Stats[domainCountKey] = domainCount
}

mi.Registrations = c.config.AccountsConfig.OpenRegistration
mi.ApprovalRequired = c.config.AccountsConfig.RequireApproval
mi.InvitesEnabled = false // TODO
Expand All @@ -523,6 +545,17 @@ func (c *converter) InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, erro
}
}

// get the instance account if it exists and just skip if it doesn't
ia := &gtsmodel.Account{}
if err := c.db.GetWhere([]db.Where{{Key: "username", Value: i.Domain}}, ia); err == nil {
// instance account exists, get the header for the account if it exists
attachment := &gtsmodel.MediaAttachment{}
if err := c.db.GetHeaderForAccountID(attachment, ia.ID); err == nil {
// header exists, set it on the api model
mi.Thumbnail = attachment.URL
}
}

// contact account is optional but let's try to get it
if i.ContactAccountID != "" {
ia := &gtsmodel.Account{}
Expand Down
5 changes: 0 additions & 5 deletions internal/util/regexes.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,7 @@ import (
)

const (
minimumPasswordEntropy = 60 // dictates password strength. See https://github.com/wagslane/go-password-validator
minimumReasonLength = 40
maximumReasonLength = 500
maximumEmailLength = 256
maximumUsernameLength = 64
maximumPasswordLength = 64
maximumEmojiShortcodeLength = 30
maximumHashtagLength = 30
)
Expand Down
Loading

0 comments on commit 8c9a853

Please sign in to comment.