add api/v1/instance info handler + instance model (#18)

This commit is contained in:
Tobi Smethurst 2021-05-09 14:06:06 +02:00 committed by GitHub
parent 0cbab627c7
commit 3363e0ebdd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 222 additions and 22 deletions

View file

@ -0,0 +1,38 @@
package instance
import (
"net/http"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/api"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/message"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
const (
// InstanceInformationPath
InstanceInformationPath = "api/v1/instance"
)
// Module implements the ClientModule interface
type Module struct {
config *config.Config
processor message.Processor
log *logrus.Logger
}
// New returns a new instance information module
func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
return &Module{
config: config,
processor: processor,
log: log,
}
}
// Route satisfies the ClientModule interface
func (m *Module) Route(s router.Router) error {
s.AttachHandler(http.MethodGet, InstanceInformationPath, m.InstanceInformationGETHandler)
return nil
}

View file

@ -0,0 +1,20 @@
package instance
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (m *Module) InstanceInformationGETHandler(c *gin.Context) {
l := m.log.WithField("func", "InstanceInformationGETHandler")
instance, err := m.processor.InstanceGet(m.config.Host)
if err != nil {
l.Debugf("error getting instance from processor: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, instance)
}

View file

@ -23,9 +23,9 @@ type Instance struct {
// REQUIRED // REQUIRED
// The domain name of the instance. // The domain name of the instance.
URI string `json:"uri"` URI string `json:"uri,omitempty"`
// The title of the website. // The title of the website.
Title string `json:"title"` Title string `json:"title,omitempty"`
// Admin-defined description of the Mastodon site. // Admin-defined description of the Mastodon site.
Description string `json:"description"` Description string `json:"description"`
// A shorter description defined by the admin. // A shorter description defined by the admin.
@ -33,9 +33,9 @@ type Instance struct {
// An email that may be contacted for any inquiries. // An email that may be contacted for any inquiries.
Email string `json:"email"` Email string `json:"email"`
// The version of Mastodon installed on the instance. // The version of Mastodon installed on the instance.
Version string `json:"version"` Version string `json:"version,omitempty"`
// Primary langauges of the website and its staff. // Primary langauges of the website and its staff.
Languages []string `json:"languages"` Languages []string `json:"languages,omitempty"`
// Whether registrations are enabled. // Whether registrations are enabled.
Registrations bool `json:"registrations"` Registrations bool `json:"registrations"`
// Whether registrations require moderator approval. // Whether registrations require moderator approval.
@ -43,16 +43,16 @@ type Instance struct {
// Whether invites are enabled. // Whether invites are enabled.
InvitesEnabled bool `json:"invites_enabled"` InvitesEnabled bool `json:"invites_enabled"`
// URLs of interest for clients apps. // URLs of interest for clients apps.
URLS *InstanceURLs `json:"urls"` 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"` Stats *InstanceStats `json:"stats,omitempty"`
// OPTIONAL
// Banner image for the website. // Banner image for the website.
Thumbnail string `json:"thumbnail,omitempty"` 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.
ContactAccount *Account `json:"contact_account,omitempty"` ContactAccount *Account `json:"contact_account,omitempty"`
// What's the maximum allowed length of a post on this instance?
// This is provided for compatibility with Tusky.
MaxTootChars uint `json:"max_toot_chars"`
} }
// InstanceURLs represents URLs necessary for successfully connecting to the instance as a user. See https://docs.joinmastodon.org/entities/instance/ // InstanceURLs represents URLs necessary for successfully connecting to the instance as a user. See https://docs.joinmastodon.org/entities/instance/

View file

@ -117,6 +117,11 @@ type DB interface {
// This is needed for things like serving files that belong to the instance and not an individual user/account. // This is needed for things like serving files that belong to the instance and not an individual user/account.
CreateInstanceAccount() error CreateInstanceAccount() error
// CreateInstanceInstance creates an instance in the database with the same domain as the instance host value.
// Ie., if the instance is hosted at 'example.org' the instance will have a domain of 'example.org'.
// This is needed for things like serving instance information through /api/v1/instance
CreateInstanceInstance() error
// GetAccountByUserID is a shortcut for the common action of fetching an account corresponding to a user ID. // GetAccountByUserID is a shortcut for the common action of fetching an account corresponding to a user ID.
// The given account pointer will be set to the result of the query, whatever it is. // The given account pointer will be set to the result of the query, whatever it is.
// In case of no entries, a 'no entries' error will be returned // In case of no entries, a 'no entries' error will be returned

View file

@ -307,17 +307,54 @@ func (ps *postgresService) DeleteWhere(key string, value interface{}, i interfac
func (ps *postgresService) CreateInstanceAccount() error { func (ps *postgresService) CreateInstanceAccount() error {
username := ps.config.Host username := ps.config.Host
instanceAccount := &gtsmodel.Account{ key, err := rsa.GenerateKey(rand.Reader, 2048)
Username: username, if err != nil {
ps.log.Errorf("error creating new rsa key: %s", err)
return err
} }
inserted, err := ps.conn.Model(instanceAccount).Where("username = ?", username).SelectOrInsert()
newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host)
a := &gtsmodel.Account{
Username: ps.config.Host,
DisplayName: username,
URL: newAccountURIs.UserURL,
PrivateKey: key,
PublicKey: &key.PublicKey,
PublicKeyURI: newAccountURIs.PublicKeyURI,
ActorType: gtsmodel.ActivityStreamsPerson,
URI: newAccountURIs.UserURI,
InboxURI: newAccountURIs.InboxURI,
OutboxURI: newAccountURIs.OutboxURI,
FollowersURI: newAccountURIs.FollowersURI,
FollowingURI: newAccountURIs.FollowingURI,
FeaturedCollectionURI: newAccountURIs.CollectionURI,
}
inserted, err := ps.conn.Model(a).Where("username = ?", username).SelectOrInsert()
if err != nil { if err != nil {
return err return err
} }
if inserted { if inserted {
ps.log.Infof("created instance account %s with id %s", username, instanceAccount.ID) ps.log.Infof("created instance account %s with id %s", username, a.ID)
} else { } else {
ps.log.Infof("instance account %s already exists with id %s", username, instanceAccount.ID) ps.log.Infof("instance account %s already exists with id %s", username, a.ID)
}
return nil
}
func (ps *postgresService) CreateInstanceInstance() error {
i := &gtsmodel.Instance{
Domain: ps.config.Host,
Title: ps.config.Host,
URI: fmt.Sprintf("%s://%s", ps.config.Protocol, ps.config.Host),
}
inserted, err := ps.conn.Model(i).Where("domain = ?", ps.config.Host).SelectOrInsert()
if err != nil {
return err
}
if inserted {
ps.log.Infof("created instance instance %s with id %s", ps.config.Host, i.ID)
} else {
ps.log.Infof("instance instance %s already exists with id %s", ps.config.Host, i.ID)
} }
return nil return nil
} }

View file

@ -34,6 +34,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/app" "github.com/superseriousbusiness/gotosocial/internal/api/client/app"
"github.com/superseriousbusiness/gotosocial/internal/api/client/auth" "github.com/superseriousbusiness/gotosocial/internal/api/client/auth"
"github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
"github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
"github.com/superseriousbusiness/gotosocial/internal/api/security" "github.com/superseriousbusiness/gotosocial/internal/api/security"
@ -68,6 +69,7 @@ var models []interface{} = []interface{}{
&gtsmodel.Tag{}, &gtsmodel.Tag{},
&gtsmodel.User{}, &gtsmodel.User{},
&gtsmodel.Emoji{}, &gtsmodel.Emoji{},
&gtsmodel.Instance{},
&oauth.Token{}, &oauth.Token{},
&oauth.Client{}, &oauth.Client{},
} }
@ -105,6 +107,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
// build client api modules // build client api modules
authModule := auth.New(c, dbService, oauthServer, log) authModule := auth.New(c, dbService, oauthServer, log)
accountModule := account.New(c, processor, log) accountModule := account.New(c, processor, log)
instanceModule := instance.New(c, processor, log)
appsModule := app.New(c, processor, log) appsModule := app.New(c, processor, log)
mm := mediaModule.New(c, processor, log) mm := mediaModule.New(c, processor, log)
fileServerModule := fileserver.New(c, processor, log) fileServerModule := fileserver.New(c, processor, log)
@ -119,6 +122,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
// now everything else // now everything else
accountModule, accountModule,
instanceModule,
appsModule, appsModule,
mm, mm,
fileServerModule, fileServerModule,
@ -142,6 +146,10 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
return fmt.Errorf("error creating instance account: %s", err) return fmt.Errorf("error creating instance account: %s", err)
} }
if err := dbService.CreateInstanceInstance(); err != nil {
return fmt.Errorf("error creating instance instance: %s", err)
}
gts, err := New(dbService, router, federator, c) gts, err := New(dbService, router, federator, c)
if err != nil { if err != nil {
return fmt.Errorf("error creating gotosocial service: %s", err) return fmt.Errorf("error creating gotosocial service: %s", err)

View file

@ -0,0 +1,33 @@
package gtsmodel
import "time"
// Instance represents a federated instance, either local or remote.
type Instance struct {
// ID of this instance in the database
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
// Instance domain eg example.org
Domain string `pg:",notnull,unique"`
// Title of this instance as it would like to be displayed.
Title string
// base URI of this instance eg https://example.org
URI string `pg:",notnull,unique"`
// When was this instance created in the db?
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
// When was this instance last updated in the db?
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
// When was this instance suspended, if at all?
SuspendedAt time.Time
// ID of any existing domain block for this instance in the database
DomainBlockID string
// Short description of this instance
ShortDescription string
// Longer description of this instance
Description string
// Contact email address for this instance
ContactEmail string
// Contact account ID in the database for this instance
ContactAccountID string
// Reputation score of this instance
Reputation int64 `pg:",notnull,default:0"`
}

View file

@ -0,0 +1,22 @@
package message
import (
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func (p *processor) InstanceGet(domain string) (*apimodel.Instance, ErrorWithCode) {
i := &gtsmodel.Instance{}
if err := p.db.GetWhere("domain", domain, i); err != nil {
return nil, NewErrorInternalError(fmt.Errorf("db error fetching instance %s: %s", p.config.Host, err))
}
ai, err := p.tc.InstanceToMasto(i)
if err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error converting instance to api representation: %s", err))
}
return ai, nil
}

View file

@ -68,9 +68,20 @@ type Processor interface {
// AccountUpdate processes the update of an account with the given form // AccountUpdate processes the update of an account with the given form
AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
// AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form.
AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error)
// AppCreate processes the creation of a new API application // AppCreate processes the creation of a new API application
AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error)
// InstanceGet retrieves instance information for serving at api/v1/instance
InstanceGet(domain string) (*apimodel.Instance, ErrorWithCode)
// MediaCreate handles the creation of a media attachment, using the given form.
MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error)
// MediaGet handles the fetching of a media attachment, using the given request form.
MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error)
// StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK. // StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK.
StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error)
// StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through. // StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through.
@ -86,13 +97,6 @@ type Processor interface {
// StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through. // StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through.
StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
// MediaCreate handles the creation of a media attachment, using the given form.
MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error)
// MediaGet handles the fetching of a media attachment, using the given request form.
MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error)
// AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form.
AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error)
/* /*
FEDERATION API-FACING PROCESSING FUNCTIONS FEDERATION API-FACING PROCESSING FUNCTIONS
These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply

View file

@ -74,6 +74,9 @@ type TypeConverter interface {
// VisToMasto converts a gts visibility into its mastodon equivalent // VisToMasto converts a gts visibility into its mastodon equivalent
VisToMasto(m gtsmodel.Visibility) model.Visibility VisToMasto(m gtsmodel.Visibility) model.Visibility
// InstanceToMasto converts a gts instance into its mastodon equivalent for serving at /api/v1/instance
InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, error)
/* /*
FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL
*/ */

View file

@ -551,3 +551,33 @@ func (c *converter) VisToMasto(m gtsmodel.Visibility) model.Visibility {
} }
return "" return ""
} }
func (c *converter) InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, error) {
mi := &model.Instance{
URI: i.URI,
Title: i.Title,
Description: i.Description,
ShortDescription: i.ShortDescription,
Email: i.ContactEmail,
}
if i.Domain == c.config.Host {
mi.Registrations = c.config.AccountsConfig.OpenRegistration
mi.ApprovalRequired = c.config.AccountsConfig.RequireApproval
mi.InvitesEnabled = false // TODO
mi.MaxTootChars = uint(c.config.StatusesConfig.MaxChars)
}
// contact account is optional but let's try to get it
if i.ContactAccountID != "" {
ia := &gtsmodel.Account{}
if err := c.db.GetByID(i.ContactAccountID, ia); err == nil {
ma, err := c.AccountToMastoPublic(ia)
if err == nil {
mi.ContactAccount = ma
}
}
}
return mi, nil
}