Instance settings updates (#59)

Allow admins to set instance settings through a PATCH to /api/v1/instance

Update templates to reflect some of the new fields
This commit is contained in:
Tobi Smethurst 2021-06-23 16:35:57 +02:00 committed by GitHub
parent fd57cca437
commit 8c9a853343
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 340 additions and 29 deletions

View file

@ -34,5 +34,6 @@ func New(config *config.Config, processor processing.Processor, log *logrus.Logg
// Route satisfies the ClientModule interface // Route satisfies the ClientModule interface
func (m *Module) Route(s router.Router) error { func (m *Module) Route(s router.Router) error {
s.AttachHandler(http.MethodGet, InstanceInformationPath, m.InstanceInformationGETHandler) s.AttachHandler(http.MethodGet, InstanceInformationPath, m.InstanceInformationGETHandler)
s.AttachHandler(http.MethodPatch, InstanceInformationPath, m.InstanceUpdatePATCHHandler)
return nil return nil
} }

View file

@ -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)
}

View file

@ -18,6 +18,8 @@
package model package model
import "mime/multipart"
// Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/ // Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/
type Instance struct { type Instance struct {
// REQUIRED // REQUIRED
@ -45,7 +47,7 @@ type Instance struct {
// URLs of interest for clients apps. // URLs of interest for clients apps.
URLS *InstanceURLs `json:"urls,omitempty"` URLS *InstanceURLs `json:"urls,omitempty"`
// Statistics about how much information the instance contains. // 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. // Banner image for the website.
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
// A user that can be contacted, as an alternative to email. // A user that can be contacted, as an alternative to email.
@ -70,3 +72,15 @@ type InstanceStats struct {
// Domains federated with this instance. // Domains federated with this instance.
DomainCount int `json:"domain_count"` 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"`
}

View file

@ -253,6 +253,14 @@ type DB interface {
// GetNotificationsForAccount returns a list of notifications that pertain to the given accountID. // GetNotificationsForAccount returns a list of notifications that pertain to the given accountID.
GetNotificationsForAccount(accountID string, limit int, maxID string, sinceID string) ([]*gtsmodel.Notification, error) 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 USEFUL CONVERSION FUNCTIONS
*/ */

View file

@ -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()
}

View file

@ -24,6 +24,8 @@ type Instance struct {
ShortDescription string ShortDescription string
// Longer description of this instance // Longer description of this instance
Description string Description string
// Terms and conditions of this instance
Terms string
// Contact email address for this instance // Contact email address for this instance
ContactEmail string ContactEmail string
// Contact account ID in the database for this instance // Contact account ID in the database for this instance

View file

@ -25,6 +25,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/util"
) )
func (p *processor) InstanceGet(domain string) (*apimodel.Instance, gtserror.WithCode) { func (p *processor) InstanceGet(domain string) (*apimodel.Instance, gtserror.WithCode) {
@ -40,3 +41,118 @@ func (p *processor) InstanceGet(domain string) (*apimodel.Instance, gtserror.Wit
return ai, nil 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
}

View file

@ -95,6 +95,10 @@ type Processor interface {
// InstanceGet retrieves instance information for serving at api/v1/instance // InstanceGet retrieves instance information for serving at api/v1/instance
InstanceGet(domain string) (*apimodel.Instance, gtserror.WithCode) 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 handles the creation of a media attachment, using the given form.
MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error)

View file

@ -511,9 +511,31 @@ func (c *converter) InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, erro
ShortDescription: i.ShortDescription, ShortDescription: i.ShortDescription,
Email: i.ContactEmail, Email: i.ContactEmail,
Version: i.Version, 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 { 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.Registrations = c.config.AccountsConfig.OpenRegistration
mi.ApprovalRequired = c.config.AccountsConfig.RequireApproval mi.ApprovalRequired = c.config.AccountsConfig.RequireApproval
mi.InvitesEnabled = false // TODO mi.InvitesEnabled = false // TODO
@ -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 // contact account is optional but let's try to get it
if i.ContactAccountID != "" { if i.ContactAccountID != "" {
ia := &gtsmodel.Account{} ia := &gtsmodel.Account{}

View file

@ -24,12 +24,7 @@ import (
) )
const ( const (
minimumPasswordEntropy = 60 // dictates password strength. See https://github.com/wagslane/go-password-validator
minimumReasonLength = 40
maximumReasonLength = 500
maximumEmailLength = 256
maximumUsernameLength = 64 maximumUsernameLength = 64
maximumPasswordLength = 64
maximumEmojiShortcodeLength = 30 maximumEmojiShortcodeLength = 30
maximumHashtagLength = 30 maximumHashtagLength = 30
) )

View file

@ -27,6 +27,17 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
) )
const (
maximumPasswordLength = 64
minimumPasswordEntropy = 60 // dictates password strength. See https://github.com/wagslane/go-password-validator
minimumReasonLength = 40
maximumReasonLength = 500
maximumSiteTitleLength = 40
maximumShortDescriptionLength = 500
maximumDescriptionLength = 5000
maximumSiteTermsLength = 5000
)
// ValidateNewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok. // ValidateNewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok.
func ValidateNewPassword(password string) error { func ValidateNewPassword(password string) error {
if password == "" { if password == "" {
@ -47,12 +58,8 @@ func ValidateUsername(username string) error {
return errors.New("no username provided") return errors.New("no username provided")
} }
if len(username) > maximumUsernameLength {
return fmt.Errorf("username should be no more than %d chars but '%s' was %d", maximumUsernameLength, username, len(username))
}
if !usernameValidationRegex.MatchString(username) { if !usernameValidationRegex.MatchString(username) {
return fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", username) return fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores, max %d characters", username, maximumUsernameLength)
} }
return nil return nil
@ -65,10 +72,6 @@ func ValidateEmail(email string) error {
return errors.New("no email provided") return errors.New("no email provided")
} }
if len(email) > maximumEmailLength {
return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", maximumEmailLength, email, len(email))
}
_, err := mail.ParseAddress(email) _, err := mail.ParseAddress(email)
return err return err
} }
@ -132,3 +135,39 @@ func ValidateEmojiShortcode(shortcode string) error {
} }
return nil return nil
} }
// ValidateSiteTitle ensures that the given site title is within spec.
func ValidateSiteTitle(siteTitle string) error {
if len(siteTitle) > maximumSiteTitleLength {
return fmt.Errorf("site title should be no more than %d chars but given title was %d", maximumSiteTitleLength, len(siteTitle))
}
return nil
}
// ValidateSiteShortDescription ensures that the given site short description is within spec.
func ValidateSiteShortDescription(d string) error {
if len(d) > maximumShortDescriptionLength {
return fmt.Errorf("short description should be no more than %d chars but given description was %d", maximumShortDescriptionLength, len(d))
}
return nil
}
// ValidateSiteDescription ensures that the given site description is within spec.
func ValidateSiteDescription(d string) error {
if len(d) > maximumDescriptionLength {
return fmt.Errorf("description should be no more than %d chars but given description was %d", maximumDescriptionLength, len(d))
}
return nil
}
// ValidateSiteTerms ensures that the given site terms string is within spec.
func ValidateSiteTerms(t string) error {
if len(t) > maximumSiteTermsLength {
return fmt.Errorf("terms should be no more than %d chars but given terms was %d", maximumSiteTermsLength, len(t))
}
return nil
}

