diff --git a/internal/api/client/instance/instance.go b/internal/api/client/instance/instance.go new file mode 100644 index 000000000..ed7c18718 --- /dev/null +++ b/internal/api/client/instance/instance.go @@ -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 +} diff --git a/internal/api/client/instance/instanceget.go b/internal/api/client/instance/instanceget.go new file mode 100644 index 000000000..f8e82c096 --- /dev/null +++ b/internal/api/client/instance/instanceget.go @@ -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) +} diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go index 857a8acc5..75ef5392e 100644 --- a/internal/api/model/instance.go +++ b/internal/api/model/instance.go @@ -23,9 +23,9 @@ type Instance struct { // REQUIRED // The domain name of the instance. - URI string `json:"uri"` + URI string `json:"uri,omitempty"` // The title of the website. - Title string `json:"title"` + Title string `json:"title,omitempty"` // Admin-defined description of the Mastodon site. Description string `json:"description"` // A shorter description defined by the admin. @@ -33,9 +33,9 @@ type Instance struct { // An email that may be contacted for any inquiries. Email string `json:"email"` // 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. - Languages []string `json:"languages"` + Languages []string `json:"languages,omitempty"` // Whether registrations are enabled. Registrations bool `json:"registrations"` // Whether registrations require moderator approval. @@ -43,16 +43,16 @@ type Instance struct { // Whether invites are enabled. InvitesEnabled bool `json:"invites_enabled"` // URLs of interest for clients apps. - URLS *InstanceURLs `json:"urls"` + URLS *InstanceURLs `json:"urls,omitempty"` // Statistics about how much information the instance contains. - Stats *InstanceStats `json:"stats"` - - // OPTIONAL - + Stats *InstanceStats `json:"stats,omitempty"` // 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. 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/ diff --git a/internal/db/db.go b/internal/db/db.go index 3e085e180..b281dd8d7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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. 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. // 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 diff --git a/internal/db/pg.go b/internal/db/pg.go index 647285032..f59103af7 100644 --- a/internal/db/pg.go +++ b/internal/db/pg.go @@ -307,17 +307,54 @@ func (ps *postgresService) DeleteWhere(key string, value interface{}, i interfac func (ps *postgresService) CreateInstanceAccount() error { username := ps.config.Host - instanceAccount := >smodel.Account{ - Username: username, + key, err := rsa.GenerateKey(rand.Reader, 2048) + 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 := >smodel.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 { return err } 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 { - 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 := >smodel.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 } diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 0cdb29de5..6d130ed2d 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -34,6 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/app" "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" "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" "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/security" @@ -68,6 +69,7 @@ var models []interface{} = []interface{}{ >smodel.Tag{}, >smodel.User{}, >smodel.Emoji{}, + >smodel.Instance{}, &oauth.Token{}, &oauth.Client{}, } @@ -105,6 +107,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr // build client api modules authModule := auth.New(c, dbService, oauthServer, log) accountModule := account.New(c, processor, log) + instanceModule := instance.New(c, processor, log) appsModule := app.New(c, processor, log) mm := mediaModule.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 accountModule, + instanceModule, appsModule, mm, 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) } + if err := dbService.CreateInstanceInstance(); err != nil { + return fmt.Errorf("error creating instance instance: %s", err) + } + gts, err := New(dbService, router, federator, c) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) diff --git a/internal/gtsmodel/instance.go b/internal/gtsmodel/instance.go new file mode 100644 index 000000000..ac7c990e3 --- /dev/null +++ b/internal/gtsmodel/instance.go @@ -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"` +} diff --git a/internal/message/instanceprocess.go b/internal/message/instanceprocess.go new file mode 100644 index 000000000..16a5594de --- /dev/null +++ b/internal/message/instanceprocess.go @@ -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 := >smodel.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 +} diff --git a/internal/message/processor.go b/internal/message/processor.go index 2126c9597..0c0334e20 100644 --- a/internal/message/processor.go +++ b/internal/message/processor.go @@ -68,9 +68,20 @@ type Processor interface { // AccountUpdate processes the update of an account with the given form 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(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(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. @@ -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(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 These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 5118386a9..f269fa182 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -74,6 +74,9 @@ type TypeConverter interface { // VisToMasto converts a gts visibility into its mastodon equivalent 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 */ diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 66c21b98a..6b0c743ff 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -551,3 +551,33 @@ func (c *converter) VisToMasto(m gtsmodel.Visibility) model.Visibility { } 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 := >smodel.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 +}