From cc48294c31a76e94fa879ad0d8d5dbd7e94c651b Mon Sep 17 00:00:00 2001
From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date: Sat, 15 May 2021 11:58:11 +0200
Subject: Inbox post (#22)
Inbox POST from federated servers now working for statuses and follow requests.
Follow request client API added.
Start work on federating outgoing messages.
Other fixes and changes/tidying up.
---
internal/api/client/auth/auth_test.go | 3 +-
internal/api/client/auth/middleware.go | 3 +-
internal/api/client/auth/token.go | 30 +
internal/api/client/followrequest/accept.go | 57 +
internal/api/client/followrequest/deny.go | 27 +
internal/api/client/followrequest/followrequest.go | 68 ++
internal/api/client/followrequest/get.go | 51 +
internal/api/client/instance/instance.go | 2 +-
internal/api/client/instance/instanceget.go | 1 +
internal/api/client/media/media.go | 2 +
internal/api/client/media/mediacreate.go | 20 +-
internal/api/client/media/mediaget.go | 2 +-
internal/api/model/application.go | 8 +-
internal/api/model/attachment.go | 12 +-
internal/api/model/status.go | 4 +
internal/api/s2s/user/inboxpost.go | 58 +
internal/api/s2s/user/user.go | 3 +
internal/api/s2s/webfinger/webfinger.go | 2 +-
internal/api/security/extraheaders.go | 8 +
internal/api/security/security.go | 1 +
internal/db/db.go | 14 +-
internal/db/federating_db.go | 359 -------
internal/db/federating_db_test.go | 21 -
internal/db/pg.go | 1089 -------------------
internal/db/pg/pg.go | 1127 ++++++++++++++++++++
internal/db/pg_test.go | 21 -
internal/federation/clock.go | 1 +
internal/federation/commonbehavior.go | 6 +-
internal/federation/federating_db.go | 599 +++++++++++
internal/federation/federating_db_test.go | 21 +
internal/federation/federatingactor.go | 14 +
internal/federation/federatingprotocol.go | 154 ++-
internal/gotosocial/actions.go | 7 +-
internal/gtsmodel/account.go | 6 +-
internal/gtsmodel/mention.go | 16 +-
internal/gtsmodel/status.go | 18 +-
internal/gtsmodel/tag.go | 2 +
internal/media/media_test.go | 3 +-
internal/message/fediprocess.go | 8 +
internal/message/frprocess.go | 42 +
internal/message/processor.go | 75 ++
internal/message/processorutil.go | 6 +-
internal/message/statusprocess.go | 7 +
internal/oauth/clientstore_test.go | 3 +-
internal/router/router.go | 15 +-
internal/transport/controller.go | 8 +-
internal/typeutils/accountable.go | 101 --
internal/typeutils/asextractionutil.go | 367 ++++++-
internal/typeutils/asinterfaces.go | 237 ++++
internal/typeutils/astointernal.go | 201 ++++
internal/typeutils/astointernal_test.go | 233 ++++
internal/typeutils/converter.go | 4 +
internal/typeutils/internaltoas.go | 16 +-
internal/util/regexes.go | 5 +
internal/util/statustools.go | 28 +-
internal/util/statustools_test.go | 8 +-
internal/util/uri.go | 6 +
57 files changed, 3516 insertions(+), 1694 deletions(-)
create mode 100644 internal/api/client/followrequest/accept.go
create mode 100644 internal/api/client/followrequest/deny.go
create mode 100644 internal/api/client/followrequest/followrequest.go
create mode 100644 internal/api/client/followrequest/get.go
create mode 100644 internal/api/s2s/user/inboxpost.go
create mode 100644 internal/api/security/extraheaders.go
delete mode 100644 internal/db/federating_db.go
delete mode 100644 internal/db/federating_db_test.go
delete mode 100644 internal/db/pg.go
create mode 100644 internal/db/pg/pg.go
delete mode 100644 internal/db/pg_test.go
create mode 100644 internal/federation/federating_db.go
create mode 100644 internal/federation/federating_db_test.go
create mode 100644 internal/message/frprocess.go
delete mode 100644 internal/typeutils/accountable.go
create mode 100644 internal/typeutils/asinterfaces.go
(limited to 'internal')
diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go
index 7ec788a0e..48d2a2508 100644
--- a/internal/api/client/auth/auth_test.go
+++ b/internal/api/client/auth/auth_test.go
@@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/pg"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"golang.org/x/crypto/bcrypt"
@@ -103,7 +104,7 @@ func (suite *AuthTestSuite) SetupTest() {
log := logrus.New()
log.SetLevel(logrus.TraceLevel)
- db, err := db.NewPostgresService(context.Background(), suite.config, log)
+ db, err := pg.NewPostgresService(context.Background(), suite.config, log)
if err != nil {
logrus.Panicf("error creating database connection: %s", err)
}
diff --git a/internal/api/client/auth/middleware.go b/internal/api/client/auth/middleware.go
index 8313b7c28..2a63cbdb6 100644
--- a/internal/api/client/auth/middleware.go
+++ b/internal/api/client/auth/middleware.go
@@ -33,7 +33,7 @@ func (m *Module) OauthTokenMiddleware(c *gin.Context) {
l := m.log.WithField("func", "OauthTokenMiddleware")
l.Trace("entering OauthTokenMiddleware")
- ti, err := m.server.ValidationBearerToken(c.Request)
+ ti, err := m.server.ValidationBearerToken(c.Copy().Request)
if err != nil {
l.Tracef("could not validate token: %s", err)
return
@@ -74,4 +74,5 @@ func (m *Module) OauthTokenMiddleware(c *gin.Context) {
c.Set(oauth.SessionAuthorizedApplication, app)
l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app)
}
+ c.Next()
}
diff --git a/internal/api/client/auth/token.go b/internal/api/client/auth/token.go
index c531a3009..798a88d19 100644
--- a/internal/api/client/auth/token.go
+++ b/internal/api/client/auth/token.go
@@ -20,16 +20,46 @@ package auth
import (
"net/http"
+ "net/url"
"github.com/gin-gonic/gin"
)
+type tokenBody struct {
+ ClientID *string `form:"client_id" json:"client_id" xml:"client_id"`
+ ClientSecret *string `form:"client_secret" json:"client_secret" xml:"client_secret"`
+ Code *string `form:"code" json:"code" xml:"code"`
+ GrantType *string `form:"grant_type" json:"grant_type" xml:"grant_type"`
+ RedirectURI *string `form:"redirect_uri" json:"redirect_uri" xml:"redirect_uri"`
+}
+
// TokenPOSTHandler should be served as a POST at https://example.org/oauth/token
// The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs.
// See https://docs.joinmastodon.org/methods/apps/oauth/#obtain-a-token
func (m *Module) TokenPOSTHandler(c *gin.Context) {
l := m.log.WithField("func", "TokenPOSTHandler")
l.Trace("entered TokenPOSTHandler")
+
+ form := &tokenBody{}
+ if err := c.ShouldBind(form); err == nil {
+ c.Request.Form = url.Values{}
+ if form.ClientID != nil {
+ c.Request.Form.Set("client_id", *form.ClientID)
+ }
+ if form.ClientSecret != nil {
+ c.Request.Form.Set("client_secret", *form.ClientSecret)
+ }
+ if form.Code != nil {
+ c.Request.Form.Set("code", *form.Code)
+ }
+ if form.GrantType != nil {
+ c.Request.Form.Set("grant_type", *form.GrantType)
+ }
+ if form.RedirectURI != nil {
+ c.Request.Form.Set("redirect_uri", *form.RedirectURI)
+ }
+ }
+
if err := m.server.HandleTokenRequest(c.Writer, c.Request); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
diff --git a/internal/api/client/followrequest/accept.go b/internal/api/client/followrequest/accept.go
new file mode 100644
index 000000000..45dc1a2af
--- /dev/null
+++ b/internal/api/client/followrequest/accept.go
@@ -0,0 +1,57 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package followrequest
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// FollowRequestAcceptPOSTHandler deals with follow request accepting. It should be served at
+// /api/v1/follow_requests/:id/authorize
+func (m *Module) FollowRequestAcceptPOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
+ return
+ }
+
+ originAccountID := c.Param(IDKey)
+ if originAccountID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no follow request origin account id provided"})
+ return
+ }
+
+ if errWithCode := m.processor.FollowRequestAccept(authed, originAccountID); errWithCode != nil {
+ l.Debug(errWithCode.Error())
+ c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
+ return
+ }
+ c.Status(http.StatusOK)
+}
diff --git a/internal/api/client/followrequest/deny.go b/internal/api/client/followrequest/deny.go
new file mode 100644
index 000000000..c1a9e4dbf
--- /dev/null
+++ b/internal/api/client/followrequest/deny.go
@@ -0,0 +1,27 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package followrequest
+
+import "github.com/gin-gonic/gin"
+
+// FollowRequestDenyPOSTHandler deals with follow request rejection. It should be served at
+// /api/v1/follow_requests/:id/reject
+func (m *Module) FollowRequestDenyPOSTHandler(c *gin.Context) {
+
+}
diff --git a/internal/api/client/followrequest/followrequest.go b/internal/api/client/followrequest/followrequest.go
new file mode 100644
index 000000000..8be957009
--- /dev/null
+++ b/internal/api/client/followrequest/followrequest.go
@@ -0,0 +1,68 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package followrequest
+
+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 (
+ // IDKey is for status UUIDs
+ IDKey = "id"
+ // BasePath is the base path for serving the follow request API
+ BasePath = "/api/v1/follow_requests"
+ // BasePathWithID is just the base path with the ID key in it.
+ // Use this anywhere you need to know the ID of the follow request being queried.
+ BasePathWithID = BasePath + "/:" + IDKey
+
+ // AcceptPath is used for accepting follow requests
+ AcceptPath = BasePathWithID + "/authorize"
+ // DenyPath is used for denying follow requests
+ DenyPath = BasePathWithID + "/reject"
+)
+
+// Module implements the ClientAPIModule interface for every related to interacting with follow requests
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new follow request module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *Module) Route(r router.Router) error {
+ r.AttachHandler(http.MethodGet, BasePath, m.FollowRequestGETHandler)
+ r.AttachHandler(http.MethodPost, AcceptPath, m.FollowRequestAcceptPOSTHandler)
+ r.AttachHandler(http.MethodPost, DenyPath, m.FollowRequestDenyPOSTHandler)
+ return nil
+}
diff --git a/internal/api/client/followrequest/get.go b/internal/api/client/followrequest/get.go
new file mode 100644
index 000000000..3f02ee02a
--- /dev/null
+++ b/internal/api/client/followrequest/get.go
@@ -0,0 +1,51 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package followrequest
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// FollowRequestGETHandler allows clients to get a list of their incoming follow requests.
+func (m *Module) FollowRequestGETHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
+ return
+ }
+
+ accts, errWithCode := m.processor.FollowRequestsGet(authed)
+ if errWithCode != nil {
+ c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
+ return
+ }
+
+ c.JSON(http.StatusOK, accts)
+}
diff --git a/internal/api/client/instance/instance.go b/internal/api/client/instance/instance.go
index ed7c18718..ba54480a5 100644
--- a/internal/api/client/instance/instance.go
+++ b/internal/api/client/instance/instance.go
@@ -11,7 +11,7 @@ import (
)
const (
- // InstanceInformationPath
+ // InstanceInformationPath is for serving instance info requests
InstanceInformationPath = "api/v1/instance"
)
diff --git a/internal/api/client/instance/instanceget.go b/internal/api/client/instance/instanceget.go
index f8e82c096..6ae419a83 100644
--- a/internal/api/client/instance/instanceget.go
+++ b/internal/api/client/instance/instanceget.go
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
)
+// InstanceInformationGETHandler is for serving instance information at /api/v1/instance
func (m *Module) InstanceInformationGETHandler(c *gin.Context) {
l := m.log.WithField("func", "InstanceInformationGETHandler")
diff --git a/internal/api/client/media/media.go b/internal/api/client/media/media.go
index e45eec7ea..f68a73c2c 100644
--- a/internal/api/client/media/media.go
+++ b/internal/api/client/media/media.go
@@ -33,8 +33,10 @@ import (
// BasePath is the base API path for making media requests
const BasePath = "/api/v1/media"
+
// IDKey is the key for media attachment IDs
const IDKey = "id"
+
// BasePathWithID corresponds to a media attachment with the given ID
const BasePathWithID = BasePath + "/:" + IDKey
diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go
index c0b4a80d7..9f4702b6b 100644
--- a/internal/api/client/media/mediacreate.go
+++ b/internal/api/client/media/mediacreate.go
@@ -35,30 +35,32 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true) // posting new media is serious business so we want *everything*
if err != nil {
l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// extract the media create form from the request context
l.Tracef("parsing request form: %s", c.Request.Form)
- var form model.AttachmentRequest
+ form := &model.AttachmentRequest{}
if err := c.ShouldBind(&form); err != nil {
- l.Debugf("could not parse form from request: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+ l.Debugf("error parsing form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("could not parse form: %s", err)})
return
}
// Give the fields on the request form a first pass to make sure the request is superficially valid.
l.Tracef("validating form %+v", form)
- if err := validateCreateMedia(&form, m.config.MediaConfig); err != nil {
+ if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
l.Debugf("error validating form: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
- mastoAttachment, err := m.processor.MediaCreate(authed, &form)
+ l.Debug("calling processor media create func")
+ mastoAttachment, err := m.processor.MediaCreate(authed, form)
if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ l.Debugf("error creating attachment: %s", err)
+ c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
@@ -67,7 +69,7 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
func validateCreateMedia(form *model.AttachmentRequest, config *config.MediaConfig) error {
// check there actually is a file attached and it's not size 0
- if form.File == nil || form.File.Size == 0 {
+ if form.File == nil {
return errors.New("no attachment given")
}
diff --git a/internal/api/client/media/mediaget.go b/internal/api/client/media/mediaget.go
index 31c40a5aa..7acb475ce 100644
--- a/internal/api/client/media/mediaget.go
+++ b/internal/api/client/media/mediaget.go
@@ -43,7 +43,7 @@ func (m *Module) MediaGETHandler(c *gin.Context) {
attachment, errWithCode := m.processor.MediaGet(authed, attachmentID)
if errWithCode != nil {
- c.JSON(errWithCode.Code(),gin.H{"error": errWithCode.Safe()})
+ c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
return
}
diff --git a/internal/api/model/application.go b/internal/api/model/application.go
index a796c88ea..fe9fada03 100644
--- a/internal/api/model/application.go
+++ b/internal/api/model/application.go
@@ -43,13 +43,13 @@ type Application struct {
// And here: https://docs.joinmastodon.org/client/token/
type ApplicationCreateRequest struct {
// A name for your application
- ClientName string `form:"client_name" binding:"required"`
+ ClientName string `form:"client_name" json:"client_name" xml:"client_name" binding:"required"`
// Where the user should be redirected after authorization.
// To display the authorization code to the user instead of redirecting
// to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter.
- RedirectURIs string `form:"redirect_uris" binding:"required"`
+ RedirectURIs string `form:"redirect_uris" json:"redirect_uris" xml:"redirect_uris" binding:"required"`
// Space separated list of scopes. If none is provided, defaults to read.
- Scopes string `form:"scopes"`
+ Scopes string `form:"scopes" json:"scopes" xml:"scopes"`
// A URL to the homepage of your app
- Website string `form:"website"`
+ Website string `form:"website" json:"website" xml:"website"`
}
diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go
index c5dbb0cba..ed53757eb 100644
--- a/internal/api/model/attachment.go
+++ b/internal/api/model/attachment.go
@@ -24,15 +24,15 @@ import "mime/multipart"
// See: https://docs.joinmastodon.org/methods/statuses/media/
type AttachmentRequest struct {
File *multipart.FileHeader `form:"file" binding:"required"`
- Description string `form:"description" json:"description" xml:"description"`
- Focus string `form:"focus" json:"focus" xml:"focus"`
+ Description string `form:"description"`
+ Focus string `form:"focus"`
}
-// AttachmentRequest represents the form data parameters submitted by a client during a media update/PUT request.
+// AttachmentUpdateRequest represents the form data parameters submitted by a client during a media update/PUT request.
// See: https://docs.joinmastodon.org/methods/statuses/media/
type AttachmentUpdateRequest struct {
- Description *string `form:"description" json:"description" xml:"description"`
- Focus *string `form:"focus" json:"focus" xml:"focus"`
+ Description *string `form:"description" json:"description" xml:"description"`
+ Focus *string `form:"focus" json:"focus" xml:"focus"`
}
// Attachment represents the object returned to a client after a successful media upload request.
@@ -63,7 +63,7 @@ type Attachment struct {
// See https://docs.joinmastodon.org/methods/statuses/media/#focal-points points for more.
Meta MediaMeta `json:"meta,omitempty"`
// Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.
- Description string `json:"description"`
+ Description string `json:"description,omitempty"`
// A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.
// See https://github.com/woltapp/blurhash
Blurhash string `json:"blurhash,omitempty"`
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
index 54d021e29..2cb22aa0d 100644
--- a/internal/api/model/status.go
+++ b/internal/api/model/status.go
@@ -119,11 +119,15 @@ const (
VisibilityDirect Visibility = "direct"
)
+// AdvancedStatusCreateForm wraps the mastodon status create form along with the GTS advanced
+// visibility settings.
type AdvancedStatusCreateForm struct {
StatusCreateRequest
AdvancedVisibilityFlagsForm
}
+// AdvancedVisibilityFlagsForm allows a few more advanced flags to be set on new statuses, in addition
+// to the standard mastodon-compatible ones.
type AdvancedVisibilityFlagsForm struct {
// The gotosocial visibility model
VisibilityAdvanced *string `form:"visibility_advanced"`
diff --git a/internal/api/s2s/user/inboxpost.go b/internal/api/s2s/user/inboxpost.go
new file mode 100644
index 000000000..60b74ab70
--- /dev/null
+++ b/internal/api/s2s/user/inboxpost.go
@@ -0,0 +1,58 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package user
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+)
+
+// InboxPOSTHandler deals with incoming POST requests to an actor's inbox.
+// Eg., POST to https://example.org/users/whatever/inbox.
+func (m *Module) InboxPOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "InboxPOSTHandler",
+ "url": c.Request.RequestURI,
+ })
+
+ requestedUsername := c.Param(UsernameKey)
+ if requestedUsername == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"})
+ return
+ }
+
+ posted, err := m.processor.InboxPost(c.Request.Context(), c.Writer, c.Request)
+ if err != nil {
+ if withCode, ok := err.(message.ErrorWithCode); ok {
+ l.Debug(withCode.Error())
+ c.JSON(withCode.Code(), withCode.Safe())
+ return
+ }
+ l.Debug(err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unable to process request"})
+ return
+ }
+
+ if !posted {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unable to process request"})
+ }
+}
diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go
index 693fac7c3..a6116247d 100644
--- a/internal/api/s2s/user/user.go
+++ b/internal/api/s2s/user/user.go
@@ -38,6 +38,8 @@ const (
// Use this anywhere you need to know the username of the user being queried.
// Eg https://example.org/users/:username
UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey
+ // UsersInboxPath is for serving POST requests to a user's inbox with the given username key.
+ UsersInboxPath = UsersBasePathWithUsername + "/" + util.InboxPath
)
// ActivityPubAcceptHeaders represents the Accept headers mentioned here:
@@ -66,5 +68,6 @@ func New(config *config.Config, processor message.Processor, log *logrus.Logger)
// Route satisfies the RESTAPIModule interface
func (m *Module) Route(s router.Router) error {
s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler)
+ s.AttachHandler(http.MethodPost, UsersInboxPath, m.InboxPOSTHandler)
return nil
}
diff --git a/internal/api/s2s/webfinger/webfinger.go b/internal/api/s2s/webfinger/webfinger.go
index c11d3fb61..168fe1e76 100644
--- a/internal/api/s2s/webfinger/webfinger.go
+++ b/internal/api/s2s/webfinger/webfinger.go
@@ -29,7 +29,7 @@ import (
)
const (
- // The base path for serving webfinger lookup requests
+ // WebfingerBasePath is the base path for serving webfinger lookup requests
WebfingerBasePath = ".well-known/webfinger"
)
diff --git a/internal/api/security/extraheaders.go b/internal/api/security/extraheaders.go
new file mode 100644
index 000000000..dfcddfbe1
--- /dev/null
+++ b/internal/api/security/extraheaders.go
@@ -0,0 +1,8 @@
+package security
+
+import "github.com/gin-gonic/gin"
+
+// ExtraHeaders adds any additional required headers to the response
+func (m *Module) ExtraHeaders(c *gin.Context) {
+ c.Header("Server", "Mastodon")
+}
diff --git a/internal/api/security/security.go b/internal/api/security/security.go
index c80b568b3..eaae8471e 100644
--- a/internal/api/security/security.go
+++ b/internal/api/security/security.go
@@ -42,5 +42,6 @@ func New(config *config.Config, log *logrus.Logger) api.ClientModule {
// Route attaches security middleware to the given router
func (m *Module) Route(s router.Router) error {
s.AttachMiddleware(m.FlocBlock)
+ s.AttachMiddleware(m.ExtraHeaders)
return nil
}
diff --git a/internal/db/db.go b/internal/db/db.go
index b281dd8d7..a354ddee8 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -26,7 +26,10 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
-const DBTypePostgres string = "POSTGRES"
+const (
+ // DBTypePostgres represents an underlying POSTGRES database type.
+ DBTypePostgres string = "POSTGRES"
+)
// ErrNoEntries is to be returned from the DB interface when no entries are found for a given query.
type ErrNoEntries struct{}
@@ -112,6 +115,10 @@ type DB interface {
HANDY SHORTCUTS
*/
+ // AcceptFollowRequest moves a follow request in the database from the follow_requests table to the follows table.
+ // In other words, it should create the follow, and delete the existing follow request.
+ AcceptFollowRequest(originAccountID string, targetAccountID string) error
+
// CreateInstanceAccount creates an account in the database with the same username as the instance host value.
// Ie., if the instance is hosted at 'example.org' the instance user will have a username of 'example.org'.
// This is needed for things like serving files that belong to the instance and not an individual user/account.
@@ -148,6 +155,11 @@ type DB interface {
// In case of no entries, a 'no entries' error will be returned
GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error
+ // GetFavesByAccountID is a shortcut for the common action of fetching a list of faves made by the given accountID.
+ // The given slice 'faves' will be set to the result of the query, whatever it is.
+ // In case of no entries, a 'no entries' error will be returned
+ GetFavesByAccountID(accountID string, faves *[]gtsmodel.StatusFave) error
+
// GetStatusesByAccountID is a shortcut for the common action of fetching a list of statuses produced by accountID.
// The given slice 'statuses' 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/federating_db.go b/internal/db/federating_db.go
deleted file mode 100644
index ab66b19de..000000000
--- a/internal/db/federating_db.go
+++ /dev/null
@@ -1,359 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-*/
-
-package db
-
-import (
- "context"
- "errors"
- "fmt"
- "net/url"
- "sync"
-
- "github.com/go-fed/activity/pub"
- "github.com/go-fed/activity/streams/vocab"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/util"
-)
-
-// FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface.
-// It doesn't care what the underlying implementation of the DB interface is, as long as it works.
-type federatingDB struct {
- locks *sync.Map
- db DB
- config *config.Config
- log *logrus.Logger
-}
-
-func NewFederatingDB(db DB, config *config.Config, log *logrus.Logger) pub.Database {
- return &federatingDB{
- locks: new(sync.Map),
- db: db,
- config: config,
- log: log,
- }
-}
-
-/*
- GO-FED DB INTERFACE-IMPLEMENTING FUNCTIONS
-*/
-
-// Lock takes a lock for the object at the specified id. If an error
-// is returned, the lock must not have been taken.
-//
-// The lock must be able to succeed for an id that does not exist in
-// the database. This means acquiring the lock does not guarantee the
-// entry exists in the database.
-//
-// Locks are encouraged to be lightweight and in the Go layer, as some
-// processes require tight loops acquiring and releasing locks.
-//
-// Used to ensure race conditions in multiple requests do not occur.
-func (f *federatingDB) Lock(c context.Context, id *url.URL) error {
- // Before any other Database methods are called, the relevant `id`
- // entries are locked to allow for fine-grained concurrency.
-
- // Strategy: create a new lock, if stored, continue. Otherwise, lock the
- // existing mutex.
- mu := &sync.Mutex{}
- mu.Lock() // Optimistically lock if we do store it.
- i, loaded := f.locks.LoadOrStore(id.String(), mu)
- if loaded {
- mu = i.(*sync.Mutex)
- mu.Lock()
- }
- return nil
-}
-
-// Unlock makes the lock for the object at the specified id available.
-// If an error is returned, the lock must have still been freed.
-//
-// Used to ensure race conditions in multiple requests do not occur.
-func (f *federatingDB) Unlock(c context.Context, id *url.URL) error {
- // Once Go-Fed is done calling Database methods, the relevant `id`
- // entries are unlocked.
-
- i, ok := f.locks.Load(id.String())
- if !ok {
- return errors.New("missing an id in unlock")
- }
- mu := i.(*sync.Mutex)
- mu.Unlock()
- return nil
-}
-
-// InboxContains returns true if the OrderedCollection at 'inbox'
-// contains the specified 'id'.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) {
-
- if !util.IsInboxPath(inbox) {
- return false, fmt.Errorf("%s is not an inbox URI", inbox.String())
- }
-
- if !util.IsStatusesPath(id) {
- return false, fmt.Errorf("%s is not a status URI", id.String())
- }
- _, statusID, err := util.ParseStatusesPath(inbox)
- if err != nil {
- return false, fmt.Errorf("status URI %s was not parseable: %s", id.String(), err)
- }
-
- if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil {
- if _, ok := err.(ErrNoEntries); ok {
- // we don't have it
- return false, nil
- }
- // actual error
- return false, fmt.Errorf("error getting status from db: %s", err)
- }
-
- // we must have it
- return true, nil
-}
-
-// GetInbox returns the first ordered collection page of the outbox at
-// the specified IRI, for prepending new items.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) GetInbox(c context.Context, inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) {
- return nil, nil
-}
-
-// SetInbox saves the inbox value given from GetInbox, with new items
-// prepended. Note that the new items must not be added as independent
-// database entries. Separate calls to Create will do that.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error {
- return nil
-}
-
-// Owns returns true if the IRI belongs to this instance, and if
-// the database has an entry for the IRI.
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) {
- // if the id host isn't this instance host, we don't own this IRI
- if id.Host != f.config.Host {
- return false, nil
- }
-
- // apparently we own it, so what *is* it?
-
- // check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS
- if util.IsStatusesPath(id) {
- _, uid, err := util.ParseStatusesPath(id)
- if err != nil {
- return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
- }
- if err := f.db.GetWhere("uri", uid, >smodel.Status{}); err != nil {
- if _, ok := err.(ErrNoEntries); ok {
- // there are no entries for this status
- return false, nil
- }
- // an actual error happened
- return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err)
- }
- return true, nil
- }
-
- // check if it's a user, eg /users/example_username
- if util.IsUserPath(id) {
- username, err := util.ParseUserPath(id)
- if err != nil {
- return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
- }
- if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil {
- if _, ok := err.(ErrNoEntries); ok {
- // there are no entries for this username
- return false, nil
- }
- // an actual error happened
- return false, fmt.Errorf("database error fetching account with username %s: %s", username, err)
- }
- return true, nil
- }
-
- return false, fmt.Errorf("could not match activityID: %s", id.String())
-}
-
-// ActorForOutbox fetches the actor's IRI for the given outbox IRI.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) {
- if !util.IsOutboxPath(outboxIRI) {
- return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String())
- }
- acct := >smodel.Account{}
- if err := f.db.GetWhere("outbox_uri", outboxIRI.String(), acct); err != nil {
- if _, ok := err.(ErrNoEntries); ok {
- return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String())
- }
- return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String())
- }
- return url.Parse(acct.URI)
-}
-
-// ActorForInbox fetches the actor's IRI for the given outbox IRI.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) {
- if !util.IsInboxPath(inboxIRI) {
- return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
- }
- acct := >smodel.Account{}
- if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
- if _, ok := err.(ErrNoEntries); ok {
- return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
- }
- return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
- }
- return url.Parse(acct.URI)
-}
-
-// OutboxForInbox fetches the corresponding actor's outbox IRI for the
-// actor's inbox IRI.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) {
- if !util.IsInboxPath(inboxIRI) {
- return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
- }
- acct := >smodel.Account{}
- if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
- if _, ok := err.(ErrNoEntries); ok {
- return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
- }
- return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
- }
- return url.Parse(acct.OutboxURI)
-}
-
-// Exists returns true if the database has an entry for the specified
-// id. It may not be owned by this application instance.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Exists(c context.Context, id *url.URL) (exists bool, err error) {
- return false, nil
-}
-
-// Get returns the database entry for the specified id.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Get(c context.Context, id *url.URL) (value vocab.Type, err error) {
- return nil, nil
-}
-
-// Create adds a new entry to the database which must be able to be
-// keyed by its id.
-//
-// Note that Activity values received from federated peers may also be
-// created in the database this way if the Federating Protocol is
-// enabled. The client may freely decide to store only the id instead of
-// the entire value.
-//
-// The library makes this call only after acquiring a lock first.
-//
-// Under certain conditions and network activities, Create may be called
-// multiple times for the same ActivityStreams object.
-func (f *federatingDB) Create(c context.Context, asType vocab.Type) error {
- return nil
-}
-
-// Update sets an existing entry to the database based on the value's
-// id.
-//
-// Note that Activity values received from federated peers may also be
-// updated in the database this way if the Federating Protocol is
-// enabled. The client may freely decide to store only the id instead of
-// the entire value.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Update(c context.Context, asType vocab.Type) error {
- return nil
-}
-
-// Delete removes the entry with the given id.
-//
-// Delete is only called for federated objects. Deletes from the Social
-// Protocol instead call Update to create a Tombstone.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Delete(c context.Context, id *url.URL) error {
- return nil
-}
-
-// GetOutbox returns the first ordered collection page of the outbox
-// at the specified IRI, for prepending new items.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) GetOutbox(c context.Context, outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) {
- return nil, nil
-}
-
-// SetOutbox saves the outbox value given from GetOutbox, with new items
-// prepended. Note that the new items must not be added as independent
-// database entries. Separate calls to Create will do that.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) SetOutbox(c context.Context, outbox vocab.ActivityStreamsOrderedCollectionPage) error {
- return nil
-}
-
-// NewID creates a new IRI id for the provided activity or object. The
-// implementation does not need to set the 'id' property and simply
-// needs to determine the value.
-//
-// The go-fed library will handle setting the 'id' property on the
-// activity or object provided with the value returned.
-func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err error) {
- return nil, nil
-}
-
-// Followers obtains the Followers Collection for an actor with the
-// given id.
-//
-// If modified, the library will then call Update.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Followers(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) {
- return nil, nil
-}
-
-// Following obtains the Following Collection for an actor with the
-// given id.
-//
-// If modified, the library will then call Update.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Following(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) {
- return nil, nil
-}
-
-// Liked obtains the Liked Collection for an actor with the
-// given id.
-//
-// If modified, the library will then call Update.
-//
-// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Liked(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) {
- return nil, nil
-}
diff --git a/internal/db/federating_db_test.go b/internal/db/federating_db_test.go
deleted file mode 100644
index 529d2efd0..000000000
--- a/internal/db/federating_db_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-*/
-
-package db
-
-// TODO: write tests for pgfed
diff --git a/internal/db/pg.go b/internal/db/pg.go
deleted file mode 100644
index c0fbcc9e0..000000000
--- a/internal/db/pg.go
+++ /dev/null
@@ -1,1089 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-*/
-
-package db
-
-import (
- "context"
- "crypto/rand"
- "crypto/rsa"
- "errors"
- "fmt"
- "net"
- "net/mail"
- "regexp"
- "strings"
- "time"
-
- "github.com/go-fed/activity/pub"
- "github.com/go-pg/pg/extra/pgdebug"
- "github.com/go-pg/pg/v10"
- "github.com/go-pg/pg/v10/orm"
- "github.com/google/uuid"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/util"
- "golang.org/x/crypto/bcrypt"
-)
-
-// postgresService satisfies the DB interface
-type postgresService struct {
- config *config.Config
- conn *pg.DB
- log *logrus.Logger
- cancel context.CancelFunc
- federationDB pub.Database
-}
-
-// NewPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface.
-// Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection.
-func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) {
- opts, err := derivePGOptions(c)
- if err != nil {
- return nil, fmt.Errorf("could not create postgres service: %s", err)
- }
- log.Debugf("using pg options: %+v", opts)
-
- // create a connection
- pgCtx, cancel := context.WithCancel(ctx)
- conn := pg.Connect(opts).WithContext(pgCtx)
-
- // this will break the logfmt format we normally log in,
- // since we can't choose where pg outputs to and it defaults to
- // stdout. So use this option with care!
- if log.GetLevel() >= logrus.TraceLevel {
- conn.AddQueryHook(pgdebug.DebugHook{
- // Print all queries.
- Verbose: true,
- })
- }
-
- // actually *begin* the connection so that we can tell if the db is there and listening
- if err := conn.Ping(ctx); err != nil {
- cancel()
- return nil, fmt.Errorf("db connection error: %s", err)
- }
-
- // print out discovered postgres version
- var version string
- if _, err = conn.QueryOneContext(ctx, pg.Scan(&version), "SELECT version()"); err != nil {
- cancel()
- return nil, fmt.Errorf("db connection error: %s", err)
- }
- log.Infof("connected to postgres version: %s", version)
-
- ps := &postgresService{
- config: c,
- conn: conn,
- log: log,
- cancel: cancel,
- }
-
- federatingDB := NewFederatingDB(ps, c, log)
- ps.federationDB = federatingDB
-
- // we can confidently return this useable postgres service now
- return ps, nil
-}
-
-/*
- HANDY STUFF
-*/
-
-// derivePGOptions takes an application config and returns either a ready-to-use *pg.Options
-// with sensible defaults, or an error if it's not satisfied by the provided config.
-func derivePGOptions(c *config.Config) (*pg.Options, error) {
- if strings.ToUpper(c.DBConfig.Type) != DBTypePostgres {
- return nil, fmt.Errorf("expected db type of %s but got %s", DBTypePostgres, c.DBConfig.Type)
- }
-
- // validate port
- if c.DBConfig.Port == 0 {
- return nil, errors.New("no port set")
- }
-
- // validate address
- if c.DBConfig.Address == "" {
- return nil, errors.New("no address set")
- }
-
- ipv4Regex := regexp.MustCompile(`^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`)
- hostnameRegex := regexp.MustCompile(`^(?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,}$`)
- if !hostnameRegex.MatchString(c.DBConfig.Address) && !ipv4Regex.MatchString(c.DBConfig.Address) && c.DBConfig.Address != "localhost" {
- return nil, fmt.Errorf("address %s was neither an ipv4 address nor a valid hostname", c.DBConfig.Address)
- }
-
- // validate username
- if c.DBConfig.User == "" {
- return nil, errors.New("no user set")
- }
-
- // validate that there's a password
- if c.DBConfig.Password == "" {
- return nil, errors.New("no password set")
- }
-
- // validate database
- if c.DBConfig.Database == "" {
- return nil, errors.New("no database set")
- }
-
- // We can rely on the pg library we're using to set
- // sensible defaults for everything we don't set here.
- options := &pg.Options{
- Addr: fmt.Sprintf("%s:%d", c.DBConfig.Address, c.DBConfig.Port),
- User: c.DBConfig.User,
- Password: c.DBConfig.Password,
- Database: c.DBConfig.Database,
- ApplicationName: c.ApplicationName,
- }
-
- return options, nil
-}
-
-/*
- FEDERATION FUNCTIONALITY
-*/
-
-func (ps *postgresService) Federation() pub.Database {
- return ps.federationDB
-}
-
-/*
- BASIC DB FUNCTIONALITY
-*/
-
-func (ps *postgresService) CreateTable(i interface{}) error {
- return ps.conn.Model(i).CreateTable(&orm.CreateTableOptions{
- IfNotExists: true,
- })
-}
-
-func (ps *postgresService) DropTable(i interface{}) error {
- return ps.conn.Model(i).DropTable(&orm.DropTableOptions{
- IfExists: true,
- })
-}
-
-func (ps *postgresService) Stop(ctx context.Context) error {
- ps.log.Info("closing db connection")
- if err := ps.conn.Close(); err != nil {
- // only cancel if there's a problem closing the db
- ps.cancel()
- return err
- }
- return nil
-}
-
-func (ps *postgresService) IsHealthy(ctx context.Context) error {
- return ps.conn.Ping(ctx)
-}
-
-func (ps *postgresService) CreateSchema(ctx context.Context) error {
- models := []interface{}{
- (*gtsmodel.Account)(nil),
- (*gtsmodel.Status)(nil),
- (*gtsmodel.User)(nil),
- }
- ps.log.Info("creating db schema")
-
- for _, model := range models {
- err := ps.conn.Model(model).CreateTable(&orm.CreateTableOptions{
- IfNotExists: true,
- })
- if err != nil {
- return err
- }
- }
-
- ps.log.Info("db schema created")
- return nil
-}
-
-func (ps *postgresService) GetByID(id string, i interface{}) error {
- if err := ps.conn.Model(i).Where("id = ?", id).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
-
- }
- return nil
-}
-
-func (ps *postgresService) GetWhere(key string, value interface{}, i interface{}) error {
- if err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-// func (ps *postgresService) GetWhereMany(i interface{}, where ...model.Where) error {
-// return nil
-// }
-
-func (ps *postgresService) GetAll(i interface{}) error {
- if err := ps.conn.Model(i).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) Put(i interface{}) error {
- _, err := ps.conn.Model(i).Insert(i)
- return err
-}
-
-func (ps *postgresService) Upsert(i interface{}, conflictColumn string) error {
- if _, err := ps.conn.Model(i).OnConflict(fmt.Sprintf("(%s) DO UPDATE", conflictColumn)).Insert(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) UpdateByID(id string, i interface{}) error {
- if _, err := ps.conn.Model(i).Where("id = ?", id).OnConflict("(id) DO UPDATE").Insert(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) UpdateOneByID(id string, key string, value interface{}, i interface{}) error {
- _, err := ps.conn.Model(i).Set("? = ?", pg.Safe(key), value).Where("id = ?", id).Update()
- return err
-}
-
-func (ps *postgresService) DeleteByID(id string, i interface{}) error {
- if _, err := ps.conn.Model(i).Where("id = ?", id).Delete(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) DeleteWhere(key string, value interface{}, i interface{}) error {
- if _, err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Delete(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-/*
- HANDY SHORTCUTS
-*/
-
-func (ps *postgresService) CreateInstanceAccount() error {
- username := ps.config.Host
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- ps.log.Errorf("error creating new rsa key: %s", err)
- return err
- }
-
- 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, a.ID)
- } else {
- 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
-}
-
-func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.Account) error {
- user := >smodel.User{
- ID: userID,
- }
- if err := ps.conn.Model(user).Where("id = ?", userID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- if err := ps.conn.Model(account).Where("id = ?", user.AccountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error {
- if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error {
- if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error {
- if err := ps.conn.Model(following).Where("account_id = ?", accountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error {
- if err := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error {
- if err := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error {
- q := ps.conn.Model(statuses).Order("created_at DESC")
- if limit != 0 {
- q = q.Limit(limit)
- }
- if accountID != "" {
- q = q.Where("account_id = ?", accountID)
- }
- if err := q.Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error {
- if err := ps.conn.Model(status).Order("created_at DESC").Limit(1).Where("account_id = ?", accountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-
-}
-
-func (ps *postgresService) IsUsernameAvailable(username string) error {
- // if no error we fail because it means we found something
- // if error but it's not pg.ErrNoRows then we fail
- // if err is pg.ErrNoRows we're good, we found nothing so continue
- if err := ps.conn.Model(>smodel.Account{}).Where("username = ?", username).Where("domain = ?", nil).Select(); err == nil {
- return fmt.Errorf("username %s already in use", username)
- } else if err != pg.ErrNoRows {
- return fmt.Errorf("db error: %s", err)
- }
- return nil
-}
-
-func (ps *postgresService) IsEmailAvailable(email string) error {
- // parse the domain from the email
- m, err := mail.ParseAddress(email)
- if err != nil {
- return fmt.Errorf("error parsing email address %s: %s", email, err)
- }
- domain := strings.Split(m.Address, "@")[1] // domain will always be the second part after @
-
- // check if the email domain is blocked
- if err := ps.conn.Model(>smodel.EmailDomainBlock{}).Where("domain = ?", domain).Select(); err == nil {
- // fail because we found something
- return fmt.Errorf("email domain %s is blocked", domain)
- } else if err != pg.ErrNoRows {
- // fail because we got an unexpected error
- return fmt.Errorf("db error: %s", err)
- }
-
- // check if this email is associated with a user already
- if err := ps.conn.Model(>smodel.User{}).Where("email = ?", email).WhereOr("unconfirmed_email = ?", email).Select(); err == nil {
- // fail because we found something
- return fmt.Errorf("email %s already in use", email)
- } else if err != pg.ErrNoRows {
- // fail because we got an unexpected error
- return fmt.Errorf("db error: %s", err)
- }
- return nil
-}
-
-func (ps *postgresService) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) {
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- ps.log.Errorf("error creating new rsa key: %s", err)
- return nil, err
- }
-
- newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host)
-
- a := >smodel.Account{
- Username: username,
- DisplayName: username,
- Reason: reason,
- 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,
- }
- if _, err = ps.conn.Model(a).Insert(); err != nil {
- return nil, err
- }
-
- pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
- if err != nil {
- return nil, fmt.Errorf("error hashing password: %s", err)
- }
- u := >smodel.User{
- AccountID: a.ID,
- EncryptedPassword: string(pw),
- SignUpIP: signUpIP,
- Locale: locale,
- UnconfirmedEmail: email,
- CreatedByApplicationID: appID,
- Approved: !requireApproval, // if we don't require moderator approval, just pre-approve the user
- }
- if _, err = ps.conn.Model(u).Insert(); err != nil {
- return nil, err
- }
-
- return u, nil
-}
-
-func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error {
- if mediaAttachment.Avatar && mediaAttachment.Header {
- return errors.New("one media attachment cannot be both header and avatar")
- }
-
- var headerOrAVI string
- if mediaAttachment.Avatar {
- headerOrAVI = "avatar"
- } else if mediaAttachment.Header {
- headerOrAVI = "header"
- } else {
- return errors.New("given media attachment was neither a header nor an avatar")
- }
-
- // TODO: there are probably more side effects here that need to be handled
- if _, err := ps.conn.Model(mediaAttachment).OnConflict("(id) DO UPDATE").Insert(); err != nil {
- return err
- }
-
- if _, err := ps.conn.Model(>smodel.Account{}).Set(fmt.Sprintf("%s_media_attachment_id = ?", headerOrAVI), mediaAttachment.ID).Where("id = ?", accountID).Update(); err != nil {
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error {
- acct := >smodel.Account{}
- if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
-
- if acct.HeaderMediaAttachmentID == "" {
- return ErrNoEntries{}
- }
-
- if err := ps.conn.Model(header).Where("id = ?", acct.HeaderMediaAttachmentID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error {
- acct := >smodel.Account{}
- if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
-
- if acct.AvatarMediaAttachmentID == "" {
- return ErrNoEntries{}
- }
-
- if err := ps.conn.Model(avatar).Where("id = ?", acct.AvatarMediaAttachmentID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return ErrNoEntries{}
- }
- return err
- }
- return nil
-}
-
-func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) {
- // TODO: check domain blocks as well
- var blocked bool
- if err := ps.conn.Model(>smodel.Block{}).
- Where("account_id = ?", account1).Where("target_account_id = ?", account2).
- WhereOr("target_account_id = ?", account1).Where("account_id = ?", account2).
- Select(); err != nil {
- if err == pg.ErrNoRows {
- blocked = false
- return blocked, nil
- }
- return blocked, err
- }
- blocked = true
- return blocked, nil
-}
-
-func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) {
- l := ps.log.WithField("func", "StatusVisible")
-
- // if target account is suspended then don't show the status
- if !targetAccount.SuspendedAt.IsZero() {
- l.Debug("target account suspended at is not zero")
- return false, nil
- }
-
- // if the target user doesn't exist (anymore) then the status also shouldn't be visible
- targetUser := >smodel.User{}
- if err := ps.conn.Model(targetUser).Where("account_id = ?", targetAccount.ID).Select(); err != nil {
- l.Debug("target user could not be selected")
- if err == pg.ErrNoRows {
- return false, ErrNoEntries{}
- }
- return false, err
- }
-
- // if target user is disabled, not yet approved, or not confirmed then don't show the status
- // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!)
- if targetUser.Disabled || !targetUser.Approved || targetUser.ConfirmedAt.IsZero() {
- l.Debug("target user is disabled, not approved, or not confirmed")
- return false, nil
- }
-
- // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed.
- // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't.
- if requestingAccount == nil {
-
- if targetStatus.Visibility == gtsmodel.VisibilityPublic {
- return true, nil
- }
- l.Debug("requesting account is nil but the target status isn't public")
- return false, nil
- }
-
- // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten
- // this far (ie., been authed) in the first place: this is just for safety.
- if !requestingAccount.SuspendedAt.IsZero() {
- l.Debug("requesting account is suspended")
- return false, nil
- }
-
- // check if we have a local account -- if so we can check the user for that account in the DB
- if requestingAccount.Domain == "" {
- requestingUser := >smodel.User{}
- if err := ps.conn.Model(requestingUser).Where("account_id = ?", requestingAccount.ID).Select(); err != nil {
- // if the requesting account is local but doesn't have a corresponding user in the db this is a problem
- if err == pg.ErrNoRows {
- l.Debug("requesting account is local but there's no corresponding user")
- return false, nil
- }
- l.Debugf("requesting account is local but there was an error getting the corresponding user: %s", err)
- return false, err
- }
- // okay, user exists, so make sure it has full privileges/is confirmed/approved
- if requestingUser.Disabled || !requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() {
- l.Debug("requesting account is local but corresponding user is either disabled, not approved, or not confirmed")
- return false, nil
- }
- }
-
- // if the target status belongs to the requesting account, they should always be able to view it at this point
- if targetStatus.AccountID == requestingAccount.ID {
- return true, nil
- }
-
- // At this point we have a populated targetAccount, targetStatus, and requestingAccount, so we can check for blocks and whathaveyou
- // First check if a block exists directly between the target account (which authored the status) and the requesting account.
- if blocked, err := ps.Blocked(targetAccount.ID, requestingAccount.ID); err != nil {
- l.Debugf("something went wrong figuring out if the accounts have a block: %s", err)
- return false, err
- } else if blocked {
- // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please
- l.Debug("a block exists between requesting account and target account")
- return false, nil
- }
-
- // check other accounts mentioned/boosted by/replied to by the status, if they exist
- if relevantAccounts != nil {
- // status replies to account id
- if relevantAccounts.ReplyToAccount != nil {
- if blocked, err := ps.Blocked(relevantAccounts.ReplyToAccount.ID, requestingAccount.ID); err != nil {
- return false, err
- } else if blocked {
- return false, nil
- }
- }
-
- // status boosts accounts id
- if relevantAccounts.BoostedAccount != nil {
- if blocked, err := ps.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil {
- return false, err
- } else if blocked {
- return false, nil
- }
- }
-
- // status boosts a reply to account id
- if relevantAccounts.BoostedReplyToAccount != nil {
- if blocked, err := ps.Blocked(relevantAccounts.BoostedReplyToAccount.ID, requestingAccount.ID); err != nil {
- return false, err
- } else if blocked {
- return false, nil
- }
- }
-
- // status mentions accounts
- for _, a := range relevantAccounts.MentionedAccounts {
- if blocked, err := ps.Blocked(a.ID, requestingAccount.ID); err != nil {
- return false, err
- } else if blocked {
- return false, nil
- }
- }
- }
-
- // at this point we know neither account blocks the other, or another account mentioned or otherwise referred to in the status
- // that means it's now just a matter of checking the visibility settings of the status itself
- switch targetStatus.Visibility {
- case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked:
- // no problem here, just return OK
- return true, nil
- case gtsmodel.VisibilityFollowersOnly:
- // check one-way follow
- follows, err := ps.Follows(requestingAccount, targetAccount)
- if err != nil {
- return false, err
- }
- if !follows {
- return false, nil
- }
- return true, nil
- case gtsmodel.VisibilityMutualsOnly:
- // check mutual follow
- mutuals, err := ps.Mutuals(requestingAccount, targetAccount)
- if err != nil {
- return false, err
- }
- if !mutuals {
- return false, nil
- }
- return true, nil
- case gtsmodel.VisibilityDirect:
- // make sure the requesting account is mentioned in the status
- for _, menchie := range targetStatus.Mentions {
- if menchie == requestingAccount.ID {
- return true, nil // yep it's mentioned!
- }
- }
- return false, nil // it's not mentioned -_-
- }
-
- return false, errors.New("reached the end of StatusVisible with no result")
-}
-
-func (ps *postgresService) Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) {
- return ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", sourceAccount.ID).Where("target_account_id = ?", targetAccount.ID).Exists()
-}
-
-func (ps *postgresService) Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) {
- // make sure account 1 follows account 2
- f1, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account1.ID).Where("target_account_id = ?", account2.ID).Exists()
- if err != nil {
- if err == pg.ErrNoRows {
- return false, nil
- }
- return false, err
- }
-
- // make sure account 2 follows account 1
- f2, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account2.ID).Where("target_account_id = ?", account1.ID).Exists()
- if err != nil {
- if err == pg.ErrNoRows {
- return false, nil
- }
- return false, err
- }
-
- return f1 && f2, nil
-}
-
-func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) {
- accounts := >smodel.RelevantAccounts{
- MentionedAccounts: []*gtsmodel.Account{},
- }
-
- // get the replied to account from the status and add it to the pile
- if targetStatus.InReplyToAccountID != "" {
- repliedToAccount := >smodel.Account{}
- if err := ps.conn.Model(repliedToAccount).Where("id = ?", targetStatus.InReplyToAccountID).Select(); err != nil {
- return accounts, err
- }
- accounts.ReplyToAccount = repliedToAccount
- }
-
- // get the boosted account from the status and add it to the pile
- if targetStatus.BoostOfID != "" {
- // retrieve the boosted status first
- boostedStatus := >smodel.Status{}
- if err := ps.conn.Model(boostedStatus).Where("id = ?", targetStatus.BoostOfID).Select(); err != nil {
- return accounts, err
- }
- boostedAccount := >smodel.Account{}
- if err := ps.conn.Model(boostedAccount).Where("id = ?", boostedStatus.AccountID).Select(); err != nil {
- return accounts, err
- }
- accounts.BoostedAccount = boostedAccount
-
- // the boosted status might be a reply to another account so we should get that too
- if boostedStatus.InReplyToAccountID != "" {
- boostedStatusRepliedToAccount := >smodel.Account{}
- if err := ps.conn.Model(boostedStatusRepliedToAccount).Where("id = ?", boostedStatus.InReplyToAccountID).Select(); err != nil {
- return accounts, err
- }
- accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount
- }
- }
-
- // now get all accounts with IDs that are mentioned in the status
- for _, mentionedAccountID := range targetStatus.Mentions {
- mentionedAccount := >smodel.Account{}
- if err := ps.conn.Model(mentionedAccount).Where("id = ?", mentionedAccountID).Select(); err != nil {
- return accounts, err
- }
- accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount)
- }
-
- return accounts, nil
-}
-
-func (ps *postgresService) GetReplyCountForStatus(status *gtsmodel.Status) (int, error) {
- return ps.conn.Model(>smodel.Status{}).Where("in_reply_to_id = ?", status.ID).Count()
-}
-
-func (ps *postgresService) GetReblogCountForStatus(status *gtsmodel.Status) (int, error) {
- return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Count()
-}
-
-func (ps *postgresService) GetFaveCountForStatus(status *gtsmodel.Status) (int, error) {
- return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Count()
-}
-
-func (ps *postgresService) StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) {
- return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
-}
-
-func (ps *postgresService) StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) {
- return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
-}
-
-func (ps *postgresService) StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) {
- return ps.conn.Model(>smodel.StatusMute{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
-}
-
-func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) {
- return ps.conn.Model(>smodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
-}
-
-func (ps *postgresService) StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) {
- return ps.conn.Model(>smodel.StatusPin{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
-}
-
-func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) {
- // first check if a fave already exists, we can just return if so
- existingFave := >smodel.StatusFave{}
- err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select()
- if err == nil {
- // fave already exists so just return nothing at all
- return nil, nil
- }
-
- // an error occurred so it might exist or not, we don't know
- if err != pg.ErrNoRows {
- return nil, err
- }
-
- // it doesn't exist so create it
- newFave := >smodel.StatusFave{
- AccountID: accountID,
- TargetAccountID: status.AccountID,
- StatusID: status.ID,
- }
- if _, err = ps.conn.Model(newFave).Insert(); err != nil {
- return nil, err
- }
-
- return newFave, nil
-}
-
-func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) {
- // if a fave doesn't exist, we don't need to do anything
- existingFave := >smodel.StatusFave{}
- err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select()
- // the fave doesn't exist so return nothing at all
- if err == pg.ErrNoRows {
- return nil, nil
- }
-
- // an error occurred so it might exist or not, we don't know
- if err != nil && err != pg.ErrNoRows {
- return nil, err
- }
-
- // the fave exists so remove it
- if _, err = ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Delete(); err != nil {
- return nil, err
- }
-
- return existingFave, nil
-}
-
-func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) {
- accounts := []*gtsmodel.Account{}
-
- faves := []*gtsmodel.StatusFave{}
- if err := ps.conn.Model(&faves).Where("status_id = ?", status.ID).Select(); err != nil {
- if err == pg.ErrNoRows {
- return accounts, nil // no rows just means nobody has faved this status, so that's fine
- }
- return nil, err // an actual error has occurred
- }
-
- for _, f := range faves {
- acc := >smodel.Account{}
- if err := ps.conn.Model(acc).Where("id = ?", f.AccountID).Select(); err != nil {
- if err == pg.ErrNoRows {
- continue // the account doesn't exist for some reason??? but this isn't the place to worry about that so just skip it
- }
- return nil, err // an actual error has occurred
- }
- accounts = append(accounts, acc)
- }
- return accounts, nil
-}
-
-/*
- CONVERSION FUNCTIONS
-*/
-
-func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) {
- menchies := []*gtsmodel.Mention{}
- for _, a := range targetAccounts {
- // A mentioned account looks like "@test@example.org" or just "@test" for a local account
- // -- we can guarantee this from the regex that targetAccounts should have been derived from.
- // But we still need to do a bit of fiddling to get what we need here -- the username and domain (if given).
-
- // 1. trim off the first @
- t := strings.TrimPrefix(a, "@")
-
- // 2. split the username and domain
- s := strings.Split(t, "@")
-
- // 3. if it's length 1 it's a local account, length 2 means remote, anything else means something is wrong
- var local bool
- switch len(s) {
- case 1:
- local = true
- case 2:
- local = false
- default:
- return nil, fmt.Errorf("mentioned account format '%s' was not valid", a)
- }
-
- var username, domain string
- username = s[0]
- if !local {
- domain = s[1]
- }
-
- // 4. check we now have a proper username and domain
- if username == "" || (!local && domain == "") {
- return nil, fmt.Errorf("username or domain for '%s' was nil", a)
- }
-
- // okay we're good now, we can start pulling accounts out of the database
- mentionedAccount := >smodel.Account{}
- var err error
- if local {
- // local user -- should have a null domain
- err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select()
- } else {
- // remote user -- should have domain defined
- err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? = ?", pg.Ident("domain"), domain).Select()
- }
-
- if err != nil {
- if err == pg.ErrNoRows {
- // no result found for this username/domain so just don't include it as a mencho and carry on about our business
- ps.log.Debugf("no account found with username '%s' and domain '%s', skipping it", username, domain)
- continue
- }
- // a serious error has happened so bail
- return nil, fmt.Errorf("error getting account with username '%s' and domain '%s': %s", username, domain, err)
- }
-
- // id, createdAt and updatedAt will be populated by the db, so we have everything we need!
- menchies = append(menchies, >smodel.Mention{
- StatusID: statusID,
- OriginAccountID: originAccountID,
- TargetAccountID: mentionedAccount.ID,
- })
- }
- return menchies, nil
-}
-
-func (ps *postgresService) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) {
- newTags := []*gtsmodel.Tag{}
- for _, t := range tags {
- tag := >smodel.Tag{}
- // we can use selectorinsert here to create the new tag if it doesn't exist already
- // inserted will be true if this is a new tag we just created
- if err := ps.conn.Model(tag).Where("name = ?", t).Select(); err != nil {
- if err == pg.ErrNoRows {
- // tag doesn't exist yet so populate it
- tag.ID = uuid.NewString()
- tag.Name = t
- tag.FirstSeenFromAccountID = originAccountID
- tag.CreatedAt = time.Now()
- tag.UpdatedAt = time.Now()
- tag.Useable = true
- tag.Listable = true
- } else {
- return nil, fmt.Errorf("error getting tag with name %s: %s", t, err)
- }
- }
-
- // bail already if the tag isn't useable
- if !tag.Useable {
- continue
- }
- tag.LastStatusAt = time.Now()
- newTags = append(newTags, tag)
- }
- return newTags, nil
-}
-
-func (ps *postgresService) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) {
- newEmojis := []*gtsmodel.Emoji{}
- for _, e := range emojis {
- emoji := >smodel.Emoji{}
- err := ps.conn.Model(emoji).Where("shortcode = ?", e).Where("visible_in_picker = true").Where("disabled = false").Select()
- if err != nil {
- if err == pg.ErrNoRows {
- // no result found for this username/domain so just don't include it as an emoji and carry on about our business
- ps.log.Debugf("no emoji found with shortcode %s, skipping it", e)
- continue
- }
- // a serious error has happened so bail
- return nil, fmt.Errorf("error getting emoji with shortcode %s: %s", e, err)
- }
- newEmojis = append(newEmojis, emoji)
- }
- return newEmojis, nil
-}
diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go
new file mode 100644
index 000000000..f8c2fdbe8
--- /dev/null
+++ b/internal/db/pg/pg.go
@@ -0,0 +1,1127 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package pg
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "errors"
+ "fmt"
+ "net"
+ "net/mail"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/go-pg/pg/extra/pgdebug"
+ "github.com/go-pg/pg/v10"
+ "github.com/go-pg/pg/v10/orm"
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// postgresService satisfies the DB interface
+type postgresService struct {
+ config *config.Config
+ conn *pg.DB
+ log *logrus.Logger
+ cancel context.CancelFunc
+ federationDB pub.Database
+}
+
+// NewPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface.
+// Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection.
+func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (db.DB, error) {
+ opts, err := derivePGOptions(c)
+ if err != nil {
+ return nil, fmt.Errorf("could not create postgres service: %s", err)
+ }
+ log.Debugf("using pg options: %+v", opts)
+
+ // create a connection
+ pgCtx, cancel := context.WithCancel(ctx)
+ conn := pg.Connect(opts).WithContext(pgCtx)
+
+ // this will break the logfmt format we normally log in,
+ // since we can't choose where pg outputs to and it defaults to
+ // stdout. So use this option with care!
+ if log.GetLevel() >= logrus.TraceLevel {
+ conn.AddQueryHook(pgdebug.DebugHook{
+ // Print all queries.
+ Verbose: true,
+ })
+ }
+
+ // actually *begin* the connection so that we can tell if the db is there and listening
+ if err := conn.Ping(ctx); err != nil {
+ cancel()
+ return nil, fmt.Errorf("db connection error: %s", err)
+ }
+
+ // print out discovered postgres version
+ var version string
+ if _, err = conn.QueryOneContext(ctx, pg.Scan(&version), "SELECT version()"); err != nil {
+ cancel()
+ return nil, fmt.Errorf("db connection error: %s", err)
+ }
+ log.Infof("connected to postgres version: %s", version)
+
+ ps := &postgresService{
+ config: c,
+ conn: conn,
+ log: log,
+ cancel: cancel,
+ }
+
+ federatingDB := federation.NewFederatingDB(ps, c, log)
+ ps.federationDB = federatingDB
+
+ // we can confidently return this useable postgres service now
+ return ps, nil
+}
+
+/*
+ HANDY STUFF
+*/
+
+// derivePGOptions takes an application config and returns either a ready-to-use *pg.Options
+// with sensible defaults, or an error if it's not satisfied by the provided config.
+func derivePGOptions(c *config.Config) (*pg.Options, error) {
+ if strings.ToUpper(c.DBConfig.Type) != db.DBTypePostgres {
+ return nil, fmt.Errorf("expected db type of %s but got %s", db.DBTypePostgres, c.DBConfig.Type)
+ }
+
+ // validate port
+ if c.DBConfig.Port == 0 {
+ return nil, errors.New("no port set")
+ }
+
+ // validate address
+ if c.DBConfig.Address == "" {
+ return nil, errors.New("no address set")
+ }
+
+ ipv4Regex := regexp.MustCompile(`^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`)
+ hostnameRegex := regexp.MustCompile(`^(?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,}$`)
+ if !hostnameRegex.MatchString(c.DBConfig.Address) && !ipv4Regex.MatchString(c.DBConfig.Address) && c.DBConfig.Address != "localhost" {
+ return nil, fmt.Errorf("address %s was neither an ipv4 address nor a valid hostname", c.DBConfig.Address)
+ }
+
+ // validate username
+ if c.DBConfig.User == "" {
+ return nil, errors.New("no user set")
+ }
+
+ // validate that there's a password
+ if c.DBConfig.Password == "" {
+ return nil, errors.New("no password set")
+ }
+
+ // validate database
+ if c.DBConfig.Database == "" {
+ return nil, errors.New("no database set")
+ }
+
+ // We can rely on the pg library we're using to set
+ // sensible defaults for everything we don't set here.
+ options := &pg.Options{
+ Addr: fmt.Sprintf("%s:%d", c.DBConfig.Address, c.DBConfig.Port),
+ User: c.DBConfig.User,
+ Password: c.DBConfig.Password,
+ Database: c.DBConfig.Database,
+ ApplicationName: c.ApplicationName,
+ }
+
+ return options, nil
+}
+
+/*
+ FEDERATION FUNCTIONALITY
+*/
+
+func (ps *postgresService) Federation() pub.Database {
+ return ps.federationDB
+}
+
+/*
+ BASIC DB FUNCTIONALITY
+*/
+
+func (ps *postgresService) CreateTable(i interface{}) error {
+ return ps.conn.Model(i).CreateTable(&orm.CreateTableOptions{
+ IfNotExists: true,
+ })
+}
+
+func (ps *postgresService) DropTable(i interface{}) error {
+ return ps.conn.Model(i).DropTable(&orm.DropTableOptions{
+ IfExists: true,
+ })
+}
+
+func (ps *postgresService) Stop(ctx context.Context) error {
+ ps.log.Info("closing db connection")
+ if err := ps.conn.Close(); err != nil {
+ // only cancel if there's a problem closing the db
+ ps.cancel()
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) IsHealthy(ctx context.Context) error {
+ return ps.conn.Ping(ctx)
+}
+
+func (ps *postgresService) CreateSchema(ctx context.Context) error {
+ models := []interface{}{
+ (*gtsmodel.Account)(nil),
+ (*gtsmodel.Status)(nil),
+ (*gtsmodel.User)(nil),
+ }
+ ps.log.Info("creating db schema")
+
+ for _, model := range models {
+ err := ps.conn.Model(model).CreateTable(&orm.CreateTableOptions{
+ IfNotExists: true,
+ })
+ if err != nil {
+ return err
+ }
+ }
+
+ ps.log.Info("db schema created")
+ return nil
+}
+
+func (ps *postgresService) GetByID(id string, i interface{}) error {
+ if err := ps.conn.Model(i).Where("id = ?", id).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+
+ }
+ return nil
+}
+
+func (ps *postgresService) GetWhere(key string, value interface{}, i interface{}) error {
+ if err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+// func (ps *postgresService) GetWhereMany(i interface{}, where ...model.Where) error {
+// return nil
+// }
+
+func (ps *postgresService) GetAll(i interface{}) error {
+ if err := ps.conn.Model(i).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) Put(i interface{}) error {
+ _, err := ps.conn.Model(i).Insert(i)
+ return err
+}
+
+func (ps *postgresService) Upsert(i interface{}, conflictColumn string) error {
+ if _, err := ps.conn.Model(i).OnConflict(fmt.Sprintf("(%s) DO UPDATE", conflictColumn)).Insert(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) UpdateByID(id string, i interface{}) error {
+ if _, err := ps.conn.Model(i).Where("id = ?", id).OnConflict("(id) DO UPDATE").Insert(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) UpdateOneByID(id string, key string, value interface{}, i interface{}) error {
+ _, err := ps.conn.Model(i).Set("? = ?", pg.Safe(key), value).Where("id = ?", id).Update()
+ return err
+}
+
+func (ps *postgresService) DeleteByID(id string, i interface{}) error {
+ if _, err := ps.conn.Model(i).Where("id = ?", id).Delete(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) DeleteWhere(key string, value interface{}, i interface{}) error {
+ if _, err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Delete(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+/*
+ HANDY SHORTCUTS
+*/
+
+func (ps *postgresService) AcceptFollowRequest(originAccountID string, targetAccountID string) error {
+ fr := >smodel.FollowRequest{}
+ if err := ps.conn.Model(fr).Where("account_id = ?", originAccountID).Where("target_account_id = ?", targetAccountID).Select(); err != nil {
+ if err == pg.ErrMultiRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+
+ follow := >smodel.Follow{
+ AccountID: originAccountID,
+ TargetAccountID: targetAccountID,
+ URI: fr.URI,
+ }
+
+ if _, err := ps.conn.Model(follow).Insert(); err != nil {
+ return err
+ }
+
+ if _, err := ps.conn.Model(>smodel.FollowRequest{}).Where("account_id = ?", originAccountID).Where("target_account_id = ?", targetAccountID).Delete(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (ps *postgresService) CreateInstanceAccount() error {
+ username := ps.config.Host
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ ps.log.Errorf("error creating new rsa key: %s", err)
+ return err
+ }
+
+ 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, a.ID)
+ } else {
+ 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
+}
+
+func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.Account) error {
+ user := >smodel.User{
+ ID: userID,
+ }
+ if err := ps.conn.Model(user).Where("id = ?", userID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ if err := ps.conn.Model(account).Where("id = ?", user.AccountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error {
+ if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error {
+ if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error {
+ if err := ps.conn.Model(following).Where("account_id = ?", accountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error {
+ if err := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetFavesByAccountID(accountID string, faves *[]gtsmodel.StatusFave) error {
+ if err := ps.conn.Model(faves).Where("account_id = ?", accountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error {
+ if err := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error {
+ q := ps.conn.Model(statuses).Order("created_at DESC")
+ if limit != 0 {
+ q = q.Limit(limit)
+ }
+ if accountID != "" {
+ q = q.Where("account_id = ?", accountID)
+ }
+ if err := q.Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error {
+ if err := ps.conn.Model(status).Order("created_at DESC").Limit(1).Where("account_id = ?", accountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+
+}
+
+func (ps *postgresService) IsUsernameAvailable(username string) error {
+ // if no error we fail because it means we found something
+ // if error but it's not pg.ErrNoRows then we fail
+ // if err is pg.ErrNoRows we're good, we found nothing so continue
+ if err := ps.conn.Model(>smodel.Account{}).Where("username = ?", username).Where("domain = ?", nil).Select(); err == nil {
+ return fmt.Errorf("username %s already in use", username)
+ } else if err != pg.ErrNoRows {
+ return fmt.Errorf("db error: %s", err)
+ }
+ return nil
+}
+
+func (ps *postgresService) IsEmailAvailable(email string) error {
+ // parse the domain from the email
+ m, err := mail.ParseAddress(email)
+ if err != nil {
+ return fmt.Errorf("error parsing email address %s: %s", email, err)
+ }
+ domain := strings.Split(m.Address, "@")[1] // domain will always be the second part after @
+
+ // check if the email domain is blocked
+ if err := ps.conn.Model(>smodel.EmailDomainBlock{}).Where("domain = ?", domain).Select(); err == nil {
+ // fail because we found something
+ return fmt.Errorf("email domain %s is blocked", domain)
+ } else if err != pg.ErrNoRows {
+ // fail because we got an unexpected error
+ return fmt.Errorf("db error: %s", err)
+ }
+
+ // check if this email is associated with a user already
+ if err := ps.conn.Model(>smodel.User{}).Where("email = ?", email).WhereOr("unconfirmed_email = ?", email).Select(); err == nil {
+ // fail because we found something
+ return fmt.Errorf("email %s already in use", email)
+ } else if err != pg.ErrNoRows {
+ // fail because we got an unexpected error
+ return fmt.Errorf("db error: %s", err)
+ }
+ return nil
+}
+
+func (ps *postgresService) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ ps.log.Errorf("error creating new rsa key: %s", err)
+ return nil, err
+ }
+
+ newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host)
+
+ a := >smodel.Account{
+ Username: username,
+ DisplayName: username,
+ Reason: reason,
+ 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,
+ }
+ if _, err = ps.conn.Model(a).Insert(); err != nil {
+ return nil, err
+ }
+
+ pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, fmt.Errorf("error hashing password: %s", err)
+ }
+ u := >smodel.User{
+ AccountID: a.ID,
+ EncryptedPassword: string(pw),
+ SignUpIP: signUpIP,
+ Locale: locale,
+ UnconfirmedEmail: email,
+ CreatedByApplicationID: appID,
+ Approved: !requireApproval, // if we don't require moderator approval, just pre-approve the user
+ }
+ if _, err = ps.conn.Model(u).Insert(); err != nil {
+ return nil, err
+ }
+
+ return u, nil
+}
+
+func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error {
+ if mediaAttachment.Avatar && mediaAttachment.Header {
+ return errors.New("one media attachment cannot be both header and avatar")
+ }
+
+ var headerOrAVI string
+ if mediaAttachment.Avatar {
+ headerOrAVI = "avatar"
+ } else if mediaAttachment.Header {
+ headerOrAVI = "header"
+ } else {
+ return errors.New("given media attachment was neither a header nor an avatar")
+ }
+
+ // TODO: there are probably more side effects here that need to be handled
+ if _, err := ps.conn.Model(mediaAttachment).OnConflict("(id) DO UPDATE").Insert(); err != nil {
+ return err
+ }
+
+ if _, err := ps.conn.Model(>smodel.Account{}).Set(fmt.Sprintf("%s_media_attachment_id = ?", headerOrAVI), mediaAttachment.ID).Where("id = ?", accountID).Update(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error {
+ acct := >smodel.Account{}
+ if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+
+ if acct.HeaderMediaAttachmentID == "" {
+ return db.ErrNoEntries{}
+ }
+
+ if err := ps.conn.Model(header).Where("id = ?", acct.HeaderMediaAttachmentID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error {
+ acct := >smodel.Account{}
+ if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+
+ if acct.AvatarMediaAttachmentID == "" {
+ return db.ErrNoEntries{}
+ }
+
+ if err := ps.conn.Model(avatar).Where("id = ?", acct.AvatarMediaAttachmentID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return db.ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
+func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) {
+ // TODO: check domain blocks as well
+ var blocked bool
+ if err := ps.conn.Model(>smodel.Block{}).
+ Where("account_id = ?", account1).Where("target_account_id = ?", account2).
+ WhereOr("target_account_id = ?", account1).Where("account_id = ?", account2).
+ Select(); err != nil {
+ if err == pg.ErrNoRows {
+ blocked = false
+ return blocked, nil
+ }
+ return blocked, err
+ }
+ blocked = true
+ return blocked, nil
+}
+
+func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) {
+ l := ps.log.WithField("func", "StatusVisible")
+
+ // if target account is suspended then don't show the status
+ if !targetAccount.SuspendedAt.IsZero() {
+ l.Debug("target account suspended at is not zero")
+ return false, nil
+ }
+
+ // if the target user doesn't exist (anymore) then the status also shouldn't be visible
+ targetUser := >smodel.User{}
+ if err := ps.conn.Model(targetUser).Where("account_id = ?", targetAccount.ID).Select(); err != nil {
+ l.Debug("target user could not be selected")
+ if err == pg.ErrNoRows {
+ return false, db.ErrNoEntries{}
+ }
+ return false, err
+ }
+
+ // if target user is disabled, not yet approved, or not confirmed then don't show the status
+ // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!)
+ if targetUser.Disabled || !targetUser.Approved || targetUser.ConfirmedAt.IsZero() {
+ l.Debug("target user is disabled, not approved, or not confirmed")
+ return false, nil
+ }
+
+ // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed.
+ // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't.
+ if requestingAccount == nil {
+
+ if targetStatus.Visibility == gtsmodel.VisibilityPublic {
+ return true, nil
+ }
+ l.Debug("requesting account is nil but the target status isn't public")
+ return false, nil
+ }
+
+ // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten
+ // this far (ie., been authed) in the first place: this is just for safety.
+ if !requestingAccount.SuspendedAt.IsZero() {
+ l.Debug("requesting account is suspended")
+ return false, nil
+ }
+
+ // check if we have a local account -- if so we can check the user for that account in the DB
+ if requestingAccount.Domain == "" {
+ requestingUser := >smodel.User{}
+ if err := ps.conn.Model(requestingUser).Where("account_id = ?", requestingAccount.ID).Select(); err != nil {
+ // if the requesting account is local but doesn't have a corresponding user in the db this is a problem
+ if err == pg.ErrNoRows {
+ l.Debug("requesting account is local but there's no corresponding user")
+ return false, nil
+ }
+ l.Debugf("requesting account is local but there was an error getting the corresponding user: %s", err)
+ return false, err
+ }
+ // okay, user exists, so make sure it has full privileges/is confirmed/approved
+ if requestingUser.Disabled || !requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() {
+ l.Debug("requesting account is local but corresponding user is either disabled, not approved, or not confirmed")
+ return false, nil
+ }
+ }
+
+ // if the target status belongs to the requesting account, they should always be able to view it at this point
+ if targetStatus.AccountID == requestingAccount.ID {
+ return true, nil
+ }
+
+ // At this point we have a populated targetAccount, targetStatus, and requestingAccount, so we can check for blocks and whathaveyou
+ // First check if a block exists directly between the target account (which authored the status) and the requesting account.
+ if blocked, err := ps.Blocked(targetAccount.ID, requestingAccount.ID); err != nil {
+ l.Debugf("something went wrong figuring out if the accounts have a block: %s", err)
+ return false, err
+ } else if blocked {
+ // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please
+ l.Debug("a block exists between requesting account and target account")
+ return false, nil
+ }
+
+ // check other accounts mentioned/boosted by/replied to by the status, if they exist
+ if relevantAccounts != nil {
+ // status replies to account id
+ if relevantAccounts.ReplyToAccount != nil {
+ if blocked, err := ps.Blocked(relevantAccounts.ReplyToAccount.ID, requestingAccount.ID); err != nil {
+ return false, err
+ } else if blocked {
+ return false, nil
+ }
+ }
+
+ // status boosts accounts id
+ if relevantAccounts.BoostedAccount != nil {
+ if blocked, err := ps.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil {
+ return false, err
+ } else if blocked {
+ return false, nil
+ }
+ }
+
+ // status boosts a reply to account id
+ if relevantAccounts.BoostedReplyToAccount != nil {
+ if blocked, err := ps.Blocked(relevantAccounts.BoostedReplyToAccount.ID, requestingAccount.ID); err != nil {
+ return false, err
+ } else if blocked {
+ return false, nil
+ }
+ }
+
+ // status mentions accounts
+ for _, a := range relevantAccounts.MentionedAccounts {
+ if blocked, err := ps.Blocked(a.ID, requestingAccount.ID); err != nil {
+ return false, err
+ } else if blocked {
+ return false, nil
+ }
+ }
+ }
+
+ // at this point we know neither account blocks the other, or another account mentioned or otherwise referred to in the status
+ // that means it's now just a matter of checking the visibility settings of the status itself
+ switch targetStatus.Visibility {
+ case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked:
+ // no problem here, just return OK
+ return true, nil
+ case gtsmodel.VisibilityFollowersOnly:
+ // check one-way follow
+ follows, err := ps.Follows(requestingAccount, targetAccount)
+ if err != nil {
+ return false, err
+ }
+ if !follows {
+ return false, nil
+ }
+ return true, nil
+ case gtsmodel.VisibilityMutualsOnly:
+ // check mutual follow
+ mutuals, err := ps.Mutuals(requestingAccount, targetAccount)
+ if err != nil {
+ return false, err
+ }
+ if !mutuals {
+ return false, nil
+ }
+ return true, nil
+ case gtsmodel.VisibilityDirect:
+ // make sure the requesting account is mentioned in the status
+ for _, menchie := range targetStatus.Mentions {
+ if menchie == requestingAccount.ID {
+ return true, nil // yep it's mentioned!
+ }
+ }
+ return false, nil // it's not mentioned -_-
+ }
+
+ return false, errors.New("reached the end of StatusVisible with no result")
+}
+
+func (ps *postgresService) Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) {
+ return ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", sourceAccount.ID).Where("target_account_id = ?", targetAccount.ID).Exists()
+}
+
+func (ps *postgresService) Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) {
+ // make sure account 1 follows account 2
+ f1, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account1.ID).Where("target_account_id = ?", account2.ID).Exists()
+ if err != nil {
+ if err == pg.ErrNoRows {
+ return false, nil
+ }
+ return false, err
+ }
+
+ // make sure account 2 follows account 1
+ f2, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account2.ID).Where("target_account_id = ?", account1.ID).Exists()
+ if err != nil {
+ if err == pg.ErrNoRows {
+ return false, nil
+ }
+ return false, err
+ }
+
+ return f1 && f2, nil
+}
+
+func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) {
+ accounts := >smodel.RelevantAccounts{
+ MentionedAccounts: []*gtsmodel.Account{},
+ }
+
+ // get the replied to account from the status and add it to the pile
+ if targetStatus.InReplyToAccountID != "" {
+ repliedToAccount := >smodel.Account{}
+ if err := ps.conn.Model(repliedToAccount).Where("id = ?", targetStatus.InReplyToAccountID).Select(); err != nil {
+ return accounts, err
+ }
+ accounts.ReplyToAccount = repliedToAccount
+ }
+
+ // get the boosted account from the status and add it to the pile
+ if targetStatus.BoostOfID != "" {
+ // retrieve the boosted status first
+ boostedStatus := >smodel.Status{}
+ if err := ps.conn.Model(boostedStatus).Where("id = ?", targetStatus.BoostOfID).Select(); err != nil {
+ return accounts, err
+ }
+ boostedAccount := >smodel.Account{}
+ if err := ps.conn.Model(boostedAccount).Where("id = ?", boostedStatus.AccountID).Select(); err != nil {
+ return accounts, err
+ }
+ accounts.BoostedAccount = boostedAccount
+
+ // the boosted status might be a reply to another account so we should get that too
+ if boostedStatus.InReplyToAccountID != "" {
+ boostedStatusRepliedToAccount := >smodel.Account{}
+ if err := ps.conn.Model(boostedStatusRepliedToAccount).Where("id = ?", boostedStatus.InReplyToAccountID).Select(); err != nil {
+ return accounts, err
+ }
+ accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount
+ }
+ }
+
+ // now get all accounts with IDs that are mentioned in the status
+ for _, mentionedAccountID := range targetStatus.Mentions {
+ mentionedAccount := >smodel.Account{}
+ if err := ps.conn.Model(mentionedAccount).Where("id = ?", mentionedAccountID).Select(); err != nil {
+ return accounts, err
+ }
+ accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount)
+ }
+
+ return accounts, nil
+}
+
+func (ps *postgresService) GetReplyCountForStatus(status *gtsmodel.Status) (int, error) {
+ return ps.conn.Model(>smodel.Status{}).Where("in_reply_to_id = ?", status.ID).Count()
+}
+
+func (ps *postgresService) GetReblogCountForStatus(status *gtsmodel.Status) (int, error) {
+ return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Count()
+}
+
+func (ps *postgresService) GetFaveCountForStatus(status *gtsmodel.Status) (int, error) {
+ return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Count()
+}
+
+func (ps *postgresService) StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) {
+ return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
+}
+
+func (ps *postgresService) StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) {
+ return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
+}
+
+func (ps *postgresService) StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) {
+ return ps.conn.Model(>smodel.StatusMute{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
+}
+
+func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) {
+ return ps.conn.Model(>smodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
+}
+
+func (ps *postgresService) StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) {
+ return ps.conn.Model(>smodel.StatusPin{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists()
+}
+
+func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) {
+ // first check if a fave already exists, we can just return if so
+ existingFave := >smodel.StatusFave{}
+ err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select()
+ if err == nil {
+ // fave already exists so just return nothing at all
+ return nil, nil
+ }
+
+ // an error occurred so it might exist or not, we don't know
+ if err != pg.ErrNoRows {
+ return nil, err
+ }
+
+ // it doesn't exist so create it
+ newFave := >smodel.StatusFave{
+ AccountID: accountID,
+ TargetAccountID: status.AccountID,
+ StatusID: status.ID,
+ }
+ if _, err = ps.conn.Model(newFave).Insert(); err != nil {
+ return nil, err
+ }
+
+ return newFave, nil
+}
+
+func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) {
+ // if a fave doesn't exist, we don't need to do anything
+ existingFave := >smodel.StatusFave{}
+ err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select()
+ // the fave doesn't exist so return nothing at all
+ if err == pg.ErrNoRows {
+ return nil, nil
+ }
+
+ // an error occurred so it might exist or not, we don't know
+ if err != nil && err != pg.ErrNoRows {
+ return nil, err
+ }
+
+ // the fave exists so remove it
+ if _, err = ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Delete(); err != nil {
+ return nil, err
+ }
+
+ return existingFave, nil
+}
+
+func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) {
+ accounts := []*gtsmodel.Account{}
+
+ faves := []*gtsmodel.StatusFave{}
+ if err := ps.conn.Model(&faves).Where("status_id = ?", status.ID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return accounts, nil // no rows just means nobody has faved this status, so that's fine
+ }
+ return nil, err // an actual error has occurred
+ }
+
+ for _, f := range faves {
+ acc := >smodel.Account{}
+ if err := ps.conn.Model(acc).Where("id = ?", f.AccountID).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ continue // the account doesn't exist for some reason??? but this isn't the place to worry about that so just skip it
+ }
+ return nil, err // an actual error has occurred
+ }
+ accounts = append(accounts, acc)
+ }
+ return accounts, nil
+}
+
+/*
+ CONVERSION FUNCTIONS
+*/
+
+func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) {
+ menchies := []*gtsmodel.Mention{}
+ for _, a := range targetAccounts {
+ // A mentioned account looks like "@test@example.org" or just "@test" for a local account
+ // -- we can guarantee this from the regex that targetAccounts should have been derived from.
+ // But we still need to do a bit of fiddling to get what we need here -- the username and domain (if given).
+
+ // 1. trim off the first @
+ t := strings.TrimPrefix(a, "@")
+
+ // 2. split the username and domain
+ s := strings.Split(t, "@")
+
+ // 3. if it's length 1 it's a local account, length 2 means remote, anything else means something is wrong
+ var local bool
+ switch len(s) {
+ case 1:
+ local = true
+ case 2:
+ local = false
+ default:
+ return nil, fmt.Errorf("mentioned account format '%s' was not valid", a)
+ }
+
+ var username, domain string
+ username = s[0]
+ if !local {
+ domain = s[1]
+ }
+
+ // 4. check we now have a proper username and domain
+ if username == "" || (!local && domain == "") {
+ return nil, fmt.Errorf("username or domain for '%s' was nil", a)
+ }
+
+ // okay we're good now, we can start pulling accounts out of the database
+ mentionedAccount := >smodel.Account{}
+ var err error
+ if local {
+ // local user -- should have a null domain
+ err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select()
+ } else {
+ // remote user -- should have domain defined
+ err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? = ?", pg.Ident("domain"), domain).Select()
+ }
+
+ if err != nil {
+ if err == pg.ErrNoRows {
+ // no result found for this username/domain so just don't include it as a mencho and carry on about our business
+ ps.log.Debugf("no account found with username '%s' and domain '%s', skipping it", username, domain)
+ continue
+ }
+ // a serious error has happened so bail
+ return nil, fmt.Errorf("error getting account with username '%s' and domain '%s': %s", username, domain, err)
+ }
+
+ // id, createdAt and updatedAt will be populated by the db, so we have everything we need!
+ menchies = append(menchies, >smodel.Mention{
+ StatusID: statusID,
+ OriginAccountID: originAccountID,
+ TargetAccountID: mentionedAccount.ID,
+ })
+ }
+ return menchies, nil
+}
+
+func (ps *postgresService) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) {
+ newTags := []*gtsmodel.Tag{}
+ for _, t := range tags {
+ tag := >smodel.Tag{}
+ // we can use selectorinsert here to create the new tag if it doesn't exist already
+ // inserted will be true if this is a new tag we just created
+ if err := ps.conn.Model(tag).Where("name = ?", t).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ // tag doesn't exist yet so populate it
+ tag.ID = uuid.NewString()
+ tag.Name = t
+ tag.FirstSeenFromAccountID = originAccountID
+ tag.CreatedAt = time.Now()
+ tag.UpdatedAt = time.Now()
+ tag.Useable = true
+ tag.Listable = true
+ } else {
+ return nil, fmt.Errorf("error getting tag with name %s: %s", t, err)
+ }
+ }
+
+ // bail already if the tag isn't useable
+ if !tag.Useable {
+ continue
+ }
+ tag.LastStatusAt = time.Now()
+ newTags = append(newTags, tag)
+ }
+ return newTags, nil
+}
+
+func (ps *postgresService) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) {
+ newEmojis := []*gtsmodel.Emoji{}
+ for _, e := range emojis {
+ emoji := >smodel.Emoji{}
+ err := ps.conn.Model(emoji).Where("shortcode = ?", e).Where("visible_in_picker = true").Where("disabled = false").Select()
+ if err != nil {
+ if err == pg.ErrNoRows {
+ // no result found for this username/domain so just don't include it as an emoji and carry on about our business
+ ps.log.Debugf("no emoji found with shortcode %s, skipping it", e)
+ continue
+ }
+ // a serious error has happened so bail
+ return nil, fmt.Errorf("error getting emoji with shortcode %s: %s", e, err)
+ }
+ newEmojis = append(newEmojis, emoji)
+ }
+ return newEmojis, nil
+}
diff --git a/internal/db/pg_test.go b/internal/db/pg_test.go
deleted file mode 100644
index a54784022..000000000
--- a/internal/db/pg_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-*/
-
-package db_test
-
-// TODO: write tests for postgres
diff --git a/internal/federation/clock.go b/internal/federation/clock.go
index f0d6f5e84..cc67f8b73 100644
--- a/internal/federation/clock.go
+++ b/internal/federation/clock.go
@@ -37,6 +37,7 @@ func (c *Clock) Now() time.Time {
return time.Now()
}
+// NewClock returns a simple pub.Clock for use in federation interfaces.
func NewClock() pub.Clock {
return &Clock{}
}
diff --git a/internal/federation/commonbehavior.go b/internal/federation/commonbehavior.go
index 9274e78b4..8ed6fd2cb 100644
--- a/internal/federation/commonbehavior.go
+++ b/internal/federation/commonbehavior.go
@@ -57,7 +57,7 @@ import (
// authenticated must be true and error nil. The request will continue
// to be processed.
func (f *federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
- // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+ // IMPLEMENTATION NOTE: For GoToSocial, we serve GETS to outboxes and inboxes through
// the CLIENT API, not through the federation API, so we just do nothing here.
return nil, false, nil
}
@@ -82,7 +82,7 @@ func (f *federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWri
// authenticated must be true and error nil. The request will continue
// to be processed.
func (f *federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
- // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+ // IMPLEMENTATION NOTE: For GoToSocial, we serve GETS to outboxes and inboxes through
// the CLIENT API, not through the federation API, so we just do nothing here.
return nil, false, nil
}
@@ -96,7 +96,7 @@ func (f *federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWr
// Always called, regardless whether the Federated Protocol or Social
// API is enabled.
func (f *federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
- // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+ // IMPLEMENTATION NOTE: For GoToSocial, we serve GETS to outboxes and inboxes through
// the CLIENT API, not through the federation API, so we just do nothing here.
return nil, nil
}
diff --git a/internal/federation/federating_db.go b/internal/federation/federating_db.go
new file mode 100644
index 000000000..4ea0412e7
--- /dev/null
+++ b/internal/federation/federating_db.go
@@ -0,0 +1,599 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package federation
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "sync"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/go-fed/activity/streams"
+ "github.com/go-fed/activity/streams/vocab"
+ "github.com/google/uuid"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface.
+// It doesn't care what the underlying implementation of the DB interface is, as long as it works.
+type federatingDB struct {
+ locks *sync.Map
+ db db.DB
+ config *config.Config
+ log *logrus.Logger
+ typeConverter typeutils.TypeConverter
+}
+
+// NewFederatingDB returns a pub.Database interface using the given database, config, and logger.
+func NewFederatingDB(db db.DB, config *config.Config, log *logrus.Logger) pub.Database {
+ return &federatingDB{
+ locks: new(sync.Map),
+ db: db,
+ config: config,
+ log: log,
+ typeConverter: typeutils.NewConverter(config, db),
+ }
+}
+
+/*
+ GO-FED DB INTERFACE-IMPLEMENTING FUNCTIONS
+*/
+
+// Lock takes a lock for the object at the specified id. If an error
+// is returned, the lock must not have been taken.
+//
+// The lock must be able to succeed for an id that does not exist in
+// the database. This means acquiring the lock does not guarantee the
+// entry exists in the database.
+//
+// Locks are encouraged to be lightweight and in the Go layer, as some
+// processes require tight loops acquiring and releasing locks.
+//
+// Used to ensure race conditions in multiple requests do not occur.
+func (f *federatingDB) Lock(c context.Context, id *url.URL) error {
+ // Before any other Database methods are called, the relevant `id`
+ // entries are locked to allow for fine-grained concurrency.
+
+ // Strategy: create a new lock, if stored, continue. Otherwise, lock the
+ // existing mutex.
+ mu := &sync.Mutex{}
+ mu.Lock() // Optimistically lock if we do store it.
+ i, loaded := f.locks.LoadOrStore(id.String(), mu)
+ if loaded {
+ mu = i.(*sync.Mutex)
+ mu.Lock()
+ }
+ return nil
+}
+
+// Unlock makes the lock for the object at the specified id available.
+// If an error is returned, the lock must have still been freed.
+//
+// Used to ensure race conditions in multiple requests do not occur.
+func (f *federatingDB) Unlock(c context.Context, id *url.URL) error {
+ // Once Go-Fed is done calling Database methods, the relevant `id`
+ // entries are unlocked.
+
+ i, ok := f.locks.Load(id.String())
+ if !ok {
+ return errors.New("missing an id in unlock")
+ }
+ mu := i.(*sync.Mutex)
+ mu.Unlock()
+ return nil
+}
+
+// InboxContains returns true if the OrderedCollection at 'inbox'
+// contains the specified 'id'.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "InboxContains",
+ "id": id.String(),
+ },
+ )
+ l.Debugf("entering INBOXCONTAINS function with for inbox %s and id %s", inbox.String(), id.String())
+
+ if !util.IsInboxPath(inbox) {
+ return false, fmt.Errorf("%s is not an inbox URI", inbox.String())
+ }
+
+ activityI := c.Value(util.APActivity)
+ if activityI == nil {
+ return false, fmt.Errorf("no activity was set for id %s", id.String())
+ }
+ activity, ok := activityI.(pub.Activity)
+ if !ok || activity == nil {
+ return false, fmt.Errorf("could not parse contextual activity for id %s", id.String())
+ }
+
+ l.Debugf("activity type %s for id %s", activity.GetTypeName(), id.String())
+
+ return false, nil
+
+ // if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil {
+ // if _, ok := err.(db.ErrNoEntries); ok {
+ // // we don't have it
+ // return false, nil
+ // }
+ // // actual error
+ // return false, fmt.Errorf("error getting status from db: %s", err)
+ // }
+
+ // // we must have it
+ // return true, nil
+}
+
+// GetInbox returns the first ordered collection page of the outbox at
+// the specified IRI, for prepending new items.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) GetInbox(c context.Context, inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "GetInbox",
+ },
+ )
+ l.Debugf("entering GETINBOX function with inboxIRI %s", inboxIRI.String())
+ return streams.NewActivityStreamsOrderedCollectionPage(), nil
+}
+
+// SetInbox saves the inbox value given from GetInbox, with new items
+// prepended. Note that the new items must not be added as independent
+// database entries. Separate calls to Create will do that.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "SetInbox",
+ },
+ )
+ l.Debug("entering SETINBOX function")
+ return nil
+}
+
+// Owns returns true if the IRI belongs to this instance, and if
+// the database has an entry for the IRI.
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Owns",
+ "id": id.String(),
+ },
+ )
+ l.Debugf("entering OWNS function with id %s", id.String())
+
+ // if the id host isn't this instance host, we don't own this IRI
+ if id.Host != f.config.Host {
+ l.Debugf("we DO NOT own activity because the host is %s not %s", id.Host, f.config.Host)
+ return false, nil
+ }
+
+ // apparently it belongs to this host, so what *is* it?
+
+ // check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS
+ if util.IsStatusesPath(id) {
+ _, uid, err := util.ParseStatusesPath(id)
+ if err != nil {
+ return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
+ }
+ if err := f.db.GetWhere("uri", uid, >smodel.Status{}); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ // there are no entries for this status
+ return false, nil
+ }
+ // an actual error happened
+ return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err)
+ }
+ l.Debug("we DO own this")
+ return true, nil
+ }
+
+ // check if it's a user, eg /users/example_username
+ if util.IsUserPath(id) {
+ username, err := util.ParseUserPath(id)
+ if err != nil {
+ return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
+ }
+ if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ // there are no entries for this username
+ return false, nil
+ }
+ // an actual error happened
+ return false, fmt.Errorf("database error fetching account with username %s: %s", username, err)
+ }
+ l.Debug("we DO own this")
+ return true, nil
+ }
+
+ return false, fmt.Errorf("could not match activityID: %s", id.String())
+}
+
+// ActorForOutbox fetches the actor's IRI for the given outbox IRI.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "ActorForOutbox",
+ "inboxIRI": outboxIRI.String(),
+ },
+ )
+ l.Debugf("entering ACTORFOROUTBOX function with outboxIRI %s", outboxIRI.String())
+
+ if !util.IsOutboxPath(outboxIRI) {
+ return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String())
+ }
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("outbox_uri", outboxIRI.String(), acct); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String())
+ }
+ return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String())
+ }
+ return url.Parse(acct.URI)
+}
+
+// ActorForInbox fetches the actor's IRI for the given outbox IRI.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "ActorForInbox",
+ "inboxIRI": inboxIRI.String(),
+ },
+ )
+ l.Debugf("entering ACTORFORINBOX function with inboxIRI %s", inboxIRI.String())
+
+ if !util.IsInboxPath(inboxIRI) {
+ return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
+ }
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
+ }
+ return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
+ }
+ return url.Parse(acct.URI)
+}
+
+// OutboxForInbox fetches the corresponding actor's outbox IRI for the
+// actor's inbox IRI.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "OutboxForInbox",
+ "inboxIRI": inboxIRI.String(),
+ },
+ )
+ l.Debugf("entering OUTBOXFORINBOX function with inboxIRI %s", inboxIRI.String())
+
+ if !util.IsInboxPath(inboxIRI) {
+ return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
+ }
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
+ }
+ return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
+ }
+ return url.Parse(acct.OutboxURI)
+}
+
+// Exists returns true if the database has an entry for the specified
+// id. It may not be owned by this application instance.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) Exists(c context.Context, id *url.URL) (exists bool, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Exists",
+ "id": id.String(),
+ },
+ )
+ l.Debugf("entering EXISTS function with id %s", id.String())
+
+ return false, nil
+}
+
+// Get returns the database entry for the specified id.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) Get(c context.Context, id *url.URL) (value vocab.Type, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Get",
+ "id": id.String(),
+ },
+ )
+ l.Debug("entering GET function")
+
+ if util.IsUserPath(id) {
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("uri", id.String(), acct); err != nil {
+ return nil, err
+ }
+ return f.typeConverter.AccountToAS(acct)
+ }
+
+ return nil, nil
+}
+
+// Create adds a new entry to the database which must be able to be
+// keyed by its id.
+//
+// Note that Activity values received from federated peers may also be
+// created in the database this way if the Federating Protocol is
+// enabled. The client may freely decide to store only the id instead of
+// the entire value.
+//
+// The library makes this call only after acquiring a lock first.
+//
+// Under certain conditions and network activities, Create may be called
+// multiple times for the same ActivityStreams object.
+func (f *federatingDB) Create(c context.Context, asType vocab.Type) error {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Create",
+ "asType": asType.GetTypeName(),
+ },
+ )
+ l.Debugf("received CREATE asType %+v", asType)
+
+ switch gtsmodel.ActivityStreamsActivity(asType.GetTypeName()) {
+ case gtsmodel.ActivityStreamsCreate:
+ create, ok := asType.(vocab.ActivityStreamsCreate)
+ if !ok {
+ return errors.New("could not convert type to create")
+ }
+ object := create.GetActivityStreamsObject()
+ for objectIter := object.Begin(); objectIter != object.End(); objectIter = objectIter.Next() {
+ switch gtsmodel.ActivityStreamsObject(objectIter.GetType().GetTypeName()) {
+ case gtsmodel.ActivityStreamsNote:
+ note := objectIter.GetActivityStreamsNote()
+ status, err := f.typeConverter.ASStatusToStatus(note)
+ if err != nil {
+ return fmt.Errorf("error converting note to status: %s", err)
+ }
+ if err := f.db.Put(status); err != nil {
+ return fmt.Errorf("database error inserting status: %s", err)
+ }
+ }
+ }
+ case gtsmodel.ActivityStreamsFollow:
+ follow, ok := asType.(vocab.ActivityStreamsFollow)
+ if !ok {
+ return errors.New("could not convert type to follow")
+ }
+
+ followRequest, err := f.typeConverter.ASFollowToFollowRequest(follow)
+ if err != nil {
+ return fmt.Errorf("could not convert Follow to follow request: %s", err)
+ }
+
+ if err := f.db.Put(followRequest); err != nil {
+ return fmt.Errorf("database error inserting follow request: %s", err)
+ }
+ }
+ return nil
+}
+
+// Update sets an existing entry to the database based on the value's
+// id.
+//
+// Note that Activity values received from federated peers may also be
+// updated in the database this way if the Federating Protocol is
+// enabled. The client may freely decide to store only the id instead of
+// the entire value.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) Update(c context.Context, asType vocab.Type) error {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Update",
+ "asType": asType.GetTypeName(),
+ },
+ )
+ l.Debugf("received UPDATE asType %+v", asType)
+ return nil
+}
+
+// Delete removes the entry with the given id.
+//
+// Delete is only called for federated objects. Deletes from the Social
+// Protocol instead call Update to create a Tombstone.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) Delete(c context.Context, id *url.URL) error {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Delete",
+ "id": id.String(),
+ },
+ )
+ l.Debugf("received DELETE id %s", id.String())
+ return nil
+}
+
+// GetOutbox returns the first ordered collection page of the outbox
+// at the specified IRI, for prepending new items.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) GetOutbox(c context.Context, outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "GetOutbox",
+ },
+ )
+ l.Debug("entering GETOUTBOX function")
+
+ return nil, nil
+}
+
+// SetOutbox saves the outbox value given from GetOutbox, with new items
+// prepended. Note that the new items must not be added as independent
+// database entries. Separate calls to Create will do that.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) SetOutbox(c context.Context, outbox vocab.ActivityStreamsOrderedCollectionPage) error {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "SetOutbox",
+ },
+ )
+ l.Debug("entering SETOUTBOX function")
+
+ return nil
+}
+
+// NewID creates a new IRI id for the provided activity or object. The
+// implementation does not need to set the 'id' property and simply
+// needs to determine the value.
+//
+// The go-fed library will handle setting the 'id' property on the
+// activity or object provided with the value returned.
+func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "NewID",
+ "asType": t.GetTypeName(),
+ },
+ )
+ l.Debugf("received NEWID request for asType %+v", t)
+
+ return url.Parse(fmt.Sprintf("%s://%s/", f.config.Protocol, uuid.NewString()))
+}
+
+// Followers obtains the Followers Collection for an actor with the
+// given id.
+//
+// If modified, the library will then call Update.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) Followers(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Followers",
+ "actorIRI": actorIRI.String(),
+ },
+ )
+ l.Debugf("entering FOLLOWERS function with actorIRI %s", actorIRI.String())
+
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("uri", actorIRI.String(), acct); err != nil {
+ return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err)
+ }
+
+ acctFollowers := []gtsmodel.Follow{}
+ if err := f.db.GetFollowersByAccountID(acct.ID, &acctFollowers); err != nil {
+ return nil, fmt.Errorf("db error getting followers for account id %s: %s", acct.ID, err)
+ }
+
+ followers = streams.NewActivityStreamsCollection()
+ items := streams.NewActivityStreamsItemsProperty()
+ for _, follow := range acctFollowers {
+ gtsFollower := >smodel.Account{}
+ if err := f.db.GetByID(follow.AccountID, gtsFollower); err != nil {
+ return nil, fmt.Errorf("db error getting account id %s: %s", follow.AccountID, err)
+ }
+ uri, err := url.Parse(gtsFollower.URI)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing %s as url: %s", gtsFollower.URI, err)
+ }
+ items.AppendIRI(uri)
+ }
+ followers.SetActivityStreamsItems(items)
+ return
+}
+
+// Following obtains the Following Collection for an actor with the
+// given id.
+//
+// If modified, the library will then call Update.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) Following(c context.Context, actorIRI *url.URL) (following vocab.ActivityStreamsCollection, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Following",
+ "actorIRI": actorIRI.String(),
+ },
+ )
+ l.Debugf("entering FOLLOWING function with actorIRI %s", actorIRI.String())
+
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("uri", actorIRI.String(), acct); err != nil {
+ return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err)
+ }
+
+ acctFollowing := []gtsmodel.Follow{}
+ if err := f.db.GetFollowingByAccountID(acct.ID, &acctFollowing); err != nil {
+ return nil, fmt.Errorf("db error getting following for account id %s: %s", acct.ID, err)
+ }
+
+ following = streams.NewActivityStreamsCollection()
+ items := streams.NewActivityStreamsItemsProperty()
+ for _, follow := range acctFollowing {
+ gtsFollowing := >smodel.Account{}
+ if err := f.db.GetByID(follow.AccountID, gtsFollowing); err != nil {
+ return nil, fmt.Errorf("db error getting account id %s: %s", follow.AccountID, err)
+ }
+ uri, err := url.Parse(gtsFollowing.URI)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing %s as url: %s", gtsFollowing.URI, err)
+ }
+ items.AppendIRI(uri)
+ }
+ following.SetActivityStreamsItems(items)
+ return
+}
+
+// Liked obtains the Liked Collection for an actor with the
+// given id.
+//
+// If modified, the library will then call Update.
+//
+// The library makes this call only after acquiring a lock first.
+func (f *federatingDB) Liked(c context.Context, actorIRI *url.URL) (liked vocab.ActivityStreamsCollection, err error) {
+ l := f.log.WithFields(
+ logrus.Fields{
+ "func": "Liked",
+ "actorIRI": actorIRI.String(),
+ },
+ )
+ l.Debugf("entering LIKED function with actorIRI %s", actorIRI.String())
+ return nil, nil
+}
diff --git a/internal/federation/federating_db_test.go b/internal/federation/federating_db_test.go
new file mode 100644
index 000000000..b4695b55b
--- /dev/null
+++ b/internal/federation/federating_db_test.go
@@ -0,0 +1,21 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package federation
+
+// TODO: write tests for pgfed
diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go
index f105d9125..65a16efaf 100644
--- a/internal/federation/federatingactor.go
+++ b/internal/federation/federatingactor.go
@@ -77,6 +77,13 @@ func (f *federatingActor) PostInbox(c context.Context, w http.ResponseWriter, r
return f.actor.PostInbox(c, w, r)
}
+// PostInboxScheme is similar to PostInbox, except clients are able to
+// specify which protocol scheme to handle the incoming request and the
+// data stored within the application (HTTP, HTTPS, etc).
+func (f *federatingActor) PostInboxScheme(c context.Context, w http.ResponseWriter, r *http.Request, scheme string) (bool, error) {
+ return f.actor.PostInboxScheme(c, w, r, scheme)
+}
+
// GetInbox returns true if the request was handled as an ActivityPub
// GET to an actor's inbox. If false, the request was not an ActivityPub
// request and may still be handled by the caller in another way, such
@@ -118,6 +125,13 @@ func (f *federatingActor) PostOutbox(c context.Context, w http.ResponseWriter, r
return f.actor.PostOutbox(c, w, r)
}
+// PostOutboxScheme is similar to PostOutbox, except clients are able to
+// specify which protocol scheme to handle the incoming request and the
+// data stored within the application (HTTP, HTTPS, etc).
+func (f *federatingActor) PostOutboxScheme(c context.Context, w http.ResponseWriter, r *http.Request, scheme string) (bool, error) {
+ return f.actor.PostOutboxScheme(c, w, r, scheme)
+}
+
// GetOutbox returns true if the request was handled as an ActivityPub
// GET to an actor's outbox. If false, the request was not an
// ActivityPub request.
diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go
index 1764eb791..0d2a8d9dd 100644
--- a/internal/federation/federatingprotocol.go
+++ b/internal/federation/federatingprotocol.go
@@ -72,8 +72,49 @@ func (f *federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Reques
return nil, err
}
- ctxWithActivity := context.WithValue(ctx, util.APActivity, activity)
- return ctxWithActivity, nil
+ // derefence the actor of the activity already
+ // var requestingActorIRI *url.URL
+ // actorProp := activity.GetActivityStreamsActor()
+ // if actorProp != nil {
+ // for i := actorProp.Begin(); i != actorProp.End(); i = i.Next() {
+ // if i.IsIRI() {
+ // requestingActorIRI = i.GetIRI()
+ // break
+ // }
+ // }
+ // }
+ // if requestingActorIRI != nil {
+
+ // requestedAccountI := ctx.Value(util.APAccount)
+ // requestedAccount, ok := requestedAccountI.(*gtsmodel.Account)
+ // if !ok {
+ // return nil, errors.New("requested account was not set on request context")
+ // }
+
+ // requestingActor := >smodel.Account{}
+ // if err := f.db.GetWhere("uri", requestingActorIRI.String(), requestingActor); err != nil {
+ // // there's been a proper error so return it
+ // if _, ok := err.(db.ErrNoEntries); !ok {
+ // return nil, fmt.Errorf("error getting requesting actor with id %s: %s", requestingActorIRI.String(), err)
+ // }
+
+ // // we don't know this account (yet) so let's dereference it right now
+ // person, err := f.DereferenceRemoteAccount(requestedAccount.Username, publicKeyOwnerURI)
+ // if err != nil {
+ // return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err)
+ // }
+
+ // a, err := f.typeConverter.ASRepresentationToAccount(person)
+ // if err != nil {
+ // return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err)
+ // }
+ // requestingAccount = a
+ // }
+ // }
+
+ // set the activity on the context for use later on
+
+ return context.WithValue(ctx, util.APActivity, activity), nil
}
// AuthenticatePostInbox delegates the authentication of a POST to an
@@ -100,14 +141,22 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
})
l.Trace("received request to authenticate")
- requestedAccountI := ctx.Value(util.APAccount)
- if requestedAccountI == nil {
- return ctx, false, errors.New("requested account not set in context")
+ if !util.IsInboxPath(r.URL) {
+ return nil, false, fmt.Errorf("path %s was not an inbox path", r.URL.String())
}
- requestedAccount, ok := requestedAccountI.(*gtsmodel.Account)
- if !ok || requestedAccount == nil {
- return ctx, false, errors.New("requested account not parsebale from context")
+ username, err := util.ParseInboxPath(r.URL)
+ if err != nil {
+ return nil, false, fmt.Errorf("could not parse path %s: %s", r.URL.String(), err)
+ }
+
+ if username == "" {
+ return nil, false, errors.New("username was empty")
+ }
+
+ requestedAccount := >smodel.Account{}
+ if err := f.db.GetLocalAccountByUsername(username, requestedAccount); err != nil {
+ return nil, false, fmt.Errorf("could not fetch requested account with username %s: %s", username, err)
}
publicKeyOwnerURI, err := f.AuthenticateFederatedRequest(requestedAccount.Username, r)
@@ -124,7 +173,6 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
}
// we don't know this account (yet) so let's dereference it right now
- // TODO: slow-fed
person, err := f.DereferenceRemoteAccount(requestedAccount.Username, publicKeyOwnerURI)
if err != nil {
return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err)
@@ -134,12 +182,17 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
if err != nil {
return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err)
}
+
+ if err := f.db.Put(a); err != nil {
+ l.Errorf("error inserting dereferenced remote account: %s", err)
+ }
+
requestingAccount = a
}
- contextWithRequestingAccount := context.WithValue(ctx, util.APRequestingAccount, requestingAccount)
-
- return contextWithRequestingAccount, true, nil
+ withRequester := context.WithValue(ctx, util.APRequestingAccount, requestingAccount)
+ withRequested := context.WithValue(withRequester, util.APAccount, requestedAccount)
+ return withRequested, true, nil
}
// Blocked should determine whether to permit a set of actors given by
@@ -156,8 +209,40 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
// Finally, if the authentication and authorization succeeds, then
// blocked must be false and error nil. The request will continue
// to be processed.
+//
+// TODO: implement domain block checking here as well
func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) {
- // TODO
+ l := f.log.WithFields(logrus.Fields{
+ "func": "Blocked",
+ })
+ l.Debugf("entering BLOCKED function with IRI list: %+v", actorIRIs)
+
+ requestedAccountI := ctx.Value(util.APAccount)
+ requestedAccount, ok := requestedAccountI.(*gtsmodel.Account)
+ if !ok {
+ f.log.Errorf("requested account not set on request context")
+ return false, errors.New("requested account not set on request context, so couldn't determine blocks")
+ }
+
+ for _, uri := range actorIRIs {
+ a := >smodel.Account{}
+ if err := f.db.GetWhere("uri", uri.String(), a); err != nil {
+ _, ok := err.(db.ErrNoEntries)
+ if ok {
+ // we don't have an entry for this account so it's not blocked
+ // TODO: allow a different default to be set for this behavior
+ continue
+ }
+ return false, fmt.Errorf("error getting account with uri %s: %s", uri.String(), err)
+ }
+ blocked, err := f.db.Blocked(requestedAccount.ID, a.ID)
+ if err != nil {
+ return false, fmt.Errorf("error checking account blocks: %s", err)
+ }
+ if blocked {
+ return true, nil
+ }
+ }
return false, nil
}
@@ -180,9 +265,40 @@ func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er
//
// Applications are not expected to handle every single ActivityStreams
// type and extension. The unhandled ones are passed to DefaultCallback.
-func (f *federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) {
- // TODO
- return pub.FederatingWrappedCallbacks{}, nil, nil
+func (f *federator) FederatingCallbacks(ctx context.Context) (wrapped pub.FederatingWrappedCallbacks, other []interface{}, err error) {
+ l := f.log.WithFields(logrus.Fields{
+ "func": "FederatingCallbacks",
+ })
+
+ targetAcctI := ctx.Value(util.APAccount)
+ if targetAcctI == nil {
+ l.Error("target account wasn't set on context")
+ }
+ targetAcct, ok := targetAcctI.(*gtsmodel.Account)
+ if !ok {
+ l.Error("target account was set on context but couldn't be parsed")
+ }
+
+ var onFollow pub.OnFollowBehavior = pub.OnFollowAutomaticallyAccept
+ if targetAcct.Locked {
+ onFollow = pub.OnFollowDoNothing
+ }
+
+ wrapped = pub.FederatingWrappedCallbacks{
+ // Follow handles additional side effects for the Follow ActivityStreams
+ // type, specific to the application using go-fed.
+ //
+ // The wrapping function can have one of several default behaviors,
+ // depending on the value of the OnFollow setting.
+ Follow: func(context.Context, vocab.ActivityStreamsFollow) error {
+ return nil
+ },
+ // OnFollow determines what action to take for this particular callback
+ // if a Follow Activity is handled.
+ OnFollow: onFollow,
+ }
+
+ return
}
// DefaultCallback is called for types that go-fed can deserialize but
@@ -207,7 +323,7 @@ func (f *federator) DefaultCallback(ctx context.Context, activity pub.Activity)
// Zero or negative numbers indicate infinite recursion.
func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {
// TODO
- return 0
+ return 4
}
// MaxDeliveryRecursionDepth determines how deep to search within
@@ -217,7 +333,7 @@ func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {
// Zero or negative numbers indicate infinite recursion.
func (f *federator) MaxDeliveryRecursionDepth(ctx context.Context) int {
// TODO
- return 0
+ return 4
}
// FilterForwarding allows the implementation to apply business logic
@@ -241,7 +357,7 @@ func (f *federator) FilterForwarding(ctx context.Context, potentialRecipients []
// Always called, regardless whether the Federated Protocol or Social
// API is enabled.
func (f *federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
- // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+ // IMPLEMENTATION NOTE: For GoToSocial, we serve GETS to outboxes and inboxes through
// the CLIENT API, not through the federation API, so we just do nothing here.
return nil, nil
}
diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go
index 5790456dd..fb83a4231 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/followrequest"
"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"
@@ -41,7 +42,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger"
"github.com/superseriousbusiness/gotosocial/internal/api/security"
"github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/pg"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
@@ -78,7 +79,7 @@ var models []interface{} = []interface{}{
// Run creates and starts a gotosocial server
var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logrus.Logger) error {
- dbService, err := db.NewPostgresService(ctx, c, log)
+ dbService, err := pg.NewPostgresService(ctx, c, log)
if err != nil {
return fmt.Errorf("error creating dbservice: %s", err)
}
@@ -111,6 +112,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
accountModule := account.New(c, processor, log)
instanceModule := instance.New(c, processor, log)
appsModule := app.New(c, processor, log)
+ followRequestsModule := followrequest.New(c, processor, log)
webfingerModule := webfinger.New(c, processor, log)
usersModule := user.New(c, processor, log)
mm := mediaModule.New(c, processor, log)
@@ -128,6 +130,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
accountModule,
instanceModule,
appsModule,
+ followRequestsModule,
mm,
fileServerModule,
adminModule,
diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go
index 1903216f8..56c401e62 100644
--- a/internal/gtsmodel/account.go
+++ b/internal/gtsmodel/account.go
@@ -76,15 +76,15 @@ type Account struct {
*/
// Does this account need an approval for new followers?
- Locked bool `pg:",default:true"`
+ Locked bool `pg:",default:'true'"`
// Should this account be shown in the instance's profile directory?
Discoverable bool
// Default post privacy for this account
Privacy Visibility
// Set posts from this account to sensitive by default?
- Sensitive bool `pg:",default:false"`
+ Sensitive bool `pg:",default:'false'"`
// What language does this account post in?
- Language string `pg:",default:en"`
+ Language string `pg:",default:'en'"`
/*
ACTIVITYPUB THINGS
diff --git a/internal/gtsmodel/mention.go b/internal/gtsmodel/mention.go
index 18eb11082..8e56a1b36 100644
--- a/internal/gtsmodel/mention.go
+++ b/internal/gtsmodel/mention.go
@@ -30,10 +30,22 @@ type Mention struct {
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
// When was this mention last updated?
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // Who created this mention?
+ // What's the internal account ID of the originator of the mention?
OriginAccountID string `pg:",notnull"`
- // Who does this mention target?
+ // What's the AP URI of the originator of the mention?
+ OriginAccountURI string `pg:",notnull"`
+ // What's the internal account ID of the mention target?
TargetAccountID string `pg:",notnull"`
// Prevent this mention from generating a notification?
Silent bool
+ // NameString is for putting in the namestring of the mentioned user
+ // before the mention is dereferenced. Should be in a form along the lines of:
+ // @whatever_username@example.org
+ //
+ // This will not be put in the database, it's just for convenience.
+ NameString string `pg:"-"`
+ // MentionedAccountURI is the AP ID (uri) of the user mentioned.
+ //
+ // This will not be put in the database, it's just for convenience.
+ MentionedAccountURI string `pg:"-"`
}
diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go
index 55ae91599..8693bce30 100644
--- a/internal/gtsmodel/status.go
+++ b/internal/gtsmodel/status.go
@@ -71,12 +71,14 @@ type Status struct {
Text string
/*
- NON-DATABASE FIELDS
+ INTERNAL MODEL NON-DATABASE FIELDS
These are for convenience while passing the status around internally,
but these fields should *never* be put in the db.
*/
+ // Account that created this status
+ GTSAccount *Account `pg:"-"`
// Mentions created in this status
GTSMentions []*Mention `pg:"-"`
// Hashtags used in this status
@@ -93,6 +95,20 @@ type Status struct {
GTSBoostedStatus *Status `pg:"-"`
// Account of the boosted status
GTSBoostedAccount *Account `pg:"-"`
+
+ /*
+ AP NON-DATABASE FIELDS
+
+ These are for convenience while passing the status around internally,
+ but these fields should *never* be put in the db.
+ */
+
+ // AP URI of the status being replied to.
+ // Useful when that status doesn't exist in the database yet and we still need to dereference it.
+ APReplyToStatusURI string `pg:"-"`
+ // The AP URI of the owner/creator of the status.
+ // Useful when that account doesn't exist in the database yet and we still need to dereference it.
+ APStatusOwnerURI string `pg:"-"`
}
// Visibility represents the visibility granularity of a status.
diff --git a/internal/gtsmodel/tag.go b/internal/gtsmodel/tag.go
index 83c471958..c1b0429d6 100644
--- a/internal/gtsmodel/tag.go
+++ b/internal/gtsmodel/tag.go
@@ -24,6 +24,8 @@ import "time"
type Tag struct {
// id of this tag in the database
ID string `pg:",unique,type:uuid,default:gen_random_uuid(),pk,notnull"`
+ // Href of this tag, eg https://example.org/tags/somehashtag
+ URL string
// name of this tag -- the tag without the hash part
Name string `pg:",unique,pk,notnull"`
// Which account ID is the first one we saw using this tag?
diff --git a/internal/media/media_test.go b/internal/media/media_test.go
index 8045295d2..03dcdc21d 100644
--- a/internal/media/media_test.go
+++ b/internal/media/media_test.go
@@ -29,6 +29,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/pg"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/storage"
)
@@ -78,7 +79,7 @@ func (suite *MediaTestSuite) SetupSuite() {
}
suite.config = c
// use an actual database for this, because it's just easier than mocking one out
- database, err := db.NewPostgresService(context.Background(), c, log)
+ database, err := pg.NewPostgresService(context.Background(), c, log)
if err != nil {
suite.FailNow(err.Error())
}
diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go
index dad1e848c..133e7dbaa 100644
--- a/internal/message/fediprocess.go
+++ b/internal/message/fediprocess.go
@@ -1,6 +1,7 @@
package message
import (
+ "context"
"fmt"
"net/http"
@@ -8,6 +9,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
// authenticateAndDereferenceFediRequest authenticates the HTTP signature of an incoming federation request, using the given
@@ -130,3 +132,9 @@ func (p *processor) GetWebfingerAccount(requestedUsername string, request *http.
},
}, nil
}
+
+func (p *processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+ contextWithChannel := context.WithValue(ctx, util.APFromFederatorChanKey, p.fromFederator)
+ posted, err := p.federator.FederatingActor().PostInbox(contextWithChannel, w, r)
+ return posted, err
+}
diff --git a/internal/message/frprocess.go b/internal/message/frprocess.go
new file mode 100644
index 000000000..c96b83dec
--- /dev/null
+++ b/internal/message/frprocess.go
@@ -0,0 +1,42 @@
+package message
+
+import (
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (p *processor) FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, ErrorWithCode) {
+ frs := []gtsmodel.FollowRequest{}
+ if err := p.db.GetFollowRequestsForAccountID(auth.Account.ID, &frs); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return nil, NewErrorInternalError(err)
+ }
+ }
+
+ accts := []apimodel.Account{}
+ for _, fr := range frs {
+ acct := >smodel.Account{}
+ if err := p.db.GetByID(fr.AccountID, acct); err != nil {
+ return nil, NewErrorInternalError(err)
+ }
+ mastoAcct, err := p.tc.AccountToMastoPublic(acct)
+ if err != nil {
+ return nil, NewErrorInternalError(err)
+ }
+ accts = append(accts, *mastoAcct)
+ }
+ return accts, nil
+}
+
+func (p *processor) FollowRequestAccept(auth *oauth.Auth, accountID string) ErrorWithCode {
+ if err := p.db.AcceptFollowRequest(accountID, auth.Account.ID); err != nil {
+ return NewErrorNotFound(err)
+ }
+ return nil
+}
+
+func (p *processor) FollowRequestDeny(auth *oauth.Auth) ErrorWithCode {
+ return nil
+}
diff --git a/internal/message/processor.go b/internal/message/processor.go
index c8d58a346..7fc850e37 100644
--- a/internal/message/processor.go
+++ b/internal/message/processor.go
@@ -19,7 +19,11 @@
package message
import (
+ "context"
+ "errors"
+ "fmt"
"net/http"
+ "net/url"
"github.com/sirupsen/logrus"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
@@ -77,6 +81,11 @@ type Processor interface {
// FileGet handles the fetching of a media attachment file via the fileserver.
FileGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error)
+ // FollowRequestsGet handles the getting of the authed account's incoming follow requests
+ FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, ErrorWithCode)
+ // FollowRequestAccept handles the acceptance of a follow request from the given account ID
+ FollowRequestAccept(auth *oauth.Auth, accountID string) ErrorWithCode
+
// InstanceGet retrieves instance information for serving at api/v1/instance
InstanceGet(domain string) (*apimodel.Instance, ErrorWithCode)
@@ -116,6 +125,18 @@ type Processor interface {
// GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups.
GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode)
+
+ // InboxPost handles POST requests to a user's inbox for new activitypub messages.
+ //
+ // InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox.
+ // If false, the request was not an ActivityPub request and may still be handled by the caller in another way, such as serving a web page.
+ //
+ // If the error is nil, then the ResponseWriter's headers and response has already been written. If a non-nil error is returned, then no response has been written.
+ //
+ // If the Actor was constructed with the Federated Protocol enabled, side effects will occur.
+ //
+ // If the Federated Protocol is not enabled, writes the http.StatusMethodNotAllowed status code in the response. No side effects occur.
+ InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error)
}
// processor just implements the Processor interface
@@ -181,6 +202,9 @@ func (p *processor) Start() error {
p.log.Infof("received message TO client API: %+v", clientMsg)
case clientMsg := <-p.fromClientAPI:
p.log.Infof("received message FROM client API: %+v", clientMsg)
+ if err := p.processFromClientAPI(clientMsg); err != nil {
+ p.log.Error(err)
+ }
case federatorMsg := <-p.toFederator:
p.log.Infof("received message TO federator: %+v", federatorMsg)
case federatorMsg := <-p.fromFederator:
@@ -227,3 +251,54 @@ type FromFederator struct {
APActivityType gtsmodel.ActivityStreamsActivity
Activity interface{}
}
+
+func (p *processor) processFromClientAPI(clientMsg FromClientAPI) error {
+ switch clientMsg.APObjectType {
+ case gtsmodel.ActivityStreamsNote:
+ status, ok := clientMsg.Activity.(*gtsmodel.Status)
+ if !ok {
+ return errors.New("note was not parseable as *gtsmodel.Status")
+ }
+
+ if err := p.notifyStatus(status); err != nil {
+ return err
+ }
+
+ if status.VisibilityAdvanced.Federated {
+ return p.federateStatus(status)
+ }
+ return nil
+ }
+ return fmt.Errorf("message type unprocessable: %+v", clientMsg)
+}
+
+func (p *processor) federateStatus(status *gtsmodel.Status) error {
+ // derive the sending account -- it might be attached to the status already
+ sendingAcct := >smodel.Account{}
+ if status.GTSAccount != nil {
+ sendingAcct = status.GTSAccount
+ } else {
+ // it wasn't attached so get it from the db instead
+ if err := p.db.GetByID(status.AccountID, sendingAcct); err != nil {
+ return err
+ }
+ }
+
+ outboxURI, err := url.Parse(sendingAcct.OutboxURI)
+ if err != nil {
+ return err
+ }
+
+ // convert the status to AS format Note
+ note, err := p.tc.StatusToAS(status)
+ if err != nil {
+ return err
+ }
+
+ _, err = p.federator.FederatingActor().Send(context.Background(), outboxURI, note)
+ return err
+}
+
+func (p *processor) notifyStatus(status *gtsmodel.Status) error {
+ return nil
+}
diff --git a/internal/message/processorutil.go b/internal/message/processorutil.go
index c928eec1a..233a18ad8 100644
--- a/internal/message/processorutil.go
+++ b/internal/message/processorutil.go
@@ -179,7 +179,7 @@ func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, acc
func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
menchies := []string{}
- gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID)
+ gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentionsFromStatus(form.Status), accountID, status.ID)
if err != nil {
return fmt.Errorf("error generating mentions from status: %s", err)
}
@@ -198,7 +198,7 @@ func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, acc
func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
tags := []string{}
- gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID)
+ gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtagsFromStatus(form.Status), accountID, status.ID)
if err != nil {
return fmt.Errorf("error generating hashtags from status: %s", err)
}
@@ -217,7 +217,7 @@ func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, account
func (p *processor) processEmojis(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
emojis := []string{}
- gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID)
+ gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojisFromStatus(form.Status), accountID, status.ID)
if err != nil {
return fmt.Errorf("error generating emojis from status: %s", err)
}
diff --git a/internal/message/statusprocess.go b/internal/message/statusprocess.go
index bd4329082..d9d115aec 100644
--- a/internal/message/statusprocess.go
+++ b/internal/message/statusprocess.go
@@ -81,6 +81,13 @@ func (p *processor) StatusCreate(auth *oauth.Auth, form *apimodel.AdvancedStatus
}
}
+ // put the new status in the appropriate channel for async processing
+ p.fromClientAPI <- FromClientAPI{
+ APObjectType: newStatus.ActivityStreamsType,
+ APActivityType: gtsmodel.ActivityStreamsCreate,
+ Activity: newStatus,
+ }
+
// return the frontend representation of the new status to the submitter
return p.tc.StatusToMasto(newStatus, auth.Account, auth.Account, nil, newStatus.GTSReplyToAccount, nil)
}
diff --git a/internal/oauth/clientstore_test.go b/internal/oauth/clientstore_test.go
index b77163e48..58c5148b2 100644
--- a/internal/oauth/clientstore_test.go
+++ b/internal/oauth/clientstore_test.go
@@ -25,6 +25,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/pg"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/oauth2/v4/models"
)
@@ -62,7 +63,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {
Database: "postgres",
ApplicationName: "gotosocial",
}
- db, err := db.NewPostgresService(context.Background(), c, log)
+ db, err := pg.NewPostgresService(context.Background(), c, log)
if err != nil {
logrus.Panicf("error creating database connection: %s", err)
}
diff --git a/internal/router/router.go b/internal/router/router.go
index cdd079634..eed85771f 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -27,6 +27,7 @@ import (
"path/filepath"
"time"
+ "github.com/gin-contrib/cors"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/memstore"
"github.com/gin-gonic/gin"
@@ -123,6 +124,14 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) {
// create the actual engine here -- this is the core request routing handler for gts
engine := gin.Default()
+ engine.Use(cors.New(cors.Config{
+ AllowAllOrigins: true,
+ AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
+ AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
+ AllowCredentials: false,
+ MaxAge: 12 * time.Hour,
+ }))
+ engine.MaxMultipartMemory = 8 << 20 // 8 MiB
// create a new session store middleware
store, err := sessionStore()
@@ -143,10 +152,10 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) {
// create the actual http server here
s := &http.Server{
Handler: engine,
- ReadTimeout: 1 * time.Second,
- WriteTimeout: 1 * time.Second,
+ ReadTimeout: 60 * time.Second,
+ WriteTimeout: 5 * time.Second,
IdleTimeout: 30 * time.Second,
- ReadHeaderTimeout: 2 * time.Second,
+ ReadHeaderTimeout: 30 * time.Second,
}
var m *autocert.Manager
diff --git a/internal/transport/controller.go b/internal/transport/controller.go
index 2ee23f141..72f41b335 100644
--- a/internal/transport/controller.go
+++ b/internal/transport/controller.go
@@ -54,15 +54,15 @@ func NewController(config *config.Config, clock pub.Clock, client pub.HttpClient
func (c *controller) NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) {
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512}
digestAlgo := httpsig.DigestSha256
- getHeaders := []string{"(request-target)", "date", "accept"}
- postHeaders := []string{"(request-target)", "date", "accept", "digest"}
+ getHeaders := []string{"(request-target)", "host", "date"}
+ postHeaders := []string{"(request-target)", "host", "date", "digest"}
- getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature)
+ getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature, 120)
if err != nil {
return nil, fmt.Errorf("error creating get signer: %s", err)
}
- postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature)
+ postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature, 120)
if err != nil {
return nil, fmt.Errorf("error creating post signer: %s", err)
}
diff --git a/internal/typeutils/accountable.go b/internal/typeutils/accountable.go
deleted file mode 100644
index ba5c4aa2a..000000000
--- a/internal/typeutils/accountable.go
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-*/
-
-package typeutils
-
-import "github.com/go-fed/activity/streams/vocab"
-
-// Accountable represents the minimum activitypub interface for representing an 'account'.
-// This interface is fulfilled by: Person, Application, Organization, Service, and Group
-type Accountable interface {
- withJSONLDId
- withGetTypeName
- withPreferredUsername
- withIcon
- withDisplayName
- withImage
- withSummary
- withDiscoverable
- withURL
- withPublicKey
- withInbox
- withOutbox
- withFollowing
- withFollowers
- withFeatured
-}
-
-type withJSONLDId interface {
- GetJSONLDId() vocab.JSONLDIdProperty
-}
-
-type withGetTypeName interface {
- GetTypeName() string
-}
-
-type withPreferredUsername interface {
- GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty
-}
-
-type withIcon interface {
- GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty
-}
-
-type withDisplayName interface {
- GetActivityStreamsName() vocab.ActivityStreamsNameProperty
-}
-
-type withImage interface {
- GetActivityStreamsImage() vocab.ActivityStreamsImageProperty
-}
-
-type withSummary interface {
- GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty
-}
-
-type withDiscoverable interface {
- GetTootDiscoverable() vocab.TootDiscoverableProperty
-}
-
-type withURL interface {
- GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty
-}
-
-type withPublicKey interface {
- GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
-}
-
-type withInbox interface {
- GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty
-}
-
-type withOutbox interface {
- GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty
-}
-
-type withFollowing interface {
- GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty
-}
-
-type withFollowers interface {
- GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty
-}
-
-type withFeatured interface {
- GetTootFeatured() vocab.TootFeaturedProperty
-}
diff --git a/internal/typeutils/asextractionutil.go b/internal/typeutils/asextractionutil.go
index 8d39be3ec..4ee3347bd 100644
--- a/internal/typeutils/asextractionutil.go
+++ b/internal/typeutils/asextractionutil.go
@@ -25,8 +25,12 @@ import (
"errors"
"fmt"
"net/url"
+ "strings"
+ "time"
"github.com/go-fed/activity/pub"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
func extractPreferredUsername(i withPreferredUsername) (string, error) {
@@ -40,22 +44,89 @@ func extractPreferredUsername(i withPreferredUsername) (string, error) {
return u.GetXMLSchemaString(), nil
}
-func extractName(i withDisplayName) (string, error) {
+func extractName(i withName) (string, error) {
nameProp := i.GetActivityStreamsName()
if nameProp == nil {
return "", errors.New("activityStreamsName not found")
}
// take the first name string we can find
- for nameIter := nameProp.Begin(); nameIter != nameProp.End(); nameIter = nameIter.Next() {
- if nameIter.IsXMLSchemaString() && nameIter.GetXMLSchemaString() != "" {
- return nameIter.GetXMLSchemaString(), nil
+ for iter := nameProp.Begin(); iter != nameProp.End(); iter = iter.Next() {
+ if iter.IsXMLSchemaString() && iter.GetXMLSchemaString() != "" {
+ return iter.GetXMLSchemaString(), nil
}
}
return "", errors.New("activityStreamsName not found")
}
+func extractInReplyToURI(i withInReplyTo) (*url.URL, error) {
+ inReplyToProp := i.GetActivityStreamsInReplyTo()
+ for iter := inReplyToProp.Begin(); iter != inReplyToProp.End(); iter = iter.Next() {
+ if iter.IsIRI() {
+ if iter.GetIRI() != nil {
+ return iter.GetIRI(), nil
+ }
+ }
+ }
+ return nil, errors.New("couldn't find iri for in reply to")
+}
+
+func extractTos(i withTo) ([]*url.URL, error) {
+ to := []*url.URL{}
+ toProp := i.GetActivityStreamsTo()
+ for iter := toProp.Begin(); iter != toProp.End(); iter = iter.Next() {
+ if iter.IsIRI() {
+ if iter.GetIRI() != nil {
+ to = append(to, iter.GetIRI())
+ }
+ }
+ }
+ return to, nil
+}
+
+func extractCCs(i withCC) ([]*url.URL, error) {
+ cc := []*url.URL{}
+ ccProp := i.GetActivityStreamsCc()
+ for iter := ccProp.Begin(); iter != ccProp.End(); iter = iter.Next() {
+ if iter.IsIRI() {
+ if iter.GetIRI() != nil {
+ cc = append(cc, iter.GetIRI())
+ }
+ }
+ }
+ return cc, nil
+}
+
+func extractAttributedTo(i withAttributedTo) (*url.URL, error) {
+ attributedToProp := i.GetActivityStreamsAttributedTo()
+ for iter := attributedToProp.Begin(); iter != attributedToProp.End(); iter = iter.Next() {
+ if iter.IsIRI() {
+ if iter.GetIRI() != nil {
+ return iter.GetIRI(), nil
+ }
+ }
+ }
+ return nil, errors.New("couldn't find iri for attributed to")
+}
+
+func extractPublished(i withPublished) (time.Time, error) {
+ publishedProp := i.GetActivityStreamsPublished()
+ if publishedProp == nil {
+ return time.Time{}, errors.New("published prop was nil")
+ }
+
+ if !publishedProp.IsXMLSchemaDateTime() {
+ return time.Time{}, errors.New("published prop was not date time")
+ }
+
+ t := publishedProp.Get()
+ if t.IsZero() {
+ return time.Time{}, errors.New("published time was zero")
+ }
+ return t, nil
+}
+
// extractIconURL extracts a URL to a supported image file from something like:
// "icon": {
// "mediaType": "image/jpeg",
@@ -72,12 +143,12 @@ func extractIconURL(i withIcon) (*url.URL, error) {
// here in order to find the first one that meets these criteria:
// 1. is an image
// 2. has a URL so we can grab it
- for iconIter := iconProp.Begin(); iconIter != iconProp.End(); iconIter = iconIter.Next() {
+ for iter := iconProp.Begin(); iter != iconProp.End(); iter = iter.Next() {
// 1. is an image
- if !iconIter.IsActivityStreamsImage() {
+ if !iter.IsActivityStreamsImage() {
continue
}
- imageValue := iconIter.GetActivityStreamsImage()
+ imageValue := iter.GetActivityStreamsImage()
if imageValue == nil {
continue
}
@@ -108,12 +179,12 @@ func extractImageURL(i withImage) (*url.URL, error) {
// here in order to find the first one that meets these criteria:
// 1. is an image
// 2. has a URL so we can grab it
- for imageIter := imageProp.Begin(); imageIter != imageProp.End(); imageIter = imageIter.Next() {
+ for iter := imageProp.Begin(); iter != imageProp.End(); iter = iter.Next() {
// 1. is an image
- if !imageIter.IsActivityStreamsImage() {
+ if !iter.IsActivityStreamsImage() {
continue
}
- imageValue := imageIter.GetActivityStreamsImage()
+ imageValue := iter.GetActivityStreamsImage()
if imageValue == nil {
continue
}
@@ -134,9 +205,9 @@ func extractSummary(i withSummary) (string, error) {
return "", errors.New("summary property was nil")
}
- for summaryIter := summaryProp.Begin(); summaryIter != summaryProp.End(); summaryIter = summaryIter.Next() {
- if summaryIter.IsXMLSchemaString() && summaryIter.GetXMLSchemaString() != "" {
- return summaryIter.GetXMLSchemaString(), nil
+ for iter := summaryProp.Begin(); iter != summaryProp.End(); iter = iter.Next() {
+ if iter.IsXMLSchemaString() && iter.GetXMLSchemaString() != "" {
+ return iter.GetXMLSchemaString(), nil
}
}
@@ -156,9 +227,9 @@ func extractURL(i withURL) (*url.URL, error) {
return nil, errors.New("url property was nil")
}
- for urlIter := urlProp.Begin(); urlIter != urlProp.End(); urlIter = urlIter.Next() {
- if urlIter.IsIRI() && urlIter.GetIRI() != nil {
- return urlIter.GetIRI(), nil
+ for iter := urlProp.Begin(); iter != urlProp.End(); iter = iter.Next() {
+ if iter.IsIRI() && iter.GetIRI() != nil {
+ return iter.GetIRI(), nil
}
}
@@ -171,8 +242,8 @@ func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKe
return nil, nil, errors.New("public key property was nil")
}
- for publicKeyIter := publicKeyProp.Begin(); publicKeyIter != publicKeyProp.End(); publicKeyIter = publicKeyIter.Next() {
- pkey := publicKeyIter.Get()
+ for iter := publicKeyProp.Begin(); iter != publicKeyProp.End(); iter = iter.Next() {
+ pkey := iter.Get()
if pkey == nil {
continue
}
@@ -214,3 +285,263 @@ func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKe
}
return nil, nil, errors.New("couldn't find public key")
}
+
+func extractContent(i withContent) (string, error) {
+ contentProperty := i.GetActivityStreamsContent()
+ if contentProperty == nil {
+ return "", errors.New("content property was nil")
+ }
+ for iter := contentProperty.Begin(); iter != contentProperty.End(); iter = iter.Next() {
+ if iter.IsXMLSchemaString() && iter.GetXMLSchemaString() != "" {
+ return iter.GetXMLSchemaString(), nil
+ }
+ }
+ return "", errors.New("no content found")
+}
+
+func extractAttachments(i withAttachment) ([]*gtsmodel.MediaAttachment, error) {
+ attachments := []*gtsmodel.MediaAttachment{}
+
+ attachmentProp := i.GetActivityStreamsAttachment()
+ for iter := attachmentProp.Begin(); iter != attachmentProp.End(); iter = iter.Next() {
+ attachmentable, ok := iter.(Attachmentable)
+ if !ok {
+ continue
+ }
+ attachment, err := extractAttachment(attachmentable)
+ if err != nil {
+ continue
+ }
+ attachments = append(attachments, attachment)
+ }
+ return attachments, nil
+}
+
+func extractAttachment(i Attachmentable) (*gtsmodel.MediaAttachment, error) {
+ attachment := >smodel.MediaAttachment{
+ File: gtsmodel.File{},
+ }
+
+ attachmentURL, err := extractURL(i)
+ if err != nil {
+ return nil, err
+ }
+ attachment.RemoteURL = attachmentURL.String()
+
+ mediaType := i.GetActivityStreamsMediaType()
+ if mediaType == nil {
+ return nil, errors.New("no media type")
+ }
+ if mediaType.Get() == "" {
+ return nil, errors.New("no media type")
+ }
+ attachment.File.ContentType = mediaType.Get()
+ attachment.Type = gtsmodel.FileTypeImage
+
+ name, err := extractName(i)
+ if err == nil {
+ attachment.Description = name
+ }
+
+ blurhash, err := extractBlurhash(i)
+ if err == nil {
+ attachment.Blurhash = blurhash
+ }
+
+ return attachment, nil
+}
+
+func extractBlurhash(i withBlurhash) (string, error) {
+ if i.GetTootBlurhashProperty() == nil {
+ return "", errors.New("blurhash property was nil")
+ }
+ if i.GetTootBlurhashProperty().Get() == "" {
+ return "", errors.New("empty blurhash string")
+ }
+ return i.GetTootBlurhashProperty().Get(), nil
+}
+
+func extractHashtags(i withTag) ([]*gtsmodel.Tag, error) {
+ tags := []*gtsmodel.Tag{}
+
+ tagsProp := i.GetActivityStreamsTag()
+ for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() {
+ t := iter.GetType()
+ if t == nil {
+ continue
+ }
+
+ if t.GetTypeName() != "Hashtag" {
+ continue
+ }
+
+ hashtaggable, ok := t.(Hashtaggable)
+ if !ok {
+ continue
+ }
+
+ tag, err := extractHashtag(hashtaggable)
+ if err != nil {
+ continue
+ }
+
+ tags = append(tags, tag)
+ }
+ return tags, nil
+}
+
+func extractHashtag(i Hashtaggable) (*gtsmodel.Tag, error) {
+ tag := >smodel.Tag{}
+
+ hrefProp := i.GetActivityStreamsHref()
+ if hrefProp == nil || !hrefProp.IsIRI() {
+ return nil, errors.New("no href prop")
+ }
+ tag.URL = hrefProp.GetIRI().String()
+
+ name, err := extractName(i)
+ if err != nil {
+ return nil, err
+ }
+ tag.Name = strings.TrimPrefix(name, "#")
+
+ return tag, nil
+}
+
+func extractEmojis(i withTag) ([]*gtsmodel.Emoji, error) {
+ emojis := []*gtsmodel.Emoji{}
+ tagsProp := i.GetActivityStreamsTag()
+ for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() {
+ t := iter.GetType()
+ if t == nil {
+ continue
+ }
+
+ if t.GetTypeName() != "Emoji" {
+ continue
+ }
+
+ emojiable, ok := t.(Emojiable)
+ if !ok {
+ continue
+ }
+
+ emoji, err := extractEmoji(emojiable)
+ if err != nil {
+ continue
+ }
+
+ emojis = append(emojis, emoji)
+ }
+ return emojis, nil
+}
+
+func extractEmoji(i Emojiable) (*gtsmodel.Emoji, error) {
+ emoji := >smodel.Emoji{}
+
+ idProp := i.GetJSONLDId()
+ if idProp == nil || !idProp.IsIRI() {
+ return nil, errors.New("no id for emoji")
+ }
+ uri := idProp.GetIRI()
+ emoji.URI = uri.String()
+ emoji.Domain = uri.Host
+
+ name, err := extractName(i)
+ if err != nil {
+ return nil, err
+ }
+ emoji.Shortcode = strings.Trim(name, ":")
+
+ if i.GetActivityStreamsIcon() == nil {
+ return nil, errors.New("no icon for emoji")
+ }
+ imageURL, err := extractIconURL(i)
+ if err != nil {
+ return nil, errors.New("no url for emoji image")
+ }
+ emoji.ImageRemoteURL = imageURL.String()
+
+ return emoji, nil
+}
+
+func extractMentions(i withTag) ([]*gtsmodel.Mention, error) {
+ mentions := []*gtsmodel.Mention{}
+ tagsProp := i.GetActivityStreamsTag()
+ for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() {
+ t := iter.GetType()
+ if t == nil {
+ continue
+ }
+
+ if t.GetTypeName() != "Mention" {
+ continue
+ }
+
+ mentionable, ok := t.(Mentionable)
+ if !ok {
+ continue
+ }
+
+ mention, err := extractMention(mentionable)
+ if err != nil {
+ continue
+ }
+
+ mentions = append(mentions, mention)
+ }
+ return mentions, nil
+}
+
+func extractMention(i Mentionable) (*gtsmodel.Mention, error) {
+ mention := >smodel.Mention{}
+
+ mentionString, err := extractName(i)
+ if err != nil {
+ return nil, err
+ }
+
+ // just make sure the mention string is valid so we can handle it properly later on...
+ username, domain, err := util.ExtractMentionParts(mentionString)
+ if err != nil {
+ return nil, err
+ }
+ if username == "" || domain == "" {
+ return nil, errors.New("username or domain was empty")
+ }
+ mention.NameString = mentionString
+
+ // the href prop should be the AP URI of a user we know, eg https://example.org/users/whatever_user
+ hrefProp := i.GetActivityStreamsHref()
+ if hrefProp == nil || !hrefProp.IsIRI() {
+ return nil, errors.New("no href prop")
+ }
+ mention.MentionedAccountURI = hrefProp.GetIRI().String()
+ return mention, nil
+}
+
+func extractActor(i withActor) (*url.URL, error) {
+ actorProp := i.GetActivityStreamsActor()
+ if actorProp == nil {
+ return nil, errors.New("actor property was nil")
+ }
+ for iter := actorProp.Begin(); iter != actorProp.End(); iter = iter.Next() {
+ if iter.IsIRI() && iter.GetIRI() != nil {
+ return iter.GetIRI(), nil
+ }
+ }
+ return nil, errors.New("no iri found for actor prop")
+}
+
+func extractObject(i withObject) (*url.URL, error) {
+ objectProp := i.GetActivityStreamsObject()
+ if objectProp == nil {
+ return nil, errors.New("object property was nil")
+ }
+ for iter := objectProp.Begin(); iter != objectProp.End(); iter = iter.Next() {
+ if iter.IsIRI() && iter.GetIRI() != nil {
+ return iter.GetIRI(), nil
+ }
+ }
+ return nil, errors.New("no iri found for object prop")
+}
diff --git a/internal/typeutils/asinterfaces.go b/internal/typeutils/asinterfaces.go
new file mode 100644
index 000000000..970ed2ecf
--- /dev/null
+++ b/internal/typeutils/asinterfaces.go
@@ -0,0 +1,237 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package typeutils
+
+import "github.com/go-fed/activity/streams/vocab"
+
+// Accountable represents the minimum activitypub interface for representing an 'account'.
+// This interface is fulfilled by: Person, Application, Organization, Service, and Group
+type Accountable interface {
+ withJSONLDId
+ withTypeName
+
+ withPreferredUsername
+ withIcon
+ withName
+ withImage
+ withSummary
+ withDiscoverable
+ withURL
+ withPublicKey
+ withInbox
+ withOutbox
+ withFollowing
+ withFollowers
+ withFeatured
+}
+
+// Statusable represents the minimum activitypub interface for representing a 'status'.
+// This interface is fulfilled by: Article, Document, Image, Video, Note, Page, Event, Place, Mention, Profile
+type Statusable interface {
+ withJSONLDId
+ withTypeName
+
+ withSummary
+ withInReplyTo
+ withPublished
+ withURL
+ withAttributedTo
+ withTo
+ withCC
+ withSensitive
+ withConversation
+ withContent
+ withAttachment
+ withTag
+ withReplies
+}
+
+// Attachmentable represents the minimum activitypub interface for representing a 'mediaAttachment'.
+// This interface is fulfilled by: Audio, Document, Image, Video
+type Attachmentable interface {
+ withTypeName
+ withMediaType
+ withURL
+ withName
+ withBlurhash
+ withFocalPoint
+}
+
+// Hashtaggable represents the minimum activitypub interface for representing a 'hashtag' tag.
+type Hashtaggable interface {
+ withTypeName
+ withHref
+ withName
+}
+
+// Emojiable represents the minimum interface for an 'emoji' tag.
+type Emojiable interface {
+ withJSONLDId
+ withTypeName
+ withName
+ withUpdated
+ withIcon
+}
+
+// Mentionable represents the minimum interface for a 'mention' tag.
+type Mentionable interface {
+ withName
+ withHref
+}
+
+// Followable represents the minimum interface for an activitystreams 'follow' activity.
+type Followable interface {
+ withJSONLDId
+ withTypeName
+
+ withActor
+ withObject
+}
+
+type withJSONLDId interface {
+ GetJSONLDId() vocab.JSONLDIdProperty
+}
+
+type withTypeName interface {
+ GetTypeName() string
+}
+
+type withPreferredUsername interface {
+ GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty
+}
+
+type withIcon interface {
+ GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty
+}
+
+type withName interface {
+ GetActivityStreamsName() vocab.ActivityStreamsNameProperty
+}
+
+type withImage interface {
+ GetActivityStreamsImage() vocab.ActivityStreamsImageProperty
+}
+
+type withSummary interface {
+ GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty
+}
+
+type withDiscoverable interface {
+ GetTootDiscoverable() vocab.TootDiscoverableProperty
+}
+
+type withURL interface {
+ GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty
+}
+
+type withPublicKey interface {
+ GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
+}
+
+type withInbox interface {
+ GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty
+}
+
+type withOutbox interface {
+ GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty
+}
+
+type withFollowing interface {
+ GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty
+}
+
+type withFollowers interface {
+ GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty
+}
+
+type withFeatured interface {
+ GetTootFeatured() vocab.TootFeaturedProperty
+}
+
+type withAttributedTo interface {
+ GetActivityStreamsAttributedTo() vocab.ActivityStreamsAttributedToProperty
+}
+
+type withAttachment interface {
+ GetActivityStreamsAttachment() vocab.ActivityStreamsAttachmentProperty
+}
+
+type withTo interface {
+ GetActivityStreamsTo() vocab.ActivityStreamsToProperty
+}
+
+type withInReplyTo interface {
+ GetActivityStreamsInReplyTo() vocab.ActivityStreamsInReplyToProperty
+}
+
+type withCC interface {
+ GetActivityStreamsCc() vocab.ActivityStreamsCcProperty
+}
+
+type withSensitive interface {
+ // TODO
+}
+
+type withConversation interface {
+ // TODO
+}
+
+type withContent interface {
+ GetActivityStreamsContent() vocab.ActivityStreamsContentProperty
+}
+
+type withPublished interface {
+ GetActivityStreamsPublished() vocab.ActivityStreamsPublishedProperty
+}
+
+type withTag interface {
+ GetActivityStreamsTag() vocab.ActivityStreamsTagProperty
+}
+
+type withReplies interface {
+ GetActivityStreamsReplies() vocab.ActivityStreamsRepliesProperty
+}
+
+type withMediaType interface {
+ GetActivityStreamsMediaType() vocab.ActivityStreamsMediaTypeProperty
+}
+
+type withBlurhash interface {
+ GetTootBlurhashProperty() vocab.TootBlurhashProperty
+}
+
+type withFocalPoint interface {
+ // TODO
+}
+
+type withHref interface {
+ GetActivityStreamsHref() vocab.ActivityStreamsHrefProperty
+}
+
+type withUpdated interface {
+ GetActivityStreamsUpdated() vocab.ActivityStreamsUpdatedProperty
+}
+
+type withActor interface {
+ GetActivityStreamsActor() vocab.ActivityStreamsActorProperty
+}
+
+type withObject interface {
+ GetActivityStreamsObject() vocab.ActivityStreamsObjectProperty
+}
diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go
index 7842411ea..7f0a4c1a4 100644
--- a/internal/typeutils/astointernal.go
+++ b/internal/typeutils/astointernal.go
@@ -21,6 +21,8 @@ package typeutils
import (
"errors"
"fmt"
+ "net/url"
+ "strings"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -157,3 +159,202 @@ func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmode
return acct, nil
}
+
+func (c *converter) ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, error) {
+ status := >smodel.Status{}
+
+ // uri at which this status is reachable
+ uriProp := statusable.GetJSONLDId()
+ if uriProp == nil || !uriProp.IsIRI() {
+ return nil, errors.New("no id property found, or id was not an iri")
+ }
+ status.URI = uriProp.GetIRI().String()
+
+ // web url for viewing this status
+ if statusURL, err := extractURL(statusable); err == nil {
+ status.URL = statusURL.String()
+ }
+
+ // the html-formatted content of this status
+ if content, err := extractContent(statusable); err == nil {
+ status.Content = content
+ }
+
+ // attachments to dereference and fetch later on (we don't do that here)
+ if attachments, err := extractAttachments(statusable); err == nil {
+ status.GTSMediaAttachments = attachments
+ }
+
+ // hashtags to dereference later on
+ if hashtags, err := extractHashtags(statusable); err == nil {
+ status.GTSTags = hashtags
+ }
+
+ // emojis to dereference and fetch later on
+ if emojis, err := extractEmojis(statusable); err == nil {
+ status.GTSEmojis = emojis
+ }
+
+ // mentions to dereference later on
+ if mentions, err := extractMentions(statusable); err == nil {
+ status.GTSMentions = mentions
+ }
+
+ // cw string for this status
+ if cw, err := extractSummary(statusable); err == nil {
+ status.ContentWarning = cw
+ }
+
+ // when was this status created?
+ published, err := extractPublished(statusable)
+ if err == nil {
+ status.CreatedAt = published
+ }
+
+ // which account posted this status?
+ // if we don't know the account yet we can dereference it later
+ attributedTo, err := extractAttributedTo(statusable)
+ if err != nil {
+ return nil, errors.New("attributedTo was empty")
+ }
+ status.APStatusOwnerURI = attributedTo.String()
+
+ statusOwner := >smodel.Account{}
+ if err := c.db.GetWhere("uri", attributedTo.String(), statusOwner); err != nil {
+ return nil, fmt.Errorf("couldn't get status owner from db: %s", err)
+ }
+ status.AccountID = statusOwner.ID
+ status.GTSAccount = statusOwner
+
+ // check if there's a post that this is a reply to
+ inReplyToURI, err := extractInReplyToURI(statusable)
+ if err == nil {
+ // something is set so we can at least set this field on the
+ // status and dereference using this later if we need to
+ status.APReplyToStatusURI = inReplyToURI.String()
+
+ // now we can check if we have the replied-to status in our db already
+ inReplyToStatus := >smodel.Status{}
+ if err := c.db.GetWhere("uri", inReplyToURI.String(), inReplyToStatus); err == nil {
+ // we have the status in our database already
+ // so we can set these fields here and then...
+ status.InReplyToID = inReplyToStatus.ID
+ status.InReplyToAccountID = inReplyToStatus.AccountID
+ status.GTSReplyToStatus = inReplyToStatus
+
+ // ... check if we've seen the account already
+ inReplyToAccount := >smodel.Account{}
+ if err := c.db.GetByID(inReplyToStatus.AccountID, inReplyToAccount); err == nil {
+ status.GTSReplyToAccount = inReplyToAccount
+ }
+ }
+ }
+
+ // visibility entry for this status
+ var visibility gtsmodel.Visibility
+
+ to, err := extractTos(statusable)
+ if err != nil {
+ return nil, fmt.Errorf("error extracting TO values: %s", err)
+ }
+
+ cc, err := extractCCs(statusable)
+ if err != nil {
+ return nil, fmt.Errorf("error extracting CC values: %s", err)
+ }
+
+ if len(to) == 0 && len(cc) == 0 {
+ return nil, errors.New("message wasn't TO or CC anyone")
+ }
+
+ // for visibility derivation, we start by assuming most restrictive, and work our way to least restrictive
+
+ // if it's a DM then it's addressed to SPECIFIC ACCOUNTS and not followers or public
+ if len(to) != 0 && len(cc) == 0 {
+ visibility = gtsmodel.VisibilityDirect
+ }
+
+ // if it's just got followers in TO and it's not also CC'ed to public, it's followers only
+ if isFollowers(to, statusOwner.FollowersURI) {
+ visibility = gtsmodel.VisibilityFollowersOnly
+ }
+
+ // if it's CC'ed to public, it's public or unlocked
+ // mentioned SPECIFIC ACCOUNTS also get added to CC'es if it's not a direct message
+ if isPublic(to) {
+ visibility = gtsmodel.VisibilityPublic
+ }
+
+ // we should have a visibility by now
+ if visibility == "" {
+ return nil, errors.New("couldn't derive visibility")
+ }
+ status.Visibility = visibility
+
+ // advanced visibility for this status
+ // TODO: a lot of work to be done here -- a new type needs to be created for this in go-fed/activity using ASTOOL
+
+ // sensitive
+ // TODO: this is a bool
+
+ // language
+ // we might be able to extract this from the contentMap field
+
+ // ActivityStreamsType
+ status.ActivityStreamsType = gtsmodel.ActivityStreamsObject(statusable.GetTypeName())
+
+ return status, nil
+}
+
+func (c *converter) ASFollowToFollowRequest(followable Followable) (*gtsmodel.FollowRequest, error) {
+
+ idProp := followable.GetJSONLDId()
+ if idProp == nil || !idProp.IsIRI() {
+ return nil, errors.New("no id property set on follow, or was not an iri")
+ }
+ uri := idProp.GetIRI().String()
+
+ origin, err := extractActor(followable)
+ if err != nil {
+ return nil, errors.New("error extracting actor property from follow")
+ }
+ originAccount := >smodel.Account{}
+ if err := c.db.GetWhere("uri", origin.String(), originAccount); err != nil {
+ return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err)
+ }
+
+ target, err := extractObject(followable)
+ if err != nil {
+ return nil, errors.New("error extracting object property from follow")
+ }
+ targetAccount := >smodel.Account{}
+ if err := c.db.GetWhere("uri", target.String(), targetAccount); err != nil {
+ return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err)
+ }
+
+ followRequest := >smodel.FollowRequest{
+ URI: uri,
+ AccountID: originAccount.ID,
+ TargetAccountID: targetAccount.ID,
+ }
+
+ return followRequest, nil
+}
+
+func isPublic(tos []*url.URL) bool {
+ for _, entry := range tos {
+ if strings.EqualFold(entry.String(), "https://www.w3.org/ns/activitystreams#Public") {
+ return true
+ }
+ }
+ return false
+}
+
+func isFollowers(ccs []*url.URL, followersURI string) bool {
+ for _, entry := range ccs {
+ if strings.EqualFold(entry.String(), followersURI) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/typeutils/astointernal_test.go b/internal/typeutils/astointernal_test.go
index 1cd66a0ab..f1287e027 100644
--- a/internal/typeutils/astointernal_test.go
+++ b/internal/typeutils/astointernal_test.go
@@ -25,6 +25,7 @@ import (
"testing"
"github.com/go-fed/activity/streams"
+ "github.com/go-fed/activity/streams/vocab"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
@@ -36,6 +37,182 @@ type ASToInternalTestSuite struct {
}
const (
+ statusWithMentionsActivityJson = `{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ {
+ "ostatus": "http://ostatus.org#",
+ "atomUri": "ostatus:atomUri",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "sensitive": "as:sensitive",
+ "toot": "http://joinmastodon.org/ns#",
+ "votersCount": "toot:votersCount"
+ }
+ ],
+ "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552/activity",
+ "type": "Create",
+ "actor": "https://ondergrond.org/users/dumpsterqueer",
+ "published": "2021-05-12T09:58:38Z",
+ "to": [
+ "https://ondergrond.org/users/dumpsterqueer/followers"
+ ],
+ "cc": [
+ "https://www.w3.org/ns/activitystreams#Public",
+ "https://social.pixie.town/users/f0x"
+ ],
+ "object": {
+ "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552",
+ "type": "Note",
+ "summary": null,
+ "inReplyTo": "https://social.pixie.town/users/f0x/statuses/106221628567855262",
+ "published": "2021-05-12T09:58:38Z",
+ "url": "https://ondergrond.org/@dumpsterqueer/106221634728637552",
+ "attributedTo": "https://ondergrond.org/users/dumpsterqueer",
+ "to": [
+ "https://ondergrond.org/users/dumpsterqueer/followers"
+ ],
+ "cc": [
+ "https://www.w3.org/ns/activitystreams#Public",
+ "https://social.pixie.town/users/f0x"
+ ],
+ "sensitive": false,
+ "atomUri": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552",
+ "inReplyToAtomUri": "https://social.pixie.town/users/f0x/statuses/106221628567855262",
+ "conversation": "tag:ondergrond.org,2021-05-12:objectId=1132361:objectType=Conversation",
+ "content": "
@f0x nice there it is:
https://social.pixie.town/users/f0x/statuses/106221628567855262/activity
",
+ "contentMap": {
+ "en": "@f0x nice there it is:
https://social.pixie.town/users/f0x/statuses/106221628567855262/activity
"
+ },
+ "attachment": [],
+ "tag": [
+ {
+ "type": "Mention",
+ "href": "https://social.pixie.town/users/f0x",
+ "name": "@f0x@pixie.town"
+ }
+ ],
+ "replies": {
+ "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552/replies",
+ "type": "Collection",
+ "first": {
+ "type": "CollectionPage",
+ "next": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552/replies?only_other_accounts=true&page=true",
+ "partOf": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552/replies",
+ "items": []
+ }
+ }
+ }
+ }`
+ statusWithEmojisAndTagsAsActivityJson = `{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ {
+ "ostatus": "http://ostatus.org#",
+ "atomUri": "ostatus:atomUri",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "sensitive": "as:sensitive",
+ "toot": "http://joinmastodon.org/ns#",
+ "votersCount": "toot:votersCount",
+ "Hashtag": "as:Hashtag",
+ "Emoji": "toot:Emoji",
+ "focalPoint": {
+ "@container": "@list",
+ "@id": "toot:focalPoint"
+ }
+ }
+ ],
+ "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704/activity",
+ "type": "Create",
+ "actor": "https://ondergrond.org/users/dumpsterqueer",
+ "published": "2021-05-12T09:41:38Z",
+ "to": [
+ "https://ondergrond.org/users/dumpsterqueer/followers"
+ ],
+ "cc": [
+ "https://www.w3.org/ns/activitystreams#Public"
+ ],
+ "object": {
+ "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704",
+ "type": "Note",
+ "summary": null,
+ "inReplyTo": null,
+ "published": "2021-05-12T09:41:38Z",
+ "url": "https://ondergrond.org/@dumpsterqueer/106221567884565704",
+ "attributedTo": "https://ondergrond.org/users/dumpsterqueer",
+ "to": [
+ "https://ondergrond.org/users/dumpsterqueer/followers"
+ ],
+ "cc": [
+ "https://www.w3.org/ns/activitystreams#Public"
+ ],
+ "sensitive": false,
+ "atomUri": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704",
+ "inReplyToAtomUri": null,
+ "conversation": "tag:ondergrond.org,2021-05-12:objectId=1132361:objectType=Conversation",
+ "content": "just testing activitypub representations of #tags and #emoji :party_parrot: :amaze: :blobsunglasses:
don't mind me....
",
+ "contentMap": {
+ "en": "just testing activitypub representations of #tags and #emoji :party_parrot: :amaze: :blobsunglasses:
don't mind me....
"
+ },
+ "attachment": [],
+ "tag": [
+ {
+ "type": "Hashtag",
+ "href": "https://ondergrond.org/tags/tags",
+ "name": "#tags"
+ },
+ {
+ "type": "Hashtag",
+ "href": "https://ondergrond.org/tags/emoji",
+ "name": "#emoji"
+ },
+ {
+ "id": "https://ondergrond.org/emojis/2390",
+ "type": "Emoji",
+ "name": ":party_parrot:",
+ "updated": "2020-11-06T13:42:11Z",
+ "icon": {
+ "type": "Image",
+ "mediaType": "image/gif",
+ "url": "https://ondergrond.org/system/custom_emojis/images/000/002/390/original/ef133aac7ab23341.gif"
+ }
+ },
+ {
+ "id": "https://ondergrond.org/emojis/2395",
+ "type": "Emoji",
+ "name": ":amaze:",
+ "updated": "2020-09-26T12:29:56Z",
+ "icon": {
+ "type": "Image",
+ "mediaType": "image/png",
+ "url": "https://ondergrond.org/system/custom_emojis/images/000/002/395/original/2c7d9345e57367ed.png"
+ }
+ },
+ {
+ "id": "https://ondergrond.org/emojis/764",
+ "type": "Emoji",
+ "name": ":blobsunglasses:",
+ "updated": "2020-09-26T12:13:23Z",
+ "icon": {
+ "type": "Image",
+ "mediaType": "image/png",
+ "url": "https://ondergrond.org/system/custom_emojis/images/000/000/764/original/3f8eef9de773c90d.png"
+ }
+ }
+ ],
+ "replies": {
+ "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704/replies",
+ "type": "Collection",
+ "first": {
+ "type": "CollectionPage",
+ "next": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704/replies?only_other_accounts=true&page=true",
+ "partOf": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704/replies",
+ "items": []
+ }
+ }
+ }
+ }`
gargronAsActivityJson = `{
"@context": [
"https://www.w3.org/ns/activitystreams",
@@ -197,6 +374,62 @@ func (suite *ASToInternalTestSuite) TestParseGargron() {
// TODO: write assertions here, rn we're just eyeballing the output
}
+func (suite *ASToInternalTestSuite) TestParseStatus() {
+ m := make(map[string]interface{})
+ err := json.Unmarshal([]byte(statusWithEmojisAndTagsAsActivityJson), &m)
+ assert.NoError(suite.T(), err)
+
+ t, err := streams.ToType(context.Background(), m)
+ assert.NoError(suite.T(), err)
+
+ create, ok := t.(vocab.ActivityStreamsCreate)
+ assert.True(suite.T(), ok)
+
+ obj := create.GetActivityStreamsObject()
+ assert.NotNil(suite.T(), obj)
+
+ first := obj.Begin()
+ assert.NotNil(suite.T(), first)
+
+ rep, ok := first.GetType().(typeutils.Statusable)
+ assert.True(suite.T(), ok)
+
+ status, err := suite.typeconverter.ASStatusToStatus(rep)
+ assert.NoError(suite.T(), err)
+
+ assert.Len(suite.T(), status.GTSEmojis, 3)
+ // assert.Len(suite.T(), status.GTSTags, 2) TODO: implement this first so that it can pick up tags
+}
+
+func (suite *ASToInternalTestSuite) TestParseStatusWithMention() {
+ m := make(map[string]interface{})
+ err := json.Unmarshal([]byte(statusWithMentionsActivityJson), &m)
+ assert.NoError(suite.T(), err)
+
+ t, err := streams.ToType(context.Background(), m)
+ assert.NoError(suite.T(), err)
+
+ create, ok := t.(vocab.ActivityStreamsCreate)
+ assert.True(suite.T(), ok)
+
+ obj := create.GetActivityStreamsObject()
+ assert.NotNil(suite.T(), obj)
+
+ first := obj.Begin()
+ assert.NotNil(suite.T(), first)
+
+ rep, ok := first.GetType().(typeutils.Statusable)
+ assert.True(suite.T(), ok)
+
+ status, err := suite.typeconverter.ASStatusToStatus(rep)
+ assert.NoError(suite.T(), err)
+
+ fmt.Printf("%+v", status)
+
+ assert.Len(suite.T(), status.GTSMentions, 1)
+ fmt.Println(status.GTSMentions[0])
+}
+
func (suite *ASToInternalTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
}
diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go
index f269fa182..8f310c921 100644
--- a/internal/typeutils/converter.go
+++ b/internal/typeutils/converter.go
@@ -90,6 +90,10 @@ type TypeConverter interface {
// ASPersonToAccount converts a remote account/person/application representation into a gts model account
ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error)
+ // ASStatus converts a remote activitystreams 'status' representation into a gts model status.
+ ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, error)
+ // ASFollowToFollowRequest converts a remote activitystreams `follow` representation into gts model follow request.
+ ASFollowToFollowRequest(followable Followable) (*gtsmodel.FollowRequest, error)
/*
INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL
diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go
index 73c121155..0216dea5e 100644
--- a/internal/typeutils/internaltoas.go
+++ b/internal/typeutils/internaltoas.go
@@ -200,15 +200,15 @@ func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerso
// icon
// Used as profile avatar.
if a.AvatarMediaAttachmentID != "" {
- iconProperty := streams.NewActivityStreamsIconProperty()
-
- iconImage := streams.NewActivityStreamsImage()
-
avatar := >smodel.MediaAttachment{}
if err := c.db.GetByID(a.AvatarMediaAttachmentID, avatar); err != nil {
return nil, err
}
+ iconProperty := streams.NewActivityStreamsIconProperty()
+
+ iconImage := streams.NewActivityStreamsImage()
+
mediaType := streams.NewActivityStreamsMediaTypeProperty()
mediaType.Set(avatar.File.ContentType)
iconImage.SetActivityStreamsMediaType(mediaType)
@@ -228,15 +228,15 @@ func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerso
// image
// Used as profile header.
if a.HeaderMediaAttachmentID != "" {
- headerProperty := streams.NewActivityStreamsImageProperty()
-
- headerImage := streams.NewActivityStreamsImage()
-
header := >smodel.MediaAttachment{}
if err := c.db.GetByID(a.HeaderMediaAttachmentID, header); err != nil {
return nil, err
}
+ headerProperty := streams.NewActivityStreamsImageProperty()
+
+ headerImage := streams.NewActivityStreamsImage()
+
mediaType := streams.NewActivityStreamsMediaTypeProperty()
mediaType.Set(header.File.ContentType)
headerImage.SetActivityStreamsMediaType(mediaType)
diff --git a/internal/util/regexes.go b/internal/util/regexes.go
index a59bd678a..adab8c87f 100644
--- a/internal/util/regexes.go
+++ b/internal/util/regexes.go
@@ -35,6 +35,11 @@ const (
)
var (
+ mentionNameRegexString = `^@([a-zA-Z0-9_]+)(?:@([a-zA-Z0-9_\-\.]+)?)$`
+ // mention name regex captures the username and domain part from a mention string
+ // such as @whatever_user@example.org, returning whatever_user and example.org (without the @ symbols)
+ mentionNameRegex = regexp.MustCompile(mentionNameRegexString)
+
// mention regex can be played around with here: https://regex101.com/r/qwM9D3/1
mentionFinderRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)`
mentionFinderRegex = regexp.MustCompile(mentionFinderRegexString)
diff --git a/internal/util/statustools.go b/internal/util/statustools.go
index 5591f185a..2c74749e5 100644
--- a/internal/util/statustools.go
+++ b/internal/util/statustools.go
@@ -19,17 +19,18 @@
package util
import (
+ "fmt"
"strings"
)
-// DeriveMentions takes a plaintext (ie., not html-formatted) status,
+// DeriveMentionsFromStatus takes a plaintext (ie., not html-formatted) status,
// and applies a regex to it to return a deduplicated list of accounts
// mentioned in that status.
//
// It will look for fully-qualified account names in the form "@user@example.org".
// or the form "@username" for local users.
// The case of the returned mentions will be lowered, for consistency.
-func DeriveMentions(status string) []string {
+func DeriveMentionsFromStatus(status string) []string {
mentionedAccounts := []string{}
for _, m := range mentionFinderRegex.FindAllStringSubmatch(status, -1) {
mentionedAccounts = append(mentionedAccounts, m[1])
@@ -37,11 +38,11 @@ func DeriveMentions(status string) []string {
return lower(unique(mentionedAccounts))
}
-// DeriveHashtags takes a plaintext (ie., not html-formatted) status,
+// DeriveHashtagsFromStatus takes a plaintext (ie., not html-formatted) status,
// and applies a regex to it to return a deduplicated list of hashtags
// used in that status, without the leading #. The case of the returned
// tags will be lowered, for consistency.
-func DeriveHashtags(status string) []string {
+func DeriveHashtagsFromStatus(status string) []string {
tags := []string{}
for _, m := range hashtagFinderRegex.FindAllStringSubmatch(status, -1) {
tags = append(tags, m[1])
@@ -49,11 +50,11 @@ func DeriveHashtags(status string) []string {
return lower(unique(tags))
}
-// DeriveEmojis takes a plaintext (ie., not html-formatted) status,
+// DeriveEmojisFromStatus takes a plaintext (ie., not html-formatted) status,
// and applies a regex to it to return a deduplicated list of emojis
// used in that status, without the surround ::. The case of the returned
// emojis will be lowered, for consistency.
-func DeriveEmojis(status string) []string {
+func DeriveEmojisFromStatus(status string) []string {
emojis := []string{}
for _, m := range emojiFinderRegex.FindAllStringSubmatch(status, -1) {
emojis = append(emojis, m[1])
@@ -61,6 +62,21 @@ func DeriveEmojis(status string) []string {
return lower(unique(emojis))
}
+// ExtractMentionParts extracts the username test_user and the domain example.org
+// from a mention string like @test_user@example.org.
+//
+// If nothing is matched, it will return an error.
+func ExtractMentionParts(mention string) (username, domain string, err error) {
+ matches := mentionNameRegex.FindStringSubmatch(mention)
+ if matches == nil || len(matches) != 3 {
+ err = fmt.Errorf("could't match mention %s", mention)
+ return
+ }
+ username = matches[1]
+ domain = matches[2]
+ return
+}
+
// unique returns a deduplicated version of a given string slice.
func unique(s []string) []string {
keys := make(map[string]bool)
diff --git a/internal/util/statustools_test.go b/internal/util/statustools_test.go
index 7c9af2cbd..2a12c7690 100644
--- a/internal/util/statustools_test.go
+++ b/internal/util/statustools_test.go
@@ -42,7 +42,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() {
here is a duplicate mention: @hello@test.lgbt
`
- menchies := util.DeriveMentions(statusText)
+ menchies := util.DeriveMentionsFromStatus(statusText)
assert.Len(suite.T(), menchies, 4)
assert.Equal(suite.T(), "@dumpsterqueer@example.org", menchies[0])
assert.Equal(suite.T(), "@someone_else@testing.best-horse.com", menchies[1])
@@ -52,7 +52,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() {
func (suite *StatusTestSuite) TestDeriveMentionsEmpty() {
statusText := ``
- menchies := util.DeriveMentions(statusText)
+ menchies := util.DeriveMentionsFromStatus(statusText)
assert.Len(suite.T(), menchies, 0)
}
@@ -67,7 +67,7 @@ func (suite *StatusTestSuite) TestDeriveHashtagsOK() {
#111111 thisalsoshouldn'twork#### ##`
- tags := util.DeriveHashtags(statusText)
+ tags := util.DeriveHashtagsFromStatus(statusText)
assert.Len(suite.T(), tags, 5)
assert.Equal(suite.T(), "testing123", tags[0])
assert.Equal(suite.T(), "also", tags[1])
@@ -90,7 +90,7 @@ Here's some normal text with an :emoji: at the end
:underscores_ok_too:
`
- tags := util.DeriveEmojis(statusText)
+ tags := util.DeriveEmojisFromStatus(statusText)
assert.Len(suite.T(), tags, 7)
assert.Equal(suite.T(), "test", tags[0])
assert.Equal(suite.T(), "another", tags[1])
diff --git a/internal/util/uri.go b/internal/util/uri.go
index 538df9210..edcfc5c02 100644
--- a/internal/util/uri.go
+++ b/internal/util/uri.go
@@ -58,9 +58,15 @@ const (
// APAccount can be used the set and retrieve the account being interacted with
APAccount APContextKey = "account"
// APRequestingAccount can be used to set and retrieve the account of an incoming federation request.
+ // This will often be the actor of the instance that's posting the request.
APRequestingAccount APContextKey = "requestingAccount"
+ // APRequestingActorIRI can be used to set and retrieve the actor of an incoming federation request.
+ // This will usually be the owner of whatever activity is being posted.
+ APRequestingActorIRI APContextKey = "requestingActorIRI"
// APRequestingPublicKeyID can be used to set and retrieve the public key ID of an incoming federation request.
APRequestingPublicKeyID APContextKey = "requestingPublicKeyID"
+ // APFromFederatorChanKey can be used to pass a pointer to the fromFederator channel into the federator for use in callbacks.
+ APFromFederatorChanKey APContextKey = "fromFederatorChan"
)
type ginContextKey struct{}
--
cgit v1.2.3