View file

@ -5,8 +5,10 @@
<a href="https://github.com/superseriousbusiness/gotosocial">Source Code</a> <a href="https://github.com/superseriousbusiness/gotosocial">Source Code</a>
</div> </div>
<div id="contact"> <div id="contact">
Contact: <a href="/{{.instance.ContactAccount}}" class="nounderline">{{.instance.ContactAccount}}</a><br> Contact: <a href="{{.instance.ContactAccount.URL}}" class="nounderline">{{.instance.ContactAccount.Username}}</a><br>
<!-- <a href="/moderation">Moderation team</a> --> </div>
<div id="email">
Email: <a href="mailto:{{.instance.Email}}" class="nounderline">{{.instance.Email}}</a><br>
</div> </div>
</footer> </footer>
</body> </body>

View file

@ -3,24 +3,19 @@
<img src="/assets/sloth.png" alt="Clipart styled sloth logo"> <img src="/assets/sloth.png" alt="Clipart styled sloth logo">
</aside> </aside>
<section> <section>
<!-- <h1>Home to <span class="count">{ {.instance.Stats.UserCount}}</span> users <h1>Home to <span class="count">{{.instance.Stats.user_count}}</span> users
who posted <span class="count">{ {.instance.Stats.StatusCount}}</span> statuses, who posted <span class="count">{{.instance.Stats.status_count}}</span> statuses,
federating with <span class="count">{ {.instance.Stats.DomainCount}}</span> other instances.</h1> --> federating with <span class="count">{{.instance.Stats.domain_count}}</span> other instances.</h1>
<h1>Home to <span class="count">3</span> users
who posted <span class="count">42069</span> statuses,
federating with <span class="count">9001</span> other instances.</h1>
<h3>This is the default landing page, you can edit it from <span class="accent">./web/template/index.tmpl</span></h1> <h3>This is the default landing page, you can edit it from <span class="accent">./web/template/index.tmpl</span></h1>
<ul> <p>
<li>Some explanation about the instance (description) with instance header and profile images.</li> {{.instance.ShortDescription}}
<li>Instructions for registering.</li> </p>
<li>Etc.</li>
</ul>
</section> </section>
<section class="apps"> <section class="apps">
<p> <p>
GoToSocial does not provide it's own frontend, but implements the Mastodon client API. GoToSocial does not provide its own frontend, but implements the Mastodon client API.
You can use this server through a variety of clients: You can use this server through a variety of clients:
</p> </p>
<div class="applist"> <div class="applist">