diff options
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | internal/api/apimodule.go (renamed from internal/apimodule/apimodule.go) | 16 | ||||
| -rw-r--r-- | internal/api/client/account/account.go (renamed from internal/apimodule/account/account.go) | 50 | ||||
| -rw-r--r-- | internal/api/client/account/account_test.go | 40 | ||||
| -rw-r--r-- | internal/api/client/account/accountcreate.go (renamed from internal/apimodule/account/accountcreate.go) | 58 | ||||
| -rw-r--r-- | internal/api/client/account/accountcreate_test.go | 388 | ||||
| -rw-r--r-- | internal/api/client/account/accountget.go (renamed from internal/apimodule/account/accountget.go) | 23 | ||||
| -rw-r--r-- | internal/api/client/account/accountupdate.go | 71 | ||||
| -rw-r--r-- | internal/api/client/account/accountupdate_test.go | 106 | ||||
| -rw-r--r-- | internal/api/client/account/accountverify.go (renamed from internal/apimodule/account/accountverify.go) | 10 | ||||
| -rw-r--r-- | internal/api/client/account/accountverify_test.go (renamed from internal/apimodule/account/test/accountverify_test.go) | 2 | ||||
| -rw-r--r-- | internal/api/client/admin/admin.go (renamed from internal/apimodule/admin/admin.go) | 50 | ||||
| -rw-r--r-- | internal/api/client/admin/emojicreate.go (renamed from internal/apimodule/admin/emojicreate.go) | 50 | ||||
| -rw-r--r-- | internal/api/client/app/app.go (renamed from internal/apimodule/app/app.go) | 43 | ||||
| -rw-r--r-- | internal/api/client/app/app_test.go (renamed from internal/apimodule/app/test/app_test.go) | 2 | ||||
| -rw-r--r-- | internal/api/client/app/appcreate.go | 79 | ||||
| -rw-r--r-- | internal/api/client/auth/auth.go (renamed from internal/apimodule/auth/auth.go) | 35 | ||||
| -rw-r--r-- | internal/api/client/auth/auth_test.go (renamed from internal/apimodule/auth/test/auth_test.go) | 6 | ||||
| -rw-r--r-- | internal/api/client/auth/authorize.go (renamed from internal/apimodule/auth/authorize.go) | 6 | ||||
| -rw-r--r-- | internal/api/client/auth/middleware.go (renamed from internal/apimodule/auth/middleware.go) | 4 | ||||
| -rw-r--r-- | internal/api/client/auth/signin.go (renamed from internal/apimodule/auth/signin.go) | 2 | ||||
| -rw-r--r-- | internal/api/client/auth/token.go (renamed from internal/apimodule/auth/token.go) | 0 | ||||
| -rw-r--r-- | internal/api/client/fileserver/fileserver.go (renamed from internal/apimodule/fileserver/fileserver.go) | 16 | ||||
| -rw-r--r-- | internal/api/client/fileserver/servefile.go | 94 | ||||
| -rw-r--r-- | internal/api/client/fileserver/servefile_test.go (renamed from internal/apimodule/fileserver/test/servefile_test.go) | 36 | ||||
| -rw-r--r-- | internal/api/client/media/media.go (renamed from internal/apimodule/media/media.go) | 25 | ||||
| -rw-r--r-- | internal/api/client/media/mediacreate.go | 91 | ||||
| -rw-r--r-- | internal/api/client/media/mediacreate_test.go (renamed from internal/apimodule/media/test/mediacreate_test.go) | 44 | ||||
| -rw-r--r-- | internal/api/client/status/status.go (renamed from internal/apimodule/status/status.go) | 78 | ||||
| -rw-r--r-- | internal/api/client/status/status_test.go | 58 | ||||
| -rw-r--r-- | internal/api/client/status/statuscreate.go | 130 | ||||
| -rw-r--r-- | internal/api/client/status/statuscreate_test.go (renamed from internal/apimodule/status/test/statuscreate_test.go) | 107 | ||||
| -rw-r--r-- | internal/api/client/status/statusdelete.go | 60 | ||||
| -rw-r--r-- | internal/api/client/status/statusfave.go | 60 | ||||
| -rw-r--r-- | internal/api/client/status/statusfave_test.go (renamed from internal/apimodule/status/test/statusfave_test.go) | 89 | ||||
| -rw-r--r-- | internal/api/client/status/statusfavedby.go | 60 | ||||
| -rw-r--r-- | internal/api/client/status/statusfavedby_test.go (renamed from internal/apimodule/status/test/statusfavedby_test.go) | 79 | ||||
| -rw-r--r-- | internal/api/client/status/statusget.go | 60 | ||||
| -rw-r--r-- | internal/api/client/status/statusget_test.go (renamed from internal/apimodule/status/test/statusget_test.go) | 91 | ||||
| -rw-r--r-- | internal/api/client/status/statusunfave.go | 60 | ||||
| -rw-r--r-- | internal/api/client/status/statusunfave_test.go (renamed from internal/apimodule/status/test/statusunfave_test.go) | 89 | ||||
| -rw-r--r-- | internal/api/model/account.go (renamed from internal/mastotypes/mastomodel/account.go) | 9 | ||||
| -rw-r--r-- | internal/api/model/activity.go (renamed from internal/mastotypes/mastomodel/activity.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/admin.go (renamed from internal/mastotypes/mastomodel/admin.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/announcement.go (renamed from internal/mastotypes/mastomodel/announcement.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/announcementreaction.go (renamed from internal/mastotypes/mastomodel/announcementreaction.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/application.go (renamed from internal/mastotypes/mastomodel/application.go) | 6 | ||||
| -rw-r--r-- | internal/api/model/attachment.go (renamed from internal/mastotypes/mastomodel/attachment.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/card.go (renamed from internal/mastotypes/mastomodel/card.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/content.go | 41 | ||||
| -rw-r--r-- | internal/api/model/context.go (renamed from internal/mastotypes/mastomodel/context.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/conversation.go (renamed from internal/mastotypes/mastomodel/conversation.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/emoji.go (renamed from internal/mastotypes/mastomodel/emoji.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/error.go (renamed from internal/mastotypes/mastomodel/error.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/featuredtag.go (renamed from internal/mastotypes/mastomodel/featuredtag.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/field.go (renamed from internal/mastotypes/mastomodel/field.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/filter.go (renamed from internal/mastotypes/mastomodel/filter.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/history.go (renamed from internal/mastotypes/mastomodel/history.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/identityproof.go (renamed from internal/mastotypes/mastomodel/identityproof.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/instance.go (renamed from internal/mastotypes/mastomodel/instance.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/list.go (renamed from internal/mastotypes/mastomodel/list.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/marker.go (renamed from internal/mastotypes/mastomodel/marker.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/mention.go (renamed from internal/mastotypes/mastomodel/mention.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/notification.go (renamed from internal/mastotypes/mastomodel/notification.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/oauth.go (renamed from internal/mastotypes/mastomodel/oauth.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/poll.go (renamed from internal/mastotypes/mastomodel/poll.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/preferences.go (renamed from internal/mastotypes/mastomodel/preferences.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/pushsubscription.go (renamed from internal/mastotypes/mastomodel/pushsubscription.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/relationship.go (renamed from internal/mastotypes/mastomodel/relationship.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/results.go (renamed from internal/mastotypes/mastomodel/results.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/scheduledstatus.go (renamed from internal/mastotypes/mastomodel/scheduledstatus.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/source.go (renamed from internal/mastotypes/mastomodel/source.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/status.go (renamed from internal/mastotypes/mastomodel/status.go) | 20 | ||||
| -rw-r--r-- | internal/api/model/tag.go (renamed from internal/mastotypes/mastomodel/tag.go) | 2 | ||||
| -rw-r--r-- | internal/api/model/token.go (renamed from internal/mastotypes/mastomodel/token.go) | 2 | ||||
| -rw-r--r-- | internal/api/s2s/user/user.go | 70 | ||||
| -rw-r--r-- | internal/api/s2s/user/user_test.go | 40 | ||||
| -rw-r--r-- | internal/api/s2s/user/userget.go | 67 | ||||
| -rw-r--r-- | internal/api/s2s/user/userget_test.go | 155 | ||||
| -rw-r--r-- | internal/api/security/flocblock.go (renamed from internal/apimodule/security/flocblock.go) | 0 | ||||
| -rw-r--r-- | internal/api/security/security.go (renamed from internal/apimodule/security/security.go) | 10 | ||||
| -rw-r--r-- | internal/apimodule/account/accountupdate.go | 260 | ||||
| -rw-r--r-- | internal/apimodule/account/test/accountcreate_test.go | 551 | ||||
| -rw-r--r-- | internal/apimodule/account/test/accountupdate_test.go | 303 | ||||
| -rw-r--r-- | internal/apimodule/app/appcreate.go | 119 | ||||
| -rw-r--r-- | internal/apimodule/fileserver/servefile.go | 243 | ||||
| -rw-r--r-- | internal/apimodule/media/mediacreate.go | 193 | ||||
| -rw-r--r-- | internal/apimodule/mock_ClientAPIModule.go | 43 | ||||
| -rw-r--r-- | internal/apimodule/status/statuscreate.go | 462 | ||||
| -rw-r--r-- | internal/apimodule/status/statusdelete.go | 107 | ||||
| -rw-r--r-- | internal/apimodule/status/statusfave.go | 137 | ||||
| -rw-r--r-- | internal/apimodule/status/statusfavedby.go | 129 | ||||
| -rw-r--r-- | internal/apimodule/status/statusget.go | 112 | ||||
| -rw-r--r-- | internal/apimodule/status/statusunfave.go | 137 | ||||
| -rw-r--r-- | internal/cache/mock_Cache.go | 47 | ||||
| -rw-r--r-- | internal/config/mock_KeyedFlags.go | 66 | ||||
| -rw-r--r-- | internal/db/db.go | 25 | ||||
| -rw-r--r-- | internal/db/federating_db.go | 119 | ||||
| -rw-r--r-- | internal/db/mock_DB.go | 484 | ||||
| -rw-r--r-- | internal/db/pg.go | 43 | ||||
| -rw-r--r-- | internal/db/pg_test.go | 2 | ||||
| -rw-r--r-- | internal/distributor/distributor.go | 110 | ||||
| -rw-r--r-- | internal/distributor/mock_Distributor.go | 70 | ||||
| -rw-r--r-- | internal/federation/clock.go (renamed from testrig/distributor.go) | 26 | ||||
| -rw-r--r-- | internal/federation/commonbehavior.go | 152 | ||||
| -rw-r--r-- | internal/federation/federatingactor.go | 136 | ||||
| -rw-r--r-- | internal/federation/federatingprotocol.go (renamed from internal/federation/federation.go) | 218 | ||||
| -rw-r--r-- | internal/federation/federator.go | 79 | ||||
| -rw-r--r-- | internal/federation/federator_test.go | 190 | ||||
| -rw-r--r-- | internal/federation/util.go | 237 | ||||
| -rw-r--r-- | internal/gotosocial/actions.go | 64 | ||||
| -rw-r--r-- | internal/gotosocial/gotosocial.go | 23 | ||||
| -rw-r--r-- | internal/gotosocial/mock_Gotosocial.go | 42 | ||||
| -rw-r--r-- | internal/gtsmodel/README.md (renamed from internal/db/gtsmodel/README.md) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/account.go (renamed from internal/db/gtsmodel/account.go) | 20 | ||||
| -rw-r--r-- | internal/gtsmodel/activitystreams.go (renamed from internal/db/gtsmodel/activitystreams.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/application.go (renamed from internal/db/gtsmodel/application.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/block.go (renamed from internal/db/gtsmodel/block.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/domainblock.go (renamed from internal/db/gtsmodel/domainblock.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/emaildomainblock.go (renamed from internal/db/gtsmodel/emaildomainblock.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/emoji.go (renamed from internal/db/gtsmodel/emoji.go) | 2 | ||||
| -rw-r--r-- | internal/gtsmodel/follow.go (renamed from internal/db/gtsmodel/follow.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/followrequest.go (renamed from internal/db/gtsmodel/followrequest.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/mediaattachment.go (renamed from internal/db/gtsmodel/mediaattachment.go) | 10 | ||||
| -rw-r--r-- | internal/gtsmodel/mention.go (renamed from internal/db/gtsmodel/mention.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/poll.go (renamed from internal/db/gtsmodel/poll.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/status.go (renamed from internal/db/gtsmodel/status.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/statusbookmark.go (renamed from internal/db/gtsmodel/statusbookmark.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/statusfave.go (renamed from internal/db/gtsmodel/statusfave.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/statusmute.go (renamed from internal/db/gtsmodel/statusmute.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/statuspin.go (renamed from internal/db/gtsmodel/statuspin.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/tag.go (renamed from internal/db/gtsmodel/tag.go) | 0 | ||||
| -rw-r--r-- | internal/gtsmodel/user.go (renamed from internal/db/gtsmodel/user.go) | 0 | ||||
| -rw-r--r-- | internal/mastotypes/mastomodel/README.md | 5 | ||||
| -rw-r--r-- | internal/mastotypes/mock_Converter.go | 148 | ||||
| -rw-r--r-- | internal/media/media.go | 148 | ||||
| -rw-r--r-- | internal/media/media_test.go | 4 | ||||
| -rw-r--r-- | internal/media/mock_MediaHandler.go | 2 | ||||
| -rw-r--r-- | internal/media/util.go | 88 | ||||
| -rw-r--r-- | internal/media/util_test.go | 4 | ||||
| -rw-r--r-- | internal/message/accountprocess.go | 168 | ||||
| -rw-r--r-- | internal/message/adminprocess.go | 48 | ||||
| -rw-r--r-- | internal/message/appprocess.go | 59 | ||||
| -rw-r--r-- | internal/message/error.go | 106 | ||||
| -rw-r--r-- | internal/message/fediprocess.go | 102 | ||||
| -rw-r--r-- | internal/message/mediaprocess.go | 188 | ||||
| -rw-r--r-- | internal/message/processor.go | 215 | ||||
| -rw-r--r-- | internal/message/processorutil.go | 304 | ||||
| -rw-r--r-- | internal/message/statusprocess.go | 350 | ||||
| -rw-r--r-- | internal/oauth/clientstore.go | 3 | ||||
| -rw-r--r-- | internal/oauth/clientstore_test.go | 13 | ||||
| -rw-r--r-- | internal/oauth/oauth_test.go | 2 | ||||
| -rw-r--r-- | internal/oauth/server.go | 172 | ||||
| -rw-r--r-- | internal/oauth/tokenstore_test.go | 2 | ||||
| -rw-r--r-- | internal/oauth/util.go | 86 | ||||
| -rw-r--r-- | internal/storage/inmem.go | 2 | ||||
| -rw-r--r-- | internal/transport/controller.go | 71 | ||||
| -rw-r--r-- | internal/typeutils/accountable.go | 101 | ||||
| -rw-r--r-- | internal/typeutils/asextractionutil.go | 216 | ||||
| -rw-r--r-- | internal/typeutils/astointernal.go | 164 | ||||
| -rw-r--r-- | internal/typeutils/astointernal_test.go | 206 | ||||
| -rw-r--r-- | internal/typeutils/converter.go | 113 | ||||
| -rw-r--r-- | internal/typeutils/converter_test.go | 40 | ||||
| -rw-r--r-- | internal/typeutils/frontendtointernal.go | 39 | ||||
| -rw-r--r-- | internal/typeutils/internaltoas.go | 260 | ||||
| -rw-r--r-- | internal/typeutils/internaltoas_test.go | 76 | ||||
| -rw-r--r-- | internal/typeutils/internaltofrontend.go (renamed from internal/mastotypes/converter.go) | 147 | ||||
| -rw-r--r-- | internal/util/parse.go | 96 | ||||
| -rw-r--r-- | internal/util/regexes.go | 79 | ||||
| -rw-r--r-- | internal/util/statustools.go (renamed from internal/util/status.go) | 20 | ||||
| -rw-r--r-- | internal/util/statustools_test.go (renamed from internal/util/status_test.go) | 11 | ||||
| -rw-r--r-- | internal/util/uri.go | 218 | ||||
| -rw-r--r-- | internal/util/validation.go | 49 | ||||
| -rw-r--r-- | internal/util/validation_test.go | 81 | ||||
| -rw-r--r-- | testrig/actions.go | 71 | ||||
| -rw-r--r-- | testrig/db.go | 4 | ||||
| -rw-r--r-- | testrig/federator.go | 29 | ||||
| -rw-r--r-- | testrig/media/test-jpeg.jpg | bin | 0 -> 269739 bytes | |||
| -rw-r--r-- | testrig/processor.go | 31 | ||||
| -rw-r--r-- | testrig/testmodels.go | 558 | ||||
| -rw-r--r-- | testrig/transportcontroller.go | 73 | ||||
| -rw-r--r-- | testrig/typeconverter.go (renamed from testrig/mastoconverter.go) | 8 | ||||
| -rw-r--r-- | testrig/util.go | 11 | 
183 files changed, 7388 insertions, 5411 deletions
| @@ -8,6 +8,7 @@ require (  	github.com/gin-contrib/sessions v0.0.3  	github.com/gin-gonic/gin v1.6.3  	github.com/go-fed/activity v1.0.0 +	github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5  	github.com/go-pg/pg/extra/pgdebug v0.2.0  	github.com/go-pg/pg/v10 v10.8.0  	github.com/golang/mock v1.4.4 // indirect diff --git a/internal/apimodule/apimodule.go b/internal/api/apimodule.go index 6d7dbdb83..d0bcc612a 100644 --- a/internal/apimodule/apimodule.go +++ b/internal/api/apimodule.go @@ -16,18 +16,22 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -// Package apimodule is basically a wrapper for a lot of modules (in subdirectories) that satisfy the ClientAPIModule interface. -package apimodule +package api  import ( -	"github.com/superseriousbusiness/gotosocial/internal/db"  	"github.com/superseriousbusiness/gotosocial/internal/router"  ) -// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set +// ClientModule represents a chunk of code (usually contained in a single package) that adds a set  // of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)  // A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/ -type ClientAPIModule interface { +type ClientModule interface { +	Route(s router.Router) error +} + +// FederationModule represents a chunk of code (usually contained in a single package) that adds a set +// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;) +// Unlike ClientAPIModule, federation API module is not intended to be interacted with by clients directly -- it is primarily a server-to-server interface. +type FederationModule interface {  	Route(s router.Router) error -	CreateTables(db db.DB) error  } diff --git a/internal/apimodule/account/account.go b/internal/api/client/account/account.go index a836afcdb..dce810202 100644 --- a/internal/apimodule/account/account.go +++ b/internal/api/client/account/account.go @@ -19,20 +19,15 @@  package account  import ( -	"fmt"  	"net/http"  	"strings"  	"github.com/gin-gonic/gin"  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/api"  	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" +	"github.com/superseriousbusiness/gotosocial/internal/message" -	"github.com/superseriousbusiness/gotosocial/internal/media" -	"github.com/superseriousbusiness/gotosocial/internal/oauth"  	"github.com/superseriousbusiness/gotosocial/internal/router"  ) @@ -51,23 +46,17 @@ const (  // Module implements the ClientAPIModule interface for account-related actions  type Module struct { -	config         *config.Config -	db             db.DB -	oauthServer    oauth.Server -	mediaHandler   media.Handler -	mastoConverter mastotypes.Converter -	log            *logrus.Logger +	config    *config.Config +	processor message.Processor +	log       *logrus.Logger  }  // New returns a new account module -func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {  	return &Module{ -		config:         config, -		db:             db, -		oauthServer:    oauthServer, -		mediaHandler:   mediaHandler, -		mastoConverter: mastoConverter, -		log:            log, +		config:    config, +		processor: processor, +		log:       log,  	}  } @@ -79,27 +68,6 @@ func (m *Module) Route(r router.Router) error {  	return nil  } -// CreateTables creates the required tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { -	models := []interface{}{ -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Follow{}, -		>smodel.FollowRequest{}, -		>smodel.Status{}, -		>smodel.Application{}, -		>smodel.EmailDomainBlock{}, -		>smodel.MediaAttachment{}, -	} - -	for _, m := range models { -		if err := db.CreateTable(m); err != nil { -			return fmt.Errorf("error creating table: %s", err) -		} -	} -	return nil -} -  func (m *Module) muxHandler(c *gin.Context) {  	ru := c.Request.RequestURI  	switch c.Request.Method { diff --git a/internal/api/client/account/account_test.go b/internal/api/client/account/account_test.go new file mode 100644 index 000000000..d0560bcb6 --- /dev/null +++ b/internal/api/client/account/account_test.go @@ -0,0 +1,40 @@ +package account_test + +import ( +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/account" +	"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/message" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type AccountStandardTestSuite struct { +	// standard suite interfaces +	suite.Suite +	config    *config.Config +	db        db.DB +	log       *logrus.Logger +	tc        typeutils.TypeConverter +	storage   storage.Storage +	federator federation.Federator +	processor message.Processor + +	// standard suite models +	testTokens       map[string]*oauth.Token +	testClients      map[string]*oauth.Client +	testApplications map[string]*gtsmodel.Application +	testUsers        map[string]*gtsmodel.User +	testAccounts     map[string]*gtsmodel.Account +	testAttachments  map[string]*gtsmodel.MediaAttachment +	testStatuses     map[string]*gtsmodel.Status + +	// module being tested +	accountModule *account.Module +} diff --git a/internal/apimodule/account/accountcreate.go b/internal/api/client/account/accountcreate.go index fb21925b8..b53d8c412 100644 --- a/internal/apimodule/account/accountcreate.go +++ b/internal/api/client/account/accountcreate.go @@ -20,18 +20,14 @@ package account  import (  	"errors" -	"fmt"  	"net"  	"net/http"  	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  	"github.com/superseriousbusiness/gotosocial/internal/util" -	"github.com/superseriousbusiness/oauth2/v4"  )  // AccountCreatePOSTHandler handles create account requests, validates them, @@ -39,7 +35,7 @@ import (  // It should be served as a POST at /api/v1/accounts  func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {  	l := m.log.WithField("func", "accountCreatePOSTHandler") -	authed, err := oauth.MustAuth(c, true, true, false, false) +	authed, err := oauth.Authed(c, true, true, false, false)  	if err != nil {  		l.Debugf("couldn't auth: %s", err)  		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) @@ -47,7 +43,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {  	}  	l.Trace("parsing request form") -	form := &mastotypes.AccountCreateRequest{} +	form := &model.AccountCreateRequest{}  	if err := c.ShouldBind(form); err != nil || form == nil {  		l.Debugf("could not parse form from request: %s", err)  		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) @@ -55,7 +51,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {  	}  	l.Tracef("validating form %+v", form) -	if err := validateCreateAccount(form, m.config.AccountsConfig, m.db); err != nil { +	if err := validateCreateAccount(form, m.config.AccountsConfig); err != nil {  		l.Debugf("error validating form: %s", err)  		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})  		return @@ -70,7 +66,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {  		return  	} -	ti, err := m.accountCreate(form, signUpIP, authed.Token, authed.Application) +	form.IP = signUpIP + +	ti, err := m.processor.AccountCreate(authed, form)  	if err != nil {  		l.Errorf("internal server error while creating new account: %s", err)  		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -80,41 +78,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {  	c.JSON(http.StatusOK, ti)  } -// accountCreate does the dirty work of making an account and user in the database. -// It then returns a token to the caller, for use with the new account, as per the -// spec here: https://docs.joinmastodon.org/methods/accounts/ -func (m *Module) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *gtsmodel.Application) (*mastotypes.Token, error) { -	l := m.log.WithField("func", "accountCreate") - -	// don't store a reason if we don't require one -	reason := form.Reason -	if !m.config.AccountsConfig.ReasonRequired { -		reason = "" -	} - -	l.Trace("creating new username and account") -	user, err := m.db.NewSignup(form.Username, reason, m.config.AccountsConfig.RequireApproval, form.Email, form.Password, signUpIP, form.Locale, app.ID) -	if err != nil { -		return nil, fmt.Errorf("error creating new signup in the database: %s", err) -	} - -	l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, app.ID) -	accessToken, err := m.oauthServer.GenerateUserAccessToken(token, app.ClientSecret, user.ID) -	if err != nil { -		return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) -	} - -	return &mastotypes.Token{ -		AccessToken: accessToken.GetAccess(), -		TokenType:   "Bearer", -		Scope:       accessToken.GetScope(), -		CreatedAt:   accessToken.GetAccessCreateAt().Unix(), -	}, nil -} -  // validateCreateAccount checks through all the necessary prerequisites for creating a new account,  // according to the provided account create request. If the account isn't eligible, an error will be returned. -func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.AccountsConfig, database db.DB) error { +func validateCreateAccount(form *model.AccountCreateRequest, c *config.AccountsConfig) error {  	if !c.OpenRegistration {  		return errors.New("registration is not open for this server")  	} @@ -143,13 +109,5 @@ func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.Acco  		return err  	} -	if err := database.IsEmailAvailable(form.Email); err != nil { -		return err -	} - -	if err := database.IsUsernameAvailable(form.Username); err != nil { -		return err -	} -  	return nil  } diff --git a/internal/api/client/account/accountcreate_test.go b/internal/api/client/account/accountcreate_test.go new file mode 100644 index 000000000..da86ee940 --- /dev/null +++ b/internal/api/client/account/accountcreate_test.go @@ -0,0 +1,388 @@ +// /* +//    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 <http://www.gnu.org/licenses/>. +// */ + +package account_test + +// import ( +// 	"bytes" +// 	"encoding/json" +// 	"fmt" +// 	"io" +// 	"io/ioutil" +// 	"mime/multipart" +// 	"net/http" +// 	"net/http/httptest" +// 	"os" +// 	"testing" + +// 	"github.com/gin-gonic/gin" +// 	"github.com/google/uuid" +// 	"github.com/stretchr/testify/assert" +// 	"github.com/stretchr/testify/suite" +// 	"github.com/superseriousbusiness/gotosocial/internal/api/client/account" +// 	"github.com/superseriousbusiness/gotosocial/internal/api/model" +// 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +// 	"github.com/superseriousbusiness/gotosocial/testrig" + +// 	"github.com/superseriousbusiness/gotosocial/internal/oauth" +// 	"golang.org/x/crypto/bcrypt" +// ) + +// type AccountCreateTestSuite struct { +// 	AccountStandardTestSuite +// } + +// func (suite *AccountCreateTestSuite) SetupSuite() { +// 	suite.testTokens = testrig.NewTestTokens() +// 	suite.testClients = testrig.NewTestClients() +// 	suite.testApplications = testrig.NewTestApplications() +// 	suite.testUsers = testrig.NewTestUsers() +// 	suite.testAccounts = testrig.NewTestAccounts() +// 	suite.testAttachments = testrig.NewTestAttachments() +// 	suite.testStatuses = testrig.NewTestStatuses() +// } + +// func (suite *AccountCreateTestSuite) SetupTest() { +// 	suite.config = testrig.NewTestConfig() +// 	suite.db = testrig.NewTestDB() +// 	suite.storage = testrig.NewTestStorage() +// 	suite.log = testrig.NewTestLog() +// 	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +// 	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +// 	suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module) +// 	testrig.StandardDBSetup(suite.db) +// 	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +// } + +// func (suite *AccountCreateTestSuite) TearDownTest() { +// 	testrig.StandardDBTeardown(suite.db) +// 	testrig.StandardStorageTeardown(suite.storage) +// } + +// // TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid, +// // and at the end of it a new user and account should be added into the database. +// // +// // This is the handler served at /api/v1/accounts as POST +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { + +// 	t := suite.testTokens["local_account_1"] +// 	oauthToken := oauth.TokenToOauthToken(t) + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +// 	ctx.Set(oauth.SessionAuthorizedToken, oauthToken) +// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// 	ctx.Request.Form = suite.newUserFormHappyPath +// 	suite.accountModule.AccountCreatePOSTHandler(ctx) + +// 	// check response + +// 	// 1. we should have OK from our call to the function +// 	suite.EqualValues(http.StatusOK, recorder.Code) + +// 	// 2. we should have a token in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	b, err := ioutil.ReadAll(result.Body) +// 	assert.NoError(suite.T(), err) +// 	t := &model.Token{} +// 	err = json.Unmarshal(b, t) +// 	assert.NoError(suite.T(), err) +// 	assert.Equal(suite.T(), "we're authorized now!", t.AccessToken) + +// 	// check new account + +// 	// 1. we should be able to get the new account from the db +// 	acct := >smodel.Account{} +// 	err = suite.db.GetLocalAccountByUsername("test_user", acct) +// 	assert.NoError(suite.T(), err) +// 	assert.NotNil(suite.T(), acct) +// 	// 2. reason should be set +// 	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason) +// 	// 3. display name should be equal to username by default +// 	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName) +// 	// 4. domain should be nil because this is a local account +// 	assert.Nil(suite.T(), nil, acct.Domain) +// 	// 5. id should be set and parseable as a uuid +// 	assert.NotNil(suite.T(), acct.ID) +// 	_, err = uuid.Parse(acct.ID) +// 	assert.Nil(suite.T(), err) +// 	// 6. private and public key should be set +// 	assert.NotNil(suite.T(), acct.PrivateKey) +// 	assert.NotNil(suite.T(), acct.PublicKey) + +// 	// check new user + +// 	// 1. we should be able to get the new user from the db +// 	usr := >smodel.User{} +// 	err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr) +// 	assert.Nil(suite.T(), err) +// 	assert.NotNil(suite.T(), usr) + +// 	// 2. user should have account id set to account we got above +// 	assert.Equal(suite.T(), acct.ID, usr.AccountID) + +// 	// 3. id should be set and parseable as a uuid +// 	assert.NotNil(suite.T(), usr.ID) +// 	_, err = uuid.Parse(usr.ID) +// 	assert.Nil(suite.T(), err) + +// 	// 4. locale should be equal to what we requested +// 	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale) + +// 	// 5. created by application id should be equal to the app id +// 	assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID) + +// 	// 6. password should be matcheable to what we set above +// 	err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password"))) +// 	assert.Nil(suite.T(), err) +// } + +// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided: +// // only registered applications can create accounts, and we don't provide one here. +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() { + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// 	ctx.Request.Form = suite.newUserFormHappyPath +// 	suite.accountModule.AccountCreatePOSTHandler(ctx) + +// 	// check response + +// 	// 1. we should have forbidden from our call to the function because we didn't auth +// 	suite.EqualValues(http.StatusForbidden, recorder.Code) + +// 	// 2. we should have an error message in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	b, err := ioutil.ReadAll(result.Body) +// 	assert.NoError(suite.T(), err) +// 	assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all. +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() { + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// 	suite.accountModule.AccountCreatePOSTHandler(ctx) + +// 	// check response +// 	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// 	// 2. we should have an error message in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	b, err := ioutil.ReadAll(result.Body) +// 	assert.NoError(suite.T(), err) +// 	assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// 	ctx.Request.Form = suite.newUserFormHappyPath +// 	// set a weak password +// 	ctx.Request.Form.Set("password", "weak") +// 	suite.accountModule.AccountCreatePOSTHandler(ctx) + +// 	// check response +// 	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// 	// 2. we should have an error message in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	b, err := ioutil.ReadAll(result.Body) +// 	assert.NoError(suite.T(), err) +// 	assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// 	ctx.Request.Form = suite.newUserFormHappyPath +// 	// set an invalid locale +// 	ctx.Request.Form.Set("locale", "neverneverland") +// 	suite.accountModule.AccountCreatePOSTHandler(ctx) + +// 	// check response +// 	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// 	// 2. we should have an error message in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	b, err := ioutil.ReadAll(result.Body) +// 	assert.NoError(suite.T(), err) +// 	assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() { + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// 	ctx.Request.Form = suite.newUserFormHappyPath + +// 	// close registrations +// 	suite.config.AccountsConfig.OpenRegistration = false +// 	suite.accountModule.AccountCreatePOSTHandler(ctx) + +// 	// check response +// 	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// 	// 2. we should have an error message in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	b, err := ioutil.ReadAll(result.Body) +// 	assert.NoError(suite.T(), err) +// 	assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// 	ctx.Request.Form = suite.newUserFormHappyPath + +// 	// remove reason +// 	ctx.Request.Form.Set("reason", "") + +// 	suite.accountModule.AccountCreatePOSTHandler(ctx) + +// 	// check response +// 	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// 	// 2. we should have an error message in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	b, err := ioutil.ReadAll(result.Body) +// 	assert.NoError(suite.T(), err) +// 	assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() { + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// 	ctx.Request.Form = suite.newUserFormHappyPath + +// 	// remove reason +// 	ctx.Request.Form.Set("reason", "just cuz") + +// 	suite.accountModule.AccountCreatePOSTHandler(ctx) + +// 	// check response +// 	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// 	// 2. we should have an error message in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	b, err := ioutil.ReadAll(result.Body) +// 	assert.NoError(suite.T(), err) +// 	assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b)) +// } + +// /* +// 	TESTING: AccountUpdateCredentialsPATCHHandler +// */ + +// func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { + +// 	// put test local account in db +// 	err := suite.db.Put(suite.testAccountLocal) +// 	assert.NoError(suite.T(), err) + +// 	// attach avatar to request +// 	aviFile, err := os.Open("../../media/test/test-jpeg.jpg") +// 	assert.NoError(suite.T(), err) +// 	body := &bytes.Buffer{} +// 	writer := multipart.NewWriter(body) + +// 	part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") +// 	assert.NoError(suite.T(), err) + +// 	_, err = io.Copy(part, aviFile) +// 	assert.NoError(suite.T(), err) + +// 	err = aviFile.Close() +// 	assert.NoError(suite.T(), err) + +// 	err = writer.Close() +// 	assert.NoError(suite.T(), err) + +// 	// setup +// 	recorder := httptest.NewRecorder() +// 	ctx, _ := gin.CreateTestContext(recorder) +// 	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) +// 	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// 	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting +// 	ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) +// 	suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) + +// 	// check response + +// 	// 1. we should have OK because our request was valid +// 	suite.EqualValues(http.StatusOK, recorder.Code) + +// 	// 2. we should have an error message in the result body +// 	result := recorder.Result() +// 	defer result.Body.Close() +// 	// TODO: implement proper checks here +// 	// +// 	// b, err := ioutil.ReadAll(result.Body) +// 	// assert.NoError(suite.T(), err) +// 	// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) +// } + +// func TestAccountCreateTestSuite(t *testing.T) { +// 	suite.Run(t, new(AccountCreateTestSuite)) +// } diff --git a/internal/apimodule/account/accountget.go b/internal/api/client/account/accountget.go index 5003be139..5ca17a167 100644 --- a/internal/apimodule/account/accountget.go +++ b/internal/api/client/account/accountget.go @@ -22,8 +22,7 @@ import (  	"net/http"  	"github.com/gin-gonic/gin" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/oauth"  )  // AccountGETHandler serves the account information held by the server in response to a GET @@ -31,25 +30,21 @@ import (  //  // See: https://docs.joinmastodon.org/methods/accounts/  func (m *Module) AccountGETHandler(c *gin.Context) { -	targetAcctID := c.Param(IDKey) -	if targetAcctID == "" { -		c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) +	authed, err := oauth.Authed(c, false, false, false, false) +	if err != nil { +		c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})  		return  	} -	targetAccount := >smodel.Account{} -	if err := m.db.GetByID(targetAcctID, targetAccount); err != nil { -		if _, ok := err.(db.ErrNoEntries); ok { -			c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"}) -			return -		} -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +	targetAcctID := c.Param(IDKey) +	if targetAcctID == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"})  		return  	} -	acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount) +	acctInfo, err := m.processor.AccountGet(authed, targetAcctID)  	if err != nil { -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})  		return  	} diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go new file mode 100644 index 000000000..406769fe7 --- /dev/null +++ b/internal/api/client/account/accountupdate.go @@ -0,0 +1,71 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings. +// It should be served as a PATCH at /api/v1/accounts/update_credentials +// +// TODO: this can be optimized massively by building up a picture of what we want the new account +// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one +// which is not gonna make the database very happy when lots of requests are going through. +// This way it would also be safer because the update won't happen until *all* the fields are validated. +// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss. +func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { +	l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler") +	authed, err := oauth.Authed(c, true, false, false, true) +	if err != nil { +		l.Debugf("couldn't auth: %s", err) +		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) +		return +	} +	l.Tracef("retrieved account %+v", authed.Account.ID) + +	l.Trace("parsing request form") +	form := &model.UpdateCredentialsRequest{} +	if err := c.ShouldBind(form); err != nil || form == nil { +		l.Debugf("could not parse form from request: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		return +	} + +	// if everything on the form is nil, then nothing has been set and we shouldn't continue +	if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil { +		l.Debugf("could not parse form from request") +		c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"}) +		return +	} + +	acctSensitive, err := m.processor.AccountUpdate(authed, form) +	if err != nil { +		l.Debugf("could not update account: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		return +	} + +	l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) +	c.JSON(http.StatusOK, acctSensitive) +} diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go new file mode 100644 index 000000000..ba7faa794 --- /dev/null +++ b/internal/api/client/account/accountupdate_test.go @@ -0,0 +1,106 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account_test + +import ( +	"bytes" +	"fmt" +	"io/ioutil" +	"net/http" +	"net/http/httptest" +	"testing" + +	"github.com/gin-gonic/gin" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/account" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type AccountUpdateTestSuite struct { +	AccountStandardTestSuite +} + +func (suite *AccountUpdateTestSuite) SetupSuite() { +	suite.testTokens = testrig.NewTestTokens() +	suite.testClients = testrig.NewTestClients() +	suite.testApplications = testrig.NewTestApplications() +	suite.testUsers = testrig.NewTestUsers() +	suite.testAccounts = testrig.NewTestAccounts() +	suite.testAttachments = testrig.NewTestAttachments() +	suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *AccountUpdateTestSuite) SetupTest() { +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.storage = testrig.NewTestStorage() +	suite.log = testrig.NewTestLog() +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +	suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module) +	testrig.StandardDBSetup(suite.db) +	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *AccountUpdateTestSuite) TearDownTest() { +	testrig.StandardDBTeardown(suite.db) +	testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { + +	requestBody, w, err := testrig.CreateMultipartFormData("header", "../../../../testrig/media/test-jpeg.jpg", map[string]string{ +		"display_name": "updated zork display name!!!", +		"locked":       "true", +	}) +	if err != nil { +		panic(err) +	} + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.TokenToOauthToken(suite.testTokens["local_account_1"])) +	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), bytes.NewReader(requestBody.Bytes())) // the endpoint we're hitting +	ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) +	suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) + +	// check response + +	// 1. we should have OK because our request was valid +	suite.EqualValues(http.StatusOK, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) + +	fmt.Println(string(b)) + +	// TODO write more assertions allee +} + +func TestAccountUpdateTestSuite(t *testing.T) { +	suite.Run(t, new(AccountUpdateTestSuite)) +} diff --git a/internal/apimodule/account/accountverify.go b/internal/api/client/account/accountverify.go index 9edf1e73a..4c62ff705 100644 --- a/internal/apimodule/account/accountverify.go +++ b/internal/api/client/account/accountverify.go @@ -30,21 +30,19 @@ import (  // It should be served as a GET at /api/v1/accounts/verify_credentials  func (m *Module) AccountVerifyGETHandler(c *gin.Context) {  	l := m.log.WithField("func", "accountVerifyGETHandler") -	authed, err := oauth.MustAuth(c, true, false, false, true) +	authed, err := oauth.Authed(c, true, false, false, true)  	if err != nil {  		l.Debugf("couldn't auth: %s", err)  		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})  		return  	} -	l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID) -	acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(authed.Account) +	acctSensitive, err := m.processor.AccountGet(authed, authed.Account.ID)  	if err != nil { -		l.Tracef("could not convert account into mastosensitive account: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		l.Debugf("error getting account from processor: %s", err) +		c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})  		return  	} -	l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)  	c.JSON(http.StatusOK, acctSensitive)  } diff --git a/internal/apimodule/account/test/accountverify_test.go b/internal/api/client/account/accountverify_test.go index 223a0c145..85b0dce50 100644 --- a/internal/apimodule/account/test/accountverify_test.go +++ b/internal/api/client/account/accountverify_test.go @@ -16,4 +16,4 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package account +package account_test diff --git a/internal/apimodule/admin/admin.go b/internal/api/client/admin/admin.go index 2ebe9c7a7..7ce5311eb 100644 --- a/internal/apimodule/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -19,43 +19,35 @@  package admin  import ( -	"fmt"  	"net/http"  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/api"  	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/message"  	"github.com/superseriousbusiness/gotosocial/internal/router"  )  const (  	// BasePath is the base API path for this module -	BasePath  = "/api/v1/admin" +	BasePath = "/api/v1/admin"  	// EmojiPath is used for posting/deleting custom emojis  	EmojiPath = BasePath + "/custom_emojis"  )  // Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc)  type Module struct { -	config         *config.Config -	db             db.DB -	mediaHandler   media.Handler -	mastoConverter mastotypes.Converter -	log            *logrus.Logger +	config    *config.Config +	processor message.Processor +	log       *logrus.Logger  }  // New returns a new admin module -func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {  	return &Module{ -		config:         config, -		db:             db, -		mediaHandler:   mediaHandler, -		mastoConverter: mastoConverter, -		log:            log, +		config:    config, +		processor: processor, +		log:       log,  	}  } @@ -64,25 +56,3 @@ func (m *Module) Route(r router.Router) error {  	r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler)  	return nil  } - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { -	models := []interface{}{ -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Follow{}, -		>smodel.FollowRequest{}, -		>smodel.Status{}, -		>smodel.Application{}, -		>smodel.EmailDomainBlock{}, -		>smodel.MediaAttachment{}, -		>smodel.Emoji{}, -	} - -	for _, m := range models { -		if err := db.CreateTable(m); err != nil { -			return fmt.Errorf("error creating table: %s", err) -		} -	} -	return nil -} diff --git a/internal/apimodule/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go index 49e5492dd..0e60db65f 100644 --- a/internal/apimodule/admin/emojicreate.go +++ b/internal/api/client/admin/emojicreate.go @@ -19,15 +19,13 @@  package admin  import ( -	"bytes"  	"errors"  	"fmt" -	"io"  	"net/http"  	"github.com/gin-gonic/gin"  	"github.com/sirupsen/logrus" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" +	"github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/media"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  	"github.com/superseriousbusiness/gotosocial/internal/util" @@ -42,7 +40,7 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {  	})  	// make sure we're authed with an admin account -	authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything* +	authed, err := oauth.Authed(c, true, true, true, true) // posting a status 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()}) @@ -56,7 +54,7 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {  	// extract the media create form from the request context  	l.Tracef("parsing request form: %+v", c.Request.Form) -	form := &mastotypes.EmojiCreateRequest{} +	form := &model.EmojiCreateRequest{}  	if err := c.ShouldBind(form); err != nil {  		l.Debugf("error parsing form %+v: %s", c.Request.Form, err)  		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)}) @@ -71,51 +69,17 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {  		return  	} -	// open the emoji and extract the bytes from it -	f, err := form.Image.Open() +	mastoEmoji, err := m.processor.AdminEmojiCreate(authed, form)  	if err != nil { -		l.Debugf("error opening emoji: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)}) -		return -	} -	buf := new(bytes.Buffer) -	size, err := io.Copy(buf, f) -	if err != nil { -		l.Debugf("error reading emoji: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)}) -		return -	} -	if size == 0 { -		l.Debug("could not read provided emoji: size 0 bytes") -		c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"}) -		return -	} - -	// allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using -	emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode) -	if err != nil { -		l.Debugf("error reading emoji: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)}) -		return -	} - -	mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji) -	if err != nil { -		l.Debugf("error converting emoji to mastotype: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)}) -		return -	} - -	if err := m.db.Put(emoji); err != nil { -		l.Debugf("database error while processing emoji: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)}) +		l.Debugf("error creating emoji: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})  		return  	}  	c.JSON(http.StatusOK, mastoEmoji)  } -func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error { +func validateCreateEmoji(form *model.EmojiCreateRequest) error {  	// check there actually is an image attached and it's not size 0  	if form.Image == nil || form.Image.Size == 0 {  		return errors.New("no emoji given") diff --git a/internal/apimodule/app/app.go b/internal/api/client/app/app.go index 518192758..d1e732a8c 100644 --- a/internal/apimodule/app/app.go +++ b/internal/api/client/app/app.go @@ -19,15 +19,12 @@  package app  import ( -	"fmt"  	"net/http"  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/api" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/message"  	"github.com/superseriousbusiness/gotosocial/internal/router"  ) @@ -36,19 +33,17 @@ const BasePath = "/api/v1/apps"  // Module implements the ClientAPIModule interface for requests relating to registering/removing applications  type Module struct { -	server         oauth.Server -	db             db.DB -	mastoConverter mastotypes.Converter -	log            *logrus.Logger +	config    *config.Config +	processor message.Processor +	log       *logrus.Logger  }  // New returns a new auth module -func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {  	return &Module{ -		server:         srv, -		db:             db, -		mastoConverter: mastoConverter, -		log:            log, +		config:    config, +		processor: processor, +		log:       log,  	}  } @@ -57,21 +52,3 @@ func (m *Module) Route(s router.Router) error {  	s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler)  	return nil  } - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { -	models := []interface{}{ -		&oauth.Client{}, -		&oauth.Token{}, -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Application{}, -	} - -	for _, m := range models { -		if err := db.CreateTable(m); err != nil { -			return fmt.Errorf("error creating table: %s", err) -		} -	} -	return nil -} diff --git a/internal/apimodule/app/test/app_test.go b/internal/api/client/app/app_test.go index d45b04e74..42760a2db 100644 --- a/internal/apimodule/app/test/app_test.go +++ b/internal/api/client/app/app_test.go @@ -16,6 +16,6 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package app +package app_test  // TODO: write tests diff --git a/internal/api/client/app/appcreate.go b/internal/api/client/app/appcreate.go new file mode 100644 index 000000000..fd42482d4 --- /dev/null +++ b/internal/api/client/app/appcreate.go @@ -0,0 +1,79 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package app + +import ( +	"fmt" +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AppsPOSTHandler should be served at https://example.org/api/v1/apps +// It is equivalent to: https://docs.joinmastodon.org/methods/apps/ +func (m *Module) AppsPOSTHandler(c *gin.Context) { +	l := m.log.WithField("func", "AppsPOSTHandler") +	l.Trace("entering AppsPOSTHandler") + +	authed, err := oauth.Authed(c, false, false, false, false) +	if err != nil { +		c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) +		return +	} + +	form := &model.ApplicationCreateRequest{} +	if err := c.ShouldBind(form); err != nil { +		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) +		return +	} + +	// permitted length for most fields +	formFieldLen := 64 +	// redirect can be a bit bigger because we probably need to encode data in the redirect uri +	formRedirectLen := 512 + +	// check lengths of fields before proceeding so the user can't spam huge entries into the database +	if len(form.ClientName) > formFieldLen { +		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", formFieldLen)}) +		return +	} +	if len(form.Website) > formFieldLen { +		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", formFieldLen)}) +		return +	} +	if len(form.RedirectURIs) > formRedirectLen { +		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", formRedirectLen)}) +		return +	} +	if len(form.Scopes) > formFieldLen { +		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", formFieldLen)}) +		return +	} + +	mastoApp, err := m.processor.AppCreate(authed, form) +	if err != nil { +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		return +	} + +	// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/ +	c.JSON(http.StatusOK, mastoApp) +} diff --git a/internal/apimodule/auth/auth.go b/internal/api/client/auth/auth.go index 341805b40..793c19f4e 100644 --- a/internal/apimodule/auth/auth.go +++ b/internal/api/client/auth/auth.go @@ -19,38 +19,39 @@  package auth  import ( -	"fmt"  	"net/http"  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/api" +	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  	"github.com/superseriousbusiness/gotosocial/internal/router"  )  const (  	// AuthSignInPath is the API path for users to sign in through -	AuthSignInPath     = "/auth/sign_in" +	AuthSignInPath = "/auth/sign_in"  	// OauthTokenPath is the API path to use for granting token requests to users with valid credentials -	OauthTokenPath     = "/oauth/token" +	OauthTokenPath = "/oauth/token"  	// OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user)  	OauthAuthorizePath = "/oauth/authorize"  )  // Module implements the ClientAPIModule interface for  type Module struct { -	server oauth.Server +	config *config.Config  	db     db.DB +	server oauth.Server  	log    *logrus.Logger  }  // New returns a new auth module -func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, db db.DB, server oauth.Server, log *logrus.Logger) api.ClientModule {  	return &Module{ -		server: srv, +		config: config,  		db:     db, +		server: server,  		log:    log,  	}  } @@ -68,21 +69,3 @@ func (m *Module) Route(s router.Router) error {  	s.AttachMiddleware(m.OauthTokenMiddleware)  	return nil  } - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { -	models := []interface{}{ -		&oauth.Client{}, -		&oauth.Token{}, -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Application{}, -	} - -	for _, m := range models { -		if err := db.CreateTable(m); err != nil { -			return fmt.Errorf("error creating table: %s", err) -		} -	} -	return nil -} diff --git a/internal/apimodule/auth/test/auth_test.go b/internal/api/client/auth/auth_test.go index 2c272e985..7ec788a0e 100644 --- a/internal/apimodule/auth/test/auth_test.go +++ b/internal/api/client/auth/auth_test.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package auth +package auth_test  import (  	"context" @@ -28,7 +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/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  	"golang.org/x/crypto/bcrypt"  ) @@ -103,7 +103,7 @@ func (suite *AuthTestSuite) SetupTest() {  	log := logrus.New()  	log.SetLevel(logrus.TraceLevel) -	db, err := db.New(context.Background(), suite.config, log) +	db, err := db.NewPostgresService(context.Background(), suite.config, log)  	if err != nil {  		logrus.Panicf("error creating database connection: %s", err)  	} diff --git a/internal/apimodule/auth/authorize.go b/internal/api/client/auth/authorize.go index 4bc1991ac..d5f8ee214 100644 --- a/internal/apimodule/auth/authorize.go +++ b/internal/api/client/auth/authorize.go @@ -27,8 +27,8 @@ import (  	"github.com/gin-contrib/sessions"  	"github.com/gin-gonic/gin"  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  )  // AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize @@ -178,7 +178,7 @@ func parseAuthForm(c *gin.Context, l *logrus.Entry) error {  	s := sessions.Default(c)  	// first make sure they've filled out the authorize form with the required values -	form := &mastotypes.OAuthAuthorize{} +	form := &model.OAuthAuthorize{}  	if err := c.ShouldBind(form); err != nil {  		return err  	} diff --git a/internal/apimodule/auth/middleware.go b/internal/api/client/auth/middleware.go index 1d9a85993..c42ba77fc 100644 --- a/internal/apimodule/auth/middleware.go +++ b/internal/api/client/auth/middleware.go @@ -20,7 +20,7 @@ package auth  import (  	"github.com/gin-gonic/gin" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  ) @@ -30,7 +30,7 @@ import (  // If user or account can't be found, then the handler won't *fail*, in case the server wants to allow  // public requests that don't have a Bearer token set (eg., for public instance information and so on).  func (m *Module) OauthTokenMiddleware(c *gin.Context) { -	l := m.log.WithField("func", "ValidatePassword") +	l := m.log.WithField("func", "OauthTokenMiddleware")  	l.Trace("entering OauthTokenMiddleware")  	ti, err := m.server.ValidationBearerToken(c.Request) diff --git a/internal/apimodule/auth/signin.go b/internal/api/client/auth/signin.go index 44de0891c..79d9b300e 100644 --- a/internal/apimodule/auth/signin.go +++ b/internal/api/client/auth/signin.go @@ -24,7 +24,7 @@ import (  	"github.com/gin-contrib/sessions"  	"github.com/gin-gonic/gin" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"golang.org/x/crypto/bcrypt"  ) diff --git a/internal/apimodule/auth/token.go b/internal/api/client/auth/token.go index c531a3009..c531a3009 100644 --- a/internal/apimodule/auth/token.go +++ b/internal/api/client/auth/token.go diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go index 7651c8cc1..63d323a01 100644 --- a/internal/apimodule/fileserver/fileserver.go +++ b/internal/api/client/fileserver/fileserver.go @@ -23,12 +23,12 @@ import (  	"net/http"  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/api"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/message"  	"github.com/superseriousbusiness/gotosocial/internal/router" -	"github.com/superseriousbusiness/gotosocial/internal/storage"  )  const ( @@ -39,25 +39,23 @@ const (  	// MediaSizeKey is the url key for the desired media size--original/small/static  	MediaSizeKey = "media_size"  	// FileNameKey is the actual filename being sought. Will usually be a UUID then something like .jpeg -	FileNameKey  = "file_name" +	FileNameKey = "file_name"  )  // FileServer implements the RESTAPIModule interface.  // The goal here is to serve requested media files if the gotosocial server is configured to use local storage.  type FileServer struct {  	config      *config.Config -	db          db.DB -	storage     storage.Storage +	processor   message.Processor  	log         *logrus.Logger  	storageBase string  }  // New returns a new fileServer module -func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {  	return &FileServer{  		config:      config, -		db:          db, -		storage:     storage, +		processor:   processor,  		log:         log,  		storageBase: config.StorageConfig.ServeBasePath,  	} diff --git a/internal/api/client/fileserver/servefile.go b/internal/api/client/fileserver/servefile.go new file mode 100644 index 000000000..9823eb387 --- /dev/null +++ b/internal/api/client/fileserver/servefile.go @@ -0,0 +1,94 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package fileserver + +import ( +	"bytes" +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. +// +// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". +// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. +func (m *FileServer) ServeFile(c *gin.Context) { +	l := m.log.WithFields(logrus.Fields{ +		"func":        "ServeFile", +		"request_uri": c.Request.RequestURI, +		"user_agent":  c.Request.UserAgent(), +		"origin_ip":   c.ClientIP(), +	}) +	l.Trace("received request") + +	authed, err := oauth.Authed(c, false, false, false, false) +	if err != nil { +		c.String(http.StatusNotFound, "404 page not found") +		return +	} + +	// We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: +	// "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" +	// "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. +	accountID := c.Param(AccountIDKey) +	if accountID == "" { +		l.Debug("missing accountID from request") +		c.String(http.StatusNotFound, "404 page not found") +		return +	} + +	mediaType := c.Param(MediaTypeKey) +	if mediaType == "" { +		l.Debug("missing mediaType from request") +		c.String(http.StatusNotFound, "404 page not found") +		return +	} + +	mediaSize := c.Param(MediaSizeKey) +	if mediaSize == "" { +		l.Debug("missing mediaSize from request") +		c.String(http.StatusNotFound, "404 page not found") +		return +	} + +	fileName := c.Param(FileNameKey) +	if fileName == "" { +		l.Debug("missing fileName from request") +		c.String(http.StatusNotFound, "404 page not found") +		return +	} + +	content, err := m.processor.MediaGet(authed, &model.GetContentRequestForm{ +		AccountID: accountID, +		MediaType: mediaType, +		MediaSize: mediaSize, +		FileName:  fileName, +	}) +	if err != nil { +		l.Debug(err) +		c.String(http.StatusNotFound, "404 page not found") +		return +	} + +	c.DataFromReader(http.StatusOK, content.ContentLength, content.ContentType, bytes.NewReader(content.Content), nil) +} diff --git a/internal/apimodule/fileserver/test/servefile_test.go b/internal/api/client/fileserver/servefile_test.go index 516e3528c..09fd8ea43 100644 --- a/internal/apimodule/fileserver/test/servefile_test.go +++ b/internal/api/client/fileserver/servefile_test.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package test +package fileserver_test  import (  	"context" @@ -30,27 +30,31 @@ import (  	"github.com/sirupsen/logrus"  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" +	"github.com/superseriousbusiness/gotosocial/internal/federation" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/message"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  	"github.com/superseriousbusiness/gotosocial/testrig"  )  type ServeFileTestSuite struct {  	// standard suite interfaces  	suite.Suite -	config         *config.Config -	db             db.DB -	log            *logrus.Logger -	storage        storage.Storage -	mastoConverter mastotypes.Converter -	mediaHandler   media.Handler -	oauthServer    oauth.Server +	config       *config.Config +	db           db.DB +	log          *logrus.Logger +	storage      storage.Storage +	federator    federation.Federator +	tc           typeutils.TypeConverter +	processor    message.Processor +	mediaHandler media.Handler +	oauthServer  oauth.Server  	// standard suite models  	testTokens       map[string]*oauth.Token @@ -74,12 +78,14 @@ func (suite *ServeFileTestSuite) SetupSuite() {  	suite.db = testrig.NewTestDB()  	suite.log = testrig.NewTestLog()  	suite.storage = testrig.NewTestStorage() -	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +	suite.tc = testrig.NewTestTypeConverter(suite.db)  	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)  	suite.oauthServer = testrig.NewTestOauthServer(suite.db)  	// setup module being tested -	suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer) +	suite.fileServer = fileserver.New(suite.config, suite.processor, suite.log).(*fileserver.FileServer)  }  func (suite *ServeFileTestSuite) TearDownSuite() { @@ -126,11 +132,11 @@ func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() {  		},  		gin.Param{  			Key:   fileserver.MediaTypeKey, -			Value: media.MediaAttachment, +			Value: string(media.Attachment),  		},  		gin.Param{  			Key:   fileserver.MediaSizeKey, -			Value: media.MediaOriginal, +			Value: string(media.Original),  		},  		gin.Param{  			Key:   fileserver.FileNameKey, diff --git a/internal/apimodule/media/media.go b/internal/api/client/media/media.go index 8fb9f16ec..2826783d6 100644 --- a/internal/apimodule/media/media.go +++ b/internal/api/client/media/media.go @@ -23,12 +23,11 @@ import (  	"net/http"  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/api"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/message"  	"github.com/superseriousbusiness/gotosocial/internal/router"  ) @@ -37,21 +36,17 @@ const BasePath = "/api/v1/media"  // Module implements the ClientAPIModule interface for media  type Module struct { -	mediaHandler   media.Handler -	config         *config.Config -	db             db.DB -	mastoConverter mastotypes.Converter -	log            *logrus.Logger +	config    *config.Config +	processor message.Processor +	log       *logrus.Logger  }  // New returns a new auth module -func New(db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {  	return &Module{ -		mediaHandler:   mediaHandler, -		config:         config, -		db:             db, -		mastoConverter: mastoConverter, -		log:            log, +		config:    config, +		processor: processor, +		log:       log,  	}  } diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go new file mode 100644 index 000000000..db57e2052 --- /dev/null +++ b/internal/api/client/media/mediacreate.go @@ -0,0 +1,91 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( +	"errors" +	"fmt" +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// MediaCreatePOSTHandler handles requests to create/upload media attachments +func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { +	l := m.log.WithField("func", "statusCreatePOSTHandler") +	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()}) +		return +	} + +	// extract the media create form from the request context +	l.Tracef("parsing request form: %s", c.Request.Form) +	form := &model.AttachmentRequest{} +	if err := c.ShouldBind(form); err != nil || form == nil { +		l.Debugf("could not parse form from request: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) +		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 { +		l.Debugf("error validating form: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		return +	} + +	mastoAttachment, err := m.processor.MediaCreate(authed, form) +	if err != nil { +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		return +	} + +	c.JSON(http.StatusAccepted, mastoAttachment) +} + +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 { +		return errors.New("no attachment given") +	} + +	// a very superficial check to see if no size limits are exceeded +	// we still don't actually know which media types we're dealing with but the other handlers will go into more detail there +	maxSize := config.MaxVideoSize +	if config.MaxImageSize > maxSize { +		maxSize = config.MaxImageSize +	} +	if form.File.Size > int64(maxSize) { +		return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) +	} + +	if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars { +		return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description)) +	} + +	// TODO: validate focus here + +	return nil +} diff --git a/internal/apimodule/media/test/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go index 30bbb117a..e86c66021 100644 --- a/internal/apimodule/media/test/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package test +package media_test  import (  	"bytes" @@ -32,28 +32,32 @@ import (  	"github.com/sirupsen/logrus"  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite" -	mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" +	mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" +	"github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" +	"github.com/superseriousbusiness/gotosocial/internal/federation" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/message"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  	"github.com/superseriousbusiness/gotosocial/testrig"  )  type MediaCreateTestSuite struct {  	// standard suite interfaces  	suite.Suite -	config         *config.Config -	db             db.DB -	log            *logrus.Logger -	storage        storage.Storage -	mastoConverter mastotypes.Converter -	mediaHandler   media.Handler -	oauthServer    oauth.Server +	config       *config.Config +	db           db.DB +	log          *logrus.Logger +	storage      storage.Storage +	federator    federation.Federator +	tc           typeutils.TypeConverter +	mediaHandler media.Handler +	oauthServer  oauth.Server +	processor    message.Processor  	// standard suite models  	testTokens       map[string]*oauth.Token @@ -77,12 +81,14 @@ func (suite *MediaCreateTestSuite) SetupSuite() {  	suite.db = testrig.NewTestDB()  	suite.log = testrig.NewTestLog()  	suite.storage = testrig.NewTestStorage() -	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) +	suite.tc = testrig.NewTestTypeConverter(suite.db)  	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)  	suite.oauthServer = testrig.NewTestOauthServer(suite.db) +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)  	// setup module being tested -	suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.Module) +	suite.mediaModule = mediamodule.New(suite.config, suite.processor, suite.log).(*mediamodule.Module)  }  func (suite *MediaCreateTestSuite) TearDownSuite() { @@ -158,26 +164,26 @@ func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful()  	assert.NoError(suite.T(), err)  	fmt.Println(string(b)) -	attachmentReply := &mastomodel.Attachment{} +	attachmentReply := &model.Attachment{}  	err = json.Unmarshal(b, attachmentReply)  	assert.NoError(suite.T(), err)  	assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description)  	assert.Equal(suite.T(), "image", attachmentReply.Type) -	assert.EqualValues(suite.T(), mastomodel.MediaMeta{ -		Original: mastomodel.MediaDimensions{ +	assert.EqualValues(suite.T(), model.MediaMeta{ +		Original: model.MediaDimensions{  			Width:  1920,  			Height: 1080,  			Size:   "1920x1080",  			Aspect: 1.7777778,  		}, -		Small: mastomodel.MediaDimensions{ +		Small: model.MediaDimensions{  			Width:  256,  			Height: 144,  			Size:   "256x144",  			Aspect: 1.7777778,  		}, -		Focus: mastomodel.MediaFocus{ +		Focus: model.MediaFocus{  			X: -0.5,  			Y: 0.5,  		}, diff --git a/internal/apimodule/status/status.go b/internal/api/client/status/status.go index 73a1b5847..ba9295623 100644 --- a/internal/apimodule/status/status.go +++ b/internal/api/client/status/status.go @@ -19,27 +19,22 @@  package status  import ( -	"fmt"  	"net/http"  	"strings"  	"github.com/gin-gonic/gin"  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/api"  	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/message"  	"github.com/superseriousbusiness/gotosocial/internal/router"  )  const (  	// IDKey is for status UUIDs -	IDKey          = "id" +	IDKey = "id"  	// BasePath is the base path for serving the status API -	BasePath       = "/api/v1/statuses" +	BasePath = "/api/v1/statuses"  	// BasePathWithID is just the base path with the ID key in it.  	// Use this anywhere you need to know the ID of the status being queried.  	BasePathWithID = BasePath + "/:" + IDKey @@ -48,54 +43,48 @@ const (  	ContextPath = BasePathWithID + "/context"  	// FavouritedPath is for seeing who's faved a given status -	FavouritedPath  = BasePathWithID + "/favourited_by" +	FavouritedPath = BasePathWithID + "/favourited_by"  	// FavouritePath is for posting a fave on a status -	FavouritePath   = BasePathWithID + "/favourite" +	FavouritePath = BasePathWithID + "/favourite"  	// UnfavouritePath is for removing a fave from a status  	UnfavouritePath = BasePathWithID + "/unfavourite"  	// RebloggedPath is for seeing who's boosted a given status  	RebloggedPath = BasePathWithID + "/reblogged_by"  	// ReblogPath is for boosting/reblogging a given status -	ReblogPath    = BasePathWithID + "/reblog" +	ReblogPath = BasePathWithID + "/reblog"  	// UnreblogPath is for undoing a boost/reblog of a given status -	UnreblogPath  = BasePathWithID + "/unreblog" +	UnreblogPath = BasePathWithID + "/unreblog"  	// BookmarkPath is for creating a bookmark on a given status -	BookmarkPath   = BasePathWithID + "/bookmark" +	BookmarkPath = BasePathWithID + "/bookmark"  	// UnbookmarkPath is for removing a bookmark from a given status  	UnbookmarkPath = BasePathWithID + "/unbookmark"  	// MutePath is for muting a given status so that notifications will no longer be received about it. -	MutePath   = BasePathWithID + "/mute" +	MutePath = BasePathWithID + "/mute"  	// UnmutePath is for undoing an existing mute  	UnmutePath = BasePathWithID + "/unmute"  	// PinPath is for pinning a status to an account profile so that it's the first thing people see -	PinPath   = BasePathWithID + "/pin" +	PinPath = BasePathWithID + "/pin"  	// UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy  	UnpinPath = BasePathWithID + "/unpin"  )  // Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses  type Module struct { -	config         *config.Config -	db             db.DB -	mediaHandler   media.Handler -	mastoConverter mastotypes.Converter -	distributor    distributor.Distributor -	log            *logrus.Logger +	config    *config.Config +	processor message.Processor +	log       *logrus.Logger  }  // New returns a new account module -func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {  	return &Module{ -		config:         config, -		db:             db, -		mediaHandler:   mediaHandler, -		mastoConverter: mastoConverter, -		distributor:    distributor, -		log:            log, +		config:    config, +		processor: processor, +		log:       log,  	}  } @@ -105,41 +94,12 @@ func (m *Module) Route(r router.Router) error {  	r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)  	r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler) -	r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler) +	r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler)  	r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)  	return nil  } -// CreateTables populates necessary tables in the given DB -func (m *Module) CreateTables(db db.DB) error { -	models := []interface{}{ -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Block{}, -		>smodel.Follow{}, -		>smodel.FollowRequest{}, -		>smodel.Status{}, -		>smodel.StatusFave{}, -		>smodel.StatusBookmark{}, -		>smodel.StatusMute{}, -		>smodel.StatusPin{}, -		>smodel.Application{}, -		>smodel.EmailDomainBlock{}, -		>smodel.MediaAttachment{}, -		>smodel.Emoji{}, -		>smodel.Tag{}, -		>smodel.Mention{}, -	} - -	for _, m := range models { -		if err := db.CreateTable(m); err != nil { -			return fmt.Errorf("error creating table: %s", err) -		} -	} -	return nil -} -  // muxHandler is a little workaround to overcome the limitations of Gin  func (m *Module) muxHandler(c *gin.Context) {  	m.log.Debug("entering mux handler") diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go new file mode 100644 index 000000000..0f77820a1 --- /dev/null +++ b/internal/api/client/status/status_test.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 <http://www.gnu.org/licenses/>. +*/ + +package status_test + +import ( +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/status" +	"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/message" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type StatusStandardTestSuite struct { +	// standard suite interfaces +	suite.Suite +	config    *config.Config +	db        db.DB +	log       *logrus.Logger +	tc        typeutils.TypeConverter +	federator federation.Federator +	processor message.Processor +	storage   storage.Storage + +	// standard suite models +	testTokens       map[string]*oauth.Token +	testClients      map[string]*oauth.Client +	testApplications map[string]*gtsmodel.Application +	testUsers        map[string]*gtsmodel.User +	testAccounts     map[string]*gtsmodel.Account +	testAttachments  map[string]*gtsmodel.MediaAttachment +	testStatuses     map[string]*gtsmodel.Status + +	// module being tested +	statusModule *status.Module +} diff --git a/internal/api/client/status/statuscreate.go b/internal/api/client/status/statuscreate.go new file mode 100644 index 000000000..02080b042 --- /dev/null +++ b/internal/api/client/status/statuscreate.go @@ -0,0 +1,130 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( +	"errors" +	"fmt" +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +// StatusCreatePOSTHandler deals with the creation of new statuses +func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { +	l := m.log.WithField("func", "statusCreatePOSTHandler") +	authed, err := oauth.Authed(c, true, true, true, true) // posting a status 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()}) +		return +	} + +	// First check this user/account is permitted to post new statuses. +	// There's no point continuing otherwise. +	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 +	} + +	// extract the status create form from the request context +	l.Tracef("parsing request form: %s", c.Request.Form) +	form := &model.AdvancedStatusCreateForm{} +	if err := c.ShouldBind(form); err != nil || form == nil { +		l.Debugf("could not parse form from request: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) +		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 := validateCreateStatus(form, m.config.StatusesConfig); err != nil { +		l.Debugf("error validating form: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		return +	} + +	mastoStatus, err := m.processor.StatusCreate(authed, form) +	if err != nil { +		l.Debugf("error processing status create: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) +		return +	} + +	c.JSON(http.StatusOK, mastoStatus) +} + +func validateCreateStatus(form *model.AdvancedStatusCreateForm, config *config.StatusesConfig) error { +	// validate that, structurally, we have a valid status/post +	if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { +		return errors.New("no status, media, or poll provided") +	} + +	if form.MediaIDs != nil && form.Poll != nil { +		return errors.New("can't post media + poll in same status") +	} + +	// validate status +	if form.Status != "" { +		if len(form.Status) > config.MaxChars { +			return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars) +		} +	} + +	// validate media attachments +	if len(form.MediaIDs) > config.MaxMediaFiles { +		return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles) +	} + +	// validate poll +	if form.Poll != nil { +		if form.Poll.Options == nil { +			return errors.New("poll with no options") +		} +		if len(form.Poll.Options) > config.PollMaxOptions { +			return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions) +		} +		for _, p := range form.Poll.Options { +			if len(p) > config.PollOptionMaxChars { +				return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars) +			} +		} +	} + +	// validate spoiler text/cw +	if form.SpoilerText != "" { +		if len(form.SpoilerText) > config.CWMaxChars { +			return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars) +		} +	} + +	// validate post language +	if form.Language != "" { +		if err := util.ValidateLanguage(form.Language); err != nil { +			return err +		} +	} + +	return nil +} diff --git a/internal/apimodule/status/test/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go index d143ac9a7..fb9b48f8a 100644 --- a/internal/apimodule/status/test/statuscreate_test.go +++ b/internal/api/client/status/statuscreate_test.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package status +package status_test  import (  	"encoding/json" @@ -28,95 +28,46 @@ import (  	"testing"  	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus"  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/status" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/storage"  	"github.com/superseriousbusiness/gotosocial/testrig"  )  type StatusCreateTestSuite struct { -	// standard suite interfaces -	suite.Suite -	config         *config.Config -	db             db.DB -	log            *logrus.Logger -	storage        storage.Storage -	mastoConverter mastotypes.Converter -	mediaHandler   media.Handler -	oauthServer    oauth.Server -	distributor    distributor.Distributor - -	// standard suite models -	testTokens       map[string]*oauth.Token -	testClients      map[string]*oauth.Client -	testApplications map[string]*gtsmodel.Application -	testUsers        map[string]*gtsmodel.User -	testAccounts     map[string]*gtsmodel.Account -	testAttachments  map[string]*gtsmodel.MediaAttachment - -	// module being tested -	statusModule *status.Module +	StatusStandardTestSuite  } -/* -	TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout  func (suite *StatusCreateTestSuite) SetupSuite() { -	// setup standard items -	suite.config = testrig.NewTestConfig() -	suite.db = testrig.NewTestDB() -	suite.log = testrig.NewTestLog() -	suite.storage = testrig.NewTestStorage() -	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) -	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) -	suite.oauthServer = testrig.NewTestOauthServer(suite.db) -	suite.distributor = testrig.NewTestDistributor() - -	// setup module being tested -	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusCreateTestSuite) TearDownSuite() { -	testrig.StandardDBTeardown(suite.db) -	testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusCreateTestSuite) SetupTest() { -	testrig.StandardDBSetup(suite.db) -	testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")  	suite.testTokens = testrig.NewTestTokens()  	suite.testClients = testrig.NewTestClients()  	suite.testApplications = testrig.NewTestApplications()  	suite.testUsers = testrig.NewTestUsers()  	suite.testAccounts = testrig.NewTestAccounts()  	suite.testAttachments = testrig.NewTestAttachments() +	suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *StatusCreateTestSuite) SetupTest() { +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.storage = testrig.NewTestStorage() +	suite.log = testrig.NewTestLog() +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) +	testrig.StandardDBSetup(suite.db) +	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")  } -// TearDownTest drops tables to make sure there's no data in the db  func (suite *StatusCreateTestSuite) TearDownTest() {  	testrig.StandardDBTeardown(suite.db) +	testrig.StandardStorageTeardown(suite.storage)  } -/* -	ACTUAL TESTS -*/ - -/* -	TESTING: StatusCreatePOSTHandler -*/ -  // Post a new status with some custom visibility settings  func (suite *StatusCreateTestSuite) TestPostNewStatus() { @@ -152,16 +103,16 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() {  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	statusReply := &mastomodel.Status{} +	statusReply := &model.Status{}  	err = json.Unmarshal(b, statusReply)  	assert.NoError(suite.T(), err)  	assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)  	assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)  	assert.True(suite.T(), statusReply.Sensitive) -	assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) +	assert.Equal(suite.T(), model.VisibilityPrivate, statusReply.Visibility)  	assert.Len(suite.T(), statusReply.Tags, 1) -	assert.Equal(suite.T(), mastomodel.Tag{ +	assert.Equal(suite.T(), model.Tag{  		Name: "helloworld",  		URL:  "http://localhost:8080/tags/helloworld",  	}, statusReply.Tags[0]) @@ -197,7 +148,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	statusReply := &mastomodel.Status{} +	statusReply := &model.Status{}  	err = json.Unmarshal(b, statusReply)  	assert.NoError(suite.T(), err) @@ -241,7 +192,7 @@ func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {  	defer result.Body.Close()  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b)) +	assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))  }  // Post a reply to the status of a local user that allows replies. @@ -271,14 +222,14 @@ func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	statusReply := &mastomodel.Status{} +	statusReply := &model.Status{}  	err = json.Unmarshal(b, statusReply)  	assert.NoError(suite.T(), err)  	assert.Equal(suite.T(), "", statusReply.SpoilerText)  	assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content)  	assert.False(suite.T(), statusReply.Sensitive) -	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) +	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)  	assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID)  	assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID)  	assert.Len(suite.T(), statusReply.Mentions, 1) @@ -313,14 +264,14 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {  	fmt.Println(string(b)) -	statusReply := &mastomodel.Status{} +	statusReply := &model.Status{}  	err = json.Unmarshal(b, statusReply)  	assert.NoError(suite.T(), err)  	assert.Equal(suite.T(), "", statusReply.SpoilerText)  	assert.Equal(suite.T(), "here's an image attachment", statusReply.Content)  	assert.False(suite.T(), statusReply.Sensitive) -	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) +	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)  	// there should be one media attachment  	assert.Len(suite.T(), statusReply.MediaAttachments, 1) @@ -331,7 +282,7 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {  	assert.NoError(suite.T(), err)  	// convert it to a masto attachment -	gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment) +	gtsAttachmentAsMasto, err := suite.tc.AttachmentToMasto(gtsAttachment)  	assert.NoError(suite.T(), err)  	// compare it with what we have now diff --git a/internal/api/client/status/statusdelete.go b/internal/api/client/status/statusdelete.go new file mode 100644 index 000000000..e55416522 --- /dev/null +++ b/internal/api/client/status/statusdelete.go @@ -0,0 +1,60 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusDELETEHandler verifies and handles deletion of a status +func (m *Module) StatusDELETEHandler(c *gin.Context) { +	l := m.log.WithFields(logrus.Fields{ +		"func":        "StatusDELETEHandler", +		"request_uri": c.Request.RequestURI, +		"user_agent":  c.Request.UserAgent(), +		"origin_ip":   c.ClientIP(), +	}) +	l.Debugf("entering function") + +	authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else +	if err != nil { +		l.Debug("not authed so can't delete status") +		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) +		return +	} + +	targetStatusID := c.Param(IDKey) +	if targetStatusID == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) +		return +	} + +	mastoStatus, err := m.processor.StatusDelete(authed, targetStatusID) +	if err != nil { +		l.Debugf("error processing status delete: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) +		return +	} + +	c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/api/client/status/statusfave.go b/internal/api/client/status/statusfave.go new file mode 100644 index 000000000..888589a8a --- /dev/null +++ b/internal/api/client/status/statusfave.go @@ -0,0 +1,60 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusFavePOSTHandler handles fave requests against a given status ID +func (m *Module) StatusFavePOSTHandler(c *gin.Context) { +	l := m.log.WithFields(logrus.Fields{ +		"func":        "StatusFavePOSTHandler", +		"request_uri": c.Request.RequestURI, +		"user_agent":  c.Request.UserAgent(), +		"origin_ip":   c.ClientIP(), +	}) +	l.Debugf("entering function") + +	authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else +	if err != nil { +		l.Debug("not authed so can't fave status") +		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) +		return +	} + +	targetStatusID := c.Param(IDKey) +	if targetStatusID == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) +		return +	} + +	mastoStatus, err := m.processor.StatusFave(authed, targetStatusID) +	if err != nil { +		l.Debugf("error processing status fave: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) +		return +	} + +	c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/test/statusfave_test.go b/internal/api/client/status/statusfave_test.go index 9ccf58948..2f779baed 100644 --- a/internal/apimodule/status/test/statusfave_test.go +++ b/internal/api/client/status/statusfave_test.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package status +package status_test  import (  	"encoding/json" @@ -28,75 +28,19 @@ import (  	"testing"  	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus"  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/status" +	"github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/storage"  	"github.com/superseriousbusiness/gotosocial/testrig"  )  type StatusFaveTestSuite struct { -	// standard suite interfaces -	suite.Suite -	config         *config.Config -	db             db.DB -	log            *logrus.Logger -	storage        storage.Storage -	mastoConverter mastotypes.Converter -	mediaHandler   media.Handler -	oauthServer    oauth.Server -	distributor    distributor.Distributor - -	// standard suite models -	testTokens       map[string]*oauth.Token -	testClients      map[string]*oauth.Client -	testApplications map[string]*gtsmodel.Application -	testUsers        map[string]*gtsmodel.User -	testAccounts     map[string]*gtsmodel.Account -	testAttachments  map[string]*gtsmodel.MediaAttachment -	testStatuses     map[string]*gtsmodel.Status - -	// module being tested -	statusModule *status.Module +	StatusStandardTestSuite  } -/* -	TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout  func (suite *StatusFaveTestSuite) SetupSuite() { -	// setup standard items -	suite.config = testrig.NewTestConfig() -	suite.db = testrig.NewTestDB() -	suite.log = testrig.NewTestLog() -	suite.storage = testrig.NewTestStorage() -	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) -	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) -	suite.oauthServer = testrig.NewTestOauthServer(suite.db) -	suite.distributor = testrig.NewTestDistributor() - -	// setup module being tested -	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusFaveTestSuite) TearDownSuite() { -	testrig.StandardDBTeardown(suite.db) -	testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusFaveTestSuite) SetupTest() { -	testrig.StandardDBSetup(suite.db) -	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")  	suite.testTokens = testrig.NewTestTokens()  	suite.testClients = testrig.NewTestClients()  	suite.testApplications = testrig.NewTestApplications() @@ -106,16 +50,23 @@ func (suite *StatusFaveTestSuite) SetupTest() {  	suite.testStatuses = testrig.NewTestStatuses()  } -// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusFaveTestSuite) SetupTest() { +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.storage = testrig.NewTestStorage() +	suite.log = testrig.NewTestLog() +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) +	testrig.StandardDBSetup(suite.db) +	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} +  func (suite *StatusFaveTestSuite) TearDownTest() {  	testrig.StandardDBTeardown(suite.db)  	testrig.StandardStorageTeardown(suite.storage)  } -/* -	ACTUAL TESTS -*/ -  // fave a status  func (suite *StatusFaveTestSuite) TestPostFave() { @@ -152,14 +103,14 @@ func (suite *StatusFaveTestSuite) TestPostFave() {  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	statusReply := &mastomodel.Status{} +	statusReply := &model.Status{}  	err = json.Unmarshal(b, statusReply)  	assert.NoError(suite.T(), err)  	assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)  	assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)  	assert.True(suite.T(), statusReply.Sensitive) -	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) +	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)  	assert.True(suite.T(), statusReply.Favourited)  	assert.Equal(suite.T(), 1, statusReply.FavouritesCount)  } @@ -193,13 +144,13 @@ func (suite *StatusFaveTestSuite) TestPostUnfaveable() {  	suite.statusModule.StatusFavePOSTHandler(ctx)  	// check response -	suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses +	suite.EqualValues(http.StatusBadRequest, recorder.Code)  	result := recorder.Result()  	defer result.Body.Close()  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b)) +	assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))  }  func TestStatusFaveTestSuite(t *testing.T) { diff --git a/internal/api/client/status/statusfavedby.go b/internal/api/client/status/statusfavedby.go new file mode 100644 index 000000000..799acb7d2 --- /dev/null +++ b/internal/api/client/status/statusfavedby.go @@ -0,0 +1,60 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status +func (m *Module) StatusFavedByGETHandler(c *gin.Context) { +	l := m.log.WithFields(logrus.Fields{ +		"func":        "statusGETHandler", +		"request_uri": c.Request.RequestURI, +		"user_agent":  c.Request.UserAgent(), +		"origin_ip":   c.ClientIP(), +	}) +	l.Debugf("entering function") + +	authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else +	if err != nil { +		l.Errorf("error authing status faved by request: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) +		return +	} + +	targetStatusID := c.Param(IDKey) +	if targetStatusID == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) +		return +	} + +	mastoAccounts, err := m.processor.StatusFavedBy(authed, targetStatusID) +	if err != nil { +		l.Debugf("error processing status faved by request: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) +		return +	} + +	c.JSON(http.StatusOK, mastoAccounts) +} diff --git a/internal/apimodule/status/test/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go index 169543a81..7b72df7bc 100644 --- a/internal/apimodule/status/test/statusfavedby_test.go +++ b/internal/api/client/status/statusfavedby_test.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package status +package status_test  import (  	"encoding/json" @@ -28,71 +28,19 @@ import (  	"testing"  	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus"  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/status" +	"github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/storage"  	"github.com/superseriousbusiness/gotosocial/testrig"  )  type StatusFavedByTestSuite struct { -	// standard suite interfaces -	suite.Suite -	config         *config.Config -	db             db.DB -	log            *logrus.Logger -	storage        storage.Storage -	mastoConverter mastotypes.Converter -	mediaHandler   media.Handler -	oauthServer    oauth.Server -	distributor    distributor.Distributor - -	// standard suite models -	testTokens       map[string]*oauth.Token -	testClients      map[string]*oauth.Client -	testApplications map[string]*gtsmodel.Application -	testUsers        map[string]*gtsmodel.User -	testAccounts     map[string]*gtsmodel.Account -	testAttachments  map[string]*gtsmodel.MediaAttachment -	testStatuses     map[string]*gtsmodel.Status - -	// module being tested -	statusModule *status.Module +	StatusStandardTestSuite  } -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout  func (suite *StatusFavedByTestSuite) SetupSuite() { -	// setup standard items -	suite.config = testrig.NewTestConfig() -	suite.db = testrig.NewTestDB() -	suite.log = testrig.NewTestLog() -	suite.storage = testrig.NewTestStorage() -	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) -	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) -	suite.oauthServer = testrig.NewTestOauthServer(suite.db) -	suite.distributor = testrig.NewTestDistributor() - -	// setup module being tested -	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusFavedByTestSuite) TearDownSuite() { -	testrig.StandardDBTeardown(suite.db) -	testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusFavedByTestSuite) SetupTest() { -	testrig.StandardDBSetup(suite.db) -	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")  	suite.testTokens = testrig.NewTestTokens()  	suite.testClients = testrig.NewTestClients()  	suite.testApplications = testrig.NewTestApplications() @@ -102,16 +50,23 @@ func (suite *StatusFavedByTestSuite) SetupTest() {  	suite.testStatuses = testrig.NewTestStatuses()  } -// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusFavedByTestSuite) SetupTest() { +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.storage = testrig.NewTestStorage() +	suite.log = testrig.NewTestLog() +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) +	testrig.StandardDBSetup(suite.db) +	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} +  func (suite *StatusFavedByTestSuite) TearDownTest() {  	testrig.StandardDBTeardown(suite.db)  	testrig.StandardStorageTeardown(suite.storage)  } -/* -	ACTUAL TESTS -*/ -  func (suite *StatusFavedByTestSuite) TestGetFavedBy() {  	t := suite.testTokens["local_account_2"]  	oauthToken := oauth.TokenToOauthToken(t) @@ -146,7 +101,7 @@ func (suite *StatusFavedByTestSuite) TestGetFavedBy() {  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	accts := []mastomodel.Account{} +	accts := []model.Account{}  	err = json.Unmarshal(b, &accts)  	assert.NoError(suite.T(), err) diff --git a/internal/api/client/status/statusget.go b/internal/api/client/status/statusget.go new file mode 100644 index 000000000..c6239cb36 --- /dev/null +++ b/internal/api/client/status/statusget.go @@ -0,0 +1,60 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusGETHandler is for handling requests to just get one status based on its ID +func (m *Module) StatusGETHandler(c *gin.Context) { +	l := m.log.WithFields(logrus.Fields{ +		"func":        "statusGETHandler", +		"request_uri": c.Request.RequestURI, +		"user_agent":  c.Request.UserAgent(), +		"origin_ip":   c.ClientIP(), +	}) +	l.Debugf("entering function") + +	authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else +	if err != nil { +		l.Errorf("error authing status faved by request: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) +		return +	} + +	targetStatusID := c.Param(IDKey) +	if targetStatusID == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) +		return +	} + +	mastoStatus, err := m.processor.StatusGet(authed, targetStatusID) +	if err != nil { +		l.Debugf("error processing status get: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) +		return +	} + +	c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/test/statusget_test.go b/internal/api/client/status/statusget_test.go index ce817d247..b31acebca 100644 --- a/internal/apimodule/status/test/statusget_test.go +++ b/internal/api/client/status/statusget_test.go @@ -16,98 +16,47 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package status +package status_test  import (  	"testing" -	"github.com/sirupsen/logrus"  	"github.com/stretchr/testify/suite" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	"github.com/superseriousbusiness/gotosocial/internal/media" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/status"  	"github.com/superseriousbusiness/gotosocial/testrig"  )  type StatusGetTestSuite struct { -	// standard suite interfaces -	suite.Suite -	config         *config.Config -	db             db.DB -	log            *logrus.Logger -	storage        storage.Storage -	mastoConverter mastotypes.Converter -	mediaHandler   media.Handler -	oauthServer    oauth.Server -	distributor    distributor.Distributor - -	// standard suite models -	testTokens       map[string]*oauth.Token -	testClients      map[string]*oauth.Client -	testApplications map[string]*gtsmodel.Application -	testUsers        map[string]*gtsmodel.User -	testAccounts     map[string]*gtsmodel.Account -	testAttachments  map[string]*gtsmodel.MediaAttachment - -	// module being tested -	statusModule *status.Module +	StatusStandardTestSuite  } -/* -	TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout  func (suite *StatusGetTestSuite) SetupSuite() { -	// setup standard items -	suite.config = testrig.NewTestConfig() -	suite.db = testrig.NewTestDB() -	suite.log = testrig.NewTestLog() -	suite.storage = testrig.NewTestStorage() -	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) -	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) -	suite.oauthServer = testrig.NewTestOauthServer(suite.db) -	suite.distributor = testrig.NewTestDistributor() - -	// setup module being tested -	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusGetTestSuite) TearDownSuite() { -	testrig.StandardDBTeardown(suite.db) -	testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusGetTestSuite) SetupTest() { -	testrig.StandardDBSetup(suite.db) -	testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")  	suite.testTokens = testrig.NewTestTokens()  	suite.testClients = testrig.NewTestClients()  	suite.testApplications = testrig.NewTestApplications()  	suite.testUsers = testrig.NewTestUsers()  	suite.testAccounts = testrig.NewTestAccounts()  	suite.testAttachments = testrig.NewTestAttachments() +	suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *StatusGetTestSuite) SetupTest() { +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.storage = testrig.NewTestStorage() +	suite.log = testrig.NewTestLog() +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) +	testrig.StandardDBSetup(suite.db) +	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")  } -// TearDownTest drops tables to make sure there's no data in the db  func (suite *StatusGetTestSuite) TearDownTest() {  	testrig.StandardDBTeardown(suite.db) +	testrig.StandardStorageTeardown(suite.storage)  } -/* -	ACTUAL TESTS -*/ - -/* -	TESTING: StatusGetPOSTHandler -*/ -  // Post a new status with some custom visibility settings  func (suite *StatusGetTestSuite) TestPostNewStatus() { @@ -143,16 +92,16 @@ func (suite *StatusGetTestSuite) TestPostNewStatus() {  	// b, err := ioutil.ReadAll(result.Body)  	// assert.NoError(suite.T(), err) -	// statusReply := &mastomodel.Status{} +	// statusReply := &mastotypes.Status{}  	// err = json.Unmarshal(b, statusReply)  	// assert.NoError(suite.T(), err)  	// assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)  	// assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)  	// assert.True(suite.T(), statusReply.Sensitive) -	// assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) +	// assert.Equal(suite.T(), mastotypes.VisibilityPrivate, statusReply.Visibility)  	// assert.Len(suite.T(), statusReply.Tags, 1) -	// assert.Equal(suite.T(), mastomodel.Tag{ +	// assert.Equal(suite.T(), mastotypes.Tag{  	// 	Name: "helloworld",  	// 	URL:  "http://localhost:8080/tags/helloworld",  	// }, statusReply.Tags[0]) diff --git a/internal/api/client/status/statusunfave.go b/internal/api/client/status/statusunfave.go new file mode 100644 index 000000000..94fd662de --- /dev/null +++ b/internal/api/client/status/statusunfave.go @@ -0,0 +1,60 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID +func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) { +	l := m.log.WithFields(logrus.Fields{ +		"func":        "StatusUnfavePOSTHandler", +		"request_uri": c.Request.RequestURI, +		"user_agent":  c.Request.UserAgent(), +		"origin_ip":   c.ClientIP(), +	}) +	l.Debugf("entering function") + +	authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else +	if err != nil { +		l.Debug("not authed so can't unfave status") +		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) +		return +	} + +	targetStatusID := c.Param(IDKey) +	if targetStatusID == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) +		return +	} + +	mastoStatus, err := m.processor.StatusUnfave(authed, targetStatusID) +	if err != nil { +		l.Debugf("error processing status unfave: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) +		return +	} + +	c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/test/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go index 5f5277921..44b1dd3a6 100644 --- a/internal/apimodule/status/test/statusunfave_test.go +++ b/internal/api/client/status/statusunfave_test.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package status +package status_test  import (  	"encoding/json" @@ -28,75 +28,19 @@ import (  	"testing"  	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus"  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/status" +	"github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/storage"  	"github.com/superseriousbusiness/gotosocial/testrig"  )  type StatusUnfaveTestSuite struct { -	// standard suite interfaces -	suite.Suite -	config         *config.Config -	db             db.DB -	log            *logrus.Logger -	storage        storage.Storage -	mastoConverter mastotypes.Converter -	mediaHandler   media.Handler -	oauthServer    oauth.Server -	distributor    distributor.Distributor - -	// standard suite models -	testTokens       map[string]*oauth.Token -	testClients      map[string]*oauth.Client -	testApplications map[string]*gtsmodel.Application -	testUsers        map[string]*gtsmodel.User -	testAccounts     map[string]*gtsmodel.Account -	testAttachments  map[string]*gtsmodel.MediaAttachment -	testStatuses     map[string]*gtsmodel.Status - -	// module being tested -	statusModule *status.Module +	StatusStandardTestSuite  } -/* -	TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout  func (suite *StatusUnfaveTestSuite) SetupSuite() { -	// setup standard items -	suite.config = testrig.NewTestConfig() -	suite.db = testrig.NewTestDB() -	suite.log = testrig.NewTestLog() -	suite.storage = testrig.NewTestStorage() -	suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) -	suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) -	suite.oauthServer = testrig.NewTestOauthServer(suite.db) -	suite.distributor = testrig.NewTestDistributor() - -	// setup module being tested -	suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusUnfaveTestSuite) TearDownSuite() { -	testrig.StandardDBTeardown(suite.db) -	testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusUnfaveTestSuite) SetupTest() { -	testrig.StandardDBSetup(suite.db) -	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")  	suite.testTokens = testrig.NewTestTokens()  	suite.testClients = testrig.NewTestClients()  	suite.testApplications = testrig.NewTestApplications() @@ -106,16 +50,23 @@ func (suite *StatusUnfaveTestSuite) SetupTest() {  	suite.testStatuses = testrig.NewTestStatuses()  } -// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusUnfaveTestSuite) SetupTest() { +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.storage = testrig.NewTestStorage() +	suite.log = testrig.NewTestLog() +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +	suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) +	testrig.StandardDBSetup(suite.db) +	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} +  func (suite *StatusUnfaveTestSuite) TearDownTest() {  	testrig.StandardDBTeardown(suite.db)  	testrig.StandardStorageTeardown(suite.storage)  } -/* -	ACTUAL TESTS -*/ -  // unfave a status  func (suite *StatusUnfaveTestSuite) TestPostUnfave() { @@ -153,14 +104,14 @@ func (suite *StatusUnfaveTestSuite) TestPostUnfave() {  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	statusReply := &mastomodel.Status{} +	statusReply := &model.Status{}  	err = json.Unmarshal(b, statusReply)  	assert.NoError(suite.T(), err)  	assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)  	assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)  	assert.False(suite.T(), statusReply.Sensitive) -	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) +	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)  	assert.False(suite.T(), statusReply.Favourited)  	assert.Equal(suite.T(), 0, statusReply.FavouritesCount)  } @@ -202,14 +153,14 @@ func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {  	b, err := ioutil.ReadAll(result.Body)  	assert.NoError(suite.T(), err) -	statusReply := &mastomodel.Status{} +	statusReply := &model.Status{}  	err = json.Unmarshal(b, statusReply)  	assert.NoError(suite.T(), err)  	assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)  	assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)  	assert.True(suite.T(), statusReply.Sensitive) -	assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) +	assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)  	assert.False(suite.T(), statusReply.Favourited)  	assert.Equal(suite.T(), 0, statusReply.FavouritesCount)  } diff --git a/internal/mastotypes/mastomodel/account.go b/internal/api/model/account.go index bbcf9c90f..efb69d6fd 100644 --- a/internal/mastotypes/mastomodel/account.go +++ b/internal/api/model/account.go @@ -16,9 +16,12 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model -import "mime/multipart" +import ( +	"mime/multipart" +	"net" +)  // Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/  type Account struct { @@ -86,6 +89,8 @@ type AccountCreateRequest struct {  	Agreement bool `form:"agreement" binding:"required"`  	// The language of the confirmation email that will be sent  	Locale string `form:"locale" binding:"required"` +	// The IP of the sign up request, will not be parsed from the form but must be added manually +	IP net.IP `form:"-"`  }  // UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials. diff --git a/internal/mastotypes/mastomodel/activity.go b/internal/api/model/activity.go index b8dbf2c1b..c1736a8d6 100644 --- a/internal/mastotypes/mastomodel/activity.go +++ b/internal/api/model/activity.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Activity represents the mastodon-api Activity type. See here: https://docs.joinmastodon.org/entities/activity/  type Activity struct { diff --git a/internal/mastotypes/mastomodel/admin.go b/internal/api/model/admin.go index 71c2bb309..036218f77 100644 --- a/internal/mastotypes/mastomodel/admin.go +++ b/internal/api/model/admin.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // AdminAccountInfo represents the *admin* view of an account's details. See here: https://docs.joinmastodon.org/entities/admin-account/  type AdminAccountInfo struct { diff --git a/internal/mastotypes/mastomodel/announcement.go b/internal/api/model/announcement.go index 882d6bb9b..eeb4b8720 100644 --- a/internal/mastotypes/mastomodel/announcement.go +++ b/internal/api/model/announcement.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Announcement represents an admin/moderator announcement for local users. See here: https://docs.joinmastodon.org/entities/announcement/  type Announcement struct { diff --git a/internal/mastotypes/mastomodel/announcementreaction.go b/internal/api/model/announcementreaction.go index 444c57e2c..81118fef0 100644 --- a/internal/mastotypes/mastomodel/announcementreaction.go +++ b/internal/api/model/announcementreaction.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // AnnouncementReaction represents a user reaction to admin/moderator announcement. See here: https://docs.joinmastodon.org/entities/announcementreaction/  type AnnouncementReaction struct { diff --git a/internal/mastotypes/mastomodel/application.go b/internal/api/model/application.go index 6140a0127..a796c88ea 100644 --- a/internal/mastotypes/mastomodel/application.go +++ b/internal/api/model/application.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/.  // Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user. @@ -38,10 +38,10 @@ type Application struct {  	VapidKey string `json:"vapid_key,omitempty"`  } -// ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps. +// ApplicationCreateRequest represents a POST request to https://example.org/api/v1/apps.  // See here: https://docs.joinmastodon.org/methods/apps/  // And here: https://docs.joinmastodon.org/client/token/ -type ApplicationPOSTRequest struct { +type ApplicationCreateRequest struct {  	// A name for your application  	ClientName string `form:"client_name" binding:"required"`  	// Where the user should be redirected after authorization. diff --git a/internal/mastotypes/mastomodel/attachment.go b/internal/api/model/attachment.go index bda79a8ee..d90247f83 100644 --- a/internal/mastotypes/mastomodel/attachment.go +++ b/internal/api/model/attachment.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  import "mime/multipart" diff --git a/internal/mastotypes/mastomodel/card.go b/internal/api/model/card.go index d1147e04b..ffa6d53e5 100644 --- a/internal/mastotypes/mastomodel/card.go +++ b/internal/api/model/card.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Card represents a rich preview card that is generated using OpenGraph tags from a URL. See here: https://docs.joinmastodon.org/entities/card/  type Card struct { diff --git a/internal/api/model/content.go b/internal/api/model/content.go new file mode 100644 index 000000000..4f004f13c --- /dev/null +++ b/internal/api/model/content.go @@ -0,0 +1,41 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package model + +// Content wraps everything needed to serve a blob of content (some kind of media) through the API. +type Content struct { +	// MIME content type +	ContentType string +	// ContentLength in bytes +	ContentLength int64 +	// Actual content blob +	Content []byte +} + +// GetContentRequestForm describes a piece of content desired by the caller of the fileserver API. +type GetContentRequestForm struct { +	// AccountID of the content owner +	AccountID string +	// MediaType of the content (should be convertible to a media.MediaType) +	MediaType string +	// MediaSize of the content (should be convertible to a media.MediaSize) +	MediaSize string +	// Filename of the content +	FileName string +} diff --git a/internal/mastotypes/mastomodel/context.go b/internal/api/model/context.go index 397522dc7..d0979319b 100644 --- a/internal/mastotypes/mastomodel/context.go +++ b/internal/api/model/context.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Context represents the tree around a given status. Used for reconstructing threads of statuses. See: https://docs.joinmastodon.org/entities/context/  type Context struct { diff --git a/internal/mastotypes/mastomodel/conversation.go b/internal/api/model/conversation.go index ed95c124c..b0568c17e 100644 --- a/internal/mastotypes/mastomodel/conversation.go +++ b/internal/api/model/conversation.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Conversation represents a conversation with "direct message" visibility. See https://docs.joinmastodon.org/entities/conversation/  type Conversation struct { diff --git a/internal/mastotypes/mastomodel/emoji.go b/internal/api/model/emoji.go index c50ca6343..c2834718f 100644 --- a/internal/mastotypes/mastomodel/emoji.go +++ b/internal/api/model/emoji.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  import "mime/multipart" diff --git a/internal/mastotypes/mastomodel/error.go b/internal/api/model/error.go index 394085724..f145d69f2 100644 --- a/internal/mastotypes/mastomodel/error.go +++ b/internal/api/model/error.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Error represents an error message returned from the API. See https://docs.joinmastodon.org/entities/error/  type Error struct { diff --git a/internal/mastotypes/mastomodel/featuredtag.go b/internal/api/model/featuredtag.go index 0e0bbe802..3df3fe4c9 100644 --- a/internal/mastotypes/mastomodel/featuredtag.go +++ b/internal/api/model/featuredtag.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // FeaturedTag represents a hashtag that is featured on a profile. See https://docs.joinmastodon.org/entities/featuredtag/  type FeaturedTag struct { diff --git a/internal/mastotypes/mastomodel/field.go b/internal/api/model/field.go index 29b5a1803..2e7662b2b 100644 --- a/internal/mastotypes/mastomodel/field.go +++ b/internal/api/model/field.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Field represents a profile field as a name-value pair with optional verification. See https://docs.joinmastodon.org/entities/field/  type Field struct { diff --git a/internal/mastotypes/mastomodel/filter.go b/internal/api/model/filter.go index 86d9795a3..519922ba3 100644 --- a/internal/mastotypes/mastomodel/filter.go +++ b/internal/api/model/filter.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Filter represents a user-defined filter for determining which statuses should not be shown to the user. See https://docs.joinmastodon.org/entities/filter/  // If whole_word is true , client app should do: diff --git a/internal/mastotypes/mastomodel/history.go b/internal/api/model/history.go index 235761378..d8b4d6b4f 100644 --- a/internal/mastotypes/mastomodel/history.go +++ b/internal/api/model/history.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // History represents daily usage history of a hashtag. See https://docs.joinmastodon.org/entities/history/  type History struct { diff --git a/internal/mastotypes/mastomodel/identityproof.go b/internal/api/model/identityproof.go index 7265d46e3..400835fca 100644 --- a/internal/mastotypes/mastomodel/identityproof.go +++ b/internal/api/model/identityproof.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // IdentityProof represents a proof from an external identity provider. See https://docs.joinmastodon.org/entities/identityproof/  type IdentityProof struct { diff --git a/internal/mastotypes/mastomodel/instance.go b/internal/api/model/instance.go index 10e626a8e..857a8acc5 100644 --- a/internal/mastotypes/mastomodel/instance.go +++ b/internal/api/model/instance.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/  type Instance struct { diff --git a/internal/mastotypes/mastomodel/list.go b/internal/api/model/list.go index 5b704367b..220cde59e 100644 --- a/internal/mastotypes/mastomodel/list.go +++ b/internal/api/model/list.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // List represents a list of some users that the authenticated user follows. See https://docs.joinmastodon.org/entities/list/  type List struct { diff --git a/internal/mastotypes/mastomodel/marker.go b/internal/api/model/marker.go index 790322313..1e39f1516 100644 --- a/internal/mastotypes/mastomodel/marker.go +++ b/internal/api/model/marker.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Marker represents the last read position within a user's timelines. See https://docs.joinmastodon.org/entities/marker/  type Marker struct { diff --git a/internal/mastotypes/mastomodel/mention.go b/internal/api/model/mention.go index 81a593d99..a7985af24 100644 --- a/internal/mastotypes/mastomodel/mention.go +++ b/internal/api/model/mention.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Mention represents the mastodon-api mention type, as documented here: https://docs.joinmastodon.org/entities/mention/  type Mention struct { diff --git a/internal/mastotypes/mastomodel/notification.go b/internal/api/model/notification.go index 26d361b43..c8d080e2a 100644 --- a/internal/mastotypes/mastomodel/notification.go +++ b/internal/api/model/notification.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Notification represents a notification of an event relevant to the user. See https://docs.joinmastodon.org/entities/notification/  type Notification struct { diff --git a/internal/mastotypes/mastomodel/oauth.go b/internal/api/model/oauth.go index d93ea079f..250d2218f 100644 --- a/internal/mastotypes/mastomodel/oauth.go +++ b/internal/api/model/oauth.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // OAuthAuthorize represents a request sent to https://example.org/oauth/authorize  // See here: https://docs.joinmastodon.org/methods/apps/oauth/ diff --git a/internal/mastotypes/mastomodel/poll.go b/internal/api/model/poll.go index bedaebec2..b00e7680a 100644 --- a/internal/mastotypes/mastomodel/poll.go +++ b/internal/api/model/poll.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Poll represents the mastodon-api poll type, as described here: https://docs.joinmastodon.org/entities/poll/  type Poll struct { diff --git a/internal/mastotypes/mastomodel/preferences.go b/internal/api/model/preferences.go index c28f5d5ab..9e410091e 100644 --- a/internal/mastotypes/mastomodel/preferences.go +++ b/internal/api/model/preferences.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Preferences represents a user's preferences. See https://docs.joinmastodon.org/entities/preferences/  type Preferences struct { diff --git a/internal/mastotypes/mastomodel/pushsubscription.go b/internal/api/model/pushsubscription.go index 4d7535100..f34c63374 100644 --- a/internal/mastotypes/mastomodel/pushsubscription.go +++ b/internal/api/model/pushsubscription.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // PushSubscription represents a subscription to the push streaming server. See https://docs.joinmastodon.org/entities/pushsubscription/  type PushSubscription struct { diff --git a/internal/mastotypes/mastomodel/relationship.go b/internal/api/model/relationship.go index 1e0bbab46..6e71023e2 100644 --- a/internal/mastotypes/mastomodel/relationship.go +++ b/internal/api/model/relationship.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Relationship represents a relationship between accounts. See https://docs.joinmastodon.org/entities/relationship/  type Relationship struct { diff --git a/internal/mastotypes/mastomodel/results.go b/internal/api/model/results.go index 3fa7c7abb..1b2625a0d 100644 --- a/internal/mastotypes/mastomodel/results.go +++ b/internal/api/model/results.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Results represents the results of a search. See https://docs.joinmastodon.org/entities/results/  type Results struct { diff --git a/internal/mastotypes/mastomodel/scheduledstatus.go b/internal/api/model/scheduledstatus.go index ff45eaade..deafd22aa 100644 --- a/internal/mastotypes/mastomodel/scheduledstatus.go +++ b/internal/api/model/scheduledstatus.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // ScheduledStatus represents a status that will be published at a future scheduled date. See https://docs.joinmastodon.org/entities/scheduledstatus/  type ScheduledStatus struct { diff --git a/internal/mastotypes/mastomodel/source.go b/internal/api/model/source.go index 0445a1ffb..441af71de 100644 --- a/internal/mastotypes/mastomodel/source.go +++ b/internal/api/model/source.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Source represents display or publishing preferences of user's own account.  // Returned as an additional entity when verifying and updated credentials, as an attribute of Account. diff --git a/internal/mastotypes/mastomodel/status.go b/internal/api/model/status.go index f5cc07a06..faf88ae84 100644 --- a/internal/mastotypes/mastomodel/status.go +++ b/internal/api/model/status.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/  type Status struct { @@ -118,3 +118,21 @@ const (  	// VisibilityDirect means visible only to tagged recipients  	VisibilityDirect Visibility = "direct"  ) + +type AdvancedStatusCreateForm struct { +	StatusCreateRequest +	AdvancedVisibilityFlagsForm +} + +type AdvancedVisibilityFlagsForm struct { +	// The gotosocial visibility model +	VisibilityAdvanced *string `form:"visibility_advanced"` +	// This status will be federated beyond the local timeline(s) +	Federated *bool `form:"federated"` +	// This status can be boosted/reblogged +	Boostable *bool `form:"boostable"` +	// This status can be replied to +	Replyable *bool `form:"replyable"` +	// This status can be liked/faved +	Likeable *bool `form:"likeable"` +} diff --git a/internal/mastotypes/mastomodel/tag.go b/internal/api/model/tag.go index 82e6e6618..f009b4cef 100644 --- a/internal/mastotypes/mastomodel/tag.go +++ b/internal/api/model/tag.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/  type Tag struct { diff --git a/internal/mastotypes/mastomodel/token.go b/internal/api/model/token.go index c9ac1f177..611ab214c 100644 --- a/internal/mastotypes/mastomodel/token.go +++ b/internal/api/model/token.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package model  // Token represents an OAuth token used for authenticating with the API and performing actions.. See https://docs.joinmastodon.org/entities/token/  type Token struct { diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go new file mode 100644 index 000000000..693fac7c3 --- /dev/null +++ b/internal/api/s2s/user/user.go @@ -0,0 +1,70 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package user + +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" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +const ( +	// UsernameKey is for account usernames. +	UsernameKey = "username" +	// UsersBasePath is the base path for serving information about Users eg https://example.org/users +	UsersBasePath = "/" + util.UsersPath +	// UsersBasePathWithUsername is just the users base path with the Username key in it. +	// Use this anywhere you need to know the username of the user being queried. +	// Eg https://example.org/users/:username +	UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey +) + +// ActivityPubAcceptHeaders represents the Accept headers mentioned here: +// https://www.w3.org/TR/activitypub/#retrieving-objects +var ActivityPubAcceptHeaders = []string{ +	`application/activity+json`, +	`application/ld+json; profile="https://www.w3.org/ns/activitystreams"`, +} + +// Module implements the FederationAPIModule interface +type Module struct { +	config    *config.Config +	processor message.Processor +	log       *logrus.Logger +} + +// New returns a new auth module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.FederationModule { +	return &Module{ +		config:    config, +		processor: processor, +		log:       log, +	} +} + +// Route satisfies the RESTAPIModule interface +func (m *Module) Route(s router.Router) error { +	s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler) +	return nil +} diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go new file mode 100644 index 000000000..84e35ab68 --- /dev/null +++ b/internal/api/s2s/user/user_test.go @@ -0,0 +1,40 @@ +package user_test + +import ( +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" +	"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/message" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type UserStandardTestSuite struct { +	// standard suite interfaces +	suite.Suite +	config    *config.Config +	db        db.DB +	log       *logrus.Logger +	tc        typeutils.TypeConverter +	federator federation.Federator +	processor message.Processor +	storage   storage.Storage + +	// standard suite models +	testTokens       map[string]*oauth.Token +	testClients      map[string]*oauth.Client +	testApplications map[string]*gtsmodel.Application +	testUsers        map[string]*gtsmodel.User +	testAccounts     map[string]*gtsmodel.Account +	testAttachments  map[string]*gtsmodel.MediaAttachment +	testStatuses     map[string]*gtsmodel.Status + +	// module being tested +	userModule *user.Module +} diff --git a/internal/api/s2s/user/userget.go b/internal/api/s2s/user/userget.go new file mode 100644 index 000000000..8df137f44 --- /dev/null +++ b/internal/api/s2s/user/userget.go @@ -0,0 +1,67 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package user + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +) + +// UsersGETHandler should be served at https://example.org/users/:username. +// +// The goal here is to return the activitypub representation of an account +// in the form of a vocab.ActivityStreamsPerson. This should only be served +// to REMOTE SERVERS that present a valid signature on the GET request, on +// behalf of a user, otherwise we risk leaking information about users publicly. +// +// And of course, the request should be refused if the account or server making the +// request is blocked. +func (m *Module) UsersGETHandler(c *gin.Context) { +	l := m.log.WithFields(logrus.Fields{ +		"func": "UsersGETHandler", +		"url":  c.Request.RequestURI, +	}) + +	requestedUsername := c.Param(UsernameKey) +	if requestedUsername == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) +		return +	} + +	// make sure this actually an AP request +	format := c.NegotiateFormat(ActivityPubAcceptHeaders...) +	if format == "" { +		c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"}) +		return +	} +	l.Tracef("negotiated format: %s", format) + +	// make a copy of the context to pass along so we don't break anything +	cp := c.Copy() +	user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetAPUser handles auth as well +	if err != nil { +		l.Info(err.Error()) +		c.JSON(err.Code(), gin.H{"error": err.Safe()}) +		return +	} + +	c.JSON(http.StatusOK, user) +} diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go new file mode 100644 index 000000000..b45b01b63 --- /dev/null +++ b/internal/api/s2s/user/userget_test.go @@ -0,0 +1,155 @@ +package user_test + +import ( +	"bytes" +	"context" +	"crypto/x509" +	"encoding/json" +	"encoding/pem" +	"fmt" +	"io/ioutil" +	"net/http" +	"net/http/httptest" +	"strings" +	"testing" + +	"github.com/gin-gonic/gin" +	"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/api/s2s/user" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserGetTestSuite struct { +	UserStandardTestSuite +} + +func (suite *UserGetTestSuite) SetupSuite() { +	suite.testTokens = testrig.NewTestTokens() +	suite.testClients = testrig.NewTestClients() +	suite.testApplications = testrig.NewTestApplications() +	suite.testUsers = testrig.NewTestUsers() +	suite.testAccounts = testrig.NewTestAccounts() +	suite.testAttachments = testrig.NewTestAttachments() +	suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *UserGetTestSuite) SetupTest() { +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.tc = testrig.NewTestTypeConverter(suite.db) +	suite.storage = testrig.NewTestStorage() +	suite.log = testrig.NewTestLog() +	suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +	suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) +	suite.userModule = user.New(suite.config, suite.processor, suite.log).(*user.Module) +	testrig.StandardDBSetup(suite.db) +	testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *UserGetTestSuite) TearDownTest() { +	testrig.StandardDBTeardown(suite.db) +	testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *UserGetTestSuite) TestGetUser() { +	// the dereference we're gonna use +	signedRequest := testrig.NewTestDereferenceRequests(suite.testAccounts)["foss_satan_dereference_zork"] + +	requestingAccount := suite.testAccounts["remote_account_1"] +	targetAccount := suite.testAccounts["local_account_1"] + +	encodedPublicKey, err := x509.MarshalPKIXPublicKey(requestingAccount.PublicKey) +	assert.NoError(suite.T(), err) +	publicKeyBytes := pem.EncodeToMemory(&pem.Block{ +		Type:  "PUBLIC KEY", +		Bytes: encodedPublicKey, +	}) +	publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n") + +	// for this test we need the client to return the public key of the requester on the 'remote' instance +	responseBodyString := fmt.Sprintf(` +	{ +		"@context": [ +			"https://www.w3.org/ns/activitystreams", +			"https://w3id.org/security/v1" +		], + +		"id": "%s", +		"type": "Person", +		"preferredUsername": "%s", +		"inbox": "%s", + +		"publicKey": { +			"id": "%s", +			"owner": "%s", +			"publicKeyPem": "%s" +		} +	}`, requestingAccount.URI, requestingAccount.Username, requestingAccount.InboxURI, requestingAccount.PublicKeyURI, requestingAccount.URI, publicKeyString) + +	// create a transport controller whose client will just return the response body string we specified above +	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { +		r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString))) +		return &http.Response{ +			StatusCode: 200, +			Body:       r, +		}, nil +	})) +	// get this transport controller embedded right in the user module we're testing +	federator := testrig.NewTestFederator(suite.db, tc) +	processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) +	userModule := user.New(suite.config, processor, suite.log).(*user.Module) + +	// setup request +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(user.UsersBasePathWithUsername, ":username", targetAccount.Username, 1)), nil) // the endpoint we're hitting + +	// normally the router would populate these params from the path values, +	// but because we're calling the function directly, we need to set them manually. +	ctx.Params = gin.Params{ +		gin.Param{ +			Key:   user.UsernameKey, +			Value: targetAccount.Username, +		}, +	} + +	// we need these headers for the request to be validated +	ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) +	ctx.Request.Header.Set("Date", signedRequest.DateHeader) +	ctx.Request.Header.Set("Digest", signedRequest.DigestHeader) + +	// trigger the function being tested +	userModule.UsersGETHandler(ctx) + +	// check response +	suite.EqualValues(http.StatusOK, recorder.Code) + +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) + +	// should be a Person +	m := make(map[string]interface{}) +	err = json.Unmarshal(b, &m) +	assert.NoError(suite.T(), err) + +	t, err := streams.ToType(context.Background(), m) +	assert.NoError(suite.T(), err) + +	person, ok := t.(vocab.ActivityStreamsPerson) +	assert.True(suite.T(), ok) + +	// convert person to account +	// since this account is already known, we should get a pretty full model of it from the conversion +	a, err := suite.tc.ASRepresentationToAccount(person) +	assert.NoError(suite.T(), err) +	assert.EqualValues(suite.T(), targetAccount.Username, a.Username) +} + +func TestUserGetTestSuite(t *testing.T) { +	suite.Run(t, new(UserGetTestSuite)) +} diff --git a/internal/apimodule/security/flocblock.go b/internal/api/security/flocblock.go index 7cedcde6b..7cedcde6b 100644 --- a/internal/apimodule/security/flocblock.go +++ b/internal/api/security/flocblock.go diff --git a/internal/apimodule/security/security.go b/internal/api/security/security.go index 8f805bc93..c80b568b3 100644 --- a/internal/apimodule/security/security.go +++ b/internal/api/security/security.go @@ -20,9 +20,8 @@ package security  import (  	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/api"  	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db"  	"github.com/superseriousbusiness/gotosocial/internal/router"  ) @@ -33,7 +32,7 @@ type Module struct {  }  // New returns a new security module -func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, log *logrus.Logger) api.ClientModule {  	return &Module{  		config: config,  		log:    log, @@ -45,8 +44,3 @@ func (m *Module) Route(s router.Router) error {  	s.AttachMiddleware(m.FlocBlock)  	return nil  } - -// CreateTables doesn't do diddly squat at the moment, it's just for fulfilling the interface -func (m *Module) CreateTables(db db.DB) error { -	return nil -} diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go deleted file mode 100644 index 7709697bf..000000000 --- a/internal/apimodule/account/accountupdate.go +++ /dev/null @@ -1,260 +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 <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( -	"bytes" -	"errors" -	"fmt" -	"io" -	"mime/multipart" -	"net/http" - -	"github.com/gin-gonic/gin" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/media" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/util" -) - -// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings. -// It should be served as a PATCH at /api/v1/accounts/update_credentials -// -// TODO: this can be optimized massively by building up a picture of what we want the new account -// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one -// which is not gonna make the database very happy when lots of requests are going through. -// This way it would also be safer because the update won't happen until *all* the fields are validated. -// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss. -func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { -	l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler") -	authed, err := oauth.MustAuth(c, true, false, false, true) -	if err != nil { -		l.Debugf("couldn't auth: %s", err) -		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) -		return -	} -	l.Tracef("retrieved account %+v", authed.Account.ID) - -	l.Trace("parsing request form") -	form := &mastotypes.UpdateCredentialsRequest{} -	if err := c.ShouldBind(form); err != nil || form == nil { -		l.Debugf("could not parse form from request: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	// if everything on the form is nil, then nothing has been set and we shouldn't continue -	if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil { -		l.Debugf("could not parse form from request") -		c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"}) -		return -	} - -	if form.Discoverable != nil { -		if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil { -			l.Debugf("error updating discoverable: %s", err) -			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -			return -		} -	} - -	if form.Bot != nil { -		if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil { -			l.Debugf("error updating bot: %s", err) -			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -			return -		} -	} - -	if form.DisplayName != nil { -		if err := util.ValidateDisplayName(*form.DisplayName); err != nil { -			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -			return -		} -		if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, >smodel.Account{}); err != nil { -			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -			return -		} -	} - -	if form.Note != nil { -		if err := util.ValidateNote(*form.Note); err != nil { -			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -			return -		} -		if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, >smodel.Account{}); err != nil { -			l.Debugf("error updating note: %s", err) -			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -			return -		} -	} - -	if form.Avatar != nil && form.Avatar.Size != 0 { -		avatarInfo, err := m.UpdateAccountAvatar(form.Avatar, authed.Account.ID) -		if err != nil { -			l.Debugf("could not update avatar for account %s: %s", authed.Account.ID, err) -			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -			return -		} -		l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo) -	} - -	if form.Header != nil && form.Header.Size != 0 { -		headerInfo, err := m.UpdateAccountHeader(form.Header, authed.Account.ID) -		if err != nil { -			l.Debugf("could not update header for account %s: %s", authed.Account.ID, err) -			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -			return -		} -		l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo) -	} - -	if form.Locked != nil { -		if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { -			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -			return -		} -	} - -	if form.Source != nil { -		if form.Source.Language != nil { -			if err := util.ValidateLanguage(*form.Source.Language); err != nil { -				c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -				return -			} -			if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil { -				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -				return -			} -		} - -		if form.Source.Sensitive != nil { -			if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { -				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -				return -			} -		} - -		if form.Source.Privacy != nil { -			if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil { -				c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -				return -			} -			if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil { -				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -				return -			} -		} -	} - -	// if form.FieldsAttributes != nil { -	// 	// TODO: parse fields attributes nicely and update -	// } - -	// fetch the account with all updated values set -	updatedAccount := >smodel.Account{} -	if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil { -		l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount) -	if err != nil { -		l.Tracef("could not convert account into mastosensitive account: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) -	c.JSON(http.StatusOK, acctSensitive) -} - -/* -	HELPER FUNCTIONS -*/ - -// TODO: try to combine the below two functions because this is a lot of code repetition. - -// UpdateAccountAvatar does the dirty work of checking the avatar part of an account update form, -// parsing and checking the image, and doing the necessary updates in the database for this to become -// the account's new avatar image. -func (m *Module) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { -	var err error -	if int(avatar.Size) > m.config.MediaConfig.MaxImageSize { -		err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.config.MediaConfig.MaxImageSize) -		return nil, err -	} -	f, err := avatar.Open() -	if err != nil { -		return nil, fmt.Errorf("could not read provided avatar: %s", err) -	} - -	// extract the bytes -	buf := new(bytes.Buffer) -	size, err := io.Copy(buf, f) -	if err != nil { -		return nil, fmt.Errorf("could not read provided avatar: %s", err) -	} -	if size == 0 { -		return nil, errors.New("could not read provided avatar: size 0 bytes") -	} - -	// do the setting -	avatarInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar) -	if err != nil { -		return nil, fmt.Errorf("error processing avatar: %s", err) -	} - -	return avatarInfo, f.Close() -} - -// UpdateAccountHeader does the dirty work of checking the header part of an account update form, -// parsing and checking the image, and doing the necessary updates in the database for this to become -// the account's new header image. -func (m *Module) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { -	var err error -	if int(header.Size) > m.config.MediaConfig.MaxImageSize { -		err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.config.MediaConfig.MaxImageSize) -		return nil, err -	} -	f, err := header.Open() -	if err != nil { -		return nil, fmt.Errorf("could not read provided header: %s", err) -	} - -	// extract the bytes -	buf := new(bytes.Buffer) -	size, err := io.Copy(buf, f) -	if err != nil { -		return nil, fmt.Errorf("could not read provided header: %s", err) -	} -	if size == 0 { -		return nil, errors.New("could not read provided header: size 0 bytes") -	} - -	// do the setting -	headerInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader) -	if err != nil { -		return nil, fmt.Errorf("error processing header: %s", err) -	} - -	return headerInfo, f.Close() -} diff --git a/internal/apimodule/account/test/accountcreate_test.go b/internal/apimodule/account/test/accountcreate_test.go deleted file mode 100644 index 81eab467a..000000000 --- a/internal/apimodule/account/test/accountcreate_test.go +++ /dev/null @@ -1,551 +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 <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( -	"bytes" -	"context" -	"encoding/json" -	"fmt" -	"io" -	"io/ioutil" -	"mime/multipart" -	"net/http" -	"net/http/httptest" -	"net/url" -	"os" -	"testing" -	"time" - -	"github.com/gin-gonic/gin" -	"github.com/google/uuid" -	"github.com/sirupsen/logrus" -	"github.com/stretchr/testify/assert" -	"github.com/stretchr/testify/mock" -	"github.com/stretchr/testify/suite" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/account" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - -	"github.com/superseriousbusiness/gotosocial/internal/media" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/storage" -	"github.com/superseriousbusiness/oauth2/v4" -	"github.com/superseriousbusiness/oauth2/v4/models" -	oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" -	"golang.org/x/crypto/bcrypt" -) - -type AccountCreateTestSuite struct { -	suite.Suite -	config               *config.Config -	log                  *logrus.Logger -	testAccountLocal     *gtsmodel.Account -	testApplication      *gtsmodel.Application -	testToken            oauth2.TokenInfo -	mockOauthServer      *oauth.MockServer -	mockStorage          *storage.MockStorage -	mediaHandler         media.Handler -	mastoConverter       mastotypes.Converter -	db                   db.DB -	accountModule        *account.Module -	newUserFormHappyPath url.Values -} - -/* -	TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *AccountCreateTestSuite) SetupSuite() { -	// some of our subsequent entities need a log so create this here -	log := logrus.New() -	log.SetLevel(logrus.TraceLevel) -	suite.log = log - -	suite.testAccountLocal = >smodel.Account{ -		ID:       uuid.NewString(), -		Username: "test_user", -	} - -	// can use this test application throughout -	suite.testApplication = >smodel.Application{ -		ID:           "weeweeeeeeeeeeeeee", -		Name:         "a test application", -		Website:      "https://some-application-website.com", -		RedirectURI:  "http://localhost:8080", -		ClientID:     "a-known-client-id", -		ClientSecret: "some-secret", -		Scopes:       "read", -		VapidKey:     "aaaaaa-aaaaaaaa-aaaaaaaaaaa", -	} - -	// can use this test token throughout -	suite.testToken = &oauthmodels.Token{ -		ClientID:      "a-known-client-id", -		RedirectURI:   "http://localhost:8080", -		Scope:         "read", -		Code:          "123456789", -		CodeCreateAt:  time.Now(), -		CodeExpiresIn: time.Duration(10 * time.Minute), -	} - -	// Direct config to local postgres instance -	c := config.Empty() -	c.Protocol = "http" -	c.Host = "localhost" -	c.DBConfig = &config.DBConfig{ -		Type:            "postgres", -		Address:         "localhost", -		Port:            5432, -		User:            "postgres", -		Password:        "postgres", -		Database:        "postgres", -		ApplicationName: "gotosocial", -	} -	c.MediaConfig = &config.MediaConfig{ -		MaxImageSize: 2 << 20, -	} -	c.StorageConfig = &config.StorageConfig{ -		Backend:       "local", -		BasePath:      "/tmp", -		ServeProtocol: "http", -		ServeHost:     "localhost", -		ServeBasePath: "/fileserver/media", -	} -	suite.config = c - -	// use an actual database for this, because it's just easier than mocking one out -	database, err := db.New(context.Background(), c, log) -	if err != nil { -		suite.FailNow(err.Error()) -	} -	suite.db = database - -	// we need to mock the oauth server because account creation needs it to create a new token -	suite.mockOauthServer = &oauth.MockServer{} -	suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) { -		l := suite.log.WithField("func", "GenerateUserAccessToken") -		token := args.Get(0).(oauth2.TokenInfo) -		l.Infof("received token %+v", token) -		clientSecret := args.Get(1).(string) -		l.Infof("received clientSecret %+v", clientSecret) -		userID := args.Get(2).(string) -		l.Infof("received userID %+v", userID) -	}).Return(&models.Token{ -		Access: "we're authorized now!", -	}, nil) - -	suite.mockStorage = &storage.MockStorage{} -	// We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage -	suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil) - -	// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) -	suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) - -	suite.mastoConverter = mastotypes.New(suite.config, suite.db) - -	// and finally here's the thing we're actually testing! -	suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module) -} - -func (suite *AccountCreateTestSuite) TearDownSuite() { -	if err := suite.db.Stop(context.Background()); err != nil { -		logrus.Panicf("error closing db connection: %s", err) -	} -} - -// SetupTest creates a db connection and creates necessary tables before each test -func (suite *AccountCreateTestSuite) SetupTest() { -	// create all the tables we might need in thie suite -	models := []interface{}{ -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Follow{}, -		>smodel.FollowRequest{}, -		>smodel.Status{}, -		>smodel.Application{}, -		>smodel.EmailDomainBlock{}, -		>smodel.MediaAttachment{}, -	} -	for _, m := range models { -		if err := suite.db.CreateTable(m); err != nil { -			logrus.Panicf("db connection error: %s", err) -		} -	} - -	// form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test -	suite.newUserFormHappyPath = url.Values{ -		"reason":    []string{"a very good reason that's at least 40 characters i swear"}, -		"username":  []string{"test_user"}, -		"email":     []string{"user@example.org"}, -		"password":  []string{"very-strong-password"}, -		"agreement": []string{"true"}, -		"locale":    []string{"en"}, -	} - -	// same with accounts config -	suite.config.AccountsConfig = &config.AccountsConfig{ -		OpenRegistration: true, -		RequireApproval:  true, -		ReasonRequired:   true, -	} -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *AccountCreateTestSuite) TearDownTest() { - -	// remove all the tables we might have used so it's clear for the next test -	models := []interface{}{ -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Follow{}, -		>smodel.FollowRequest{}, -		>smodel.Status{}, -		>smodel.Application{}, -		>smodel.EmailDomainBlock{}, -		>smodel.MediaAttachment{}, -	} -	for _, m := range models { -		if err := suite.db.DropTable(m); err != nil { -			logrus.Panicf("error dropping table: %s", err) -		} -	} -} - -/* -	ACTUAL TESTS -*/ - -/* -	TESTING: AccountCreatePOSTHandler -*/ - -// TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid, -// and at the end of it a new user and account should be added into the database. -// -// This is the handler served at /api/v1/accounts as POST -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting -	ctx.Request.Form = suite.newUserFormHappyPath -	suite.accountModule.AccountCreatePOSTHandler(ctx) - -	// check response - -	// 1. we should have OK from our call to the function -	suite.EqualValues(http.StatusOK, recorder.Code) - -	// 2. we should have a token in the result body -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) -	t := &mastomodel.Token{} -	err = json.Unmarshal(b, t) -	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), "we're authorized now!", t.AccessToken) - -	// check new account - -	// 1. we should be able to get the new account from the db -	acct := >smodel.Account{} -	err = suite.db.GetWhere("username", "test_user", acct) -	assert.NoError(suite.T(), err) -	assert.NotNil(suite.T(), acct) -	// 2. reason should be set -	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason) -	// 3. display name should be equal to username by default -	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName) -	// 4. domain should be nil because this is a local account -	assert.Nil(suite.T(), nil, acct.Domain) -	// 5. id should be set and parseable as a uuid -	assert.NotNil(suite.T(), acct.ID) -	_, err = uuid.Parse(acct.ID) -	assert.Nil(suite.T(), err) -	// 6. private and public key should be set -	assert.NotNil(suite.T(), acct.PrivateKey) -	assert.NotNil(suite.T(), acct.PublicKey) - -	// check new user - -	// 1. we should be able to get the new user from the db -	usr := >smodel.User{} -	err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr) -	assert.Nil(suite.T(), err) -	assert.NotNil(suite.T(), usr) - -	// 2. user should have account id set to account we got above -	assert.Equal(suite.T(), acct.ID, usr.AccountID) - -	// 3. id should be set and parseable as a uuid -	assert.NotNil(suite.T(), usr.ID) -	_, err = uuid.Parse(usr.ID) -	assert.Nil(suite.T(), err) - -	// 4. locale should be equal to what we requested -	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale) - -	// 5. created by application id should be equal to the app id -	assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID) - -	// 6. password should be matcheable to what we set above -	err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password"))) -	assert.Nil(suite.T(), err) -} - -// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided: -// only registered applications can create accounts, and we don't provide one here. -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() { - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting -	ctx.Request.Form = suite.newUserFormHappyPath -	suite.accountModule.AccountCreatePOSTHandler(ctx) - -	// check response - -	// 1. we should have forbidden from our call to the function because we didn't auth -	suite.EqualValues(http.StatusForbidden, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all. -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() { - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting -	suite.accountModule.AccountCreatePOSTHandler(ctx) - -	// check response -	suite.EqualValues(http.StatusBadRequest, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting -	ctx.Request.Form = suite.newUserFormHappyPath -	// set a weak password -	ctx.Request.Form.Set("password", "weak") -	suite.accountModule.AccountCreatePOSTHandler(ctx) - -	// check response -	suite.EqualValues(http.StatusBadRequest, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting -	ctx.Request.Form = suite.newUserFormHappyPath -	// set an invalid locale -	ctx.Request.Form.Set("locale", "neverneverland") -	suite.accountModule.AccountCreatePOSTHandler(ctx) - -	// check response -	suite.EqualValues(http.StatusBadRequest, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() { - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting -	ctx.Request.Form = suite.newUserFormHappyPath - -	// close registrations -	suite.config.AccountsConfig.OpenRegistration = false -	suite.accountModule.AccountCreatePOSTHandler(ctx) - -	// check response -	suite.EqualValues(http.StatusBadRequest, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting -	ctx.Request.Form = suite.newUserFormHappyPath - -	// remove reason -	ctx.Request.Form.Set("reason", "") - -	suite.accountModule.AccountCreatePOSTHandler(ctx) - -	// check response -	suite.EqualValues(http.StatusBadRequest, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() { - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting -	ctx.Request.Form = suite.newUserFormHappyPath - -	// remove reason -	ctx.Request.Form.Set("reason", "just cuz") - -	suite.accountModule.AccountCreatePOSTHandler(ctx) - -	// check response -	suite.EqualValues(http.StatusBadRequest, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) -	assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b)) -} - -/* -	TESTING: AccountUpdateCredentialsPATCHHandler -*/ - -func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { - -	// put test local account in db -	err := suite.db.Put(suite.testAccountLocal) -	assert.NoError(suite.T(), err) - -	// attach avatar to request -	aviFile, err := os.Open("../../media/test/test-jpeg.jpg") -	assert.NoError(suite.T(), err) -	body := &bytes.Buffer{} -	writer := multipart.NewWriter(body) - -	part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") -	assert.NoError(suite.T(), err) - -	_, err = io.Copy(part, aviFile) -	assert.NoError(suite.T(), err) - -	err = aviFile.Close() -	assert.NoError(suite.T(), err) - -	err = writer.Close() -	assert.NoError(suite.T(), err) - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting -	ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) -	suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - -	// check response - -	// 1. we should have OK because our request was valid -	suite.EqualValues(http.StatusOK, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	// TODO: implement proper checks here -	// -	// b, err := ioutil.ReadAll(result.Body) -	// assert.NoError(suite.T(), err) -	// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) -} - -func TestAccountCreateTestSuite(t *testing.T) { -	suite.Run(t, new(AccountCreateTestSuite)) -} diff --git a/internal/apimodule/account/test/accountupdate_test.go b/internal/apimodule/account/test/accountupdate_test.go deleted file mode 100644 index 1c6f528a1..000000000 --- a/internal/apimodule/account/test/accountupdate_test.go +++ /dev/null @@ -1,303 +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 <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( -	"bytes" -	"context" -	"fmt" -	"io" -	"mime/multipart" -	"net/http" -	"net/http/httptest" -	"net/url" -	"os" -	"testing" -	"time" - -	"github.com/gin-gonic/gin" -	"github.com/google/uuid" -	"github.com/sirupsen/logrus" -	"github.com/stretchr/testify/assert" -	"github.com/stretchr/testify/mock" -	"github.com/stretchr/testify/suite" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/account" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" -	"github.com/superseriousbusiness/gotosocial/internal/media" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/storage" -	"github.com/superseriousbusiness/oauth2/v4" -	"github.com/superseriousbusiness/oauth2/v4/models" -	oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" -) - -type AccountUpdateTestSuite struct { -	suite.Suite -	config               *config.Config -	log                  *logrus.Logger -	testAccountLocal     *gtsmodel.Account -	testApplication      *gtsmodel.Application -	testToken            oauth2.TokenInfo -	mockOauthServer      *oauth.MockServer -	mockStorage          *storage.MockStorage -	mediaHandler         media.Handler -	mastoConverter       mastotypes.Converter -	db                   db.DB -	accountModule        *account.Module -	newUserFormHappyPath url.Values -} - -/* -	TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *AccountUpdateTestSuite) SetupSuite() { -	// some of our subsequent entities need a log so create this here -	log := logrus.New() -	log.SetLevel(logrus.TraceLevel) -	suite.log = log - -	suite.testAccountLocal = >smodel.Account{ -		ID:       uuid.NewString(), -		Username: "test_user", -	} - -	// can use this test application throughout -	suite.testApplication = >smodel.Application{ -		ID:           "weeweeeeeeeeeeeeee", -		Name:         "a test application", -		Website:      "https://some-application-website.com", -		RedirectURI:  "http://localhost:8080", -		ClientID:     "a-known-client-id", -		ClientSecret: "some-secret", -		Scopes:       "read", -		VapidKey:     "aaaaaa-aaaaaaaa-aaaaaaaaaaa", -	} - -	// can use this test token throughout -	suite.testToken = &oauthmodels.Token{ -		ClientID:      "a-known-client-id", -		RedirectURI:   "http://localhost:8080", -		Scope:         "read", -		Code:          "123456789", -		CodeCreateAt:  time.Now(), -		CodeExpiresIn: time.Duration(10 * time.Minute), -	} - -	// Direct config to local postgres instance -	c := config.Empty() -	c.Protocol = "http" -	c.Host = "localhost" -	c.DBConfig = &config.DBConfig{ -		Type:            "postgres", -		Address:         "localhost", -		Port:            5432, -		User:            "postgres", -		Password:        "postgres", -		Database:        "postgres", -		ApplicationName: "gotosocial", -	} -	c.MediaConfig = &config.MediaConfig{ -		MaxImageSize: 2 << 20, -	} -	c.StorageConfig = &config.StorageConfig{ -		Backend:       "local", -		BasePath:      "/tmp", -		ServeProtocol: "http", -		ServeHost:     "localhost", -		ServeBasePath: "/fileserver/media", -	} -	suite.config = c - -	// use an actual database for this, because it's just easier than mocking one out -	database, err := db.New(context.Background(), c, log) -	if err != nil { -		suite.FailNow(err.Error()) -	} -	suite.db = database - -	// we need to mock the oauth server because account creation needs it to create a new token -	suite.mockOauthServer = &oauth.MockServer{} -	suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) { -		l := suite.log.WithField("func", "GenerateUserAccessToken") -		token := args.Get(0).(oauth2.TokenInfo) -		l.Infof("received token %+v", token) -		clientSecret := args.Get(1).(string) -		l.Infof("received clientSecret %+v", clientSecret) -		userID := args.Get(2).(string) -		l.Infof("received userID %+v", userID) -	}).Return(&models.Token{ -		Code: "we're authorized now!", -	}, nil) - -	suite.mockStorage = &storage.MockStorage{} -	// We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage -	suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil) - -	// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) -	suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) - -	suite.mastoConverter = mastotypes.New(suite.config, suite.db) - -	// and finally here's the thing we're actually testing! -	suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module) -} - -func (suite *AccountUpdateTestSuite) TearDownSuite() { -	if err := suite.db.Stop(context.Background()); err != nil { -		logrus.Panicf("error closing db connection: %s", err) -	} -} - -// SetupTest creates a db connection and creates necessary tables before each test -func (suite *AccountUpdateTestSuite) SetupTest() { -	// create all the tables we might need in thie suite -	models := []interface{}{ -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Follow{}, -		>smodel.FollowRequest{}, -		>smodel.Status{}, -		>smodel.Application{}, -		>smodel.EmailDomainBlock{}, -		>smodel.MediaAttachment{}, -	} -	for _, m := range models { -		if err := suite.db.CreateTable(m); err != nil { -			logrus.Panicf("db connection error: %s", err) -		} -	} - -	// form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test -	suite.newUserFormHappyPath = url.Values{ -		"reason":    []string{"a very good reason that's at least 40 characters i swear"}, -		"username":  []string{"test_user"}, -		"email":     []string{"user@example.org"}, -		"password":  []string{"very-strong-password"}, -		"agreement": []string{"true"}, -		"locale":    []string{"en"}, -	} - -	// same with accounts config -	suite.config.AccountsConfig = &config.AccountsConfig{ -		OpenRegistration: true, -		RequireApproval:  true, -		ReasonRequired:   true, -	} -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *AccountUpdateTestSuite) TearDownTest() { - -	// remove all the tables we might have used so it's clear for the next test -	models := []interface{}{ -		>smodel.User{}, -		>smodel.Account{}, -		>smodel.Follow{}, -		>smodel.FollowRequest{}, -		>smodel.Status{}, -		>smodel.Application{}, -		>smodel.EmailDomainBlock{}, -		>smodel.MediaAttachment{}, -	} -	for _, m := range models { -		if err := suite.db.DropTable(m); err != nil { -			logrus.Panicf("error dropping table: %s", err) -		} -	} -} - -/* -	ACTUAL TESTS -*/ - -/* -	TESTING: AccountUpdateCredentialsPATCHHandler -*/ - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { - -	// put test local account in db -	err := suite.db.Put(suite.testAccountLocal) -	assert.NoError(suite.T(), err) - -	// attach avatar to request form -	avatarFile, err := os.Open("../../media/test/test-jpeg.jpg") -	assert.NoError(suite.T(), err) -	body := &bytes.Buffer{} -	writer := multipart.NewWriter(body) - -	avatarPart, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") -	assert.NoError(suite.T(), err) - -	_, err = io.Copy(avatarPart, avatarFile) -	assert.NoError(suite.T(), err) - -	err = avatarFile.Close() -	assert.NoError(suite.T(), err) - -	// set display name to a new value -	displayNamePart, err := writer.CreateFormField("display_name") -	assert.NoError(suite.T(), err) - -	_, err = io.Copy(displayNamePart, bytes.NewBufferString("test_user_wohoah")) -	assert.NoError(suite.T(), err) - -	// set locked to true -	lockedPart, err := writer.CreateFormField("locked") -	assert.NoError(suite.T(), err) - -	_, err = io.Copy(lockedPart, bytes.NewBufferString("true")) -	assert.NoError(suite.T(), err) - -	// close the request writer, the form is now prepared -	err = writer.Close() -	assert.NoError(suite.T(), err) - -	// setup -	recorder := httptest.NewRecorder() -	ctx, _ := gin.CreateTestContext(recorder) -	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) -	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) -	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting -	ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) -	suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - -	// check response - -	// 1. we should have OK because our request was valid -	suite.EqualValues(http.StatusOK, recorder.Code) - -	// 2. we should have an error message in the result body -	result := recorder.Result() -	defer result.Body.Close() -	// TODO: implement proper checks here -	// -	// b, err := ioutil.ReadAll(result.Body) -	// assert.NoError(suite.T(), err) -	// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) -} - -func TestAccountUpdateTestSuite(t *testing.T) { -	suite.Run(t, new(AccountUpdateTestSuite)) -} diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go deleted file mode 100644 index 99b79d470..000000000 --- a/internal/apimodule/app/appcreate.go +++ /dev/null @@ -1,119 +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 <http://www.gnu.org/licenses/>. -*/ - -package app - -import ( -	"fmt" -	"net/http" - -	"github.com/gin-gonic/gin" -	"github.com/google/uuid" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AppsPOSTHandler should be served at https://example.org/api/v1/apps -// It is equivalent to: https://docs.joinmastodon.org/methods/apps/ -func (m *Module) AppsPOSTHandler(c *gin.Context) { -	l := m.log.WithField("func", "AppsPOSTHandler") -	l.Trace("entering AppsPOSTHandler") - -	form := &mastotypes.ApplicationPOSTRequest{} -	if err := c.ShouldBind(form); err != nil { -		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) -		return -	} - -	// permitted length for most fields -	permittedLength := 64 -	// redirect can be a bit bigger because we probably need to encode data in the redirect uri -	permittedRedirect := 256 - -	// check lengths of fields before proceeding so the user can't spam huge entries into the database -	if len(form.ClientName) > permittedLength { -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", permittedLength)}) -		return -	} -	if len(form.Website) > permittedLength { -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", permittedLength)}) -		return -	} -	if len(form.RedirectURIs) > permittedRedirect { -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", permittedRedirect)}) -		return -	} -	if len(form.Scopes) > permittedLength { -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", permittedLength)}) -		return -	} - -	// set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/ -	var scopes string -	if form.Scopes == "" { -		scopes = "read" -	} else { -		scopes = form.Scopes -	} - -	// generate new IDs for this application and its associated client -	clientID := uuid.NewString() -	clientSecret := uuid.NewString() -	vapidKey := uuid.NewString() - -	// generate the application to put in the database -	app := >smodel.Application{ -		Name:         form.ClientName, -		Website:      form.Website, -		RedirectURI:  form.RedirectURIs, -		ClientID:     clientID, -		ClientSecret: clientSecret, -		Scopes:       scopes, -		VapidKey:     vapidKey, -	} - -	// chuck it in the db -	if err := m.db.Put(app); err != nil { -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	// now we need to model an oauth client from the application that the oauth library can use -	oc := &oauth.Client{ -		ID:     clientID, -		Secret: clientSecret, -		Domain: form.RedirectURIs, -		UserID: "", // This client isn't yet associated with a specific user,  it's just an app client right now -	} - -	// chuck it in the db -	if err := m.db.Put(oc); err != nil { -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	mastoApp, err := m.mastoConverter.AppToMastoSensitive(app) -	if err != nil { -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/ -	c.JSON(http.StatusOK, mastoApp) -} diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go deleted file mode 100644 index 0421c5095..000000000 --- a/internal/apimodule/fileserver/servefile.go +++ /dev/null @@ -1,243 +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 <http://www.gnu.org/licenses/>. -*/ - -package fileserver - -import ( -	"bytes" -	"net/http" -	"strings" - -	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/media" -) - -// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. -// -// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". -// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. -func (m *FileServer) ServeFile(c *gin.Context) { -	l := m.log.WithFields(logrus.Fields{ -		"func":        "ServeFile", -		"request_uri": c.Request.RequestURI, -		"user_agent":  c.Request.UserAgent(), -		"origin_ip":   c.ClientIP(), -	}) -	l.Trace("received request") - -	// We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: -	// "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" -	// "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. -	accountID := c.Param(AccountIDKey) -	if accountID == "" { -		l.Debug("missing accountID from request") -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	mediaType := c.Param(MediaTypeKey) -	if mediaType == "" { -		l.Debug("missing mediaType from request") -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	mediaSize := c.Param(MediaSizeKey) -	if mediaSize == "" { -		l.Debug("missing mediaSize from request") -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	fileName := c.Param(FileNameKey) -	if fileName == "" { -		l.Debug("missing fileName from request") -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// Only serve media types that are defined in our internal media module -	switch mediaType { -	case media.MediaHeader, media.MediaAvatar, media.MediaAttachment: -		m.serveAttachment(c, accountID, mediaType, mediaSize, fileName) -		return -	case media.MediaEmoji: -		m.serveEmoji(c, accountID, mediaType, mediaSize, fileName) -		return -	} -	l.Debugf("mediatype %s not recognized", mediaType) -	c.String(http.StatusNotFound, "404 page not found") -} - -func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { -	l := m.log.WithFields(logrus.Fields{ -		"func":        "serveAttachment", -		"request_uri": c.Request.RequestURI, -		"user_agent":  c.Request.UserAgent(), -		"origin_ip":   c.ClientIP(), -	}) - -	// This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static -	switch mediaSize { -	case media.MediaOriginal, media.MediaSmall, media.MediaStatic: -	default: -		l.Debugf("mediasize %s not recognized", mediaSize) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// derive the media id and the file extension from the last part of the request -	spl := strings.Split(fileName, ".") -	if len(spl) != 2 { -		l.Debugf("filename %s not parseable", fileName) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} -	wantedMediaID := spl[0] -	fileExtension := spl[1] -	if wantedMediaID == "" || fileExtension == "" { -		l.Debugf("filename %s not parseable", fileName) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db -	attachment := >smodel.MediaAttachment{} -	if err := m.db.GetByID(wantedMediaID, attachment); err != nil { -		l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// make sure the given account id owns the requested attachment -	if accountID != attachment.AccountID { -		l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment -	var storagePath string -	var contentType string -	var contentLength int -	switch mediaSize { -	case media.MediaOriginal: -		storagePath = attachment.File.Path -		contentType = attachment.File.ContentType -		contentLength = attachment.File.FileSize -	case media.MediaSmall: -		storagePath = attachment.Thumbnail.Path -		contentType = attachment.Thumbnail.ContentType -		contentLength = attachment.Thumbnail.FileSize -	} - -	// use the path listed on the attachment we pulled out of the database to retrieve the object from storage -	attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath) -	if err != nil { -		l.Debugf("error retrieving from storage: %s", err) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes))) - -	// finally we can return with all the information we derived above -	c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{}) -} - -func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { -	l := m.log.WithFields(logrus.Fields{ -		"func":        "serveEmoji", -		"request_uri": c.Request.RequestURI, -		"user_agent":  c.Request.UserAgent(), -		"origin_ip":   c.ClientIP(), -	}) - -	// This corresponds to original-sized emoji as it was uploaded, or static -	switch mediaSize { -	case media.MediaOriginal, media.MediaStatic: -	default: -		l.Debugf("mediasize %s not recognized", mediaSize) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// derive the media id and the file extension from the last part of the request -	spl := strings.Split(fileName, ".") -	if len(spl) != 2 { -		l.Debugf("filename %s not parseable", fileName) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} -	wantedEmojiID := spl[0] -	fileExtension := spl[1] -	if wantedEmojiID == "" || fileExtension == "" { -		l.Debugf("filename %s not parseable", fileName) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db -	emoji := >smodel.Emoji{} -	if err := m.db.GetByID(wantedEmojiID, emoji); err != nil { -		l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// make sure the instance account id owns the requested emoji -	instanceAccount := >smodel.Account{} -	if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil { -		l.Debugf("error fetching instance account: %s", err) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} -	if accountID != instanceAccount.ID { -		l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment -	var storagePath string -	var contentType string -	var contentLength int -	switch mediaSize { -	case media.MediaOriginal: -		storagePath = emoji.ImagePath -		contentType = emoji.ImageContentType -		contentLength = emoji.ImageFileSize -	case media.MediaStatic: -		storagePath = emoji.ImageStaticPath -		contentType = "image/png" -		contentLength = emoji.ImageStaticFileSize -	} - -	// use the path listed on the emoji we pulled out of the database to retrieve the object from storage -	emojiBytes, err := m.storage.RetrieveFileFrom(storagePath) -	if err != nil { -		l.Debugf("error retrieving emoji from storage: %s", err) -		c.String(http.StatusNotFound, "404 page not found") -		return -	} - -	// finally we can return with all the information we derived above -	c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{}) -} diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go deleted file mode 100644 index ee713a471..000000000 --- a/internal/apimodule/media/mediacreate.go +++ /dev/null @@ -1,193 +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 <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( -	"bytes" -	"errors" -	"fmt" -	"io" -	"net/http" -	"strconv" -	"strings" - -	"github.com/gin-gonic/gin" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// MediaCreatePOSTHandler handles requests to create/upload media attachments -func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { -	l := m.log.WithField("func", "statusCreatePOSTHandler") -	authed, err := oauth.MustAuth(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()}) -		return -	} - -	// First check this user/account is permitted to create media -	// There's no point continuing otherwise. -	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 -	} - -	// extract the media create form from the request context -	l.Tracef("parsing request form: %s", c.Request.Form) -	form := &mastotypes.AttachmentRequest{} -	if err := c.ShouldBind(form); err != nil || form == nil { -		l.Debugf("could not parse form from request: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) -		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 { -		l.Debugf("error validating form: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	// open the attachment and extract the bytes from it -	f, err := form.File.Open() -	if err != nil { -		l.Debugf("error opening attachment: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)}) -		return -	} -	buf := new(bytes.Buffer) -	size, err := io.Copy(buf, f) -	if err != nil { -		l.Debugf("error reading attachment: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)}) -		return -	} -	if size == 0 { -		l.Debug("could not read provided attachment: size 0 bytes") -		c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"}) -		return -	} - -	// allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using -	attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID) -	if err != nil { -		l.Debugf("error reading attachment: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)}) -		return -	} - -	// now we need to add extra fields that the attachment processor doesn't know (from the form) -	// TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it) - -	// first description -	attachment.Description = form.Description - -	// now parse the focus parameter -	// TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated -	var focusx, focusy float32 -	if form.Focus != "" { -		spl := strings.Split(form.Focus, ",") -		if len(spl) != 2 { -			l.Debugf("improperly formatted focus %s", form.Focus) -			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) -			return -		} -		xStr := spl[0] -		yStr := spl[1] -		if xStr == "" || yStr == "" { -			l.Debugf("improperly formatted focus %s", form.Focus) -			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) -			return -		} -		fx, err := strconv.ParseFloat(xStr, 32) -		if err != nil { -			l.Debugf("improperly formatted focus %s: %s", form.Focus, err) -			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) -			return -		} -		if fx > 1 || fx < -1 { -			l.Debugf("improperly formatted focus %s", form.Focus) -			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) -			return -		} -		focusx = float32(fx) -		fy, err := strconv.ParseFloat(yStr, 32) -		if err != nil { -			l.Debugf("improperly formatted focus %s: %s", form.Focus, err) -			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) -			return -		} -		if fy > 1 || fy < -1 { -			l.Debugf("improperly formatted focus %s", form.Focus) -			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) -			return -		} -		focusy = float32(fy) -	} -	attachment.FileMeta.Focus.X = focusx -	attachment.FileMeta.Focus.Y = focusy - -	// prepare the frontend representation now -- if there are any errors here at least we can bail without -	// having already put something in the database and then having to clean it up again (eugh) -	mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment) -	if err != nil { -		l.Debugf("error parsing media attachment to frontend type: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)}) -		return -	} - -	// now we can confidently put the attachment in the database -	if err := m.db.Put(attachment); err != nil { -		l.Debugf("error storing media attachment in db: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)}) -		return -	} - -	// and return its frontend representation -	c.JSON(http.StatusAccepted, mastoAttachment) -} - -func validateCreateMedia(form *mastotypes.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 { -		return errors.New("no attachment given") -	} - -	// a very superficial check to see if no size limits are exceeded -	// we still don't actually know which media types we're dealing with but the other handlers will go into more detail there -	maxSize := config.MaxVideoSize -	if config.MaxImageSize > maxSize { -		maxSize = config.MaxImageSize -	} -	if form.File.Size > int64(maxSize) { -		return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) -	} - -	if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars { -		return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description)) -	} - -	// TODO: validate focus here - -	return nil -} diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go deleted file mode 100644 index 2d4293d0e..000000000 --- a/internal/apimodule/mock_ClientAPIModule.go +++ /dev/null @@ -1,43 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package apimodule - -import ( -	mock "github.com/stretchr/testify/mock" -	db "github.com/superseriousbusiness/gotosocial/internal/db" - -	router "github.com/superseriousbusiness/gotosocial/internal/router" -) - -// MockClientAPIModule is an autogenerated mock type for the ClientAPIModule type -type MockClientAPIModule struct { -	mock.Mock -} - -// CreateTables provides a mock function with given fields: _a0 -func (_m *MockClientAPIModule) CreateTables(_a0 db.DB) error { -	ret := _m.Called(_a0) - -	var r0 error -	if rf, ok := ret.Get(0).(func(db.DB) error); ok { -		r0 = rf(_a0) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// Route provides a mock function with given fields: s -func (_m *MockClientAPIModule) Route(s router.Router) error { -	ret := _m.Called(s) - -	var r0 error -	if rf, ok := ret.Get(0).(func(router.Router) error); ok { -		r0 = rf(s) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go deleted file mode 100644 index 97354e767..000000000 --- a/internal/apimodule/status/statuscreate.go +++ /dev/null @@ -1,462 +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 <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( -	"errors" -	"fmt" -	"net/http" -	"time" - -	"github.com/gin-gonic/gin" -	"github.com/google/uuid" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -	"github.com/superseriousbusiness/gotosocial/internal/util" -) - -type advancedStatusCreateForm struct { -	mastotypes.StatusCreateRequest -	advancedVisibilityFlagsForm -} - -type advancedVisibilityFlagsForm struct { -	// The gotosocial visibility model -	VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"` -	// This status will be federated beyond the local timeline(s) -	Federated *bool `form:"federated"` -	// This status can be boosted/reblogged -	Boostable *bool `form:"boostable"` -	// This status can be replied to -	Replyable *bool `form:"replyable"` -	// This status can be liked/faved -	Likeable *bool `form:"likeable"` -} - -// StatusCreatePOSTHandler deals with the creation of new statuses -func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { -	l := m.log.WithField("func", "statusCreatePOSTHandler") -	authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status 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()}) -		return -	} - -	// First check this user/account is permitted to post new statuses. -	// There's no point continuing otherwise. -	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 -	} - -	// extract the status create form from the request context -	l.Tracef("parsing request form: %s", c.Request.Form) -	form := &advancedStatusCreateForm{} -	if err := c.ShouldBind(form); err != nil || form == nil { -		l.Debugf("could not parse form from request: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) -		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 := validateCreateStatus(form, m.config.StatusesConfig); err != nil { -		l.Debugf("error validating form: %s", err) -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	// At this point we know the account is permitted to post, and we know the request form -	// is valid (at least according to the API specifications and the instance configuration). -	// So now we can start digging a bit deeper into the form and building up the new status from it. - -	// first we create a new status and add some basic info to it -	uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host) -	thisStatusID := uuid.NewString() -	thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) -	thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) -	newStatus := >smodel.Status{ -		ID:                       thisStatusID, -		URI:                      thisStatusURI, -		URL:                      thisStatusURL, -		Content:                  util.HTMLFormat(form.Status), -		CreatedAt:                time.Now(), -		UpdatedAt:                time.Now(), -		Local:                    true, -		AccountID:                authed.Account.ID, -		ContentWarning:           form.SpoilerText, -		ActivityStreamsType:      gtsmodel.ActivityStreamsNote, -		Sensitive:                form.Sensitive, -		Language:                 form.Language, -		CreatedWithApplicationID: authed.Application.ID, -		Text:                     form.Status, -	} - -	// check if replyToID is ok -	if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil { -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	// check if mediaIDs are ok -	if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil { -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	// check if visibility settings are ok -	if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil { -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	// handle language settings -	if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil { -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	// handle mentions -	if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil { -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil { -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil { -		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -		return -	} - -	/* -		FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it -	*/ - -	// put the new status in the database, generating an ID for it in the process -	if err := m.db.Put(newStatus); err != nil { -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	// change the status ID of the media attachments to the new status -	for _, a := range newStatus.GTSMediaAttachments { -		a.StatusID = newStatus.ID -		a.UpdatedAt = time.Now() -		if err := m.db.UpdateByID(a.ID, a); err != nil { -			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -			return -		} -	} - -	// pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc -	m.distributor.FromClientAPI() <- distributor.FromClientAPI{ -		APObjectType:   gtsmodel.ActivityStreamsNote, -		APActivityType: gtsmodel.ActivityStreamsCreate, -		Activity:       newStatus, -	} - -	// return the frontend representation of the new status to the submitter -	mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil) -	if err != nil { -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} -	c.JSON(http.StatusOK, mastoStatus) -} - -func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error { -	// validate that, structurally, we have a valid status/post -	if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { -		return errors.New("no status, media, or poll provided") -	} - -	if form.MediaIDs != nil && form.Poll != nil { -		return errors.New("can't post media + poll in same status") -	} - -	// validate status -	if form.Status != "" { -		if len(form.Status) > config.MaxChars { -			return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars) -		} -	} - -	// validate media attachments -	if len(form.MediaIDs) > config.MaxMediaFiles { -		return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles) -	} - -	// validate poll -	if form.Poll != nil { -		if form.Poll.Options == nil { -			return errors.New("poll with no options") -		} -		if len(form.Poll.Options) > config.PollMaxOptions { -			return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions) -		} -		for _, p := range form.Poll.Options { -			if len(p) > config.PollOptionMaxChars { -				return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars) -			} -		} -	} - -	// validate spoiler text/cw -	if form.SpoilerText != "" { -		if len(form.SpoilerText) > config.CWMaxChars { -			return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars) -		} -	} - -	// validate post language -	if form.Language != "" { -		if err := util.ValidateLanguage(form.Language); err != nil { -			return err -		} -	} - -	return nil -} - -func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { -	// by default all flags are set to true -	gtsAdvancedVis := >smodel.VisibilityAdvanced{ -		Federated: true, -		Boostable: true, -		Replyable: true, -		Likeable:  true, -	} - -	var gtsBasicVis gtsmodel.Visibility -	// Advanced takes priority if it's set. -	// If it's not set, take whatever masto visibility is set. -	// If *that's* not set either, then just take the account default. -	// If that's also not set, take the default for the whole instance. -	if form.VisibilityAdvanced != nil { -		gtsBasicVis = *form.VisibilityAdvanced -	} else if form.Visibility != "" { -		gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility) -	} else if accountDefaultVis != "" { -		gtsBasicVis = accountDefaultVis -	} else { -		gtsBasicVis = gtsmodel.VisibilityDefault -	} - -	switch gtsBasicVis { -	case gtsmodel.VisibilityPublic: -		// for public, there's no need to change any of the advanced flags from true regardless of what the user filled out -		break -	case gtsmodel.VisibilityUnlocked: -		// for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them -		if form.Federated != nil { -			gtsAdvancedVis.Federated = *form.Federated -		} - -		if form.Boostable != nil { -			gtsAdvancedVis.Boostable = *form.Boostable -		} - -		if form.Replyable != nil { -			gtsAdvancedVis.Replyable = *form.Replyable -		} - -		if form.Likeable != nil { -			gtsAdvancedVis.Likeable = *form.Likeable -		} - -	case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: -		// for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them -		gtsAdvancedVis.Boostable = false - -		if form.Federated != nil { -			gtsAdvancedVis.Federated = *form.Federated -		} - -		if form.Replyable != nil { -			gtsAdvancedVis.Replyable = *form.Replyable -		} - -		if form.Likeable != nil { -			gtsAdvancedVis.Likeable = *form.Likeable -		} - -	case gtsmodel.VisibilityDirect: -		// direct is pretty easy: there's only one possible setting so return it -		gtsAdvancedVis.Federated = true -		gtsAdvancedVis.Boostable = false -		gtsAdvancedVis.Federated = true -		gtsAdvancedVis.Likeable = true -	} - -	status.Visibility = gtsBasicVis -	status.VisibilityAdvanced = gtsAdvancedVis -	return nil -} - -func (m *Module) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { -	if form.InReplyToID == "" { -		return nil -	} - -	// If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted: -	// -	// 1. Does the replied status exist in the database? -	// 2. Is the replied status marked as replyable? -	// 3. Does a block exist between either the current account or the account that posted the status it's replying to? -	// -	// If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. -	repliedStatus := >smodel.Status{} -	repliedAccount := >smodel.Account{} -	// check replied status exists + is replyable -	if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil { -		if _, ok := err.(db.ErrNoEntries); ok { -			return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) -		} -		return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) -	} - -	if !repliedStatus.VisibilityAdvanced.Replyable { -		return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) -	} - -	// check replied account is known to us -	if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { -		if _, ok := err.(db.ErrNoEntries); ok { -			return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) -		} -		return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) -	} -	// check if a block exists -	if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { -		if _, ok := err.(db.ErrNoEntries); !ok { -			return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) -		} -	} else if blocked { -		return fmt.Errorf("status with id %s not replyable", form.InReplyToID) -	} -	status.InReplyToID = repliedStatus.ID -	status.InReplyToAccountID = repliedAccount.ID - -	return nil -} - -func (m *Module) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { -	if form.MediaIDs == nil { -		return nil -	} - -	gtsMediaAttachments := []*gtsmodel.MediaAttachment{} -	attachments := []string{} -	for _, mediaID := range form.MediaIDs { -		// check these attachments exist -		a := >smodel.MediaAttachment{} -		if err := m.db.GetByID(mediaID, a); err != nil { -			return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) -		} -		// check they belong to the requesting account id -		if a.AccountID != thisAccountID { -			return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) -		} -		// check they're not already used in a status -		if a.StatusID != "" || a.ScheduledStatusID != "" { -			return fmt.Errorf("media with id %s is already attached to a status", mediaID) -		} -		gtsMediaAttachments = append(gtsMediaAttachments, a) -		attachments = append(attachments, a.ID) -	} -	status.GTSMediaAttachments = gtsMediaAttachments -	status.Attachments = attachments -	return nil -} - -func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { -	if form.Language != "" { -		status.Language = form.Language -	} else { -		status.Language = accountDefaultLanguage -	} -	if status.Language == "" { -		return errors.New("no language given either in status create form or account default") -	} -	return nil -} - -func (m *Module) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { -	menchies := []string{} -	gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID) -	if err != nil { -		return fmt.Errorf("error generating mentions from status: %s", err) -	} -	for _, menchie := range gtsMenchies { -		if err := m.db.Put(menchie); err != nil { -			return fmt.Errorf("error putting mentions in db: %s", err) -		} -		menchies = append(menchies, menchie.TargetAccountID) -	} -	// add full populated gts menchies to the status for passing them around conveniently -	status.GTSMentions = gtsMenchies -	// add just the ids of the mentioned accounts to the status for putting in the db -	status.Mentions = menchies -	return nil -} - -func (m *Module) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { -	tags := []string{} -	gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID) -	if err != nil { -		return fmt.Errorf("error generating hashtags from status: %s", err) -	} -	for _, tag := range gtsTags { -		if err := m.db.Upsert(tag, "name"); err != nil { -			return fmt.Errorf("error putting tags in db: %s", err) -		} -		tags = append(tags, tag.ID) -	} -	// add full populated gts tags to the status for passing them around conveniently -	status.GTSTags = gtsTags -	// add just the ids of the used tags to the status for putting in the db -	status.Tags = tags -	return nil -} - -func (m *Module) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { -	emojis := []string{} -	gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID) -	if err != nil { -		return fmt.Errorf("error generating emojis from status: %s", err) -	} -	for _, e := range gtsEmojis { -		emojis = append(emojis, e.ID) -	} -	// add full populated gts emojis to the status for passing them around conveniently -	status.GTSEmojis = gtsEmojis -	// add just the ids of the used emojis to the status for putting in the db -	status.Emojis = emojis -	return nil -} diff --git a/internal/apimodule/status/statusdelete.go b/internal/apimodule/status/statusdelete.go deleted file mode 100644 index 01dfe81df..000000000 --- a/internal/apimodule/status/statusdelete.go +++ /dev/null @@ -1,107 +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 <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( -	"fmt" -	"net/http" - -	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusDELETEHandler verifies and handles deletion of a status -func (m *Module) StatusDELETEHandler(c *gin.Context) { -	l := m.log.WithFields(logrus.Fields{ -		"func":        "StatusDELETEHandler", -		"request_uri": c.Request.RequestURI, -		"user_agent":  c.Request.UserAgent(), -		"origin_ip":   c.ClientIP(), -	}) -	l.Debugf("entering function") - -	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else -	if err != nil { -		l.Debug("not authed so can't delete status") -		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) -		return -	} - -	targetStatusID := c.Param(IDKey) -	if targetStatusID == "" { -		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) -		return -	} - -	l.Tracef("going to search for target status %s", targetStatusID) -	targetStatus := >smodel.Status{} -	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { -		l.Errorf("error fetching status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	if targetStatus.AccountID != authed.Account.ID { -		l.Debug("status doesn't belong to requesting account") -		c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"}) -		return -	} - -	l.Trace("going to get relevant accounts") -	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) -	if err != nil { -		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	var boostOfStatus *gtsmodel.Status -	if targetStatus.BoostOfID != "" { -		boostOfStatus = >smodel.Status{} -		if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { -			l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) -			c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -			return -		} -	} - -	mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) -	if err != nil { -		l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { -		l.Errorf("error deleting status from the database: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	m.distributor.FromClientAPI() <- distributor.FromClientAPI{ -		APObjectType:   gtsmodel.ActivityStreamsNote, -		APActivityType: gtsmodel.ActivityStreamsDelete, -		Activity:       targetStatus, -	} - -	c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusfave.go b/internal/apimodule/status/statusfave.go deleted file mode 100644 index 9ce68af09..000000000 --- a/internal/apimodule/status/statusfave.go +++ /dev/null @@ -1,137 +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 <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( -	"fmt" -	"net/http" - -	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusFavePOSTHandler handles fave requests against a given status ID -func (m *Module) StatusFavePOSTHandler(c *gin.Context) { -	l := m.log.WithFields(logrus.Fields{ -		"func":        "StatusFavePOSTHandler", -		"request_uri": c.Request.RequestURI, -		"user_agent":  c.Request.UserAgent(), -		"origin_ip":   c.ClientIP(), -	}) -	l.Debugf("entering function") - -	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else -	if err != nil { -		l.Debug("not authed so can't fave status") -		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) -		return -	} - -	targetStatusID := c.Param(IDKey) -	if targetStatusID == "" { -		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) -		return -	} - -	l.Tracef("going to search for target status %s", targetStatusID) -	targetStatus := >smodel.Status{} -	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { -		l.Errorf("error fetching status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Tracef("going to search for target account %s", targetStatus.AccountID) -	targetAccount := >smodel.Account{} -	if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { -		l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Trace("going to get relevant accounts") -	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) -	if err != nil { -		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Trace("going to see if status is visible") -	visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that -	if err != nil { -		l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	if !visible { -		l.Trace("status is not visible") -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	// is the status faveable? -	if !targetStatus.VisibilityAdvanced.Likeable { -		l.Debug("status is not faveable") -		c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)}) -		return -	} - -	// it's visible! it's faveable! so let's fave the FUCK out of it -	fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID) -	if err != nil { -		l.Debugf("error faveing status: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	var boostOfStatus *gtsmodel.Status -	if targetStatus.BoostOfID != "" { -		boostOfStatus = >smodel.Status{} -		if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { -			l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) -			c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -			return -		} -	} - -	mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) -	if err != nil { -		l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	// if the targeted status was already faved, faved will be nil -	// only put the fave in the distributor if something actually changed -	if fave != nil { -		fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor -		m.distributor.FromClientAPI() <- distributor.FromClientAPI{ -			APObjectType:   gtsmodel.ActivityStreamsNote, // status is a note -			APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note -			Activity:       fave,                         // pass the fave along for processing -		} -	} - -	c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusfavedby.go b/internal/apimodule/status/statusfavedby.go deleted file mode 100644 index 58236edc2..000000000 --- a/internal/apimodule/status/statusfavedby.go +++ /dev/null @@ -1,129 +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 <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( -	"fmt" -	"net/http" - -	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status -func (m *Module) StatusFavedByGETHandler(c *gin.Context) { -	l := m.log.WithFields(logrus.Fields{ -		"func":        "statusGETHandler", -		"request_uri": c.Request.RequestURI, -		"user_agent":  c.Request.UserAgent(), -		"origin_ip":   c.ClientIP(), -	}) -	l.Debugf("entering function") - -	var requestingAccount *gtsmodel.Account -	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else -	if err != nil { -		l.Debug("not authed but will continue to serve anyway if public status") -		requestingAccount = nil -	} else { -		requestingAccount = authed.Account -	} - -	targetStatusID := c.Param(IDKey) -	if targetStatusID == "" { -		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) -		return -	} - -	l.Tracef("going to search for target status %s", targetStatusID) -	targetStatus := >smodel.Status{} -	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { -		l.Errorf("error fetching status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Tracef("going to search for target account %s", targetStatus.AccountID) -	targetAccount := >smodel.Account{} -	if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { -		l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Trace("going to get relevant accounts") -	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) -	if err != nil { -		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Trace("going to see if status is visible") -	visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that -	if err != nil { -		l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	if !visible { -		l.Trace("status is not visible") -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	// get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff -	favingAccounts, err := m.db.WhoFavedStatus(targetStatus) -	if err != nil { -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	// filter the list so the user doesn't see accounts they blocked or which blocked them -	filteredAccounts := []*gtsmodel.Account{} -	for _, acc := range favingAccounts { -		blocked, err := m.db.Blocked(authed.Account.ID, acc.ID) -		if err != nil { -			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -			return -		} -		if !blocked { -			filteredAccounts = append(filteredAccounts, acc) -		} -	} - -	// TODO: filter other things here? suspended? muted? silenced? - -	// now we can return the masto representation of those accounts -	mastoAccounts := []*mastotypes.Account{} -	for _, acc := range filteredAccounts { -		mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc) -		if err != nil { -			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -			return -		} -		mastoAccounts = append(mastoAccounts, mastoAccount) -	} - -	c.JSON(http.StatusOK, mastoAccounts) -} diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go deleted file mode 100644 index 76918c782..000000000 --- a/internal/apimodule/status/statusget.go +++ /dev/null @@ -1,112 +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 <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( -	"fmt" -	"net/http" - -	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusGETHandler is for handling requests to just get one status based on its ID -func (m *Module) StatusGETHandler(c *gin.Context) { -	l := m.log.WithFields(logrus.Fields{ -		"func":        "statusGETHandler", -		"request_uri": c.Request.RequestURI, -		"user_agent":  c.Request.UserAgent(), -		"origin_ip":   c.ClientIP(), -	}) -	l.Debugf("entering function") - -	var requestingAccount *gtsmodel.Account -	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else -	if err != nil { -		l.Debug("not authed but will continue to serve anyway if public status") -		requestingAccount = nil -	} else { -		requestingAccount = authed.Account -	} - -	targetStatusID := c.Param(IDKey) -	if targetStatusID == "" { -		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) -		return -	} - -	l.Tracef("going to search for target status %s", targetStatusID) -	targetStatus := >smodel.Status{} -	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { -		l.Errorf("error fetching status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Tracef("going to search for target account %s", targetStatus.AccountID) -	targetAccount := >smodel.Account{} -	if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { -		l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Trace("going to get relevant accounts") -	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) -	if err != nil { -		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Trace("going to see if status is visible") -	visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that -	if err != nil { -		l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	if !visible { -		l.Trace("status is not visible") -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	var boostOfStatus *gtsmodel.Status -	if targetStatus.BoostOfID != "" { -		boostOfStatus = >smodel.Status{} -		if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { -			l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) -			c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -			return -		} -	} - -	mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) -	if err != nil { -		l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusunfave.go b/internal/apimodule/status/statusunfave.go deleted file mode 100644 index 9c06eaf92..000000000 --- a/internal/apimodule/status/statusunfave.go +++ /dev/null @@ -1,137 +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 <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( -	"fmt" -	"net/http" - -	"github.com/gin-gonic/gin" -	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/distributor" -	"github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID -func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) { -	l := m.log.WithFields(logrus.Fields{ -		"func":        "StatusUnfavePOSTHandler", -		"request_uri": c.Request.RequestURI, -		"user_agent":  c.Request.UserAgent(), -		"origin_ip":   c.ClientIP(), -	}) -	l.Debugf("entering function") - -	authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else -	if err != nil { -		l.Debug("not authed so can't unfave status") -		c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) -		return -	} - -	targetStatusID := c.Param(IDKey) -	if targetStatusID == "" { -		c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) -		return -	} - -	l.Tracef("going to search for target status %s", targetStatusID) -	targetStatus := >smodel.Status{} -	if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { -		l.Errorf("error fetching status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Tracef("going to search for target account %s", targetStatus.AccountID) -	targetAccount := >smodel.Account{} -	if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { -		l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Trace("going to get relevant accounts") -	relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) -	if err != nil { -		l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	l.Trace("going to see if status is visible") -	visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that -	if err != nil { -		l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	if !visible { -		l.Trace("status is not visible") -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	// is the status faveable? -	if !targetStatus.VisibilityAdvanced.Likeable { -		l.Debug("status is not faveable") -		c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)}) -		return -	} - -	// it's visible! it's faveable! so let's unfave the FUCK out of it -	fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID) -	if err != nil { -		l.Debugf("error unfaveing status: %s", err) -		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) -		return -	} - -	var boostOfStatus *gtsmodel.Status -	if targetStatus.BoostOfID != "" { -		boostOfStatus = >smodel.Status{} -		if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { -			l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) -			c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -			return -		} -	} - -	mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) -	if err != nil { -		l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) -		c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) -		return -	} - -	// fave might be nil if this status wasn't faved in the first place -	// we only want to pass the message to the distributor if something actually changed -	if fave != nil { -		fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor -		m.distributor.FromClientAPI() <- distributor.FromClientAPI{ -			APObjectType:   gtsmodel.ActivityStreamsNote, // status is a note -			APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave -			Activity:       fave,                         // pass the undone fave along -		} -	} - -	c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/cache/mock_Cache.go b/internal/cache/mock_Cache.go deleted file mode 100644 index d8d18d68a..000000000 --- a/internal/cache/mock_Cache.go +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package cache - -import mock "github.com/stretchr/testify/mock" - -// MockCache is an autogenerated mock type for the Cache type -type MockCache struct { -	mock.Mock -} - -// Fetch provides a mock function with given fields: k -func (_m *MockCache) Fetch(k string) (interface{}, error) { -	ret := _m.Called(k) - -	var r0 interface{} -	if rf, ok := ret.Get(0).(func(string) interface{}); ok { -		r0 = rf(k) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(interface{}) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(string) error); ok { -		r1 = rf(k) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// Store provides a mock function with given fields: k, v -func (_m *MockCache) Store(k string, v interface{}) error { -	ret := _m.Called(k, v) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { -		r0 = rf(k, v) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} diff --git a/internal/config/mock_KeyedFlags.go b/internal/config/mock_KeyedFlags.go deleted file mode 100644 index 95057d1d3..000000000 --- a/internal/config/mock_KeyedFlags.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package config - -import mock "github.com/stretchr/testify/mock" - -// MockKeyedFlags is an autogenerated mock type for the KeyedFlags type -type MockKeyedFlags struct { -	mock.Mock -} - -// Bool provides a mock function with given fields: k -func (_m *MockKeyedFlags) Bool(k string) bool { -	ret := _m.Called(k) - -	var r0 bool -	if rf, ok := ret.Get(0).(func(string) bool); ok { -		r0 = rf(k) -	} else { -		r0 = ret.Get(0).(bool) -	} - -	return r0 -} - -// Int provides a mock function with given fields: k -func (_m *MockKeyedFlags) Int(k string) int { -	ret := _m.Called(k) - -	var r0 int -	if rf, ok := ret.Get(0).(func(string) int); ok { -		r0 = rf(k) -	} else { -		r0 = ret.Get(0).(int) -	} - -	return r0 -} - -// IsSet provides a mock function with given fields: k -func (_m *MockKeyedFlags) IsSet(k string) bool { -	ret := _m.Called(k) - -	var r0 bool -	if rf, ok := ret.Get(0).(func(string) bool); ok { -		r0 = rf(k) -	} else { -		r0 = ret.Get(0).(bool) -	} - -	return r0 -} - -// String provides a mock function with given fields: k -func (_m *MockKeyedFlags) String(k string) string { -	ret := _m.Called(k) - -	var r0 string -	if rf, ok := ret.Get(0).(func(string) string); ok { -		r0 = rf(k) -	} else { -		r0 = ret.Get(0).(string) -	} - -	return r0 -} diff --git a/internal/db/db.go b/internal/db/db.go index 69ad7b822..3e085e180 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -20,17 +20,13 @@ package db  import (  	"context" -	"fmt"  	"net" -	"strings"  	"github.com/go-fed/activity/pub" -	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  ) -const dbTypePostgres string = "POSTGRES" +const DBTypePostgres string = "POSTGRES"  // ErrNoEntries is to be returned from the DB interface when no entries are found for a given query.  type ErrNoEntries struct{} @@ -126,6 +122,12 @@ type DB interface {  	// In case of no entries, a 'no entries' error will be returned  	GetAccountByUserID(userID string, account *gtsmodel.Account) error +	// GetLocalAccountByUsername is a shortcut for the common action of fetching an account ON THIS INSTANCE +	// according to its username, which should be unique. +	// The given account pointer will be set to the result of the query, whatever it is. +	// In case of no entries, a 'no entries' error will be returned +	GetLocalAccountByUsername(username string, account *gtsmodel.Account) error +  	// GetFollowRequestsForAccountID is a shortcut for the common action of fetching a list of follow requests targeting the given account ID.  	// The given slice 'followRequests' will be set to the result of the query, whatever it is.  	// In case of no entries, a 'no entries' error will be returned @@ -277,14 +279,3 @@ type DB interface {  	// if they exist in the db and conveniently returning them if they do.  	EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error)  } - -// New returns a new database service that satisfies the DB interface and, by extension, -// the go-fed database interface described here: https://github.com/go-fed/activity/blob/master/pub/database.go -func New(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) { -	switch strings.ToUpper(c.DBConfig.Type) { -	case dbTypePostgres: -		return newPostgresService(ctx, c, log.WithField("service", "db")) -	default: -		return nil, fmt.Errorf("database type %s not supported", c.DBConfig.Type) -	} -} diff --git a/internal/db/federating_db.go b/internal/db/federating_db.go index 16e3262ae..ab66b19de 100644 --- a/internal/db/federating_db.go +++ b/internal/db/federating_db.go @@ -21,12 +21,16 @@ 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. @@ -35,13 +39,15 @@ type federatingDB struct {  	locks  *sync.Map  	db     DB  	config *config.Config +	log    *logrus.Logger  } -func newFederatingDB(db DB, config *config.Config) pub.Database { +func NewFederatingDB(db DB, config *config.Config, log *logrus.Logger) pub.Database {  	return &federatingDB{  		locks:  new(sync.Map),  		db:     db,  		config: config, +		log:    log,  	}  } @@ -98,7 +104,30 @@ func (f *federatingDB) Unlock(c context.Context, id *url.URL) error {  //  // 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) { -	return false, nil + +	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 @@ -118,26 +147,86 @@ func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOr  	return nil  } -// Owns returns true if the database has an entry for the IRI and it -// exists in the database. -// +// 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) (owns bool, err error) { -	return false, nil +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) { -	return nil, nil +	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) { -	return nil, nil +	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 @@ -145,7 +234,17 @@ func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (acto  //  // 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) { -	return nil, nil +	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 diff --git a/internal/db/mock_DB.go b/internal/db/mock_DB.go deleted file mode 100644 index df2e41907..000000000 --- a/internal/db/mock_DB.go +++ /dev/null @@ -1,484 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package db - -import ( -	context "context" - -	mock "github.com/stretchr/testify/mock" -	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - -	net "net" - -	pub "github.com/go-fed/activity/pub" -) - -// MockDB is an autogenerated mock type for the DB type -type MockDB struct { -	mock.Mock -} - -// Blocked provides a mock function with given fields: account1, account2 -func (_m *MockDB) Blocked(account1 string, account2 string) (bool, error) { -	ret := _m.Called(account1, account2) - -	var r0 bool -	if rf, ok := ret.Get(0).(func(string, string) bool); ok { -		r0 = rf(account1, account2) -	} else { -		r0 = ret.Get(0).(bool) -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(string, string) error); ok { -		r1 = rf(account1, account2) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// CreateTable provides a mock function with given fields: i -func (_m *MockDB) CreateTable(i interface{}) error { -	ret := _m.Called(i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(interface{}) error); ok { -		r0 = rf(i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// DeleteByID provides a mock function with given fields: id, i -func (_m *MockDB) DeleteByID(id string, i interface{}) error { -	ret := _m.Called(id, i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { -		r0 = rf(id, i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// DeleteWhere provides a mock function with given fields: key, value, i -func (_m *MockDB) DeleteWhere(key string, value interface{}, i interface{}) error { -	ret := _m.Called(key, value, i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok { -		r0 = rf(key, value, i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// DropTable provides a mock function with given fields: i -func (_m *MockDB) DropTable(i interface{}) error { -	ret := _m.Called(i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(interface{}) error); ok { -		r0 = rf(i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// EmojiStringsToEmojis provides a mock function with given fields: emojis, originAccountID, statusID -func (_m *MockDB) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) { -	ret := _m.Called(emojis, originAccountID, statusID) - -	var r0 []*gtsmodel.Emoji -	if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Emoji); ok { -		r0 = rf(emojis, originAccountID, statusID) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).([]*gtsmodel.Emoji) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { -		r1 = rf(emojis, originAccountID, statusID) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// Federation provides a mock function with given fields: -func (_m *MockDB) Federation() pub.Database { -	ret := _m.Called() - -	var r0 pub.Database -	if rf, ok := ret.Get(0).(func() pub.Database); ok { -		r0 = rf() -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(pub.Database) -		} -	} - -	return r0 -} - -// GetAccountByUserID provides a mock function with given fields: userID, account -func (_m *MockDB) GetAccountByUserID(userID string, account *gtsmodel.Account) error { -	ret := _m.Called(userID, account) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, *gtsmodel.Account) error); ok { -		r0 = rf(userID, account) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetAll provides a mock function with given fields: i -func (_m *MockDB) GetAll(i interface{}) error { -	ret := _m.Called(i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(interface{}) error); ok { -		r0 = rf(i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetAvatarForAccountID provides a mock function with given fields: avatar, accountID -func (_m *MockDB) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error { -	ret := _m.Called(avatar, accountID) - -	var r0 error -	if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { -		r0 = rf(avatar, accountID) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetByID provides a mock function with given fields: id, i -func (_m *MockDB) GetByID(id string, i interface{}) error { -	ret := _m.Called(id, i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { -		r0 = rf(id, i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetFollowRequestsForAccountID provides a mock function with given fields: accountID, followRequests -func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error { -	ret := _m.Called(accountID, followRequests) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.FollowRequest) error); ok { -		r0 = rf(accountID, followRequests) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetFollowersByAccountID provides a mock function with given fields: accountID, followers -func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error { -	ret := _m.Called(accountID, followers) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok { -		r0 = rf(accountID, followers) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetFollowingByAccountID provides a mock function with given fields: accountID, following -func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error { -	ret := _m.Called(accountID, following) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok { -		r0 = rf(accountID, following) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetHeaderForAccountID provides a mock function with given fields: header, accountID -func (_m *MockDB) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error { -	ret := _m.Called(header, accountID) - -	var r0 error -	if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { -		r0 = rf(header, accountID) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetLastStatusForAccountID provides a mock function with given fields: accountID, status -func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error { -	ret := _m.Called(accountID, status) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, *gtsmodel.Status) error); ok { -		r0 = rf(accountID, status) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetStatusesByAccountID provides a mock function with given fields: accountID, statuses -func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error { -	ret := _m.Called(accountID, statuses) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status) error); ok { -		r0 = rf(accountID, statuses) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetStatusesByTimeDescending provides a mock function with given fields: accountID, statuses, limit -func (_m *MockDB) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error { -	ret := _m.Called(accountID, statuses, limit) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status, int) error); ok { -		r0 = rf(accountID, statuses, limit) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// GetWhere provides a mock function with given fields: key, value, i -func (_m *MockDB) GetWhere(key string, value interface{}, i interface{}) error { -	ret := _m.Called(key, value, i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok { -		r0 = rf(key, value, i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// IsEmailAvailable provides a mock function with given fields: email -func (_m *MockDB) IsEmailAvailable(email string) error { -	ret := _m.Called(email) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string) error); ok { -		r0 = rf(email) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// IsHealthy provides a mock function with given fields: ctx -func (_m *MockDB) IsHealthy(ctx context.Context) error { -	ret := _m.Called(ctx) - -	var r0 error -	if rf, ok := ret.Get(0).(func(context.Context) error); ok { -		r0 = rf(ctx) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// IsUsernameAvailable provides a mock function with given fields: username -func (_m *MockDB) IsUsernameAvailable(username string) error { -	ret := _m.Called(username) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string) error); ok { -		r0 = rf(username) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// MentionStringsToMentions provides a mock function with given fields: targetAccounts, originAccountID, statusID -func (_m *MockDB) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { -	ret := _m.Called(targetAccounts, originAccountID, statusID) - -	var r0 []*gtsmodel.Mention -	if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Mention); ok { -		r0 = rf(targetAccounts, originAccountID, statusID) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).([]*gtsmodel.Mention) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { -		r1 = rf(targetAccounts, originAccountID, statusID) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// NewSignup provides a mock function with given fields: username, reason, requireApproval, email, password, signUpIP, locale, appID -func (_m *MockDB) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) { -	ret := _m.Called(username, reason, requireApproval, email, password, signUpIP, locale, appID) - -	var r0 *gtsmodel.User -	if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *gtsmodel.User); ok { -		r0 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(*gtsmodel.User) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(string, string, bool, string, string, net.IP, string, string) error); ok { -		r1 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// Put provides a mock function with given fields: i -func (_m *MockDB) Put(i interface{}) error { -	ret := _m.Called(i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(interface{}) error); ok { -		r0 = rf(i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// SetHeaderOrAvatarForAccountID provides a mock function with given fields: mediaAttachment, accountID -func (_m *MockDB) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error { -	ret := _m.Called(mediaAttachment, accountID) - -	var r0 error -	if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { -		r0 = rf(mediaAttachment, accountID) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// Stop provides a mock function with given fields: ctx -func (_m *MockDB) Stop(ctx context.Context) error { -	ret := _m.Called(ctx) - -	var r0 error -	if rf, ok := ret.Get(0).(func(context.Context) error); ok { -		r0 = rf(ctx) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// TagStringsToTags provides a mock function with given fields: tags, originAccountID, statusID -func (_m *MockDB) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) { -	ret := _m.Called(tags, originAccountID, statusID) - -	var r0 []*gtsmodel.Tag -	if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Tag); ok { -		r0 = rf(tags, originAccountID, statusID) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).([]*gtsmodel.Tag) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { -		r1 = rf(tags, originAccountID, statusID) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// UpdateByID provides a mock function with given fields: id, i -func (_m *MockDB) UpdateByID(id string, i interface{}) error { -	ret := _m.Called(id, i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { -		r0 = rf(id, i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// UpdateOneByID provides a mock function with given fields: id, key, value, i -func (_m *MockDB) UpdateOneByID(id string, key string, value interface{}, i interface{}) error { -	ret := _m.Called(id, key, value, i) - -	var r0 error -	if rf, ok := ret.Get(0).(func(string, string, interface{}, interface{}) error); ok { -		r0 = rf(id, key, value, i) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} diff --git a/internal/db/pg.go b/internal/db/pg.go index 24a57d8a5..647285032 100644 --- a/internal/db/pg.go +++ b/internal/db/pg.go @@ -37,7 +37,7 @@ import (  	"github.com/google/uuid"  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/util"  	"golang.org/x/crypto/bcrypt"  ) @@ -46,14 +46,14 @@ import (  type postgresService struct {  	config       *config.Config  	conn         *pg.DB -	log          *logrus.Entry +	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. +// 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.Entry) (DB, error) { +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) @@ -67,7 +67,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry  	// 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.Logger.GetLevel() >= logrus.TraceLevel { +	if log.GetLevel() >= logrus.TraceLevel {  		conn.AddQueryHook(pgdebug.DebugHook{  			// Print all queries.  			Verbose: true, @@ -95,7 +95,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry  		cancel: cancel,  	} -	federatingDB := newFederatingDB(ps, c) +	federatingDB := NewFederatingDB(ps, c, log)  	ps.federationDB = federatingDB  	// we can confidently return this useable postgres service now @@ -109,8 +109,8 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry  // 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) +	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 @@ -341,6 +341,16 @@ func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.A  	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 { @@ -456,21 +466,23 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr  		return nil, err  	} -	uris := util.GenerateURIs(username, ps.config.Protocol, ps.config.Host) +	newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host)  	a := >smodel.Account{  		Username:              username,  		DisplayName:           username,  		Reason:                reason, -		URL:                   uris.UserURL, +		URL:                   newAccountURIs.UserURL,  		PrivateKey:            key,  		PublicKey:             &key.PublicKey, +		PublicKeyURI:          newAccountURIs.PublicKeyURI,  		ActorType:             gtsmodel.ActivityStreamsPerson, -		URI:                   uris.UserURI, -		InboxURL:              uris.InboxURI, -		OutboxURL:             uris.OutboxURI, -		FollowersURL:          uris.FollowersURI, -		FeaturedCollectionURL: uris.CollectionURI, +		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 @@ -566,6 +578,7 @@ func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachmen  }  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). diff --git a/internal/db/pg_test.go b/internal/db/pg_test.go index f9bd21c48..a54784022 100644 --- a/internal/db/pg_test.go +++ b/internal/db/pg_test.go @@ -16,6 +16,6 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package db +package db_test  // TODO: write tests for postgres diff --git a/internal/distributor/distributor.go b/internal/distributor/distributor.go deleted file mode 100644 index 151c1b522..000000000 --- a/internal/distributor/distributor.go +++ /dev/null @@ -1,110 +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 <http://www.gnu.org/licenses/>. -*/ - -package distributor - -import ( -	"github.com/sirupsen/logrus" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -) - -// Distributor should be passed to api modules (see internal/apimodule/...). It is used for -// passing messages back and forth from the client API and the federating interface, via channels. -// It also contains logic for filtering which messages should end up where. -// It is designed to be used asynchronously: the client API and the federating API should just be able to -// fire messages into the distributor and not wait for a reply before proceeding with other work. This allows -// for clean distribution of messages without slowing down the client API and harming the user experience. -type Distributor interface { -	// FromClientAPI returns a channel for accepting messages that come from the gts client API. -	FromClientAPI() chan FromClientAPI -	// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API. -	ToClientAPI() chan ToClientAPI -	// Start starts the Distributor, reading from its channels and passing messages back and forth. -	Start() error -	// Stop stops the distributor cleanly, finishing handling any remaining messages before closing down. -	Stop() error -} - -// distributor just implements the Distributor interface -type distributor struct { -	// federator     pub.FederatingActor -	fromClientAPI chan FromClientAPI -	toClientAPI   chan ToClientAPI -	stop          chan interface{} -	log           *logrus.Logger -} - -// New returns a new Distributor that uses the given federator and logger -func New(log *logrus.Logger) Distributor { -	return &distributor{ -		// federator:     federator, -		fromClientAPI: make(chan FromClientAPI, 100), -		toClientAPI:   make(chan ToClientAPI, 100), -		stop:          make(chan interface{}), -		log:           log, -	} -} - -// ClientAPIIn returns a channel for accepting messages that come from the gts client API. -func (d *distributor) FromClientAPI() chan FromClientAPI { -	return d.fromClientAPI -} - -// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API. -func (d *distributor) ToClientAPI() chan ToClientAPI { -	return d.toClientAPI -} - -// Start starts the Distributor, reading from its channels and passing messages back and forth. -func (d *distributor) Start() error { -	go func() { -	DistLoop: -		for { -			select { -			case clientMsg := <-d.fromClientAPI: -				d.log.Infof("received message FROM client API: %+v", clientMsg) -			case clientMsg := <-d.toClientAPI: -				d.log.Infof("received message TO client API: %+v", clientMsg) -			case <-d.stop: -				break DistLoop -			} -		} -	}() -	return nil -} - -// Stop stops the distributor cleanly, finishing handling any remaining messages before closing down. -// TODO: empty message buffer properly before stopping otherwise we'll lose federating messages. -func (d *distributor) Stop() error { -	close(d.stop) -	return nil -} - -// FromClientAPI wraps a message that travels from the client API into the distributor -type FromClientAPI struct { -	APObjectType   gtsmodel.ActivityStreamsObject -	APActivityType gtsmodel.ActivityStreamsActivity -	Activity       interface{} -} - -// ToClientAPI wraps a message that travels from the distributor into the client API -type ToClientAPI struct { -	APObjectType   gtsmodel.ActivityStreamsObject -	APActivityType gtsmodel.ActivityStreamsActivity -	Activity       interface{} -} diff --git a/internal/distributor/mock_Distributor.go b/internal/distributor/mock_Distributor.go deleted file mode 100644 index 42248c3f2..000000000 --- a/internal/distributor/mock_Distributor.go +++ /dev/null @@ -1,70 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package distributor - -import mock "github.com/stretchr/testify/mock" - -// MockDistributor is an autogenerated mock type for the Distributor type -type MockDistributor struct { -	mock.Mock -} - -// FromClientAPI provides a mock function with given fields: -func (_m *MockDistributor) FromClientAPI() chan FromClientAPI { -	ret := _m.Called() - -	var r0 chan FromClientAPI -	if rf, ok := ret.Get(0).(func() chan FromClientAPI); ok { -		r0 = rf() -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(chan FromClientAPI) -		} -	} - -	return r0 -} - -// Start provides a mock function with given fields: -func (_m *MockDistributor) Start() error { -	ret := _m.Called() - -	var r0 error -	if rf, ok := ret.Get(0).(func() error); ok { -		r0 = rf() -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// Stop provides a mock function with given fields: -func (_m *MockDistributor) Stop() error { -	ret := _m.Called() - -	var r0 error -	if rf, ok := ret.Get(0).(func() error); ok { -		r0 = rf() -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// ToClientAPI provides a mock function with given fields: -func (_m *MockDistributor) ToClientAPI() chan ToClientAPI { -	ret := _m.Called() - -	var r0 chan ToClientAPI -	if rf, ok := ret.Get(0).(func() chan ToClientAPI); ok { -		r0 = rf() -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(chan ToClientAPI) -		} -	} - -	return r0 -} diff --git a/testrig/distributor.go b/internal/federation/clock.go index a7206e5ea..f0d6f5e84 100644 --- a/testrig/distributor.go +++ b/internal/federation/clock.go @@ -16,11 +16,27 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package testrig +package federation -import "github.com/superseriousbusiness/gotosocial/internal/distributor" +import ( +	"time" -// NewTestDistributor returns a Distributor suitable for testing purposes -func NewTestDistributor() distributor.Distributor { -	return distributor.New(NewTestLog()) +	"github.com/go-fed/activity/pub" +) + +/* +	GOFED CLOCK INTERFACE +	Determines the time. +*/ + +// Clock implements the Clock interface of go-fed +type Clock struct{} + +// Now just returns the time now +func (c *Clock) Now() time.Time { +	return time.Now() +} + +func NewClock() pub.Clock { +	return &Clock{}  } diff --git a/internal/federation/commonbehavior.go b/internal/federation/commonbehavior.go new file mode 100644 index 000000000..9274e78b4 --- /dev/null +++ b/internal/federation/commonbehavior.go @@ -0,0 +1,152 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"context" +	"fmt" +	"net/http" +	"net/url" + +	"github.com/go-fed/activity/pub" +	"github.com/go-fed/activity/streams/vocab" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +/* +	GOFED COMMON BEHAVIOR INTERFACE +	Contains functions required for both the Social API and Federating Protocol. +	It is passed to the library as a dependency injection from the client +	application. +*/ + +// AuthenticateGetInbox delegates the authentication of a GET to an +// inbox. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +// +// If an error is returned, it is passed back to the caller of +// GetInbox. In this case, the implementation must not write a +// response to the ResponseWriter as is expected that the client will +// do so when handling the error. The 'authenticated' is ignored. +// +// If no error is returned, but authentication or authorization fails, +// then authenticated must be false and error nil. It is expected that +// the implementation handles writing to the ResponseWriter in this +// case. +// +// Finally, if the authentication and authorization succeeds, then +// 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 +	// the CLIENT API, not through the federation API, so we just do nothing here. +	return nil, false, nil +} + +// AuthenticateGetOutbox delegates the authentication of a GET to an +// outbox. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +// +// If an error is returned, it is passed back to the caller of +// GetOutbox. In this case, the implementation must not write a +// response to the ResponseWriter as is expected that the client will +// do so when handling the error. The 'authenticated' is ignored. +// +// If no error is returned, but authentication or authorization fails, +// then authenticated must be false and error nil. It is expected that +// the implementation handles writing to the ResponseWriter in this +// case. +// +// Finally, if the authentication and authorization succeeds, then +// 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 +	// the CLIENT API, not through the federation API, so we just do nothing here. +	return nil, false, nil +} + +// GetOutbox returns the OrderedCollection inbox of the actor for this +// context. It is up to the implementation to provide the correct +// collection for the kind of authorization given in the request. +// +// AuthenticateGetOutbox will be called prior to this. +// +// 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 +	// the CLIENT API, not through the federation API, so we just do nothing here. +	return nil, nil +} + +// NewTransport returns a new Transport on behalf of a specific actor. +// +// The actorBoxIRI will be either the inbox or outbox of an actor who is +// attempting to do the dereferencing or delivery. Any authentication +// scheme applied on the request must be based on this actor. The +// request must contain some sort of credential of the user, such as a +// HTTP Signature. +// +// The gofedAgent passed in should be used by the Transport +// implementation in the User-Agent, as well as the application-specific +// user agent string. The gofedAgent will indicate this library's use as +// well as the library's version number. +// +// Any server-wide rate-limiting that needs to occur should happen in a +// Transport implementation. This factory function allows this to be +// created, so peer servers are not DOS'd. +// +// Any retry logic should also be handled by the Transport +// implementation. +// +// Note that the library will not maintain a long-lived pointer to the +// returned Transport so that any private credentials are able to be +// garbage collected. +func (f *federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) { + +	var username string +	var err error + +	if util.IsInboxPath(actorBoxIRI) { +		username, err = util.ParseInboxPath(actorBoxIRI) +		if err != nil { +			return nil, fmt.Errorf("couldn't parse path %s as an inbox: %s", actorBoxIRI.String(), err) +		} +	} else if util.IsOutboxPath(actorBoxIRI) { +		username, err = util.ParseOutboxPath(actorBoxIRI) +		if err != nil { +			return nil, fmt.Errorf("couldn't parse path %s as an outbox: %s", actorBoxIRI.String(), err) +		} +	} else { +		return nil, fmt.Errorf("id %s was neither an inbox path nor an outbox path", actorBoxIRI.String()) +	} + +	account := >smodel.Account{} +	if err := f.db.GetLocalAccountByUsername(username, account); err != nil { +		return nil, fmt.Errorf("error getting account with username %s from the db: %s", username, err) +	} + +	return f.transportController.NewTransport(account.PublicKeyURI, account.PrivateKey) +} diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go new file mode 100644 index 000000000..f105d9125 --- /dev/null +++ b/internal/federation/federatingactor.go @@ -0,0 +1,136 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"context" +	"net/http" +	"net/url" + +	"github.com/go-fed/activity/pub" +	"github.com/go-fed/activity/streams/vocab" +) + +// federatingActor implements the go-fed federating protocol interface +type federatingActor struct { +	actor pub.FederatingActor +} + +// newFederatingProtocol returns the gotosocial implementation of the GTSFederatingProtocol interface +func newFederatingActor(c pub.CommonBehavior, s2s pub.FederatingProtocol, db pub.Database, clock pub.Clock) pub.FederatingActor { +	actor := pub.NewFederatingActor(c, s2s, db, clock) + +	return &federatingActor{ +		actor: actor, +	} +} + +// Send a federated activity. +// +// The provided url must be the outbox of the sender. All processing of +// the activity occurs similarly to the C2S flow: +//   - If t is not an Activity, it is wrapped in a Create activity. +//   - A new ID is generated for the activity. +//   - The activity is added to the specified outbox. +//   - The activity is prepared and delivered to recipients. +// +// Note that this function will only behave as expected if the +// implementation has been constructed to support federation. This +// method will guaranteed work for non-custom Actors. For custom actors, +// care should be used to not call this method if only C2S is supported. +func (f *federatingActor) Send(c context.Context, outbox *url.URL, t vocab.Type) (pub.Activity, error) { +	return f.actor.Send(c, outbox, t) +} + +// PostInbox 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. +func (f *federatingActor) PostInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { +	return f.actor.PostInbox(c, w, r) +} + +// 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 +// 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 request is an ActivityPub request, the Actor will defer to the +// application to determine the correct authorization of the request and +// the resulting OrderedCollection to respond with. The Actor handles +// serializing this OrderedCollection and responding with the correct +// headers and http.StatusOK. +func (f *federatingActor) GetInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { +	return f.actor.GetInbox(c, w, r) +} + +// PostOutbox returns true if the request was handled as an ActivityPub +// POST to an actor's outbox. 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 Social Protocol enabled, side +// effects will occur. +// +// If the Social Protocol is not enabled, writes the +// http.StatusMethodNotAllowed status code in the response. No side +// effects occur. +// +// If the Social and Federated Protocol are both enabled, it will handle +// the side effects of receiving an ActivityStream Activity, and then +// federate the Activity to peers. +func (f *federatingActor) PostOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { +	return f.actor.PostOutbox(c, w, r) +} + +// 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. +// +// 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 request is an ActivityPub request, the Actor will defer to the +// application to determine the correct authorization of the request and +// the resulting OrderedCollection to respond with. The Actor handles +// serializing this OrderedCollection and responding with the correct +// headers and http.StatusOK. +func (f *federatingActor) GetOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { +	return f.actor.GetOutbox(c, w, r) +} diff --git a/internal/federation/federation.go b/internal/federation/federatingprotocol.go index a2aba3fcf..1764eb791 100644 --- a/internal/federation/federation.go +++ b/internal/federation/federatingprotocol.go @@ -16,34 +16,23 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -// Package federation provides ActivityPub/federation functionality for GoToSocial  package federation  import (  	"context" +	"errors" +	"fmt"  	"net/http"  	"net/url" -	"time"  	"github.com/go-fed/activity/pub"  	"github.com/go-fed/activity/streams/vocab"  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/util"  ) -// New returns a go-fed compatible federating actor -func New(db db.DB, log *logrus.Logger) pub.FederatingActor { -	f := &Federator{ -		db: db, -	} -	return pub.NewFederatingActor(f, f, db.Federation(), f) -} - -// Federator implements several go-fed interfaces in one convenient location -type Federator struct { -	db db.DB -} -  /*  	GO FED FEDERATING PROTOCOL INTERFACE  	FederatingProtocol contains behaviors an application needs to satisfy for the @@ -70,9 +59,21 @@ type Federator struct {  // PostInbox. In this case, the DelegateActor implementation must not  // write a response to the ResponseWriter as is expected that the caller  // to PostInbox will do so when handling the error. -func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) { -	// TODO -	return nil, nil +func (f *federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) { +	l := f.log.WithFields(logrus.Fields{ +		"func":      "PostInboxRequestBodyHook", +		"useragent": r.UserAgent(), +		"url":       r.URL.String(), +	}) + +	if activity == nil { +		err := errors.New("nil activity in PostInboxRequestBodyHook") +		l.Debug(err) +		return nil, err +	} + +	ctxWithActivity := context.WithValue(ctx, util.APActivity, activity) +	return ctxWithActivity, nil  }  // AuthenticatePostInbox delegates the authentication of a POST to an @@ -91,9 +92,54 @@ func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Reques  // Finally, if the authentication and authorization succeeds, then  // authenticated must be true and error nil. The request will continue  // to be processed. -func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { -	// TODO -	return nil, false, nil +func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { +	l := f.log.WithFields(logrus.Fields{ +		"func":      "AuthenticatePostInbox", +		"useragent": r.UserAgent(), +		"url":       r.URL.String(), +	}) +	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") +	} + +	requestedAccount, ok := requestedAccountI.(*gtsmodel.Account) +	if !ok || requestedAccount == nil { +		return ctx, false, errors.New("requested account not parsebale from context") +	} + +	publicKeyOwnerURI, err := f.AuthenticateFederatedRequest(requestedAccount.Username, r) +	if err != nil { +		l.Debugf("request not authenticated: %s", err) +		return ctx, false, fmt.Errorf("not authenticated: %s", err) +	} + +	requestingAccount := >smodel.Account{} +	if err := f.db.GetWhere("uri", publicKeyOwnerURI.String(), requestingAccount); err != nil { +		// there's been a proper error so return it +		if _, ok := err.(db.ErrNoEntries); !ok { +			return ctx, false, fmt.Errorf("error getting requesting account with public key id %s: %s", publicKeyOwnerURI.String(), err) +		} + +		// 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) +		} + +		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 +	} + +	contextWithRequestingAccount := context.WithValue(ctx, util.APRequestingAccount, requestingAccount) + +	return contextWithRequestingAccount, true, nil  }  // Blocked should determine whether to permit a set of actors given by @@ -110,7 +156,7 @@ 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. -func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) { +func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) {  	// TODO  	return false, nil  } @@ -134,7 +180,7 @@ 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) { +func (f *federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) {  	// TODO  	return pub.FederatingWrappedCallbacks{}, nil, nil  } @@ -146,8 +192,12 @@ func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrap  // Applications are not expected to handle every single ActivityStreams  // type and extension, so the unhandled ones are passed to  // DefaultCallback. -func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity) error { -	// TODO +func (f *federator) DefaultCallback(ctx context.Context, activity pub.Activity) error { +	l := f.log.WithFields(logrus.Fields{ +		"func":   "DefaultCallback", +		"aptype": activity.GetTypeName(), +	}) +	l.Debugf("received unhandle-able activity type so ignoring it")  	return nil  } @@ -155,7 +205,7 @@ func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity)  // an activity to determine if inbox forwarding needs to occur.  //  // Zero or negative numbers indicate infinite recursion. -func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int { +func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {  	// TODO  	return 0  } @@ -165,7 +215,7 @@ func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {  // delivery.  //  // Zero or negative numbers indicate infinite recursion. -func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int { +func (f *federator) MaxDeliveryRecursionDepth(ctx context.Context) int {  	// TODO  	return 0  } @@ -177,7 +227,7 @@ func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int {  //  // The activity is provided as a reference for more intelligent  // logic to be used, but the implementation must not modify it. -func (f *Federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) { +func (f *federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) {  	// TODO  	return nil, nil  } @@ -190,114 +240,8 @@ 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) { -	// TODO +func (f *federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { +	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through +	// the CLIENT API, not through the federation API, so we just do nothing here.  	return nil, nil  } - -/* -	GOFED COMMON BEHAVIOR INTERFACE -	Contains functions required for both the Social API and Federating Protocol. -	It is passed to the library as a dependency injection from the client -	application. -*/ - -// AuthenticateGetInbox delegates the authentication of a GET to an -// inbox. -// -// Always called, regardless whether the Federated Protocol or Social -// API is enabled. -// -// If an error is returned, it is passed back to the caller of -// GetInbox. In this case, the implementation must not write a -// response to the ResponseWriter as is expected that the client will -// do so when handling the error. The 'authenticated' is ignored. -// -// If no error is returned, but authentication or authorization fails, -// then authenticated must be false and error nil. It is expected that -// the implementation handles writing to the ResponseWriter in this -// case. -// -// Finally, if the authentication and authorization succeeds, then -// 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) { -	// TODO -	// use context.WithValue() and context.Value() to set and get values through here -	return nil, false, nil -} - -// AuthenticateGetOutbox delegates the authentication of a GET to an -// outbox. -// -// Always called, regardless whether the Federated Protocol or Social -// API is enabled. -// -// If an error is returned, it is passed back to the caller of -// GetOutbox. In this case, the implementation must not write a -// response to the ResponseWriter as is expected that the client will -// do so when handling the error. The 'authenticated' is ignored. -// -// If no error is returned, but authentication or authorization fails, -// then authenticated must be false and error nil. It is expected that -// the implementation handles writing to the ResponseWriter in this -// case. -// -// Finally, if the authentication and authorization succeeds, then -// 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) { -	// TODO -	return nil, false, nil -} - -// GetOutbox returns the OrderedCollection inbox of the actor for this -// context. It is up to the implementation to provide the correct -// collection for the kind of authorization given in the request. -// -// AuthenticateGetOutbox will be called prior to this. -// -// 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) { -	// TODO -	return nil, nil -} - -// NewTransport returns a new Transport on behalf of a specific actor. -// -// The actorBoxIRI will be either the inbox or outbox of an actor who is -// attempting to do the dereferencing or delivery. Any authentication -// scheme applied on the request must be based on this actor. The -// request must contain some sort of credential of the user, such as a -// HTTP Signature. -// -// The gofedAgent passed in should be used by the Transport -// implementation in the User-Agent, as well as the application-specific -// user agent string. The gofedAgent will indicate this library's use as -// well as the library's version number. -// -// Any server-wide rate-limiting that needs to occur should happen in a -// Transport implementation. This factory function allows this to be -// created, so peer servers are not DOS'd. -// -// Any retry logic should also be handled by the Transport -// implementation. -// -// Note that the library will not maintain a long-lived pointer to the -// returned Transport so that any private credentials are able to be -// garbage collected. -func (f *Federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) { -	// TODO -	return nil, nil -} - -/* -	GOFED CLOCK INTERFACE -	Determines the time. -*/ - -// Now returns the current time. -func (f *Federator) Now() time.Time { -	return time.Now() -} diff --git a/internal/federation/federator.go b/internal/federation/federator.go new file mode 100644 index 000000000..4fe0369b9 --- /dev/null +++ b/internal/federation/federator.go @@ -0,0 +1,79 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"net/http" +	"net/url" + +	"github.com/go-fed/activity/pub" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/transport" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Federator wraps various interfaces and functions to manage activitypub federation from gotosocial +type Federator interface { +	// FederatingActor returns the underlying pub.FederatingActor, which can be used to send activities, and serve actors at inboxes/outboxes. +	FederatingActor() pub.FederatingActor +	// AuthenticateFederatedRequest can be used to check the authenticity of incoming http-signed requests for federating resources. +	// The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. +	AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) +	// DereferenceRemoteAccount can be used to get the representation of a remote account, based on the account ID (which is a URI). +	// The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. +	DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) +	// GetTransportForUser returns a new transport initialized with the key credentials belonging to the given username. +	// This can be used for making signed http requests. +	GetTransportForUser(username string) (pub.Transport, error) +	pub.CommonBehavior +	pub.FederatingProtocol +} + +type federator struct { +	config              *config.Config +	db                  db.DB +	clock               pub.Clock +	typeConverter       typeutils.TypeConverter +	transportController transport.Controller +	actor               pub.FederatingActor +	log                 *logrus.Logger +} + +// NewFederator returns a new federator +func NewFederator(db db.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator { + +	clock := &Clock{} +	f := &federator{ +		config:              config, +		db:                  db, +		clock:               &Clock{}, +		typeConverter:       typeConverter, +		transportController: transportController, +		log:                 log, +	} +	actor := newFederatingActor(f, f, db.Federation(), clock) +	f.actor = actor +	return f +} + +func (f *federator) FederatingActor() pub.FederatingActor { +	return f.actor +} diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go new file mode 100644 index 000000000..2eab09507 --- /dev/null +++ b/internal/federation/federator_test.go @@ -0,0 +1,190 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package federation_test + +import ( +	"bytes" +	"context" +	"crypto/x509" +	"encoding/pem" +	"fmt" +	"io/ioutil" +	"net/http" +	"net/http/httptest" +	"strings" +	"testing" + +	"github.com/go-fed/activity/pub" +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/suite" + +	"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/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type ProtocolTestSuite struct { +	suite.Suite +	config        *config.Config +	db            db.DB +	log           *logrus.Logger +	storage       storage.Storage +	typeConverter typeutils.TypeConverter +	accounts      map[string]*gtsmodel.Account +	activities    map[string]testrig.ActivityWithSignature +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *ProtocolTestSuite) SetupSuite() { +	// setup standard items +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.log = testrig.NewTestLog() +	suite.storage = testrig.NewTestStorage() +	suite.typeConverter = testrig.NewTestTypeConverter(suite.db) +	suite.accounts = testrig.NewTestAccounts() +	suite.activities = testrig.NewTestActivities(suite.accounts) +} + +func (suite *ProtocolTestSuite) SetupTest() { +	testrig.StandardDBSetup(suite.db) + +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *ProtocolTestSuite) TearDownTest() { +	testrig.StandardDBTeardown(suite.db) +} + +// make sure PostInboxRequestBodyHook properly sets the inbox username and activity on the context +func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() { + +	// the activity we're gonna use +	activity := suite.activities["dm_for_zork"] + +	// setup transport controller with a no-op client so we don't make external calls +	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { +		return nil, nil +	})) +	// setup module being tested +	federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter) + +	// setup request +	ctx := context.Background() +	request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting +	request.Header.Set("Signature", activity.SignatureHeader) + +	// trigger the function being tested, and return the new context it creates +	newContext, err := federator.PostInboxRequestBodyHook(ctx, request, activity.Activity) +	assert.NoError(suite.T(), err) +	assert.NotNil(suite.T(), newContext) + +	// activity should be set on context now +	activityI := newContext.Value(util.APActivity) +	assert.NotNil(suite.T(), activityI) +	returnedActivity, ok := activityI.(pub.Activity) +	assert.True(suite.T(), ok) +	assert.NotNil(suite.T(), returnedActivity) +	assert.EqualValues(suite.T(), activity.Activity, returnedActivity) +} + +func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() { + +	// the activity we're gonna use +	activity := suite.activities["dm_for_zork"] +	sendingAccount := suite.accounts["remote_account_1"] +	inboxAccount := suite.accounts["local_account_1"] + +	encodedPublicKey, err := x509.MarshalPKIXPublicKey(sendingAccount.PublicKey) +	assert.NoError(suite.T(), err) +	publicKeyBytes := pem.EncodeToMemory(&pem.Block{ +		Type:  "PUBLIC KEY", +		Bytes: encodedPublicKey, +	}) +	publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n") + +	// for this test we need the client to return the public key of the activity creator on the 'remote' instance +	responseBodyString := fmt.Sprintf(` +	{ +		"@context": [ +			"https://www.w3.org/ns/activitystreams", +			"https://w3id.org/security/v1" +		], + +		"id": "%s", +		"type": "Person", +		"preferredUsername": "%s", +		"inbox": "%s", + +		"publicKey": { +			"id": "%s", +			"owner": "%s", +			"publicKeyPem": "%s" +		} +	}`, sendingAccount.URI, sendingAccount.Username, sendingAccount.InboxURI, sendingAccount.PublicKeyURI, sendingAccount.URI, publicKeyString) + +	// create a transport controller whose client will just return the response body string we specified above +	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { +		r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString))) +		return &http.Response{ +			StatusCode: 200, +			Body:       r, +		}, nil +	})) + +	// now setup module being tested, with the mock transport controller +	federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter) + +	// setup request +	ctx := context.Background() +	// by the time AuthenticatePostInbox is called, PostInboxRequestBodyHook should have already been called, +	// which should have set the account and username onto the request. We can replicate that behavior here: +	ctxWithAccount := context.WithValue(ctx, util.APAccount, inboxAccount) +	ctxWithActivity := context.WithValue(ctxWithAccount, util.APActivity, activity) + +	request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting +	// we need these headers for the request to be validated +	request.Header.Set("Signature", activity.SignatureHeader) +	request.Header.Set("Date", activity.DateHeader) +	request.Header.Set("Digest", activity.DigestHeader) +	// we can pass this recorder as a writer and read it back after +	recorder := httptest.NewRecorder() + +	// trigger the function being tested, and return the new context it creates +	newContext, authed, err := federator.AuthenticatePostInbox(ctxWithActivity, recorder, request) +	assert.NoError(suite.T(), err) +	assert.True(suite.T(), authed) + +	// since we know this account already it should be set on the context +	requestingAccountI := newContext.Value(util.APRequestingAccount) +	assert.NotNil(suite.T(), requestingAccountI) +	requestingAccount, ok := requestingAccountI.(*gtsmodel.Account) +	assert.True(suite.T(), ok) +	assert.Equal(suite.T(), sendingAccount.Username, requestingAccount.Username) +} + +func TestProtocolTestSuite(t *testing.T) { +	suite.Run(t, new(ProtocolTestSuite)) +} diff --git a/internal/federation/util.go b/internal/federation/util.go new file mode 100644 index 000000000..ab854db7c --- /dev/null +++ b/internal/federation/util.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 <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"context" +	"crypto/x509" +	"encoding/json" +	"encoding/pem" +	"errors" +	"fmt" +	"net/http" +	"net/url" + +	"github.com/go-fed/activity/pub" +	"github.com/go-fed/activity/streams" +	"github.com/go-fed/activity/streams/vocab" +	"github.com/go-fed/httpsig" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +/* +	publicKeyer is BORROWED DIRECTLY FROM https://github.com/go-fed/apcore/blob/master/ap/util.go +	Thank you @cj@mastodon.technology ! <3 +*/ +type publicKeyer interface { +	GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty +} + +/* +	getPublicKeyFromResponse is adapted from https://github.com/go-fed/apcore/blob/master/ap/util.go +	Thank you @cj@mastodon.technology ! <3 +*/ +func getPublicKeyFromResponse(c context.Context, b []byte, keyID *url.URL) (vocab.W3IDSecurityV1PublicKey, error) { +	m := make(map[string]interface{}) +	if err := json.Unmarshal(b, &m); err != nil { +		return nil, err +	} + +	t, err := streams.ToType(c, m) +	if err != nil { +		return nil, err +	} + +	pker, ok := t.(publicKeyer) +	if !ok { +		return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %T", t) +	} + +	pkp := pker.GetW3IDSecurityV1PublicKey() +	if pkp == nil { +		return nil, errors.New("publicKey property is not provided") +	} + +	var pkpFound vocab.W3IDSecurityV1PublicKey +	for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() { +		if !pkpIter.IsW3IDSecurityV1PublicKey() { +			continue +		} +		pkValue := pkpIter.Get() +		var pkID *url.URL +		pkID, err = pub.GetId(pkValue) +		if err != nil { +			return nil, err +		} +		if pkID.String() != keyID.String() { +			continue +		} +		pkpFound = pkValue +		break +	} + +	if pkpFound == nil { +		return nil, fmt.Errorf("cannot find publicKey with id: %s", keyID) +	} + +	return pkpFound, nil +} + +// AuthenticateFederatedRequest authenticates any kind of incoming federated request from a remote server. This includes things like +// GET requests for dereferencing our users or statuses etc, and POST requests for delivering new Activities. The function returns +// the URL of the owner of the public key used in the http signature. +// +// Authenticate in this case is defined as just making sure that the http request is actually signed by whoever claims +// to have signed it, by fetching the public key from the signature and checking it against the remote public key. This function +// *does not* check whether the request is authorized, only whether it's authentic. +// +// The provided username will be used to generate a transport for making remote requests/derefencing the public key ID of the request signature. +// Ideally you should pass in the username of the user *being requested*, so that the remote server can decide how to handle the request based on who's making it. +// Ie., if the request on this server is for https://example.org/users/some_username then you should pass in the username 'some_username'. +// The remote server will then know that this is the user making the dereferencing request, and they can decide to allow or deny the request depending on their settings. +// +// Note that it is also valid to pass in an empty string here, in which case the keys of the instance account will be used. +// +// Also note that this function *does not* dereference the remote account that the signature key is associated with. +// Other functions should use the returned URL to dereference the remote account, if required. +func (f *federator) AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) { +	verifier, err := httpsig.NewVerifier(r) +	if err != nil { +		return nil, fmt.Errorf("could not create http sig verifier: %s", err) +	} + +	// The key ID should be given in the signature so that we know where to fetch it from the remote server. +	// This will be something like https://example.org/users/whatever_requesting_user#main-key +	requestingPublicKeyID, err := url.Parse(verifier.KeyId()) +	if err != nil { +		return nil, fmt.Errorf("could not parse key id into a url: %s", err) +	} + +	transport, err := f.GetTransportForUser(username) +	if err != nil { +		return nil, fmt.Errorf("transport err: %s", err) +	} + +	// The actual http call to the remote server is made right here in the Dereference function. +	b, err := transport.Dereference(context.Background(), requestingPublicKeyID) +	if err != nil { +		return nil, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err) +	} + +	// if the key isn't in the response, we can't authenticate the request +	requestingPublicKey, err := getPublicKeyFromResponse(context.Background(), b, requestingPublicKeyID) +	if err != nil { +		return nil, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err) +	} + +	// we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey +	pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem() +	if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() { +		return nil, errors.New("publicKeyPem property is not provided or it is not embedded as a value") +	} + +	// and decode the PEM so that we can parse it as a golang public key +	pubKeyPem := pkPemProp.Get() +	block, _ := pem.Decode([]byte(pubKeyPem)) +	if block == nil || block.Type != "PUBLIC KEY" { +		return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") +	} + +	p, err := x509.ParsePKIXPublicKey(block.Bytes) +	if err != nil { +		return nil, fmt.Errorf("could not parse public key from block bytes: %s", err) +	} +	if p == nil { +		return nil, errors.New("returned public key was empty") +	} + +	// do the actual authentication here! +	algo := httpsig.RSA_SHA256 // TODO: make this more robust +	if err := verifier.Verify(p, algo); err != nil { +		return nil, fmt.Errorf("error verifying key %s: %s", requestingPublicKeyID.String(), err) +	} + +	// all good! we just need the URI of the key owner to return +	pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner() +	if pkOwnerProp == nil || !pkOwnerProp.IsIRI() { +		return nil, errors.New("publicKeyOwner property is not provided or it is not embedded as a value") +	} +	pkOwnerURI := pkOwnerProp.GetIRI() + +	return pkOwnerURI, nil +} + +func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) { + +	transport, err := f.GetTransportForUser(username) +	if err != nil { +		return nil, fmt.Errorf("transport err: %s", err) +	} + +	b, err := transport.Dereference(context.Background(), remoteAccountID) +	if err != nil { +		return nil, fmt.Errorf("error deferencing %s: %s", remoteAccountID.String(), err) +	} + +	m := make(map[string]interface{}) +	if err := json.Unmarshal(b, &m); err != nil { +		return nil, fmt.Errorf("error unmarshalling bytes into json: %s", err) +	} + +	t, err := streams.ToType(context.Background(), m) +	if err != nil { +		return nil, fmt.Errorf("error resolving json into ap vocab type: %s", err) +	} + +	switch t.GetTypeName() { +	case string(gtsmodel.ActivityStreamsPerson): +		p, ok := t.(vocab.ActivityStreamsPerson) +		if !ok { +			return nil, errors.New("error resolving type as activitystreams person") +		} +		return p, nil +	case string(gtsmodel.ActivityStreamsApplication): +		// TODO: convert application into person +	} + +	return nil, fmt.Errorf("type name %s not supported", t.GetTypeName()) +} + +func (f *federator) GetTransportForUser(username string) (pub.Transport, error) { +	// We need an account to use to create a transport for dereferecing the signature. +	// If a username has been given, we can fetch the account with that username and use it. +	// Otherwise, we can take the instance account and use those credentials to make the request. +	ourAccount := >smodel.Account{} +	var u string +	if username == "" { +		u = f.config.Host +	} else { +		u = username +	} +	if err := f.db.GetLocalAccountByUsername(u, ourAccount); err != nil { +		return nil, fmt.Errorf("error getting account %s from db: %s", username, err) +	} + +	transport, err := f.transportController.NewTransport(ourAccount.PublicKeyURI, ourAccount.PrivateKey) +	if err != nil { +		return nil, fmt.Errorf("error creating transport for user %s: %s", username, err) +	} +	return transport, nil +} diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 2f90858b4..8d3142f84 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -21,36 +21,37 @@ package gotosocial  import (  	"context"  	"fmt" +	"net/http"  	"os"  	"os/signal"  	"syscall"  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/action" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/account" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/admin" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/app" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/auth" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" -	mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/security" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status" -	"github.com/superseriousbusiness/gotosocial/internal/cache" +	"github.com/superseriousbusiness/gotosocial/internal/api" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/account" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/app" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/auth" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" +	mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/status" +	"github.com/superseriousbusiness/gotosocial/internal/api/security"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/distributor"  	"github.com/superseriousbusiness/gotosocial/internal/federation" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes"  	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/message"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  	"github.com/superseriousbusiness/gotosocial/internal/router"  	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/transport" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  )  // 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.New(ctx, c, log) +	dbService, err := db.NewPostgresService(ctx, c, log)  	if err != nil {  		return fmt.Errorf("error creating dbservice: %s", err)  	} @@ -65,28 +66,30 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr  		return fmt.Errorf("error creating storage backend: %s", err)  	} +	// build converters and util +	typeConverter := typeutils.NewConverter(c, dbService) +  	// build backend handlers  	mediaHandler := media.New(c, dbService, storageBackend, log)  	oauthServer := oauth.New(dbService, log) -	distributor := distributor.New(log) -	if err := distributor.Start(); err != nil { -		return fmt.Errorf("error starting distributor: %s", err) +	transportController := transport.NewController(c, &federation.Clock{}, http.DefaultClient, log) +	federator := federation.NewFederator(dbService, transportController, c, log, typeConverter) +	processor := message.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storageBackend, dbService, log) +	if err := processor.Start(); err != nil { +		return fmt.Errorf("error starting processor: %s", err)  	} -	// build converters and util -	mastoConverter := mastotypes.New(c, dbService) -  	// build client api modules -	authModule := auth.New(oauthServer, dbService, log) -	accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log) -	appsModule := app.New(oauthServer, dbService, mastoConverter, log) -	mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log) -	fileServerModule := fileserver.New(c, dbService, storageBackend, log) -	adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log) -	statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log) +	authModule := auth.New(c, dbService, oauthServer, log) +	accountModule := account.New(c, processor, log) +	appsModule := app.New(c, processor, log) +	mm := mediaModule.New(c, processor, log) +	fileServerModule := fileserver.New(c, processor, log) +	adminModule := admin.New(c, processor, log) +	statusModule := status.New(c, processor, log)  	securityModule := security.New(c, log) -	apiModules := []apimodule.ClientAPIModule{ +	apis := []api.ClientModule{  		// modules with middleware go first  		securityModule,  		authModule, @@ -100,20 +103,17 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr  		statusModule,  	} -	for _, m := range apiModules { +	for _, m := range apis {  		if err := m.Route(router); err != nil {  			return fmt.Errorf("routing error: %s", err)  		} -		if err := m.CreateTables(dbService); err != nil { -			return fmt.Errorf("table creation error: %s", err) -		}  	}  	if err := dbService.CreateInstanceAccount(); err != nil {  		return fmt.Errorf("error creating instance account: %s", err)  	} -	gts, err := New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c) +	gts, err := New(dbService, router, federator, c)  	if err != nil {  		return fmt.Errorf("error creating gotosocial service: %s", err)  	} diff --git a/internal/gotosocial/gotosocial.go b/internal/gotosocial/gotosocial.go index d8f46f873..f20e1161d 100644 --- a/internal/gotosocial/gotosocial.go +++ b/internal/gotosocial/gotosocial.go @@ -21,10 +21,9 @@ package gotosocial  import (  	"context" -	"github.com/go-fed/activity/pub" -	"github.com/superseriousbusiness/gotosocial/internal/cache"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/federation"  	"github.com/superseriousbusiness/gotosocial/internal/router"  ) @@ -38,23 +37,21 @@ type Gotosocial interface {  // New returns a new gotosocial server, initialized with the given configuration.  // An error will be returned the caller if something goes wrong during initialization  // eg., no db or storage connection, port for router already in use, etc. -func New(db db.DB, cache cache.Cache, apiRouter router.Router, federationAPI pub.FederatingActor, config *config.Config) (Gotosocial, error) { +func New(db db.DB, apiRouter router.Router, federator federation.Federator, config *config.Config) (Gotosocial, error) {  	return &gotosocial{ -		db:            db, -		cache:         cache, -		apiRouter:     apiRouter, -		federationAPI: federationAPI, -		config:        config, +		db:        db, +		apiRouter: apiRouter, +		federator: federator, +		config:    config,  	}, nil  }  // gotosocial fulfils the gotosocial interface.  type gotosocial struct { -	db            db.DB -	cache         cache.Cache -	apiRouter     router.Router -	federationAPI pub.FederatingActor -	config        *config.Config +	db        db.DB +	apiRouter router.Router +	federator federation.Federator +	config    *config.Config  }  // Start starts up the gotosocial server. If something goes wrong diff --git a/internal/gotosocial/mock_Gotosocial.go b/internal/gotosocial/mock_Gotosocial.go deleted file mode 100644 index 66f776e5c..000000000 --- a/internal/gotosocial/mock_Gotosocial.go +++ /dev/null @@ -1,42 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package gotosocial - -import ( -	context "context" - -	mock "github.com/stretchr/testify/mock" -) - -// MockGotosocial is an autogenerated mock type for the Gotosocial type -type MockGotosocial struct { -	mock.Mock -} - -// Start provides a mock function with given fields: _a0 -func (_m *MockGotosocial) Start(_a0 context.Context) error { -	ret := _m.Called(_a0) - -	var r0 error -	if rf, ok := ret.Get(0).(func(context.Context) error); ok { -		r0 = rf(_a0) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} - -// Stop provides a mock function with given fields: _a0 -func (_m *MockGotosocial) Stop(_a0 context.Context) error { -	ret := _m.Called(_a0) - -	var r0 error -	if rf, ok := ret.Get(0).(func(context.Context) error); ok { -		r0 = rf(_a0) -	} else { -		r0 = ret.Error(0) -	} - -	return r0 -} diff --git a/internal/db/gtsmodel/README.md b/internal/gtsmodel/README.md index 12a05ddec..12a05ddec 100644 --- a/internal/db/gtsmodel/README.md +++ b/internal/gtsmodel/README.md diff --git a/internal/db/gtsmodel/account.go b/internal/gtsmodel/account.go index 4bf5a9d33..181b061df 100644 --- a/internal/db/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -46,8 +46,12 @@ type Account struct {  	// ID of the avatar as a media attachment  	AvatarMediaAttachmentID string +	// For a non-local account, where can the header be fetched? +	AvatarRemoteURL string  	// ID of the header as a media attachment  	HeaderMediaAttachmentID string +	// For a non-local account, where can the header be fetched? +	HeaderRemoteURL string  	// DisplayName for this account. Can be empty, then just the Username will be used for display purposes.  	DisplayName string  	// a key/value map of fields that this account has added to their profile @@ -93,15 +97,15 @@ type Account struct {  	// Last time this account was located using the webfinger API.  	LastWebfingeredAt time.Time `pg:"type:timestamp"`  	// Address of this account's activitypub inbox, for sending activity to -	InboxURL string `pg:",unique"` +	InboxURI string `pg:",unique"`  	// Address of this account's activitypub outbox -	OutboxURL string `pg:",unique"` -	// Don't support shared inbox right now so this is just a stub for a future implementation -	SharedInboxURL string `pg:",unique"` -	// URL for getting the followers list of this account -	FollowersURL string `pg:",unique"` +	OutboxURI string `pg:",unique"` +	// URI for getting the following list of this account +	FollowingURI string `pg:",unique"` +	// URI for getting the followers list of this account +	FollowersURI string `pg:",unique"`  	// URL for getting the featured collection list of this account -	FeaturedCollectionURL string `pg:",unique"` +	FeaturedCollectionURI string `pg:",unique"`  	// What type of activitypub actor is this account?  	ActorType ActivityStreamsActor  	// This account is associated with x account id @@ -115,6 +119,8 @@ type Account struct {  	PrivateKey *rsa.PrivateKey  	// Publickey for encoding activitypub requests, will be defined for both local and remote accounts  	PublicKey *rsa.PublicKey +	// Web-reachable location of this account's public key +	PublicKeyURI string  	/*  		ADMIN FIELDS diff --git a/internal/db/gtsmodel/activitystreams.go b/internal/gtsmodel/activitystreams.go index f852340bb..f852340bb 100644 --- a/internal/db/gtsmodel/activitystreams.go +++ b/internal/gtsmodel/activitystreams.go diff --git a/internal/db/gtsmodel/application.go b/internal/gtsmodel/application.go index 8e1398beb..8e1398beb 100644 --- a/internal/db/gtsmodel/application.go +++ b/internal/gtsmodel/application.go diff --git a/internal/db/gtsmodel/block.go b/internal/gtsmodel/block.go index fae43fbef..fae43fbef 100644 --- a/internal/db/gtsmodel/block.go +++ b/internal/gtsmodel/block.go diff --git a/internal/db/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go index dcfb2acee..dcfb2acee 100644 --- a/internal/db/gtsmodel/domainblock.go +++ b/internal/gtsmodel/domainblock.go diff --git a/internal/db/gtsmodel/emaildomainblock.go b/internal/gtsmodel/emaildomainblock.go index 4cda68b02..4cda68b02 100644 --- a/internal/db/gtsmodel/emaildomainblock.go +++ b/internal/gtsmodel/emaildomainblock.go diff --git a/internal/db/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go index c11e2e6b0..c175a1c57 100644 --- a/internal/db/gtsmodel/emoji.go +++ b/internal/gtsmodel/emoji.go @@ -58,6 +58,8 @@ type Emoji struct {  	// MIME content type of the emoji image  	// Probably "image/png"  	ImageContentType string `pg:",notnull"` +	// MIME content type of the static version of the emoji image. +	ImageStaticContentType string `pg:",notnull"`  	// Size of the emoji image file in bytes, for serving purposes.  	ImageFileSize int `pg:",notnull"`  	// Size of the static version of the emoji image file in bytes, for serving purposes. diff --git a/internal/db/gtsmodel/follow.go b/internal/gtsmodel/follow.go index 90080da6e..90080da6e 100644 --- a/internal/db/gtsmodel/follow.go +++ b/internal/gtsmodel/follow.go diff --git a/internal/db/gtsmodel/followrequest.go b/internal/gtsmodel/followrequest.go index 1401a26f1..1401a26f1 100644 --- a/internal/db/gtsmodel/followrequest.go +++ b/internal/gtsmodel/followrequest.go diff --git a/internal/db/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go index 751956252..e98602842 100644 --- a/internal/db/gtsmodel/mediaattachment.go +++ b/internal/gtsmodel/mediaattachment.go @@ -108,15 +108,15 @@ type FileType string  const (  	// FileTypeImage is for jpegs and pngs -	FileTypeImage FileType = "image" +	FileTypeImage FileType = "Image"  	// FileTypeGif is for native gifs and soundless videos that have been converted to gifs -	FileTypeGif FileType = "gif" +	FileTypeGif FileType = "Gif"  	// FileTypeAudio is for audio-only files (no video) -	FileTypeAudio FileType = "audio" +	FileTypeAudio FileType = "Audio"  	// FileTypeVideo is for files with audio + visual -	FileTypeVideo FileType = "video" +	FileTypeVideo FileType = "Video"  	// FileTypeUnknown is for unknown file types (surprise surprise!) -	FileTypeUnknown FileType = "unknown" +	FileTypeUnknown FileType = "Unknown"  )  // FileMeta describes metadata about the actual contents of the file. diff --git a/internal/db/gtsmodel/mention.go b/internal/gtsmodel/mention.go index 18eb11082..18eb11082 100644 --- a/internal/db/gtsmodel/mention.go +++ b/internal/gtsmodel/mention.go diff --git a/internal/db/gtsmodel/poll.go b/internal/gtsmodel/poll.go index c39497cdd..c39497cdd 100644 --- a/internal/db/gtsmodel/poll.go +++ b/internal/gtsmodel/poll.go diff --git a/internal/db/gtsmodel/status.go b/internal/gtsmodel/status.go index 06ef61760..06ef61760 100644 --- a/internal/db/gtsmodel/status.go +++ b/internal/gtsmodel/status.go diff --git a/internal/db/gtsmodel/statusbookmark.go b/internal/gtsmodel/statusbookmark.go index 6246334e3..6246334e3 100644 --- a/internal/db/gtsmodel/statusbookmark.go +++ b/internal/gtsmodel/statusbookmark.go diff --git a/internal/db/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go index 9fb92b931..9fb92b931 100644 --- a/internal/db/gtsmodel/statusfave.go +++ b/internal/gtsmodel/statusfave.go diff --git a/internal/db/gtsmodel/statusmute.go b/internal/gtsmodel/statusmute.go index 53c15e5b5..53c15e5b5 100644 --- a/internal/db/gtsmodel/statusmute.go +++ b/internal/gtsmodel/statusmute.go diff --git a/internal/db/gtsmodel/statuspin.go b/internal/gtsmodel/statuspin.go index 1df333387..1df333387 100644 --- a/internal/db/gtsmodel/statuspin.go +++ b/internal/gtsmodel/statuspin.go diff --git a/internal/db/gtsmodel/tag.go b/internal/gtsmodel/tag.go index 83c471958..83c471958 100644 --- a/internal/db/gtsmodel/tag.go +++ b/internal/gtsmodel/tag.go diff --git a/internal/db/gtsmodel/user.go b/internal/gtsmodel/user.go index a72569945..a72569945 100644 --- a/internal/db/gtsmodel/user.go +++ b/internal/gtsmodel/user.go diff --git a/internal/mastotypes/mastomodel/README.md b/internal/mastotypes/mastomodel/README.md deleted file mode 100644 index 38f9e89c4..000000000 --- a/internal/mastotypes/mastomodel/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Mastotypes - -This package contains Go types/structs for Mastodon's REST API. - -See [here](https://docs.joinmastodon.org/methods/apps/). diff --git a/internal/mastotypes/mock_Converter.go b/internal/mastotypes/mock_Converter.go deleted file mode 100644 index 732d933ae..000000000 --- a/internal/mastotypes/mock_Converter.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package mastotypes - -import ( -	mock "github.com/stretchr/testify/mock" -	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -) - -// MockConverter is an autogenerated mock type for the Converter type -type MockConverter struct { -	mock.Mock -} - -// AccountToMastoPublic provides a mock function with given fields: account -func (_m *MockConverter) AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) { -	ret := _m.Called(account) - -	var r0 *mastotypes.Account -	if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok { -		r0 = rf(account) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(*mastotypes.Account) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok { -		r1 = rf(account) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// AccountToMastoSensitive provides a mock function with given fields: account -func (_m *MockConverter) AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) { -	ret := _m.Called(account) - -	var r0 *mastotypes.Account -	if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok { -		r0 = rf(account) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(*mastotypes.Account) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok { -		r1 = rf(account) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// AppToMastoPublic provides a mock function with given fields: application -func (_m *MockConverter) AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) { -	ret := _m.Called(application) - -	var r0 *mastotypes.Application -	if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok { -		r0 = rf(application) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(*mastotypes.Application) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok { -		r1 = rf(application) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// AppToMastoSensitive provides a mock function with given fields: application -func (_m *MockConverter) AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) { -	ret := _m.Called(application) - -	var r0 *mastotypes.Application -	if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok { -		r0 = rf(application) -	} else { -		if ret.Get(0) != nil { -			r0 = ret.Get(0).(*mastotypes.Application) -		} -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok { -		r1 = rf(application) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// AttachmentToMasto provides a mock function with given fields: attachment -func (_m *MockConverter) AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { -	ret := _m.Called(attachment) - -	var r0 mastotypes.Attachment -	if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment) mastotypes.Attachment); ok { -		r0 = rf(attachment) -	} else { -		r0 = ret.Get(0).(mastotypes.Attachment) -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(*gtsmodel.MediaAttachment) error); ok { -		r1 = rf(attachment) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} - -// MentionToMasto provides a mock function with given fields: m -func (_m *MockConverter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) { -	ret := _m.Called(m) - -	var r0 mastotypes.Mention -	if rf, ok := ret.Get(0).(func(*gtsmodel.Mention) mastotypes.Mention); ok { -		r0 = rf(m) -	} else { -		r0 = ret.Get(0).(mastotypes.Mention) -	} - -	var r1 error -	if rf, ok := ret.Get(1).(func(*gtsmodel.Mention) error); ok { -		r1 = rf(m) -	} else { -		r1 = ret.Error(1) -	} - -	return r0, r1 -} diff --git a/internal/media/media.go b/internal/media/media.go index df8c01e48..c6403fc81 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -28,25 +28,32 @@ import (  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/storage"  ) +// Size describes the *size* of a piece of media +type Size string + +// Type describes the *type* of a piece of media +type Type string +  const ( -	// MediaSmall is the key for small/thumbnail versions of media -	MediaSmall = "small" -	// MediaOriginal is the key for original/fullsize versions of media and emoji -	MediaOriginal = "original" -	// MediaStatic is the key for static (non-animated) versions of emoji -	MediaStatic = "static" -	// MediaAttachment is the key for media attachments -	MediaAttachment = "attachment" -	// MediaHeader is the key for profile header requests -	MediaHeader = "header" -	// MediaAvatar is the key for profile avatar requests -	MediaAvatar = "avatar" -	// MediaEmoji is the key for emoji type requests -	MediaEmoji = "emoji" +	// Small is the key for small/thumbnail versions of media +	Small Size = "small" +	// Original is the key for original/fullsize versions of media and emoji +	Original Size = "original" +	// Static is the key for static (non-animated) versions of emoji +	Static Size = "static" + +	// Attachment is the key for media attachments +	Attachment Type = "attachment" +	// Header is the key for profile header requests +	Header Type = "header" +	// Avatar is the key for profile avatar requests +	Avatar Type = "avatar" +	// Emoji is the key for emoji type requests +	Emoji Type = "emoji"  	// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)  	EmojiMaxBytes = 51200 @@ -57,7 +64,7 @@ type Handler interface {  	// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,  	// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,  	// and then returns information to the caller about the new header. -	ProcessHeaderOrAvatar(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) +	ProcessHeaderOrAvatar(img []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error)  	// ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it,  	// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media, @@ -94,10 +101,10 @@ func New(config *config.Config, database db.DB, storage storage.Storage, log *lo  // ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,  // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,  // and then returns information to the caller about the new header. -func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) { +func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) {  	l := mh.log.WithField("func", "SetHeaderForAccountID") -	if headerOrAvi != MediaHeader && headerOrAvi != MediaAvatar { +	if mediaType != Header && mediaType != Avatar {  		return nil, errors.New("header or avatar not selected")  	} @@ -106,7 +113,7 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin  	if err != nil {  		return nil, err  	} -	if !supportedImageType(contentType) { +	if !SupportedImageType(contentType) {  		return nil, fmt.Errorf("%s is not an accepted image type", contentType)  	} @@ -116,14 +123,14 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin  	l.Tracef("read %d bytes of file", len(attachment))  	// process it -	ma, err := mh.processHeaderOrAvi(attachment, contentType, headerOrAvi, accountID) +	ma, err := mh.processHeaderOrAvi(attachment, contentType, mediaType, accountID)  	if err != nil { -		return nil, fmt.Errorf("error processing %s: %s", headerOrAvi, err) +		return nil, fmt.Errorf("error processing %s: %s", mediaType, err)  	}  	// set it in the database  	if err := mh.db.SetHeaderOrAvatarForAccountID(ma, accountID); err != nil { -		return nil, fmt.Errorf("error putting %s in database: %s", headerOrAvi, err) +		return nil, fmt.Errorf("error putting %s in database: %s", mediaType, err)  	}  	return ma, nil @@ -139,8 +146,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri  	}  	mainType := strings.Split(contentType, "/")[0]  	switch mainType { -	case "video": -		if !supportedVideoType(contentType) { +	case MIMEVideo: +		if !SupportedVideoType(contentType) {  			return nil, fmt.Errorf("video type %s not supported", contentType)  		}  		if len(attachment) == 0 { @@ -150,8 +157,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri  			return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(attachment), mh.config.MediaConfig.MaxVideoSize)  		}  		return mh.processVideoAttachment(attachment, accountID, contentType) -	case "image": -		if !supportedImageType(contentType) { +	case MIMEImage: +		if !SupportedImageType(contentType) {  			return nil, fmt.Errorf("image type %s not supported", contentType)  		}  		if len(attachment) == 0 { @@ -192,13 +199,13 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (  		return nil, fmt.Errorf("emoji size %d bytes exceeded max emoji size of %d bytes", len(emojiBytes), EmojiMaxBytes)  	} -	// clean any exif data from image/png type but leave gifs alone +	// clean any exif data from png but leave gifs alone  	switch contentType { -	case "image/png": +	case MIMEPng:  		if clean, err = purgeExif(emojiBytes); err != nil {  			return nil, fmt.Errorf("error cleaning exif data: %s", err)  		} -	case "image/gif": +	case MIMEGif:  		clean = emojiBytes  	default:  		return nil, errors.New("media type unrecognized") @@ -218,7 +225,7 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (  	// (ie., fileserver/ACCOUNT_ID/etc etc) we need to fetch the INSTANCE ACCOUNT from the database. That is, the account that's created  	// with the same username as the instance hostname, which doesn't belong to any particular user.  	instanceAccount := >smodel.Account{} -	if err := mh.db.GetWhere("username", mh.config.Host, instanceAccount); err != nil { +	if err := mh.db.GetLocalAccountByUsername(mh.config.Host, instanceAccount); err != nil {  		return nil, fmt.Errorf("error fetching instance account: %s", err)  	} @@ -234,15 +241,15 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (  	// webfinger uri for the emoji -- unrelated to actually serving the image  	// will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c -	emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, MediaEmoji, newEmojiID) +	emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, Emoji, newEmojiID)  	// serve url and storage path for the original emoji -- can be png or gif -	emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension) -	emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension) +	emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, Emoji, Original, newEmojiID, extension) +	emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Original, newEmojiID, extension)  	// serve url and storage path for the static version -- will always be png -	emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID) -	emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID) +	emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, Emoji, Static, newEmojiID) +	emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Static, newEmojiID)  	// store the original  	if err := mh.storage.StoreFileAt(emojiPath, original.image); err != nil { @@ -256,25 +263,26 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (  	// and finally return the new emoji data to the caller -- it's up to them what to do with it  	e := >smodel.Emoji{ -		ID:                   newEmojiID, -		Shortcode:            shortcode, -		Domain:               "", // empty because this is a local emoji -		CreatedAt:            time.Now(), -		UpdatedAt:            time.Now(), -		ImageRemoteURL:       "", // empty because this is a local emoji -		ImageStaticRemoteURL: "", // empty because this is a local emoji -		ImageURL:             emojiURL, -		ImageStaticURL:       emojiStaticURL, -		ImagePath:            emojiPath, -		ImageStaticPath:      emojiStaticPath, -		ImageContentType:     contentType, -		ImageFileSize:        len(original.image), -		ImageStaticFileSize:  len(static.image), -		ImageUpdatedAt:       time.Now(), -		Disabled:             false, -		URI:                  emojiURI, -		VisibleInPicker:      true, -		CategoryID:           "", // empty because this is a new emoji -- no category yet +		ID:                     newEmojiID, +		Shortcode:              shortcode, +		Domain:                 "", // empty because this is a local emoji +		CreatedAt:              time.Now(), +		UpdatedAt:              time.Now(), +		ImageRemoteURL:         "", // empty because this is a local emoji +		ImageStaticRemoteURL:   "", // empty because this is a local emoji +		ImageURL:               emojiURL, +		ImageStaticURL:         emojiStaticURL, +		ImagePath:              emojiPath, +		ImageStaticPath:        emojiStaticPath, +		ImageContentType:       contentType, +		ImageStaticContentType: MIMEPng, // static version will always be a png +		ImageFileSize:          len(original.image), +		ImageStaticFileSize:    len(static.image), +		ImageUpdatedAt:         time.Now(), +		Disabled:               false, +		URI:                    emojiURI, +		VisibleInPicker:        true, +		CategoryID:             "", // empty because this is a new emoji -- no category yet  	}  	return e, nil  } @@ -294,7 +302,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co  	var small *imageAndMeta  	switch contentType { -	case "image/jpeg", "image/png": +	case MIMEJpeg, MIMEPng:  		if clean, err = purgeExif(data); err != nil {  			return nil, fmt.Errorf("error cleaning exif data: %s", err)  		} @@ -302,7 +310,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co  		if err != nil {  			return nil, fmt.Errorf("error parsing image: %s", err)  		} -	case "image/gif": +	case MIMEGif:  		clean = data  		original, err = deriveGif(clean, contentType)  		if err != nil { @@ -326,13 +334,13 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co  	smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.jpeg", URLbase, accountID, newMediaID) // all thumbnails/smalls are encoded as jpeg  	// we store the original... -	originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaOriginal, newMediaID, extension) +	originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, Attachment, Original, newMediaID, extension)  	if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {  		return nil, fmt.Errorf("storage error: %s", err)  	}  	// and a thumbnail... -	smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg +	smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, Attachment, Small, newMediaID) // all thumbnails/smalls are encoded as jpeg  	if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {  		return nil, fmt.Errorf("storage error: %s", err)  	} @@ -372,7 +380,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co  		},  		Thumbnail: gtsmodel.Thumbnail{  			Path:        smallPath, -			ContentType: "image/jpeg", // all thumbnails/smalls are encoded as jpeg +			ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg  			FileSize:    len(small.image),  			UpdatedAt:   time.Now(),  			URL:         smallURL, @@ -386,14 +394,14 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co  } -func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, headerOrAvi string, accountID string) (*gtsmodel.MediaAttachment, error) { +func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string) (*gtsmodel.MediaAttachment, error) {  	var isHeader bool  	var isAvatar bool -	switch headerOrAvi { -	case MediaHeader: +	switch mediaType { +	case Header:  		isHeader = true -	case MediaAvatar: +	case Avatar:  		isAvatar = true  	default:  		return nil, errors.New("header or avatar not selected") @@ -403,15 +411,15 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string  	var err error  	switch contentType { -	case "image/jpeg": +	case MIMEJpeg:  		if clean, err = purgeExif(imageBytes); err != nil {  			return nil, fmt.Errorf("error cleaning exif data: %s", err)  		} -	case "image/png": +	case MIMEPng:  		if clean, err = purgeExif(imageBytes); err != nil {  			return nil, fmt.Errorf("error cleaning exif data: %s", err)  		} -	case "image/gif": +	case MIMEGif:  		clean = imageBytes  	default:  		return nil, errors.New("media type unrecognized") @@ -432,17 +440,17 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string  	newMediaID := uuid.NewString()  	URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) -	originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension) -	smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension) +	originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, mediaType, newMediaID, extension) +	smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)  	// we store the original... -	originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaOriginal, newMediaID, extension) +	originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Original, newMediaID, extension)  	if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {  		return nil, fmt.Errorf("storage error: %s", err)  	}  	// and a thumbnail... -	smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaSmall, newMediaID, extension) +	smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Small, newMediaID, extension)  	if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {  		return nil, fmt.Errorf("storage error: %s", err)  	} diff --git a/internal/media/media_test.go b/internal/media/media_test.go index 58f2e029e..8045295d2 100644 --- a/internal/media/media_test.go +++ b/internal/media/media_test.go @@ -29,7 +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/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/storage"  ) @@ -78,7 +78,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.New(context.Background(), c, log) +	database, err := db.NewPostgresService(context.Background(), c, log)  	if err != nil {  		suite.FailNow(err.Error())  	} diff --git a/internal/media/mock_MediaHandler.go b/internal/media/mock_MediaHandler.go index 1f875557a..10fffbba4 100644 --- a/internal/media/mock_MediaHandler.go +++ b/internal/media/mock_MediaHandler.go @@ -4,7 +4,7 @@ package media  import (  	mock "github.com/stretchr/testify/mock" -	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  )  // MockMediaHandler is an autogenerated mock type for the MediaHandler type diff --git a/internal/media/util.go b/internal/media/util.go index 64d1ee770..f4f2819af 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -33,6 +33,26 @@ import (  	"github.com/superseriousbusiness/exifremove/pkg/exifremove"  ) +const ( +	// MIMEImage is the mime type for image +	MIMEImage = "image" +	// MIMEJpeg is the jpeg image mime type +	MIMEJpeg = "image/jpeg" +	// MIMEGif is the gif image mime type +	MIMEGif = "image/gif" +	// MIMEPng is the png image mime type +	MIMEPng = "image/png" + +	// MIMEVideo is the mime type for video +	MIMEVideo = "video" +	// MIMEMp4 is the mp4 video mime type +	MIMEMp4 = "video/mp4" +	// MIMEMpeg is the mpeg video mime type +	MIMEMpeg = "video/mpeg" +	// MIMEWebm is the webm video mime type +	MIMEWebm = "video/webm" +) +  // parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg").  // Returns an error if the content type is not something we can process.  func parseContentType(content []byte) (string, error) { @@ -54,13 +74,13 @@ func parseContentType(content []byte) (string, error) {  	return kind.MIME.Value, nil  } -// supportedImageType checks mime type of an image against a slice of accepted types, +// SupportedImageType checks mime type of an image against a slice of accepted types,  // and returns True if the mime type is accepted. -func supportedImageType(mimeType string) bool { +func SupportedImageType(mimeType string) bool {  	acceptedImageTypes := []string{ -		"image/jpeg", -		"image/gif", -		"image/png", +		MIMEJpeg, +		MIMEGif, +		MIMEPng,  	}  	for _, accepted := range acceptedImageTypes {  		if mimeType == accepted { @@ -70,13 +90,13 @@ func supportedImageType(mimeType string) bool {  	return false  } -// supportedVideoType checks mime type of a video against a slice of accepted types, +// SupportedVideoType checks mime type of a video against a slice of accepted types,  // and returns True if the mime type is accepted. -func supportedVideoType(mimeType string) bool { +func SupportedVideoType(mimeType string) bool {  	acceptedVideoTypes := []string{ -		"video/mp4", -		"video/mpeg", -		"video/webm", +		MIMEMp4, +		MIMEMpeg, +		MIMEWebm,  	}  	for _, accepted := range acceptedVideoTypes {  		if mimeType == accepted { @@ -89,8 +109,8 @@ func supportedVideoType(mimeType string) bool {  // supportedEmojiType checks that the content type is image/png -- the only type supported for emoji.  func supportedEmojiType(mimeType string) bool {  	acceptedEmojiTypes := []string{ -		"image/gif", -		"image/png", +		MIMEGif, +		MIMEPng,  	}  	for _, accepted := range acceptedEmojiTypes {  		if mimeType == accepted { @@ -121,7 +141,7 @@ func deriveGif(b []byte, extension string) (*imageAndMeta, error) {  	var g *gif.GIF  	var err error  	switch extension { -	case "image/gif": +	case MIMEGif:  		g, err = gif.DecodeAll(bytes.NewReader(b))  		if err != nil {  			return nil, err @@ -161,12 +181,12 @@ func deriveImage(b []byte, contentType string) (*imageAndMeta, error) {  	var err error  	switch contentType { -	case "image/jpeg": +	case MIMEJpeg:  		i, err = jpeg.Decode(bytes.NewReader(b))  		if err != nil {  			return nil, err  		} -	case "image/png": +	case MIMEPng:  		i, err = png.Decode(bytes.NewReader(b))  		if err != nil {  			return nil, err @@ -210,17 +230,17 @@ func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMet  	var err error  	switch contentType { -	case "image/jpeg": +	case MIMEJpeg:  		i, err = jpeg.Decode(bytes.NewReader(b))  		if err != nil {  			return nil, err  		} -	case "image/png": +	case MIMEPng:  		i, err = png.Decode(bytes.NewReader(b))  		if err != nil {  			return nil, err  		} -	case "image/gif": +	case MIMEGif:  		i, err = gif.Decode(bytes.NewReader(b))  		if err != nil {  			return nil, err @@ -254,12 +274,12 @@ func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) {  	var err error  	switch contentType { -	case "image/png": +	case MIMEPng:  		i, err = png.Decode(bytes.NewReader(b))  		if err != nil {  			return nil, err  		} -	case "image/gif": +	case MIMEGif:  		i, err = gif.Decode(bytes.NewReader(b))  		if err != nil {  			return nil, err @@ -285,3 +305,31 @@ type imageAndMeta struct {  	aspect   float64  	blurhash string  } + +// ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized +func ParseMediaType(s string) (Type, error) { +	switch Type(s) { +	case Attachment: +		return Attachment, nil +	case Header: +		return Header, nil +	case Avatar: +		return Avatar, nil +	case Emoji: +		return Emoji, nil +	} +	return "", fmt.Errorf("%s not a recognized MediaType", s) +} + +// ParseMediaSize converts s to a recognized MediaSize, or returns an error if unrecognized +func ParseMediaSize(s string) (Size, error) { +	switch Size(s) { +	case Small: +		return Small, nil +	case Original: +		return Original, nil +	case Static: +		return Static, nil +	} +	return "", fmt.Errorf("%s not a recognized MediaSize", s) +} diff --git a/internal/media/util_test.go b/internal/media/util_test.go index be617a256..db2cca690 100644 --- a/internal/media/util_test.go +++ b/internal/media/util_test.go @@ -135,10 +135,10 @@ func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() {  }  func (suite *MediaUtilTestSuite) TestSupportedImageTypes() { -	ok := supportedImageType("image/jpeg") +	ok := SupportedImageType("image/jpeg")  	assert.True(suite.T(), ok) -	ok = supportedImageType("image/bmp") +	ok = SupportedImageType("image/bmp")  	assert.False(suite.T(), ok)  } diff --git a/internal/message/accountprocess.go b/internal/message/accountprocess.go new file mode 100644 index 000000000..9433140d7 --- /dev/null +++ b/internal/message/accountprocess.go @@ -0,0 +1,168 @@ +package message + +import ( +	"errors" +	"fmt" + +	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" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +// accountCreate does the dirty work of making an account and user in the database. +// It then returns a token to the caller, for use with the new account, as per the +// spec here: https://docs.joinmastodon.org/methods/accounts/ +func (p *processor) AccountCreate(authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) { +	l := p.log.WithField("func", "accountCreate") + +	if err := p.db.IsEmailAvailable(form.Email); err != nil { +		return nil, err +	} + +	if err := p.db.IsUsernameAvailable(form.Username); err != nil { +		return nil, err +	} + +	// don't store a reason if we don't require one +	reason := form.Reason +	if !p.config.AccountsConfig.ReasonRequired { +		reason = "" +	} + +	l.Trace("creating new username and account") +	user, err := p.db.NewSignup(form.Username, reason, p.config.AccountsConfig.RequireApproval, form.Email, form.Password, form.IP, form.Locale, authed.Application.ID) +	if err != nil { +		return nil, fmt.Errorf("error creating new signup in the database: %s", err) +	} + +	l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, authed.Application.ID) +	accessToken, err := p.oauthServer.GenerateUserAccessToken(authed.Token, authed.Application.ClientSecret, user.ID) +	if err != nil { +		return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) +	} + +	return &apimodel.Token{ +		AccessToken: accessToken.GetAccess(), +		TokenType:   "Bearer", +		Scope:       accessToken.GetScope(), +		CreatedAt:   accessToken.GetAccessCreateAt().Unix(), +	}, nil +} + +func (p *processor) AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) { +	targetAccount := >smodel.Account{} +	if err := p.db.GetByID(targetAccountID, targetAccount); err != nil { +		if _, ok := err.(db.ErrNoEntries); ok { +			return nil, errors.New("account not found") +		} +		return nil, fmt.Errorf("db error: %s", err) +	} + +	var mastoAccount *apimodel.Account +	var err error +	if authed.Account != nil && targetAccount.ID == authed.Account.ID { +		mastoAccount, err = p.tc.AccountToMastoSensitive(targetAccount) +	} else { +		mastoAccount, err = p.tc.AccountToMastoPublic(targetAccount) +	} +	if err != nil { +		return nil, fmt.Errorf("error converting account: %s", err) +	} +	return mastoAccount, nil +} + +func (p *processor) AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) { +	l := p.log.WithField("func", "AccountUpdate") + +	if form.Discoverable != nil { +		if err := p.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil { +			return nil, fmt.Errorf("error updating discoverable: %s", err) +		} +	} + +	if form.Bot != nil { +		if err := p.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil { +			return nil, fmt.Errorf("error updating bot: %s", err) +		} +	} + +	if form.DisplayName != nil { +		if err := util.ValidateDisplayName(*form.DisplayName); err != nil { +			return nil, err +		} +		if err := p.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, >smodel.Account{}); err != nil { +			return nil, err +		} +	} + +	if form.Note != nil { +		if err := util.ValidateNote(*form.Note); err != nil { +			return nil, err +		} +		if err := p.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, >smodel.Account{}); err != nil { +			return nil, err +		} +	} + +	if form.Avatar != nil && form.Avatar.Size != 0 { +		avatarInfo, err := p.updateAccountAvatar(form.Avatar, authed.Account.ID) +		if err != nil { +			return nil, err +		} +		l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo) +	} + +	if form.Header != nil && form.Header.Size != 0 { +		headerInfo, err := p.updateAccountHeader(form.Header, authed.Account.ID) +		if err != nil { +			return nil, err +		} +		l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo) +	} + +	if form.Locked != nil { +		if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { +			return nil, err +		} +	} + +	if form.Source != nil { +		if form.Source.Language != nil { +			if err := util.ValidateLanguage(*form.Source.Language); err != nil { +				return nil, err +			} +			if err := p.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil { +				return nil, err +			} +		} + +		if form.Source.Sensitive != nil { +			if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { +				return nil, err +			} +		} + +		if form.Source.Privacy != nil { +			if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil { +				return nil, err +			} +			if err := p.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil { +				return nil, err +			} +		} +	} + +	// fetch the account with all updated values set +	updatedAccount := >smodel.Account{} +	if err := p.db.GetByID(authed.Account.ID, updatedAccount); err != nil { +		return nil, fmt.Errorf("could not fetch updated account %s: %s", authed.Account.ID, err) +	} + +	acctSensitive, err := p.tc.AccountToMastoSensitive(updatedAccount) +	if err != nil { +		return nil, fmt.Errorf("could not convert account into mastosensitive account: %s", err) +	} +	return acctSensitive, nil +} diff --git a/internal/message/adminprocess.go b/internal/message/adminprocess.go new file mode 100644 index 000000000..abf7b61c7 --- /dev/null +++ b/internal/message/adminprocess.go @@ -0,0 +1,48 @@ +package message + +import ( +	"bytes" +	"errors" +	"fmt" +	"io" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) { +	if !authed.User.Admin { +		return nil, fmt.Errorf("user %s not an admin", authed.User.ID) +	} + +	// open the emoji and extract the bytes from it +	f, err := form.Image.Open() +	if err != nil { +		return nil, fmt.Errorf("error opening emoji: %s", err) +	} +	buf := new(bytes.Buffer) +	size, err := io.Copy(buf, f) +	if err != nil { +		return nil, fmt.Errorf("error reading emoji: %s", err) +	} +	if size == 0 { +		return nil, errors.New("could not read provided emoji: size 0 bytes") +	} + +	// allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using +	emoji, err := p.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode) +	if err != nil { +		return nil, fmt.Errorf("error reading emoji: %s", err) +	} + +	mastoEmoji, err := p.tc.EmojiToMasto(emoji) +	if err != nil { +		return nil, fmt.Errorf("error converting emoji to mastotype: %s", err) +	} + +	if err := p.db.Put(emoji); err != nil { +		return nil, fmt.Errorf("database error while processing emoji: %s", err) +	} + +	return &mastoEmoji, nil +} diff --git a/internal/message/appprocess.go b/internal/message/appprocess.go new file mode 100644 index 000000000..bf56f0874 --- /dev/null +++ b/internal/message/appprocess.go @@ -0,0 +1,59 @@ +package message + +import ( +	"github.com/google/uuid" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) { +	// set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/ +	var scopes string +	if form.Scopes == "" { +		scopes = "read" +	} else { +		scopes = form.Scopes +	} + +	// generate new IDs for this application and its associated client +	clientID := uuid.NewString() +	clientSecret := uuid.NewString() +	vapidKey := uuid.NewString() + +	// generate the application to put in the database +	app := >smodel.Application{ +		Name:         form.ClientName, +		Website:      form.Website, +		RedirectURI:  form.RedirectURIs, +		ClientID:     clientID, +		ClientSecret: clientSecret, +		Scopes:       scopes, +		VapidKey:     vapidKey, +	} + +	// chuck it in the db +	if err := p.db.Put(app); err != nil { +		return nil, err +	} + +	// now we need to model an oauth client from the application that the oauth library can use +	oc := &oauth.Client{ +		ID:     clientID, +		Secret: clientSecret, +		Domain: form.RedirectURIs, +		UserID: "", // This client isn't yet associated with a specific user,  it's just an app client right now +	} + +	// chuck it in the db +	if err := p.db.Put(oc); err != nil { +		return nil, err +	} + +	mastoApp, err := p.tc.AppToMastoSensitive(app) +	if err != nil { +		return nil, err +	} + +	return mastoApp, nil +} diff --git a/internal/message/error.go b/internal/message/error.go new file mode 100644 index 000000000..cbd55dc78 --- /dev/null +++ b/internal/message/error.go @@ -0,0 +1,106 @@ +package message + +import ( +	"errors" +	"net/http" +	"strings" +) + +// ErrorWithCode wraps an internal error with an http code, and a 'safe' version of +// the error that can be served to clients without revealing internal business logic. +// +// A typical use of this error would be to first log the Original error, then return +// the Safe error and the StatusCode to an API caller. +type ErrorWithCode interface { +	// Error returns the original internal error for debugging within the GoToSocial logs. +	// This should *NEVER* be returned to a client as it may contain sensitive information. +	Error() string +	// Safe returns the API-safe version of the error for serialization towards a client. +	// There's not much point logging this internally because it won't contain much helpful information. +	Safe() string +	//  Code returns the status code for serving to a client. +	Code() int +} + +type errorWithCode struct { +	original error +	safe     error +	code     int +} + +func (e errorWithCode) Error() string { +	return e.original.Error() +} + +func (e errorWithCode) Safe() string { +	return e.safe.Error() +} + +func (e errorWithCode) Code() int { +	return e.code +} + +// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text. +func NewErrorBadRequest(original error, helpText ...string) ErrorWithCode { +	safe := "bad request" +	if helpText != nil { +		safe = safe + ": " + strings.Join(helpText, ": ") +	} +	return errorWithCode{ +		original: original, +		safe:     errors.New(safe), +		code:     http.StatusBadRequest, +	} +} + +// NewErrorNotAuthorized returns an ErrorWithCode 401 with the given original error and optional help text. +func NewErrorNotAuthorized(original error, helpText ...string) ErrorWithCode { +	safe := "not authorized" +	if helpText != nil { +		safe = safe + ": " + strings.Join(helpText, ": ") +	} +	return errorWithCode{ +		original: original, +		safe:     errors.New(safe), +		code:     http.StatusUnauthorized, +	} +} + +// NewErrorForbidden returns an ErrorWithCode 403 with the given original error and optional help text. +func NewErrorForbidden(original error, helpText ...string) ErrorWithCode { +	safe := "forbidden" +	if helpText != nil { +		safe = safe + ": " + strings.Join(helpText, ": ") +	} +	return errorWithCode{ +		original: original, +		safe:     errors.New(safe), +		code:     http.StatusForbidden, +	} +} + +// NewErrorNotFound returns an ErrorWithCode 404 with the given original error and optional help text. +func NewErrorNotFound(original error, helpText ...string) ErrorWithCode { +	safe := "404 not found" +	if helpText != nil { +		safe = safe + ": " + strings.Join(helpText, ": ") +	} +	return errorWithCode{ +		original: original, +		safe:     errors.New(safe), +		code:     http.StatusNotFound, +	} +} + +// NewErrorInternalError returns an ErrorWithCode 500 with the given original error and optional help text. +func NewErrorInternalError(original error, helpText ...string) ErrorWithCode { +	safe := "internal server error" +	if helpText != nil { +		safe = safe + ": " + strings.Join(helpText, ": ") +	} +	return errorWithCode{ +		original: original, +		safe:     errors.New(safe), +		code:     http.StatusInternalServerError, +	} +} diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go new file mode 100644 index 000000000..6dc6330cf --- /dev/null +++ b/internal/message/fediprocess.go @@ -0,0 +1,102 @@ +package message + +import ( +	"fmt" +	"net/http" + +	"github.com/go-fed/activity/streams" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// authenticateAndDereferenceFediRequest authenticates the HTTP signature of an incoming federation request, using the given +// username to perform the validation. It will *also* dereference the originator of the request and return it as a gtsmodel account +// for further processing. NOTE that this function will have the side effect of putting the dereferenced account into the database, +// and passing it into the processor through a channel for further asynchronous processing. +func (p *processor) authenticateAndDereferenceFediRequest(username string, r *http.Request) (*gtsmodel.Account, error) { + +	// first authenticate +	requestingAccountURI, err := p.federator.AuthenticateFederatedRequest(username, r) +	if err != nil { +		return nil, fmt.Errorf("couldn't authenticate request for username %s: %s", username, err) +	} + +	// OK now we can do the dereferencing part +	// we might already have an entry for this account so check that first +	requestingAccount := >smodel.Account{} + +	err = p.db.GetWhere("uri", requestingAccountURI.String(), requestingAccount) +	if err == nil { +		// we do have it yay, return it +		return requestingAccount, nil +	} + +	if _, ok := err.(db.ErrNoEntries); !ok { +		// something has actually gone wrong so bail +		return nil, fmt.Errorf("database error getting account with uri %s: %s", requestingAccountURI.String(), err) +	} + +	// we just don't have an entry for this account yet +	// what we do now should depend on our chosen federation method +	// for now though, we'll just dereference it +	// TODO: slow-fed +	requestingPerson, err := p.federator.DereferenceRemoteAccount(username, requestingAccountURI) +	if err != nil { +		return nil, fmt.Errorf("couldn't dereference %s: %s", requestingAccountURI.String(), err) +	} + +	// convert it to our internal account representation +	requestingAccount, err = p.tc.ASRepresentationToAccount(requestingPerson) +	if err != nil { +		return nil, fmt.Errorf("couldn't convert dereferenced uri %s to gtsmodel account: %s", requestingAccountURI.String(), err) +	} + +	// shove it in the database for later +	if err := p.db.Put(requestingAccount); err != nil { +		return nil, fmt.Errorf("database error inserting account with uri %s: %s", requestingAccountURI.String(), err) +	} + +	// put it in our channel to queue it for async processing +	p.FromFederator() <- FromFederator{ +		APObjectType:   gtsmodel.ActivityStreamsProfile, +		APActivityType: gtsmodel.ActivityStreamsCreate, +		Activity:       requestingAccount, +	} + +	return requestingAccount, nil +} + +func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) { +	// get the account the request is referring to +	requestedAccount := >smodel.Account{} +	if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { +		return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) +	} + +	// authenticate the request +	requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) +	if err != nil { +		return nil, NewErrorNotAuthorized(err) +	} + +	blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) +	if err != nil { +		return nil, NewErrorInternalError(err) +	} + +	if blocked { +		return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) +	} + +	requestedPerson, err := p.tc.AccountToAS(requestedAccount) +	if err != nil { +		return nil, NewErrorInternalError(err) +	} + +	data, err := streams.Serialize(requestedPerson) +	if err != nil { +		return nil, NewErrorInternalError(err) +	} + +	return data, nil +} diff --git a/internal/message/mediaprocess.go b/internal/message/mediaprocess.go new file mode 100644 index 000000000..77b387df3 --- /dev/null +++ b/internal/message/mediaprocess.go @@ -0,0 +1,188 @@ +package message + +import ( +	"bytes" +	"errors" +	"fmt" +	"io" +	"strconv" +	"strings" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { +	// First check this user/account is permitted to create media +	// There's no point continuing otherwise. +	if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { +		return nil, errors.New("not authorized to post new media") +	} + +	// open the attachment and extract the bytes from it +	f, err := form.File.Open() +	if err != nil { +		return nil, fmt.Errorf("error opening attachment: %s", err) +	} +	buf := new(bytes.Buffer) +	size, err := io.Copy(buf, f) +	if err != nil { +		return nil, fmt.Errorf("error reading attachment: %s", err) + +	} +	if size == 0 { +		return nil, errors.New("could not read provided attachment: size 0 bytes") +	} + +	// allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using +	attachment, err := p.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID) +	if err != nil { +		return nil, fmt.Errorf("error reading attachment: %s", err) +	} + +	// now we need to add extra fields that the attachment processor doesn't know (from the form) +	// TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it) + +	// first description +	attachment.Description = form.Description + +	// now parse the focus parameter +	// TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated +	var focusx, focusy float32 +	if form.Focus != "" { +		spl := strings.Split(form.Focus, ",") +		if len(spl) != 2 { +			return nil, fmt.Errorf("improperly formatted focus %s", form.Focus) +		} +		xStr := spl[0] +		yStr := spl[1] +		if xStr == "" || yStr == "" { +			return nil, fmt.Errorf("improperly formatted focus %s", form.Focus) +		} +		fx, err := strconv.ParseFloat(xStr, 32) +		if err != nil { +			return nil, fmt.Errorf("improperly formatted focus %s: %s", form.Focus, err) +		} +		if fx > 1 || fx < -1 { +			return nil, fmt.Errorf("improperly formatted focus %s", form.Focus) +		} +		focusx = float32(fx) +		fy, err := strconv.ParseFloat(yStr, 32) +		if err != nil { +			return nil, fmt.Errorf("improperly formatted focus %s: %s", form.Focus, err) +		} +		if fy > 1 || fy < -1 { +			return nil, fmt.Errorf("improperly formatted focus %s", form.Focus) +		} +		focusy = float32(fy) +	} +	attachment.FileMeta.Focus.X = focusx +	attachment.FileMeta.Focus.Y = focusy + +	// prepare the frontend representation now -- if there are any errors here at least we can bail without +	// having already put something in the database and then having to clean it up again (eugh) +	mastoAttachment, err := p.tc.AttachmentToMasto(attachment) +	if err != nil { +		return nil, fmt.Errorf("error parsing media attachment to frontend type: %s", err) +	} + +	// now we can confidently put the attachment in the database +	if err := p.db.Put(attachment); err != nil { +		return nil, fmt.Errorf("error storing media attachment in db: %s", err) +	} + +	return &mastoAttachment, nil +} + +func (p *processor) MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) { +	// parse the form fields +	mediaSize, err := media.ParseMediaSize(form.MediaSize) +	if err != nil { +		return nil, NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize)) +	} + +	mediaType, err := media.ParseMediaType(form.MediaType) +	if err != nil { +		return nil, NewErrorNotFound(fmt.Errorf("media type %s not valid", form.MediaType)) +	} + +	spl := strings.Split(form.FileName, ".") +	if len(spl) != 2 || spl[0] == "" || spl[1] == "" { +		return nil, NewErrorNotFound(fmt.Errorf("file name %s not parseable", form.FileName)) +	} +	wantedMediaID := spl[0] + +	// get the account that owns the media and make sure it's not suspended +	acct := >smodel.Account{} +	if err := p.db.GetByID(form.AccountID, acct); err != nil { +		return nil, NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", form.AccountID, err)) +	} +	if !acct.SuspendedAt.IsZero() { +		return nil, NewErrorNotFound(fmt.Errorf("account with id %s is suspended", form.AccountID)) +	} + +	// make sure the requesting account and the media account don't block each other +	if authed.Account != nil { +		blocked, err := p.db.Blocked(authed.Account.ID, form.AccountID) +		if err != nil { +			return nil, NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", form.AccountID, authed.Account.ID, err)) +		} +		if blocked { +			return nil, NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", form.AccountID, authed.Account.ID)) +		} +	} + +	// the way we store emojis is a little different from the way we store other attachments, +	// so we need to take different steps depending on the media type being requested +	content := &apimodel.Content{} +	var storagePath string +	switch mediaType { +	case media.Emoji: +		e := >smodel.Emoji{} +		if err := p.db.GetByID(wantedMediaID, e); err != nil { +			return nil, NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", wantedMediaID, err)) +		} +		if e.Disabled { +			return nil, NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", wantedMediaID)) +		} +		switch mediaSize { +		case media.Original: +			content.ContentType = e.ImageContentType +			storagePath = e.ImagePath +		case media.Static: +			content.ContentType = e.ImageStaticContentType +			storagePath = e.ImageStaticPath +		default: +			return nil, NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", mediaSize)) +		} +	case media.Attachment, media.Header, media.Avatar: +		a := >smodel.MediaAttachment{} +		if err := p.db.GetByID(wantedMediaID, a); err != nil { +			return nil, NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err)) +		} +		if a.AccountID != form.AccountID { +			return nil, NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, form.AccountID)) +		} +		switch mediaSize { +		case media.Original: +			content.ContentType = a.File.ContentType +			storagePath = a.File.Path +		case media.Small: +			content.ContentType = a.Thumbnail.ContentType +			storagePath = a.Thumbnail.Path +		default: +			return nil, NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) +		} +	} + +	bytes, err := p.storage.RetrieveFileFrom(storagePath) +	if err != nil { +		return nil, NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) +	} + +	content.ContentLength = int64(len(bytes)) +	content.Content = bytes +	return content, nil +} diff --git a/internal/message/processor.go b/internal/message/processor.go new file mode 100644 index 000000000..d0027c915 --- /dev/null +++ b/internal/message/processor.go @@ -0,0 +1,215 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package message + +import ( +	"net/http" + +	"github.com/sirupsen/logrus" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"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/media" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Processor should be passed to api modules (see internal/apimodule/...). It is used for +// passing messages back and forth from the client API and the federating interface, via channels. +// It also contains logic for filtering which messages should end up where. +// It is designed to be used asynchronously: the client API and the federating API should just be able to +// fire messages into the processor and not wait for a reply before proceeding with other work. This allows +// for clean distribution of messages without slowing down the client API and harming the user experience. +type Processor interface { +	// ToClientAPI returns a channel for putting in messages that need to go to the gts client API. +	ToClientAPI() chan ToClientAPI +	// FromClientAPI returns a channel for putting messages in that come from the client api going to the processor +	FromClientAPI() chan FromClientAPI +	// ToFederator returns a channel for putting in messages that need to go to the federator (activitypub). +	ToFederator() chan ToFederator +	// FromFederator returns a channel for putting messages in that come from the federator (activitypub) going into the processor +	FromFederator() chan FromFederator +	// Start starts the Processor, reading from its channels and passing messages back and forth. +	Start() error +	// Stop stops the processor cleanly, finishing handling any remaining messages before closing down. +	Stop() error + +	/* +		CLIENT API-FACING PROCESSING FUNCTIONS +		These functions are intended to be called when the API client needs an immediate (ie., synchronous) reply +		to an HTTP request. As such, they will only do the bare-minimum of work necessary to give a properly +		formed reply. For more intensive (and time-consuming) calls, where you don't require an immediate +		response, pass work to the processor using a channel instead. +	*/ + +	// AccountCreate processes the given form for creating a new account, returning an oauth token for that account if successful. +	AccountCreate(authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) +	// AccountGet processes the given request for account information. +	AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) +	// AccountUpdate processes the update of an account with the given form +	AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) + +	// AppCreate processes the creation of a new API application +	AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) + +	// StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK. +	StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) +	// StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through. +	StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) +	// StatusFave processes the faving of a given status, returning the updated status if the fave goes through. +	StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) +	// StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings. +	StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) +	// StatusGet gets the given status, taking account of privacy settings and blocks etc. +	StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) +	// StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through. +	StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + +	// MediaCreate handles the creation of a media attachment, using the given form. +	MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) +	// MediaGet handles the fetching of a media attachment, using the given request form. +	MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) +	// AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form. +	AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) + +	/* +		FEDERATION API-FACING PROCESSING FUNCTIONS +		These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply +		to an HTTP request. As such, they will only do the bare-minimum of work necessary to give a properly +		formed reply. For more intensive (and time-consuming) calls, where you don't require an immediate +		response, pass work to the processor using a channel instead. +	*/ + +	// GetFediUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication +	// before returning a JSON serializable interface to the caller. +	GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) +} + +// processor just implements the Processor interface +type processor struct { +	// federator     pub.FederatingActor +	toClientAPI   chan ToClientAPI +	fromClientAPI chan FromClientAPI +	toFederator   chan ToFederator +	fromFederator chan FromFederator +	federator     federation.Federator +	stop          chan interface{} +	log           *logrus.Logger +	config        *config.Config +	tc            typeutils.TypeConverter +	oauthServer   oauth.Server +	mediaHandler  media.Handler +	storage       storage.Storage +	db            db.DB +} + +// NewProcessor returns a new Processor that uses the given federator and logger +func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator federation.Federator, oauthServer oauth.Server, mediaHandler media.Handler, storage storage.Storage, db db.DB, log *logrus.Logger) Processor { +	return &processor{ +		toClientAPI:   make(chan ToClientAPI, 100), +		fromClientAPI: make(chan FromClientAPI, 100), +		toFederator:   make(chan ToFederator, 100), +		fromFederator: make(chan FromFederator, 100), +		federator:     federator, +		stop:          make(chan interface{}), +		log:           log, +		config:        config, +		tc:            tc, +		oauthServer:   oauthServer, +		mediaHandler:  mediaHandler, +		storage:       storage, +		db:            db, +	} +} + +func (p *processor) ToClientAPI() chan ToClientAPI { +	return p.toClientAPI +} + +func (p *processor) FromClientAPI() chan FromClientAPI { +	return p.fromClientAPI +} + +func (p *processor) ToFederator() chan ToFederator { +	return p.toFederator +} + +func (p *processor) FromFederator() chan FromFederator { +	return p.fromFederator +} + +// Start starts the Processor, reading from its channels and passing messages back and forth. +func (p *processor) Start() error { +	go func() { +	DistLoop: +		for { +			select { +			case clientMsg := <-p.toClientAPI: +				p.log.Infof("received message TO client API: %+v", clientMsg) +			case clientMsg := <-p.fromClientAPI: +				p.log.Infof("received message FROM client API: %+v", clientMsg) +			case federatorMsg := <-p.toFederator: +				p.log.Infof("received message TO federator: %+v", federatorMsg) +			case federatorMsg := <-p.fromFederator: +				p.log.Infof("received message FROM federator: %+v", federatorMsg) +			case <-p.stop: +				break DistLoop +			} +		} +	}() +	return nil +} + +// Stop stops the processor cleanly, finishing handling any remaining messages before closing down. +// TODO: empty message buffer properly before stopping otherwise we'll lose federating messages. +func (p *processor) Stop() error { +	close(p.stop) +	return nil +} + +// ToClientAPI wraps a message that travels from the processor into the client API +type ToClientAPI struct { +	APObjectType   gtsmodel.ActivityStreamsObject +	APActivityType gtsmodel.ActivityStreamsActivity +	Activity       interface{} +} + +// FromClientAPI wraps a message that travels from client API into the processor +type FromClientAPI struct { +	APObjectType   gtsmodel.ActivityStreamsObject +	APActivityType gtsmodel.ActivityStreamsActivity +	Activity       interface{} +} + +// ToFederator wraps a message that travels from the processor into the federator +type ToFederator struct { +	APObjectType   gtsmodel.ActivityStreamsObject +	APActivityType gtsmodel.ActivityStreamsActivity +	Activity       interface{} +} + +// FromFederator wraps a message that travels from the federator into the processor +type FromFederator struct { +	APObjectType   gtsmodel.ActivityStreamsObject +	APActivityType gtsmodel.ActivityStreamsActivity +	Activity       interface{} +} diff --git a/internal/message/processorutil.go b/internal/message/processorutil.go new file mode 100644 index 000000000..c928eec1a --- /dev/null +++ b/internal/message/processorutil.go @@ -0,0 +1,304 @@ +package message + +import ( +	"bytes" +	"errors" +	"fmt" +	"io" +	"mime/multipart" + +	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/media" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { +	// by default all flags are set to true +	gtsAdvancedVis := >smodel.VisibilityAdvanced{ +		Federated: true, +		Boostable: true, +		Replyable: true, +		Likeable:  true, +	} + +	var gtsBasicVis gtsmodel.Visibility +	// Advanced takes priority if it's set. +	// If it's not set, take whatever masto visibility is set. +	// If *that's* not set either, then just take the account default. +	// If that's also not set, take the default for the whole instance. +	if form.VisibilityAdvanced != nil { +		gtsBasicVis = gtsmodel.Visibility(*form.VisibilityAdvanced) +	} else if form.Visibility != "" { +		gtsBasicVis = p.tc.MastoVisToVis(form.Visibility) +	} else if accountDefaultVis != "" { +		gtsBasicVis = accountDefaultVis +	} else { +		gtsBasicVis = gtsmodel.VisibilityDefault +	} + +	switch gtsBasicVis { +	case gtsmodel.VisibilityPublic: +		// for public, there's no need to change any of the advanced flags from true regardless of what the user filled out +		break +	case gtsmodel.VisibilityUnlocked: +		// for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them +		if form.Federated != nil { +			gtsAdvancedVis.Federated = *form.Federated +		} + +		if form.Boostable != nil { +			gtsAdvancedVis.Boostable = *form.Boostable +		} + +		if form.Replyable != nil { +			gtsAdvancedVis.Replyable = *form.Replyable +		} + +		if form.Likeable != nil { +			gtsAdvancedVis.Likeable = *form.Likeable +		} + +	case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: +		// for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them +		gtsAdvancedVis.Boostable = false + +		if form.Federated != nil { +			gtsAdvancedVis.Federated = *form.Federated +		} + +		if form.Replyable != nil { +			gtsAdvancedVis.Replyable = *form.Replyable +		} + +		if form.Likeable != nil { +			gtsAdvancedVis.Likeable = *form.Likeable +		} + +	case gtsmodel.VisibilityDirect: +		// direct is pretty easy: there's only one possible setting so return it +		gtsAdvancedVis.Federated = true +		gtsAdvancedVis.Boostable = false +		gtsAdvancedVis.Federated = true +		gtsAdvancedVis.Likeable = true +	} + +	status.Visibility = gtsBasicVis +	status.VisibilityAdvanced = gtsAdvancedVis +	return nil +} + +func (p *processor) processReplyToID(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { +	if form.InReplyToID == "" { +		return nil +	} + +	// If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted: +	// +	// 1. Does the replied status exist in the database? +	// 2. Is the replied status marked as replyable? +	// 3. Does a block exist between either the current account or the account that posted the status it's replying to? +	// +	// If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. +	repliedStatus := >smodel.Status{} +	repliedAccount := >smodel.Account{} +	// check replied status exists + is replyable +	if err := p.db.GetByID(form.InReplyToID, repliedStatus); err != nil { +		if _, ok := err.(db.ErrNoEntries); ok { +			return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) +		} +		return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) +	} + +	if !repliedStatus.VisibilityAdvanced.Replyable { +		return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) +	} + +	// check replied account is known to us +	if err := p.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { +		if _, ok := err.(db.ErrNoEntries); ok { +			return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) +		} +		return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) +	} +	// check if a block exists +	if blocked, err := p.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { +		if _, ok := err.(db.ErrNoEntries); !ok { +			return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) +		} +	} else if blocked { +		return fmt.Errorf("status with id %s not replyable", form.InReplyToID) +	} +	status.InReplyToID = repliedStatus.ID +	status.InReplyToAccountID = repliedAccount.ID + +	return nil +} + +func (p *processor) processMediaIDs(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { +	if form.MediaIDs == nil { +		return nil +	} + +	gtsMediaAttachments := []*gtsmodel.MediaAttachment{} +	attachments := []string{} +	for _, mediaID := range form.MediaIDs { +		// check these attachments exist +		a := >smodel.MediaAttachment{} +		if err := p.db.GetByID(mediaID, a); err != nil { +			return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) +		} +		// check they belong to the requesting account id +		if a.AccountID != thisAccountID { +			return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) +		} +		// check they're not already used in a status +		if a.StatusID != "" || a.ScheduledStatusID != "" { +			return fmt.Errorf("media with id %s is already attached to a status", mediaID) +		} +		gtsMediaAttachments = append(gtsMediaAttachments, a) +		attachments = append(attachments, a.ID) +	} +	status.GTSMediaAttachments = gtsMediaAttachments +	status.Attachments = attachments +	return nil +} + +func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { +	if form.Language != "" { +		status.Language = form.Language +	} else { +		status.Language = accountDefaultLanguage +	} +	if status.Language == "" { +		return errors.New("no language given either in status create form or account default") +	} +	return nil +} + +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) +	if err != nil { +		return fmt.Errorf("error generating mentions from status: %s", err) +	} +	for _, menchie := range gtsMenchies { +		if err := p.db.Put(menchie); err != nil { +			return fmt.Errorf("error putting mentions in db: %s", err) +		} +		menchies = append(menchies, menchie.TargetAccountID) +	} +	// add full populated gts menchies to the status for passing them around conveniently +	status.GTSMentions = gtsMenchies +	// add just the ids of the mentioned accounts to the status for putting in the db +	status.Mentions = menchies +	return nil +} + +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) +	if err != nil { +		return fmt.Errorf("error generating hashtags from status: %s", err) +	} +	for _, tag := range gtsTags { +		if err := p.db.Upsert(tag, "name"); err != nil { +			return fmt.Errorf("error putting tags in db: %s", err) +		} +		tags = append(tags, tag.ID) +	} +	// add full populated gts tags to the status for passing them around conveniently +	status.GTSTags = gtsTags +	// add just the ids of the used tags to the status for putting in the db +	status.Tags = tags +	return nil +} + +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) +	if err != nil { +		return fmt.Errorf("error generating emojis from status: %s", err) +	} +	for _, e := range gtsEmojis { +		emojis = append(emojis, e.ID) +	} +	// add full populated gts emojis to the status for passing them around conveniently +	status.GTSEmojis = gtsEmojis +	// add just the ids of the used emojis to the status for putting in the db +	status.Emojis = emojis +	return nil +} + +/* +	HELPER FUNCTIONS +*/ + +// TODO: try to combine the below two functions because this is a lot of code repetition. + +// updateAccountAvatar does the dirty work of checking the avatar part of an account update form, +// parsing and checking the image, and doing the necessary updates in the database for this to become +// the account's new avatar image. +func (p *processor) updateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { +	var err error +	if int(avatar.Size) > p.config.MediaConfig.MaxImageSize { +		err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, p.config.MediaConfig.MaxImageSize) +		return nil, err +	} +	f, err := avatar.Open() +	if err != nil { +		return nil, fmt.Errorf("could not read provided avatar: %s", err) +	} + +	// extract the bytes +	buf := new(bytes.Buffer) +	size, err := io.Copy(buf, f) +	if err != nil { +		return nil, fmt.Errorf("could not read provided avatar: %s", err) +	} +	if size == 0 { +		return nil, errors.New("could not read provided avatar: size 0 bytes") +	} + +	// do the setting +	avatarInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Avatar) +	if err != nil { +		return nil, fmt.Errorf("error processing avatar: %s", err) +	} + +	return avatarInfo, f.Close() +} + +// updateAccountHeader does the dirty work of checking the header part of an account update form, +// parsing and checking the image, and doing the necessary updates in the database for this to become +// the account's new header image. +func (p *processor) updateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { +	var err error +	if int(header.Size) > p.config.MediaConfig.MaxImageSize { +		err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, p.config.MediaConfig.MaxImageSize) +		return nil, err +	} +	f, err := header.Open() +	if err != nil { +		return nil, fmt.Errorf("could not read provided header: %s", err) +	} + +	// extract the bytes +	buf := new(bytes.Buffer) +	size, err := io.Copy(buf, f) +	if err != nil { +		return nil, fmt.Errorf("could not read provided header: %s", err) +	} +	if size == 0 { +		return nil, errors.New("could not read provided header: size 0 bytes") +	} + +	// do the setting +	headerInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Header) +	if err != nil { +		return nil, fmt.Errorf("error processing header: %s", err) +	} + +	return headerInfo, f.Close() +} diff --git a/internal/message/statusprocess.go b/internal/message/statusprocess.go new file mode 100644 index 000000000..b7237fecf --- /dev/null +++ b/internal/message/statusprocess.go @@ -0,0 +1,350 @@ +package message + +import ( +	"errors" +	"fmt" +	"time" + +	"github.com/google/uuid" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) StatusCreate(auth *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) { +	uris := util.GenerateURIsForAccount(auth.Account.Username, p.config.Protocol, p.config.Host) +	thisStatusID := uuid.NewString() +	thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) +	thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) +	newStatus := >smodel.Status{ +		ID:                       thisStatusID, +		URI:                      thisStatusURI, +		URL:                      thisStatusURL, +		Content:                  util.HTMLFormat(form.Status), +		CreatedAt:                time.Now(), +		UpdatedAt:                time.Now(), +		Local:                    true, +		AccountID:                auth.Account.ID, +		ContentWarning:           form.SpoilerText, +		ActivityStreamsType:      gtsmodel.ActivityStreamsNote, +		Sensitive:                form.Sensitive, +		Language:                 form.Language, +		CreatedWithApplicationID: auth.Application.ID, +		Text:                     form.Status, +	} + +	// check if replyToID is ok +	if err := p.processReplyToID(form, auth.Account.ID, newStatus); err != nil { +		return nil, err +	} + +	// check if mediaIDs are ok +	if err := p.processMediaIDs(form, auth.Account.ID, newStatus); err != nil { +		return nil, err +	} + +	// check if visibility settings are ok +	if err := p.processVisibility(form, auth.Account.Privacy, newStatus); err != nil { +		return nil, err +	} + +	// handle language settings +	if err := p.processLanguage(form, auth.Account.Language, newStatus); err != nil { +		return nil, err +	} + +	// handle mentions +	if err := p.processMentions(form, auth.Account.ID, newStatus); err != nil { +		return nil, err +	} + +	if err := p.processTags(form, auth.Account.ID, newStatus); err != nil { +		return nil, err +	} + +	if err := p.processEmojis(form, auth.Account.ID, newStatus); err != nil { +		return nil, err +	} + +	// put the new status in the database, generating an ID for it in the process +	if err := p.db.Put(newStatus); err != nil { +		return nil, err +	} + +	// change the status ID of the media attachments to the new status +	for _, a := range newStatus.GTSMediaAttachments { +		a.StatusID = newStatus.ID +		a.UpdatedAt = time.Now() +		if err := p.db.UpdateByID(a.ID, a); err != nil { +			return nil, err +		} +	} + +	// return the frontend representation of the new status to the submitter +	return p.tc.StatusToMasto(newStatus, auth.Account, auth.Account, nil, newStatus.GTSReplyToAccount, nil) +} + +func (p *processor) StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { +	l := p.log.WithField("func", "StatusDelete") +	l.Tracef("going to search for target status %s", targetStatusID) +	targetStatus := >smodel.Status{} +	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { +		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) +	} + +	if targetStatus.AccountID != authed.Account.ID { +		return nil, errors.New("status doesn't belong to requesting account") +	} + +	l.Trace("going to get relevant accounts") +	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) +	if err != nil { +		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) +	} + +	var boostOfStatus *gtsmodel.Status +	if targetStatus.BoostOfID != "" { +		boostOfStatus = >smodel.Status{} +		if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { +			return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) +		} +	} + +	mastoStatus, err := p.tc.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) +	if err != nil { +		return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) +	} + +	if err := p.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { +		return nil, fmt.Errorf("error deleting status from the database: %s", err) +	} + +	return mastoStatus, nil +} + +func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { +	l := p.log.WithField("func", "StatusFave") +	l.Tracef("going to search for target status %s", targetStatusID) +	targetStatus := >smodel.Status{} +	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { +		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) +	} + +	l.Tracef("going to search for target account %s", targetStatus.AccountID) +	targetAccount := >smodel.Account{} +	if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { +		return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) +	} + +	l.Trace("going to get relevant accounts") +	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) +	if err != nil { +		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) +	} + +	l.Trace("going to see if status is visible") +	visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that +	if err != nil { +		return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) +	} + +	if !visible { +		return nil, errors.New("status is not visible") +	} + +	// is the status faveable? +	if !targetStatus.VisibilityAdvanced.Likeable { +		return nil, errors.New("status is not faveable") +	} + +	// it's visible! it's faveable! so let's fave the FUCK out of it +	_, err = p.db.FaveStatus(targetStatus, authed.Account.ID) +	if err != nil { +		return nil, fmt.Errorf("error faveing status: %s", err) +	} + +	var boostOfStatus *gtsmodel.Status +	if targetStatus.BoostOfID != "" { +		boostOfStatus = >smodel.Status{} +		if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { +			return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) +		} +	} + +	mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) +	if err != nil { +		return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) +	} + +	return mastoStatus, nil +} + +func (p *processor) StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) { +	l := p.log.WithField("func", "StatusFavedBy") + +	l.Tracef("going to search for target status %s", targetStatusID) +	targetStatus := >smodel.Status{} +	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { +		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) +	} + +	l.Tracef("going to search for target account %s", targetStatus.AccountID) +	targetAccount := >smodel.Account{} +	if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { +		return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) +	} + +	l.Trace("going to get relevant accounts") +	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) +	if err != nil { +		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) +	} + +	l.Trace("going to see if status is visible") +	visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that +	if err != nil { +		return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) +	} + +	if !visible { +		return nil, errors.New("status is not visible") +	} + +	// get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff +	favingAccounts, err := p.db.WhoFavedStatus(targetStatus) +	if err != nil { +		return nil, fmt.Errorf("error seeing who faved status: %s", err) +	} + +	// filter the list so the user doesn't see accounts they blocked or which blocked them +	filteredAccounts := []*gtsmodel.Account{} +	for _, acc := range favingAccounts { +		blocked, err := p.db.Blocked(authed.Account.ID, acc.ID) +		if err != nil { +			return nil, fmt.Errorf("error checking blocks: %s", err) +		} +		if !blocked { +			filteredAccounts = append(filteredAccounts, acc) +		} +	} + +	// TODO: filter other things here? suspended? muted? silenced? + +	// now we can return the masto representation of those accounts +	mastoAccounts := []*apimodel.Account{} +	for _, acc := range filteredAccounts { +		mastoAccount, err := p.tc.AccountToMastoPublic(acc) +		if err != nil { +			return nil, fmt.Errorf("error converting account to api model: %s", err) +		} +		mastoAccounts = append(mastoAccounts, mastoAccount) +	} + +	return mastoAccounts, nil +} + +func (p *processor) StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { +	l := p.log.WithField("func", "StatusGet") + +	l.Tracef("going to search for target status %s", targetStatusID) +	targetStatus := >smodel.Status{} +	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { +		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) +	} + +	l.Tracef("going to search for target account %s", targetStatus.AccountID) +	targetAccount := >smodel.Account{} +	if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { +		return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) +	} + +	l.Trace("going to get relevant accounts") +	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) +	if err != nil { +		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) +	} + +	l.Trace("going to see if status is visible") +	visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that +	if err != nil { +		return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) +	} + +	if !visible { +		return nil, errors.New("status is not visible") +	} + +	var boostOfStatus *gtsmodel.Status +	if targetStatus.BoostOfID != "" { +		boostOfStatus = >smodel.Status{} +		if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { +			return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) +		} +	} + +	mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) +	if err != nil { +		return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) +	} + +	return mastoStatus, nil + +} + +func (p *processor) StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { +	l := p.log.WithField("func", "StatusUnfave") +	l.Tracef("going to search for target status %s", targetStatusID) +	targetStatus := >smodel.Status{} +	if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { +		return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) +	} + +	l.Tracef("going to search for target account %s", targetStatus.AccountID) +	targetAccount := >smodel.Account{} +	if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { +		return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) +	} + +	l.Trace("going to get relevant accounts") +	relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) +	if err != nil { +		return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) +	} + +	l.Trace("going to see if status is visible") +	visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that +	if err != nil { +		return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) +	} + +	if !visible { +		return nil, errors.New("status is not visible") +	} + +	// is the status faveable? +	if !targetStatus.VisibilityAdvanced.Likeable { +		return nil, errors.New("status is not faveable") +	} + +	// it's visible! it's faveable! so let's unfave the FUCK out of it +	_, err = p.db.UnfaveStatus(targetStatus, authed.Account.ID) +	if err != nil { +		return nil, fmt.Errorf("error unfaveing status: %s", err) +	} + +	var boostOfStatus *gtsmodel.Status +	if targetStatus.BoostOfID != "" { +		boostOfStatus = >smodel.Status{} +		if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { +			return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) +		} +	} + +	mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) +	if err != nil { +		return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) +	} + +	return mastoStatus, nil +} diff --git a/internal/oauth/clientstore.go b/internal/oauth/clientstore.go index 4e678891a..5241cf412 100644 --- a/internal/oauth/clientstore.go +++ b/internal/oauth/clientstore.go @@ -30,7 +30,8 @@ type clientStore struct {  	db db.DB  } -func newClientStore(db db.DB) oauth2.ClientStore { +// NewClientStore returns an implementation of the oauth2 ClientStore interface, using the given db as a storage backend. +func NewClientStore(db db.DB) oauth2.ClientStore {  	pts := &clientStore{  		db: db,  	} diff --git a/internal/oauth/clientstore_test.go b/internal/oauth/clientstore_test.go index a7028228d..b77163e48 100644 --- a/internal/oauth/clientstore_test.go +++ b/internal/oauth/clientstore_test.go @@ -15,7 +15,7 @@     You should have received a copy of the GNU Affero General Public License     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package oauth +package oauth_test  import (  	"context" @@ -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/oauth"  	"github.com/superseriousbusiness/oauth2/v4/models"  ) @@ -61,7 +62,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {  		Database:        "postgres",  		ApplicationName: "gotosocial",  	} -	db, err := db.New(context.Background(), c, log) +	db, err := db.NewPostgresService(context.Background(), c, log)  	if err != nil {  		logrus.Panicf("error creating database connection: %s", err)  	} @@ -69,7 +70,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {  	suite.db = db  	models := []interface{}{ -		&Client{}, +		&oauth.Client{},  	}  	for _, m := range models { @@ -82,7 +83,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {  // TearDownTest drops the oauth_clients table and closes the pg connection after each test  func (suite *PgClientStoreTestSuite) TearDownTest() {  	models := []interface{}{ -		&Client{}, +		&oauth.Client{},  	}  	for _, m := range models {  		if err := suite.db.DropTable(m); err != nil { @@ -97,7 +98,7 @@ func (suite *PgClientStoreTestSuite) TearDownTest() {  func (suite *PgClientStoreTestSuite) TestClientStoreSetAndGet() {  	// set a new client in the store -	cs := newClientStore(suite.db) +	cs := oauth.NewClientStore(suite.db)  	if err := cs.Set(context.Background(), suite.testClientID, models.New(suite.testClientID, suite.testClientSecret, suite.testClientDomain, suite.testClientUserID)); err != nil {  		suite.FailNow(err.Error())  	} @@ -115,7 +116,7 @@ func (suite *PgClientStoreTestSuite) TestClientStoreSetAndGet() {  func (suite *PgClientStoreTestSuite) TestClientSetAndDelete() {  	// set a new client in the store -	cs := newClientStore(suite.db) +	cs := oauth.NewClientStore(suite.db)  	if err := cs.Set(context.Background(), suite.testClientID, models.New(suite.testClientID, suite.testClientSecret, suite.testClientDomain, suite.testClientUserID)); err != nil {  		suite.FailNow(err.Error())  	} diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index 594b9b5a9..1b8449619 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -16,6 +16,6 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package oauth +package oauth_test  // TODO: write tests diff --git a/internal/oauth/server.go b/internal/oauth/server.go index 1ddf18b03..7877d667e 100644 --- a/internal/oauth/server.go +++ b/internal/oauth/server.go @@ -23,10 +23,8 @@ import (  	"fmt"  	"net/http" -	"github.com/gin-gonic/gin"  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"  	"github.com/superseriousbusiness/oauth2/v4"  	"github.com/superseriousbusiness/oauth2/v4/errors"  	"github.com/superseriousbusiness/oauth2/v4/manage" @@ -66,94 +64,53 @@ type s struct {  	log    *logrus.Logger  } -// Authed wraps an authorized token, application, user, and account. -// It is used in the functions GetAuthed and MustAuth. -// Because the user might *not* be authed, any of the fields in this struct -// might be nil, so make sure to check that when you're using this struct anywhere. -type Authed struct { -	Token       oauth2.TokenInfo -	Application *gtsmodel.Application -	User        *gtsmodel.User -	Account     *gtsmodel.Account -} - -// GetAuthed is a convenience function for returning an Authed struct from a gin context. -// In essence, it tries to extract a token, application, user, and account from the context, -// and then sets them on a struct for convenience. -// -// If any are not present in the context, they will be set to nil on the returned Authed struct. -// -// If *ALL* are not present, then nil and an error will be returned. -// -// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed). -func GetAuthed(c *gin.Context) (*Authed, error) { -	ctx := c.Copy() -	a := &Authed{} -	var i interface{} -	var ok bool +// New returns a new oauth server that implements the Server interface +func New(database db.DB, log *logrus.Logger) Server { +	ts := newTokenStore(context.Background(), database, log) +	cs := NewClientStore(database) -	i, ok = ctx.Get(SessionAuthorizedToken) -	if ok { -		parsed, ok := i.(oauth2.TokenInfo) -		if !ok { -			return nil, errors.New("could not parse token from session context") -		} -		a.Token = parsed +	manager := manage.NewDefaultManager() +	manager.MapTokenStorage(ts) +	manager.MapClientStorage(cs) +	manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) +	sc := &server.Config{ +		TokenType: "Bearer", +		// Must follow the spec. +		AllowGetAccessRequest: false, +		// Support only the non-implicit flow. +		AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code}, +		// Allow: +		// - Authorization Code (for first & third parties) +		// - Client Credentials (for applications) +		AllowedGrantTypes: []oauth2.GrantType{ +			oauth2.AuthorizationCode, +			oauth2.ClientCredentials, +		}, +		AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{oauth2.CodeChallengePlain},  	} -	i, ok = ctx.Get(SessionAuthorizedApplication) -	if ok { -		parsed, ok := i.(*gtsmodel.Application) -		if !ok { -			return nil, errors.New("could not parse application from session context") -		} -		a.Application = parsed -	} +	srv := server.NewServer(sc, manager) +	srv.SetInternalErrorHandler(func(err error) *errors.Response { +		log.Errorf("internal oauth error: %s", err) +		return nil +	}) -	i, ok = ctx.Get(SessionAuthorizedUser) -	if ok { -		parsed, ok := i.(*gtsmodel.User) -		if !ok { -			return nil, errors.New("could not parse user from session context") -		} -		a.User = parsed -	} +	srv.SetResponseErrorHandler(func(re *errors.Response) { +		log.Errorf("internal response error: %s", re.Error) +	}) -	i, ok = ctx.Get(SessionAuthorizedAccount) -	if ok { -		parsed, ok := i.(*gtsmodel.Account) -		if !ok { -			return nil, errors.New("could not parse account from session context") +	srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) { +		userID := r.FormValue("userid") +		if userID == "" { +			return "", errors.New("userid was empty")  		} -		a.Account = parsed -	} - -	if a.Token == nil && a.Application == nil && a.User == nil && a.Account == nil { -		return nil, errors.New("not authorized") -	} - -	return a, nil -} - -// MustAuth is like GetAuthed, but will fail if one of the requirements is not met. -func MustAuth(c *gin.Context, requireToken bool, requireApp bool, requireUser bool, requireAccount bool) (*Authed, error) { -	a, err := GetAuthed(c) -	if err != nil { -		return nil, err -	} -	if requireToken && a.Token == nil { -		return nil, errors.New("token not supplied") -	} -	if requireApp && a.Application == nil { -		return nil, errors.New("application not supplied") -	} -	if requireUser && a.User == nil { -		return nil, errors.New("user not supplied") -	} -	if requireAccount && a.Account == nil { -		return nil, errors.New("account not supplied") +		return userID, nil +	}) +	srv.SetClientInfoHandler(server.ClientFormHandler) +	return &s{ +		server: srv, +		log:    log,  	} -	return a, nil  }  // HandleTokenRequest wraps the oauth2 library's HandleTokenRequest function @@ -211,52 +168,3 @@ func (s *s) GenerateUserAccessToken(ti oauth2.TokenInfo, clientSecret string, us  	s.log.Tracef("obtained user-level access token: %+v", accessToken)  	return accessToken, nil  } - -// New returns a new oauth server that implements the Server interface -func New(database db.DB, log *logrus.Logger) Server { -	ts := newTokenStore(context.Background(), database, log) -	cs := newClientStore(database) - -	manager := manage.NewDefaultManager() -	manager.MapTokenStorage(ts) -	manager.MapClientStorage(cs) -	manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) -	sc := &server.Config{ -		TokenType: "Bearer", -		// Must follow the spec. -		AllowGetAccessRequest: false, -		// Support only the non-implicit flow. -		AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code}, -		// Allow: -		// - Authorization Code (for first & third parties) -		// - Client Credentials (for applications) -		AllowedGrantTypes: []oauth2.GrantType{ -			oauth2.AuthorizationCode, -			oauth2.ClientCredentials, -		}, -		AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{oauth2.CodeChallengePlain}, -	} - -	srv := server.NewServer(sc, manager) -	srv.SetInternalErrorHandler(func(err error) *errors.Response { -		log.Errorf("internal oauth error: %s", err) -		return nil -	}) - -	srv.SetResponseErrorHandler(func(re *errors.Response) { -		log.Errorf("internal response error: %s", re.Error) -	}) - -	srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) { -		userID := r.FormValue("userid") -		if userID == "" { -			return "", errors.New("userid was empty") -		} -		return userID, nil -	}) -	srv.SetClientInfoHandler(server.ClientFormHandler) -	return &s{ -		server: srv, -		log:    log, -	} -} diff --git a/internal/oauth/tokenstore_test.go b/internal/oauth/tokenstore_test.go index 594b9b5a9..1b8449619 100644 --- a/internal/oauth/tokenstore_test.go +++ b/internal/oauth/tokenstore_test.go @@ -16,6 +16,6 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package oauth +package oauth_test  // TODO: write tests diff --git a/internal/oauth/util.go b/internal/oauth/util.go new file mode 100644 index 000000000..378b81450 --- /dev/null +++ b/internal/oauth/util.go @@ -0,0 +1,86 @@ +package oauth + +import ( +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/oauth2/v4" +	"github.com/superseriousbusiness/oauth2/v4/errors" +) + +// Auth wraps an authorized token, application, user, and account. +// It is used in the functions GetAuthed and MustAuth. +// Because the user might *not* be authed, any of the fields in this struct +// might be nil, so make sure to check that when you're using this struct anywhere. +type Auth struct { +	Token       oauth2.TokenInfo +	Application *gtsmodel.Application +	User        *gtsmodel.User +	Account     *gtsmodel.Account +} + +// Authed is a convenience function for returning an Authed struct from a gin context. +// In essence, it tries to extract a token, application, user, and account from the context, +// and then sets them on a struct for convenience. +// +// If any are not present in the context, they will be set to nil on the returned Authed struct. +// +// If *ALL* are not present, then nil and an error will be returned. +// +// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed). +// Authed is like GetAuthed, but will fail if one of the requirements is not met. +func Authed(c *gin.Context, requireToken bool, requireApp bool, requireUser bool, requireAccount bool) (*Auth, error) { +	ctx := c.Copy() +	a := &Auth{} +	var i interface{} +	var ok bool + +	i, ok = ctx.Get(SessionAuthorizedToken) +	if ok { +		parsed, ok := i.(oauth2.TokenInfo) +		if !ok { +			return nil, errors.New("could not parse token from session context") +		} +		a.Token = parsed +	} + +	i, ok = ctx.Get(SessionAuthorizedApplication) +	if ok { +		parsed, ok := i.(*gtsmodel.Application) +		if !ok { +			return nil, errors.New("could not parse application from session context") +		} +		a.Application = parsed +	} + +	i, ok = ctx.Get(SessionAuthorizedUser) +	if ok { +		parsed, ok := i.(*gtsmodel.User) +		if !ok { +			return nil, errors.New("could not parse user from session context") +		} +		a.User = parsed +	} + +	i, ok = ctx.Get(SessionAuthorizedAccount) +	if ok { +		parsed, ok := i.(*gtsmodel.Account) +		if !ok { +			return nil, errors.New("could not parse account from session context") +		} +		a.Account = parsed +	} + +	if requireToken && a.Token == nil { +		return nil, errors.New("token not supplied") +	} +	if requireApp && a.Application == nil { +		return nil, errors.New("application not supplied") +	} +	if requireUser && a.User == nil { +		return nil, errors.New("user not supplied") +	} +	if requireAccount && a.Account == nil { +		return nil, errors.New("account not supplied") +	} +	return a, nil +} diff --git a/internal/storage/inmem.go b/internal/storage/inmem.go index 2d88189db..a596c3d97 100644 --- a/internal/storage/inmem.go +++ b/internal/storage/inmem.go @@ -35,7 +35,7 @@ func (s *inMemStorage) RetrieveFileFrom(path string) ([]byte, error) {  	l := s.log.WithField("func", "RetrieveFileFrom")  	l.Debugf("retrieving from path %s", path)  	d, ok := s.stored[path] -	if !ok { +	if !ok || len(d) == 0 {  		return nil, fmt.Errorf("no data found at path %s", path)  	}  	return d, nil diff --git a/internal/transport/controller.go b/internal/transport/controller.go new file mode 100644 index 000000000..525141025 --- /dev/null +++ b/internal/transport/controller.go @@ -0,0 +1,71 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package transport + +import ( +	"crypto" +	"fmt" + +	"github.com/go-fed/activity/pub" +	"github.com/go-fed/httpsig" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/config" +) + +// Controller generates transports for use in making federation requests to other servers. +type Controller interface { +	NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) +} + +type controller struct { +	config   *config.Config +	clock    pub.Clock +	client   pub.HttpClient +	appAgent string +} + +// NewController returns an implementation of the Controller interface for creating new transports +func NewController(config *config.Config, clock pub.Clock, client pub.HttpClient, log *logrus.Logger) Controller { +	return &controller{ +		config:   config, +		clock:    clock, +		client:   client, +		appAgent: fmt.Sprintf("%s %s", config.ApplicationName, config.Host), +	} +} + +// NewTransport returns a new http signature transport with the given public key id (a URL), and the given private key. +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"} +	postHeaders := []string{"(request-target)", "date", "digest"} + +	getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature) +	if err != nil { +		return nil, fmt.Errorf("error creating get signer: %s", err) +	} + +	postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature) +	if err != nil { +		return nil, fmt.Errorf("error creating post signer: %s", err) +	} + +	return pub.NewHttpSigTransport(c.client, c.appAgent, c.clock, getSigner, postSigner, pubKeyID, privkey), nil +} diff --git a/internal/typeutils/accountable.go b/internal/typeutils/accountable.go new file mode 100644 index 000000000..ba5c4aa2a --- /dev/null +++ b/internal/typeutils/accountable.go @@ -0,0 +1,101 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +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 new file mode 100644 index 000000000..8d39be3ec --- /dev/null +++ b/internal/typeutils/asextractionutil.go @@ -0,0 +1,216 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( +	"crypto/rsa" +	"crypto/x509" +	"encoding/pem" +	"errors" +	"fmt" +	"net/url" + +	"github.com/go-fed/activity/pub" +) + +func extractPreferredUsername(i withPreferredUsername) (string, error) { +	u := i.GetActivityStreamsPreferredUsername() +	if u == nil || !u.IsXMLSchemaString() { +		return "", errors.New("preferredUsername was not a string") +	} +	if u.GetXMLSchemaString() == "" { +		return "", errors.New("preferredUsername was empty") +	} +	return u.GetXMLSchemaString(), nil +} + +func extractName(i withDisplayName) (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 +		} +	} + +	return "", errors.New("activityStreamsName not found") +} + +// extractIconURL extracts a URL to a supported image file from something like: +//   "icon": { +//     "mediaType": "image/jpeg", +//     "type": "Image", +//     "url": "http://example.org/path/to/some/file.jpeg" +//   }, +func extractIconURL(i withIcon) (*url.URL, error) { +	iconProp := i.GetActivityStreamsIcon() +	if iconProp == nil { +		return nil, errors.New("icon property was nil") +	} + +	// icon can potentially contain multiple entries, so we iterate through all of them +	// 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() { +		// 1. is an image +		if !iconIter.IsActivityStreamsImage() { +			continue +		} +		imageValue := iconIter.GetActivityStreamsImage() +		if imageValue == nil { +			continue +		} + +		// 2. has a URL so we can grab it +		url, err := extractURL(imageValue) +		if err == nil && url != nil { +			return url, nil +		} +	} +	// if we get to this point we didn't find an icon meeting our criteria :'( +	return nil, errors.New("could not extract valid image from icon") +} + +// extractImageURL extracts a URL to a supported image file from something like: +//   "image": { +//     "mediaType": "image/jpeg", +//     "type": "Image", +//     "url": "http://example.org/path/to/some/file.jpeg" +//   }, +func extractImageURL(i withImage) (*url.URL, error) { +	imageProp := i.GetActivityStreamsImage() +	if imageProp == nil { +		return nil, errors.New("icon property was nil") +	} + +	// icon can potentially contain multiple entries, so we iterate through all of them +	// 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() { +		// 1. is an image +		if !imageIter.IsActivityStreamsImage() { +			continue +		} +		imageValue := imageIter.GetActivityStreamsImage() +		if imageValue == nil { +			continue +		} + +		// 2. has a URL so we can grab it +		url, err := extractURL(imageValue) +		if err == nil && url != nil { +			return url, nil +		} +	} +	// if we get to this point we didn't find an image meeting our criteria :'( +	return nil, errors.New("could not extract valid image from image property") +} + +func extractSummary(i withSummary) (string, error) { +	summaryProp := i.GetActivityStreamsSummary() +	if summaryProp == nil { +		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 +		} +	} + +	return "", errors.New("could not extract summary") +} + +func extractDiscoverable(i withDiscoverable) (bool, error) { +	if i.GetTootDiscoverable() == nil { +		return false, errors.New("discoverable was nil") +	} +	return i.GetTootDiscoverable().Get(), nil +} + +func extractURL(i withURL) (*url.URL, error) { +	urlProp := i.GetActivityStreamsUrl() +	if urlProp == nil { +		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 +		} +	} + +	return nil, errors.New("could not extract url") +} + +func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKey, *url.URL, error) { +	publicKeyProp := i.GetW3IDSecurityV1PublicKey() +	if publicKeyProp == nil { +		return nil, nil, errors.New("public key property was nil") +	} + +	for publicKeyIter := publicKeyProp.Begin(); publicKeyIter != publicKeyProp.End(); publicKeyIter = publicKeyIter.Next() { +		pkey := publicKeyIter.Get() +		if pkey == nil { +			continue +		} + +		pkeyID, err := pub.GetId(pkey) +		if err != nil || pkeyID == nil { +			continue +		} + +		if pkey.GetW3IDSecurityV1Owner() == nil || pkey.GetW3IDSecurityV1Owner().Get() == nil || pkey.GetW3IDSecurityV1Owner().Get().String() != forOwner.String() { +			continue +		} + +		if pkey.GetW3IDSecurityV1PublicKeyPem() == nil { +			continue +		} + +		pkeyPem := pkey.GetW3IDSecurityV1PublicKeyPem().Get() +		if pkeyPem == "" { +			continue +		} + +		block, _ := pem.Decode([]byte(pkeyPem)) +		if block == nil || block.Type != "PUBLIC KEY" { +			return nil, nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") +		} + +		p, err := x509.ParsePKIXPublicKey(block.Bytes) +		if err != nil { +			return nil, nil, fmt.Errorf("could not parse public key from block bytes: %s", err) +		} +		if p == nil { +			return nil, nil, errors.New("returned public key was empty") +		} + +		if publicKey, ok := p.(*rsa.PublicKey); ok { +			return publicKey, pkeyID, nil +		} +	} +	return nil, nil, errors.New("couldn't find public key") +} diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go new file mode 100644 index 000000000..5e3b6b052 --- /dev/null +++ b/internal/typeutils/astointernal.go @@ -0,0 +1,164 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( +	"errors" +	"fmt" + +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) { +	// first check if we actually already know this account +	uriProp := accountable.GetJSONLDId() +	if uriProp == nil || !uriProp.IsIRI() { +		return nil, errors.New("no id property found on person, or id was not an iri") +	} +	uri := uriProp.GetIRI() + +	acct := >smodel.Account{} +	err := c.db.GetWhere("uri", uri.String(), acct) +	if err == nil { +		// we already know this account so we can skip generating it +		return acct, nil +	} +	if _, ok := err.(db.ErrNoEntries); !ok { +		// we don't know the account and there's been a real error +		return nil, fmt.Errorf("error getting account with uri %s from the database: %s", uri.String(), err) +	} + +	// we don't know the account so we need to generate it from the person -- at least we already have the URI! +	acct = >smodel.Account{} +	acct.URI = uri.String() + +	// Username aka preferredUsername +	// We need this one so bail if it's not set. +	username, err := extractPreferredUsername(accountable) +	if err != nil { +		return nil, fmt.Errorf("couldn't extract username: %s", err) +	} +	acct.Username = username + +	// Domain +	acct.Domain = uri.Host + +	// avatar aka icon +	// if this one isn't extractable in a format we recognise we'll just skip it +	if avatarURL, err := extractIconURL(accountable); err == nil { +		acct.AvatarRemoteURL = avatarURL.String() +	} + +	// header aka image +	// if this one isn't extractable in a format we recognise we'll just skip it +	if headerURL, err := extractImageURL(accountable); err == nil { +		acct.HeaderRemoteURL = headerURL.String() +	} + +	// display name aka name +	// we default to the username, but take the more nuanced name property if it exists +	acct.DisplayName = username +	if displayName, err := extractName(accountable); err == nil { +		acct.DisplayName = displayName +	} + +	// TODO: fields aka attachment array + +	// note aka summary +	note, err := extractSummary(accountable) +	if err == nil && note != "" { +		acct.Note = note +	} + +	// check for bot and actor type +	switch gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) { +	case gtsmodel.ActivityStreamsPerson, gtsmodel.ActivityStreamsGroup, gtsmodel.ActivityStreamsOrganization: +		// people, groups, and organizations aren't bots +		acct.Bot = false +		// apps and services are +	case gtsmodel.ActivityStreamsApplication, gtsmodel.ActivityStreamsService: +		acct.Bot = true +	default: +		// we don't know what this is! +		return nil, fmt.Errorf("type name %s not recognised or not convertible to gtsmodel.ActivityStreamsActor", accountable.GetTypeName()) +	} +	acct.ActorType = gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) + +	// TODO: locked aka manuallyApprovesFollowers + +	// discoverable +	// default to false -- take custom value if it's set though +	acct.Discoverable = false +	discoverable, err := extractDiscoverable(accountable) +	if err == nil { +		acct.Discoverable = discoverable +	} + +	// url property +	url, err := extractURL(accountable) +	if err != nil { +		return nil, fmt.Errorf("could not extract url for person with id %s: %s", uri.String(), err) +	} +	acct.URL = url.String() + +	// InboxURI +	if accountable.GetActivityStreamsInbox() == nil || accountable.GetActivityStreamsInbox().GetIRI() == nil { +		return nil, fmt.Errorf("person with id %s had no inbox uri", uri.String()) +	} +	acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String() + +	// OutboxURI +	if accountable.GetActivityStreamsOutbox() == nil || accountable.GetActivityStreamsOutbox().GetIRI() == nil { +		return nil, fmt.Errorf("person with id %s had no outbox uri", uri.String()) +	} +	acct.OutboxURI = accountable.GetActivityStreamsOutbox().GetIRI().String() + +	// FollowingURI +	if accountable.GetActivityStreamsFollowing() == nil || accountable.GetActivityStreamsFollowing().GetIRI() == nil { +		return nil, fmt.Errorf("person with id %s had no following uri", uri.String()) +	} +	acct.FollowingURI = accountable.GetActivityStreamsFollowing().GetIRI().String() + +	// FollowersURI +	if accountable.GetActivityStreamsFollowers() == nil || accountable.GetActivityStreamsFollowers().GetIRI() == nil { +		return nil, fmt.Errorf("person with id %s had no followers uri", uri.String()) +	} +	acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String() + +	// FeaturedURI +	// very much optional +	if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil { +		acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String() +	} + +	// TODO: FeaturedTagsURI + +	// TODO: alsoKnownAs + +	// publicKey +	pkey, pkeyURL, err := extractPublicKeyForOwner(accountable, uri) +	if err != nil { +		return nil, fmt.Errorf("couldn't get public key for person %s: %s", uri.String(), err) +	} +	acct.PublicKey = pkey +	acct.PublicKeyURI = pkeyURL.String() + +	return acct, nil +} diff --git a/internal/typeutils/astointernal_test.go b/internal/typeutils/astointernal_test.go new file mode 100644 index 000000000..1cd66a0ab --- /dev/null +++ b/internal/typeutils/astointernal_test.go @@ -0,0 +1,206 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package typeutils_test + +import ( +	"context" +	"encoding/json" +	"fmt" +	"testing" + +	"github.com/go-fed/activity/streams" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type ASToInternalTestSuite struct { +	ConverterStandardTestSuite +} + +const ( +	gargronAsActivityJson = `{ +		"@context": [ +		  "https://www.w3.org/ns/activitystreams", +		  "https://w3id.org/security/v1", +		  { +			"manuallyApprovesFollowers": "as:manuallyApprovesFollowers", +			"toot": "http://joinmastodon.org/ns#", +			"featured": { +			  "@id": "toot:featured", +			  "@type": "@id" +			}, +			"featuredTags": { +			  "@id": "toot:featuredTags", +			  "@type": "@id" +			}, +			"alsoKnownAs": { +			  "@id": "as:alsoKnownAs", +			  "@type": "@id" +			}, +			"movedTo": { +			  "@id": "as:movedTo", +			  "@type": "@id" +			}, +			"schema": "http://schema.org#", +			"PropertyValue": "schema:PropertyValue", +			"value": "schema:value", +			"IdentityProof": "toot:IdentityProof", +			"discoverable": "toot:discoverable", +			"Device": "toot:Device", +			"Ed25519Signature": "toot:Ed25519Signature", +			"Ed25519Key": "toot:Ed25519Key", +			"Curve25519Key": "toot:Curve25519Key", +			"EncryptedMessage": "toot:EncryptedMessage", +			"publicKeyBase64": "toot:publicKeyBase64", +			"deviceId": "toot:deviceId", +			"claim": { +			  "@type": "@id", +			  "@id": "toot:claim" +			}, +			"fingerprintKey": { +			  "@type": "@id", +			  "@id": "toot:fingerprintKey" +			}, +			"identityKey": { +			  "@type": "@id", +			  "@id": "toot:identityKey" +			}, +			"devices": { +			  "@type": "@id", +			  "@id": "toot:devices" +			}, +			"messageFranking": "toot:messageFranking", +			"messageType": "toot:messageType", +			"cipherText": "toot:cipherText", +			"suspended": "toot:suspended", +			"focalPoint": { +			  "@container": "@list", +			  "@id": "toot:focalPoint" +			} +		  } +		], +		"id": "https://mastodon.social/users/Gargron", +		"type": "Person", +		"following": "https://mastodon.social/users/Gargron/following", +		"followers": "https://mastodon.social/users/Gargron/followers", +		"inbox": "https://mastodon.social/users/Gargron/inbox", +		"outbox": "https://mastodon.social/users/Gargron/outbox", +		"featured": "https://mastodon.social/users/Gargron/collections/featured", +		"featuredTags": "https://mastodon.social/users/Gargron/collections/tags", +		"preferredUsername": "Gargron", +		"name": "Eugen", +		"summary": "<p>Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.</p>", +		"url": "https://mastodon.social/@Gargron", +		"manuallyApprovesFollowers": false, +		"discoverable": true, +		"devices": "https://mastodon.social/users/Gargron/collections/devices", +		"alsoKnownAs": [ +		  "https://tooting.ai/users/Gargron" +		], +		"publicKey": { +		  "id": "https://mastodon.social/users/Gargron#main-key", +		  "owner": "https://mastodon.social/users/Gargron", +		  "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n" +		}, +		"tag": [], +		"attachment": [ +		  { +			"type": "PropertyValue", +			"name": "Patreon", +			"value": "<a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>" +		  }, +		  { +			"type": "PropertyValue", +			"name": "Homepage", +			"value": "<a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span></a>" +		  }, +		  { +			"type": "IdentityProof", +			"name": "gargron", +			"signatureAlgorithm": "keybase", +			"signatureValue": "5cfc20c7018f2beefb42a68836da59a792e55daa4d118498c9b1898de7e845690f" +		  } +		], +		"endpoints": { +		  "sharedInbox": "https://mastodon.social/inbox" +		}, +		"icon": { +		  "type": "Image", +		  "mediaType": "image/jpeg", +		  "url": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg" +		}, +		"image": { +		  "type": "Image", +		  "mediaType": "image/png", +		  "url": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png" +		} +	  }` +) + +func (suite *ASToInternalTestSuite) SetupSuite() { +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.log = testrig.NewTestLog() +	suite.accounts = testrig.NewTestAccounts() +	suite.people = testrig.NewTestFediPeople() +	suite.typeconverter = typeutils.NewConverter(suite.config, suite.db) +} + +func (suite *ASToInternalTestSuite) SetupTest() { +	testrig.StandardDBSetup(suite.db) +} + +func (suite *ASToInternalTestSuite) TestParsePerson() { + +	testPerson := suite.people["new_person_1"] + +	acct, err := suite.typeconverter.ASRepresentationToAccount(testPerson) +	assert.NoError(suite.T(), err) + +	fmt.Printf("%+v", acct) +	// TODO: write assertions here, rn we're just eyeballing the output +} + +func (suite *ASToInternalTestSuite) TestParseGargron() { +	m := make(map[string]interface{}) +	err := json.Unmarshal([]byte(gargronAsActivityJson), &m) +	assert.NoError(suite.T(), err) + +	t, err := streams.ToType(context.Background(), m) +	assert.NoError(suite.T(), err) + +	rep, ok := t.(typeutils.Accountable) +	assert.True(suite.T(), ok) + +	acct, err := suite.typeconverter.ASRepresentationToAccount(rep) +	assert.NoError(suite.T(), err) + +	fmt.Printf("%+v", acct) +	// TODO: write assertions here, rn we're just eyeballing the output +} + +func (suite *ASToInternalTestSuite) TearDownTest() { +	testrig.StandardDBTeardown(suite.db) +} + +func TestASToInternalTestSuite(t *testing.T) { +	suite.Run(t, new(ASToInternalTestSuite)) +} diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go new file mode 100644 index 000000000..5118386a9 --- /dev/null +++ b/internal/typeutils/converter.go @@ -0,0 +1,113 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( +	"github.com/go-fed/activity/streams/vocab" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// TypeConverter is an interface for the common action of converting between apimodule (frontend, serializable) models, +// internal gts models used in the database, and activitypub models used in federation. +// +// It requires access to the database because many of the conversions require pulling out database entries and counting them etc. +// That said, it *absolutely should not* manipulate database entries in any way, only examine them. +type TypeConverter interface { +	/* +		INTERNAL (gts) MODEL TO FRONTEND (mastodon) MODEL +	*/ + +	// AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error +	// if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, +	// so serve it only to an authorized user who should have permission to see it. +	AccountToMastoSensitive(account *gtsmodel.Account) (*model.Account, error) + +	// AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error +	// if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. +	// In other words, this is the public record that the server has of an account. +	AccountToMastoPublic(account *gtsmodel.Account) (*model.Account, error) + +	// AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error +	// if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields +	// (such as client id and client secret), so serve it only to an authorized user who should have permission to see it. +	AppToMastoSensitive(application *gtsmodel.Application) (*model.Application, error) + +	// AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error +	// if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive +	// fields sanitized so that it can be served to non-authorized accounts without revealing any private information. +	AppToMastoPublic(application *gtsmodel.Application) (*model.Application, error) + +	// AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API. +	AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (model.Attachment, error) + +	// MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API. +	MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) + +	// EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API. +	EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) + +	// TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API. +	TagToMasto(t *gtsmodel.Tag) (model.Tag, error) + +	// StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API. +	StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*model.Status, error) + +	// VisToMasto converts a gts visibility into its mastodon equivalent +	VisToMasto(m gtsmodel.Visibility) model.Visibility + +	/* +		FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL +	*/ + +	// MastoVisToVis converts a mastodon visibility into its gts equivalent. +	MastoVisToVis(m model.Visibility) gtsmodel.Visibility + +	/* +		ACTIVITYSTREAMS MODEL TO INTERNAL (gts) MODEL +	*/ + +	// ASPersonToAccount converts a remote account/person/application representation into a gts model account +	ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) + +	/* +		INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL +	*/ + +	// AccountToAS converts a gts model account into an activity streams person, suitable for federation +	AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) + +	// StatusToAS converts a gts model status into an activity streams note, suitable for federation +	StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) +} + +type converter struct { +	config *config.Config +	db     db.DB +} + +// NewConverter returns a new Converter +func NewConverter(config *config.Config, db db.DB) TypeConverter { +	return &converter{ +		config: config, +		db:     db, +	} +} diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go new file mode 100644 index 000000000..b2272f50c --- /dev/null +++ b/internal/typeutils/converter_test.go @@ -0,0 +1,40 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package typeutils_test + +import ( +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type ConverterStandardTestSuite struct { +	suite.Suite +	config   *config.Config +	db       db.DB +	log      *logrus.Logger +	accounts map[string]*gtsmodel.Account +	people   map[string]typeutils.Accountable + +	typeconverter typeutils.TypeConverter +} diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go new file mode 100644 index 000000000..6bb45d61b --- /dev/null +++ b/internal/typeutils/frontendtointernal.go @@ -0,0 +1,39 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// MastoVisToVis converts a mastodon visibility into its gts equivalent. +func (c *converter) MastoVisToVis(m model.Visibility) gtsmodel.Visibility { +	switch m { +	case model.VisibilityPublic: +		return gtsmodel.VisibilityPublic +	case model.VisibilityUnlisted: +		return gtsmodel.VisibilityUnlocked +	case model.VisibilityPrivate: +		return gtsmodel.VisibilityFollowersOnly +	case model.VisibilityDirect: +		return gtsmodel.VisibilityDirect +	} +	return "" +} diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go new file mode 100644 index 000000000..73c121155 --- /dev/null +++ b/internal/typeutils/internaltoas.go @@ -0,0 +1,260 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( +	"crypto/x509" +	"encoding/pem" +	"net/url" + +	"github.com/go-fed/activity/streams" +	"github.com/go-fed/activity/streams/vocab" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Converts a gts model account into an Activity Streams person type, following +// the spec laid out for mastodon here: https://docs.joinmastodon.org/spec/activitypub/ +func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) { +	person := streams.NewActivityStreamsPerson() + +	// id should be the activitypub URI of this user +	// something like https://example.org/users/example_user +	profileIDURI, err := url.Parse(a.URI) +	if err != nil { +		return nil, err +	} +	idProp := streams.NewJSONLDIdProperty() +	idProp.SetIRI(profileIDURI) +	person.SetJSONLDId(idProp) + +	// following +	// The URI for retrieving a list of accounts this user is following +	followingURI, err := url.Parse(a.FollowingURI) +	if err != nil { +		return nil, err +	} +	followingProp := streams.NewActivityStreamsFollowingProperty() +	followingProp.SetIRI(followingURI) +	person.SetActivityStreamsFollowing(followingProp) + +	// followers +	// The URI for retrieving a list of this user's followers +	followersURI, err := url.Parse(a.FollowersURI) +	if err != nil { +		return nil, err +	} +	followersProp := streams.NewActivityStreamsFollowersProperty() +	followersProp.SetIRI(followersURI) +	person.SetActivityStreamsFollowers(followersProp) + +	// inbox +	// the activitypub inbox of this user for accepting messages +	inboxURI, err := url.Parse(a.InboxURI) +	if err != nil { +		return nil, err +	} +	inboxProp := streams.NewActivityStreamsInboxProperty() +	inboxProp.SetIRI(inboxURI) +	person.SetActivityStreamsInbox(inboxProp) + +	// outbox +	// the activitypub outbox of this user for serving messages +	outboxURI, err := url.Parse(a.OutboxURI) +	if err != nil { +		return nil, err +	} +	outboxProp := streams.NewActivityStreamsOutboxProperty() +	outboxProp.SetIRI(outboxURI) +	person.SetActivityStreamsOutbox(outboxProp) + +	// featured posts +	// Pinned posts. +	featuredURI, err := url.Parse(a.FeaturedCollectionURI) +	if err != nil { +		return nil, err +	} +	featuredProp := streams.NewTootFeaturedProperty() +	featuredProp.SetIRI(featuredURI) +	person.SetTootFeatured(featuredProp) + +	// featuredTags +	// NOT IMPLEMENTED + +	// preferredUsername +	// Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI. +	preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty() +	preferredUsernameProp.SetXMLSchemaString(a.Username) +	person.SetActivityStreamsPreferredUsername(preferredUsernameProp) + +	// name +	// Used as profile display name. +	nameProp := streams.NewActivityStreamsNameProperty() +	if a.Username != "" { +		nameProp.AppendXMLSchemaString(a.DisplayName) +	} else { +		nameProp.AppendXMLSchemaString(a.Username) +	} +	person.SetActivityStreamsName(nameProp) + +	// summary +	// Used as profile bio. +	if a.Note != "" { +		summaryProp := streams.NewActivityStreamsSummaryProperty() +		summaryProp.AppendXMLSchemaString(a.Note) +		person.SetActivityStreamsSummary(summaryProp) +	} + +	// url +	// Used as profile link. +	profileURL, err := url.Parse(a.URL) +	if err != nil { +		return nil, err +	} +	urlProp := streams.NewActivityStreamsUrlProperty() +	urlProp.AppendIRI(profileURL) +	person.SetActivityStreamsUrl(urlProp) + +	// manuallyApprovesFollowers +	// Will be shown as a locked account. +	// TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + +	// discoverable +	// Will be shown in the profile directory. +	discoverableProp := streams.NewTootDiscoverableProperty() +	discoverableProp.Set(a.Discoverable) +	person.SetTootDiscoverable(discoverableProp) + +	// devices +	// NOT IMPLEMENTED, probably won't implement + +	// alsoKnownAs +	// Required for Move activity. +	// TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + +	// publicKey +	// Required for signatures. +	publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty() + +	// create the public key +	publicKey := streams.NewW3IDSecurityV1PublicKey() + +	// set ID for the public key +	publicKeyIDProp := streams.NewJSONLDIdProperty() +	publicKeyURI, err := url.Parse(a.PublicKeyURI) +	if err != nil { +		return nil, err +	} +	publicKeyIDProp.SetIRI(publicKeyURI) +	publicKey.SetJSONLDId(publicKeyIDProp) + +	// set owner for the public key +	publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty() +	publicKeyOwnerProp.SetIRI(profileIDURI) +	publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp) + +	// set the pem key itself +	encodedPublicKey, err := x509.MarshalPKIXPublicKey(a.PublicKey) +	if err != nil { +		return nil, err +	} +	publicKeyBytes := pem.EncodeToMemory(&pem.Block{ +		Type:  "PUBLIC KEY", +		Bytes: encodedPublicKey, +	}) +	publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty() +	publicKeyPEMProp.Set(string(publicKeyBytes)) +	publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp) + +	// append the public key to the public key property +	publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey) + +	// set the public key property on the Person +	person.SetW3IDSecurityV1PublicKey(publicKeyProp) + +	// tag +	// TODO: Any tags used in the summary of this profile + +	// attachment +	// Used for profile fields. +	// TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue + +	// endpoints +	// NOT IMPLEMENTED -- this is for shared inbox which we don't use + +	// 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 +		} + +		mediaType := streams.NewActivityStreamsMediaTypeProperty() +		mediaType.Set(avatar.File.ContentType) +		iconImage.SetActivityStreamsMediaType(mediaType) + +		avatarURLProperty := streams.NewActivityStreamsUrlProperty() +		avatarURL, err := url.Parse(avatar.URL) +		if err != nil { +			return nil, err +		} +		avatarURLProperty.AppendIRI(avatarURL) +		iconImage.SetActivityStreamsUrl(avatarURLProperty) + +		iconProperty.AppendActivityStreamsImage(iconImage) +		person.SetActivityStreamsIcon(iconProperty) +	} + +	// 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 +		} + +		mediaType := streams.NewActivityStreamsMediaTypeProperty() +		mediaType.Set(header.File.ContentType) +		headerImage.SetActivityStreamsMediaType(mediaType) + +		headerURLProperty := streams.NewActivityStreamsUrlProperty() +		headerURL, err := url.Parse(header.URL) +		if err != nil { +			return nil, err +		} +		headerURLProperty.AppendIRI(headerURL) +		headerImage.SetActivityStreamsUrl(headerURLProperty) + +		headerProperty.AppendActivityStreamsImage(headerImage) +	} + +	return person, nil +} + +func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { +	return nil, nil +} diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go new file mode 100644 index 000000000..8eb827e35 --- /dev/null +++ b/internal/typeutils/internaltoas_test.go @@ -0,0 +1,76 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package typeutils_test + +import ( +	"encoding/json" +	"fmt" +	"testing" + +	"github.com/go-fed/activity/streams" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/suite" + +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type InternalToASTestSuite struct { +	ConverterStandardTestSuite +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *InternalToASTestSuite) SetupSuite() { +	// setup standard items +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.log = testrig.NewTestLog() +	suite.accounts = testrig.NewTestAccounts() +	suite.people = testrig.NewTestFediPeople() +	suite.typeconverter = typeutils.NewConverter(suite.config, suite.db) +} + +func (suite *InternalToASTestSuite) SetupTest() { +	testrig.StandardDBSetup(suite.db) +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *InternalToASTestSuite) TearDownTest() { +	testrig.StandardDBTeardown(suite.db) +} + +func (suite *InternalToASTestSuite) TestAccountToAS() { +	testAccount := suite.accounts["local_account_1"] // take zork for this test + +	asPerson, err := suite.typeconverter.AccountToAS(testAccount) +	assert.NoError(suite.T(), err) + +	ser, err := streams.Serialize(asPerson) +	assert.NoError(suite.T(), err) + +	bytes, err := json.Marshal(ser) +	assert.NoError(suite.T(), err) + +	fmt.Println(string(bytes)) +	// TODO: write assertions here, rn we're just eyeballing the output +} + +func TestInternalToASTestSuite(t *testing.T) { +	suite.Run(t, new(InternalToASTestSuite)) +} diff --git a/internal/mastotypes/converter.go b/internal/typeutils/internaltofrontend.go index e689b62da..9456ef531 100644 --- a/internal/mastotypes/converter.go +++ b/internal/typeutils/internaltofrontend.go @@ -16,72 +16,18 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package mastotypes +package typeutils  import (  	"fmt"  	"time" -	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  ) -// Converter is an interface for the common action of converting between mastotypes (frontend, serializable) models and internal gts models used in the database. -// It requires access to the database because many of the conversions require pulling out database entries and counting them etc. -type Converter interface { -	// AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error -	// if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, -	// so serve it only to an authorized user who should have permission to see it. -	AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) - -	// AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error -	// if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. -	// In other words, this is the public record that the server has of an account. -	AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) - -	// AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error -	// if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields -	// (such as client id and client secret), so serve it only to an authorized user who should have permission to see it. -	AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) - -	// AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error -	// if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive -	// fields sanitized so that it can be served to non-authorized accounts without revealing any private information. -	AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) - -	// AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API. -	AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) - -	// MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API. -	MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) - -	// EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API. -	EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) - -	// TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API. -	TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) - -	// StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API. -	StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) -} - -type converter struct { -	config *config.Config -	db     db.DB -} - -// New returns a new Converter -func New(config *config.Config, db db.DB) Converter { -	return &converter{ -		config: config, -		db:     db, -	} -} - -func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Account, error) { +func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*model.Account, error) {  	// we can build this sensitive account easily by first getting the public account....  	mastoAccount, err := c.AccountToMastoPublic(a)  	if err != nil { @@ -102,8 +48,8 @@ func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Ac  		frc = len(fr)  	} -	mastoAccount.Source = &mastotypes.Source{ -		Privacy:             util.ParseMastoVisFromGTSVis(a.Privacy), +	mastoAccount.Source = &model.Source{ +		Privacy:             c.VisToMasto(a.Privacy),  		Sensitive:           a.Sensitive,  		Language:            a.Language,  		Note:                a.Note, @@ -114,7 +60,7 @@ func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Ac  	return mastoAccount, nil  } -func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) { +func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, error) {  	// count followers  	followers := []gtsmodel.Follow{}  	if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil { @@ -174,7 +120,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou  	aviURLStatic := avi.Thumbnail.URL  	header := >smodel.MediaAttachment{} -	if err := c.db.GetHeaderForAccountID(avi, a.ID); err != nil { +	if err := c.db.GetHeaderForAccountID(header, a.ID); err != nil {  		if _, ok := err.(db.ErrNoEntries); !ok {  			return nil, fmt.Errorf("error getting header: %s", err)  		} @@ -183,9 +129,9 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou  	headerURLStatic := header.Thumbnail.URL  	// get the fields set on this account -	fields := []mastotypes.Field{} +	fields := []model.Field{}  	for _, f := range a.Fields { -		mField := mastotypes.Field{ +		mField := model.Field{  			Name:  f.Name,  			Value: f.Value,  		} @@ -204,7 +150,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou  		acct = a.Username  	} -	return &mastotypes.Account{ +	return &model.Account{  		ID:             a.ID,  		Username:       a.Username,  		Acct:           acct, @@ -227,8 +173,8 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou  	}, nil  } -func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Application, error) { -	return &mastotypes.Application{ +func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*model.Application, error) { +	return &model.Application{  		ID:           a.ID,  		Name:         a.Name,  		Website:      a.Website, @@ -239,35 +185,35 @@ func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Ap  	}, nil  } -func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*mastotypes.Application, error) { -	return &mastotypes.Application{ +func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*model.Application, error) { +	return &model.Application{  		Name:    a.Name,  		Website: a.Website,  	}, nil  } -func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { -	return mastotypes.Attachment{ +func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (model.Attachment, error) { +	return model.Attachment{  		ID:               a.ID,  		Type:             string(a.Type),  		URL:              a.URL,  		PreviewURL:       a.Thumbnail.URL,  		RemoteURL:        a.RemoteURL,  		PreviewRemoteURL: a.Thumbnail.RemoteURL, -		Meta: mastotypes.MediaMeta{ -			Original: mastotypes.MediaDimensions{ +		Meta: model.MediaMeta{ +			Original: model.MediaDimensions{  				Width:  a.FileMeta.Original.Width,  				Height: a.FileMeta.Original.Height,  				Size:   fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height),  				Aspect: float32(a.FileMeta.Original.Aspect),  			}, -			Small: mastotypes.MediaDimensions{ +			Small: model.MediaDimensions{  				Width:  a.FileMeta.Small.Width,  				Height: a.FileMeta.Small.Height,  				Size:   fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height),  				Aspect: float32(a.FileMeta.Small.Aspect),  			}, -			Focus: mastotypes.MediaFocus{ +			Focus: model.MediaFocus{  				X: a.FileMeta.Focus.X,  				Y: a.FileMeta.Focus.Y,  			}, @@ -277,10 +223,10 @@ func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.A  	}, nil  } -func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) { +func (c *converter) MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) {  	target := >smodel.Account{}  	if err := c.db.GetByID(m.TargetAccountID, target); err != nil { -		return mastotypes.Mention{}, err +		return model.Mention{}, err  	}  	var local bool @@ -295,7 +241,7 @@ func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, err  		acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain)  	} -	return mastotypes.Mention{ +	return model.Mention{  		ID:       target.ID,  		Username: target.Username,  		URL:      target.URL, @@ -303,8 +249,8 @@ func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, err  	}, nil  } -func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) { -	return mastotypes.Emoji{ +func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) { +	return model.Emoji{  		Shortcode:       e.Shortcode,  		URL:             e.ImageURL,  		StaticURL:       e.ImageStaticURL, @@ -313,10 +259,10 @@ func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) {  	}, nil  } -func (c *converter) TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) { +func (c *converter) TagToMasto(t *gtsmodel.Tag) (model.Tag, error) {  	tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name) -	return mastotypes.Tag{ +	return model.Tag{  		Name: t.Name,  		URL:  tagURL, // we don't serve URLs with collections of tagged statuses (FOR NOW) so this is purely for mastodon compatibility ¯\_(ツ)_/¯  	}, nil @@ -328,7 +274,7 @@ func (c *converter) StatusToMasto(  	requestingAccount *gtsmodel.Account,  	boostOfAccount *gtsmodel.Account,  	replyToAccount *gtsmodel.Account, -	reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) { +	reblogOfStatus *gtsmodel.Status) (*model.Status, error) {  	repliesCount, err := c.db.GetReplyCountForStatus(s)  	if err != nil { @@ -380,9 +326,9 @@ func (c *converter) StatusToMasto(  		}  	} -	var mastoRebloggedStatus *mastotypes.Status // TODO +	var mastoRebloggedStatus *model.Status // TODO -	var mastoApplication *mastotypes.Application +	var mastoApplication *model.Application  	if s.CreatedWithApplicationID != "" {  		gtsApplication := >smodel.Application{}  		if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil { @@ -399,7 +345,7 @@ func (c *converter) StatusToMasto(  		return nil, fmt.Errorf("error parsing account of status author: %s", err)  	} -	mastoAttachments := []mastotypes.Attachment{} +	mastoAttachments := []model.Attachment{}  	// the status might already have some gts attachments on it if it's not been pulled directly from the database  	// if so, we can directly convert the gts attachments into masto ones  	if s.GTSMediaAttachments != nil { @@ -426,7 +372,7 @@ func (c *converter) StatusToMasto(  		}  	} -	mastoMentions := []mastotypes.Mention{} +	mastoMentions := []model.Mention{}  	// the status might already have some gts mentions on it if it's not been pulled directly from the database  	// if so, we can directly convert the gts mentions into masto ones  	if s.GTSMentions != nil { @@ -453,7 +399,7 @@ func (c *converter) StatusToMasto(  		}  	} -	mastoTags := []mastotypes.Tag{} +	mastoTags := []model.Tag{}  	// the status might already have some gts tags on it if it's not been pulled directly from the database  	// if so, we can directly convert the gts tags into masto ones  	if s.GTSTags != nil { @@ -480,7 +426,7 @@ func (c *converter) StatusToMasto(  		}  	} -	mastoEmojis := []mastotypes.Emoji{} +	mastoEmojis := []model.Emoji{}  	// the status might already have some gts emojis on it if it's not been pulled directly from the database  	// if so, we can directly convert the gts emojis into masto ones  	if s.GTSEmojis != nil { @@ -507,17 +453,17 @@ func (c *converter) StatusToMasto(  		}  	} -	var mastoCard *mastotypes.Card -	var mastoPoll *mastotypes.Poll +	var mastoCard *model.Card +	var mastoPoll *model.Poll -	return &mastotypes.Status{ +	return &model.Status{  		ID:                 s.ID,  		CreatedAt:          s.CreatedAt.Format(time.RFC3339),  		InReplyToID:        s.InReplyToID,  		InReplyToAccountID: s.InReplyToAccountID,  		Sensitive:          s.Sensitive,  		SpoilerText:        s.ContentWarning, -		Visibility:         util.ParseMastoVisFromGTSVis(s.Visibility), +		Visibility:         c.VisToMasto(s.Visibility),  		Language:           s.Language,  		URI:                s.URI,  		URL:                s.URL, @@ -542,3 +488,18 @@ func (c *converter) StatusToMasto(  		Text:               s.Text,  	}, nil  } + +// VisToMasto converts a gts visibility into its mastodon equivalent +func (c *converter) VisToMasto(m gtsmodel.Visibility) model.Visibility { +	switch m { +	case gtsmodel.VisibilityPublic: +		return model.VisibilityPublic +	case gtsmodel.VisibilityUnlocked: +		return model.VisibilityUnlisted +	case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: +		return model.VisibilityPrivate +	case gtsmodel.VisibilityDirect: +		return model.VisibilityDirect +	} +	return "" +} diff --git a/internal/util/parse.go b/internal/util/parse.go deleted file mode 100644 index f0bcff5dc..000000000 --- a/internal/util/parse.go +++ /dev/null @@ -1,96 +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 <http://www.gnu.org/licenses/>. -*/ - -package util - -import ( -	"fmt" - -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -	mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -) - -// URIs contains a bunch of URIs and URLs for a user, host, account, etc. -type URIs struct { -	HostURL     string -	UserURL     string -	StatusesURL string - -	UserURI       string -	StatusesURI   string -	InboxURI      string -	OutboxURI     string -	FollowersURI  string -	CollectionURI string -} - -// GenerateURIs throws together a bunch of URIs for the given username, with the given protocol and host. -func GenerateURIs(username string, protocol string, host string) *URIs { -	hostURL := fmt.Sprintf("%s://%s", protocol, host) -	userURL := fmt.Sprintf("%s/@%s", hostURL, username) -	statusesURL := fmt.Sprintf("%s/statuses", userURL) - -	userURI := fmt.Sprintf("%s/users/%s", hostURL, username) -	statusesURI := fmt.Sprintf("%s/statuses", userURI) -	inboxURI := fmt.Sprintf("%s/inbox", userURI) -	outboxURI := fmt.Sprintf("%s/outbox", userURI) -	followersURI := fmt.Sprintf("%s/followers", userURI) -	collectionURI := fmt.Sprintf("%s/collections/featured", userURI) -	return &URIs{ -		HostURL:     hostURL, -		UserURL:     userURL, -		StatusesURL: statusesURL, - -		UserURI:       userURI, -		StatusesURI:   statusesURI, -		InboxURI:      inboxURI, -		OutboxURI:     outboxURI, -		FollowersURI:  followersURI, -		CollectionURI: collectionURI, -	} -} - -// ParseGTSVisFromMastoVis converts a mastodon visibility into its gts equivalent. -func ParseGTSVisFromMastoVis(m mastotypes.Visibility) gtsmodel.Visibility { -	switch m { -	case mastotypes.VisibilityPublic: -		return gtsmodel.VisibilityPublic -	case mastotypes.VisibilityUnlisted: -		return gtsmodel.VisibilityUnlocked -	case mastotypes.VisibilityPrivate: -		return gtsmodel.VisibilityFollowersOnly -	case mastotypes.VisibilityDirect: -		return gtsmodel.VisibilityDirect -	} -	return "" -} - -// ParseMastoVisFromGTSVis converts a gts visibility into its mastodon equivalent -func ParseMastoVisFromGTSVis(m gtsmodel.Visibility) mastotypes.Visibility { -	switch m { -	case gtsmodel.VisibilityPublic: -		return mastotypes.VisibilityPublic -	case gtsmodel.VisibilityUnlocked: -		return mastotypes.VisibilityUnlisted -	case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: -		return mastotypes.VisibilityPrivate -	case gtsmodel.VisibilityDirect: -		return mastotypes.VisibilityDirect -	} -	return "" -} diff --git a/internal/util/regexes.go b/internal/util/regexes.go index 60b397d86..a59bd678a 100644 --- a/internal/util/regexes.go +++ b/internal/util/regexes.go @@ -18,19 +18,78 @@  package util -import "regexp" +import ( +	"fmt" +	"regexp" +) + +const ( +	minimumPasswordEntropy      = 60 // dictates password strength. See https://github.com/wagslane/go-password-validator +	minimumReasonLength         = 40 +	maximumReasonLength         = 500 +	maximumEmailLength          = 256 +	maximumUsernameLength       = 64 +	maximumPasswordLength       = 64 +	maximumEmojiShortcodeLength = 30 +	maximumHashtagLength        = 30 +)  var (  	// mention regex can be played around with here: https://regex101.com/r/qwM9D3/1 -	mentionRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` -	mentionRegex       = regexp.MustCompile(mentionRegexString) +	mentionFinderRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` +	mentionFinderRegex       = regexp.MustCompile(mentionFinderRegexString) +  	// hashtag regex can be played with here: https://regex101.com/r/Vhy8pg/1 -	hashtagRegexString = `(?: |^|\W)?#([a-zA-Z0-9]{1,30})(?:\b|\r)` -	hashtagRegex       = regexp.MustCompile(hashtagRegexString) -	// emoji regex can be played with here: https://regex101.com/r/478XGM/1 -	emojiRegexString = `(?: |^|\W)?:([a-zA-Z0-9_]{2,30}):(?:\b|\r)?` -	emojiRegex       = regexp.MustCompile(emojiRegexString) +	hashtagFinderRegexString = fmt.Sprintf(`(?: |^|\W)?#([a-zA-Z0-9]{1,%d})(?:\b|\r)`, maximumHashtagLength) +	hashtagFinderRegex       = regexp.MustCompile(hashtagFinderRegexString) +  	// emoji shortcode regex can be played with here: https://regex101.com/r/zMDRaG/1 -	emojiShortcodeString = `^[a-z0-9_]{2,30}$` -	emojiShortcodeRegex  = regexp.MustCompile(emojiShortcodeString) +	emojiShortcodeRegexString     = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumEmojiShortcodeLength) +	emojiShortcodeValidationRegex = regexp.MustCompile(fmt.Sprintf("^%s$", emojiShortcodeRegexString)) + +	// emoji regex can be played with here: https://regex101.com/r/478XGM/1 +	emojiFinderRegexString = fmt.Sprintf(`(?: |^|\W)?:(%s):(?:\b|\r)?`, emojiShortcodeRegexString) +	emojiFinderRegex       = regexp.MustCompile(emojiFinderRegexString) + +	// usernameRegexString defines an acceptable username on this instance +	usernameRegexString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumUsernameLength) +	// usernameValidationRegex can be used to validate usernames of new signups +	usernameValidationRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, usernameRegexString)) + +	userPathRegexString = fmt.Sprintf(`^?/%s/(%s)$`, UsersPath, usernameRegexString) +	// userPathRegex parses a path that validates and captures the username part from eg /users/example_username +	userPathRegex = regexp.MustCompile(userPathRegexString) + +	inboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, InboxPath) +	// inboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/inbox +	inboxPathRegex = regexp.MustCompile(inboxPathRegexString) + +	outboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, OutboxPath) +	// outboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/outbox +	outboxPathRegex = regexp.MustCompile(outboxPathRegexString) + +	actorPathRegexString = fmt.Sprintf(`^?/%s/(%s)$`, ActorsPath, usernameRegexString) +	// actorPathRegex parses a path that validates and captures the username part from eg /actors/example_username +	actorPathRegex = regexp.MustCompile(actorPathRegexString) + +	followersPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, FollowersPath) +	// followersPathRegex parses a path that validates and captures the username part from eg /users/example_username/followers +	followersPathRegex = regexp.MustCompile(followersPathRegexString) + +	followingPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, FollowingPath) +	// followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/following +	followingPathRegex = regexp.MustCompile(followingPathRegexString) + +	likedPathRegexString = fmt.Sprintf(`^/?%s/%s/%s$`, UsersPath, usernameRegexString, LikedPath) +	// followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked +	likedPathRegex = regexp.MustCompile(likedPathRegexString) + +	// see https://ihateregex.io/expr/uuid/ +	uuidRegexString = `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}` + +	statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, uuidRegexString) +	// statusesPathRegex parses a path that validates and captures the username part and the uuid part +	// from eg /users/example_username/statuses/123e4567-e89b-12d3-a456-426655440000. +	// The regex can be played with here: https://regex101.com/r/G9zuxQ/1 +	statusesPathRegex = regexp.MustCompile(statusesPathRegexString)  ) diff --git a/internal/util/status.go b/internal/util/statustools.go index e4b3ec6a5..5591f185a 100644 --- a/internal/util/status.go +++ b/internal/util/statustools.go @@ -31,10 +31,10 @@ import (  // The case of the returned mentions will be lowered, for consistency.  func DeriveMentions(status string) []string {  	mentionedAccounts := []string{} -	for _, m := range mentionRegex.FindAllStringSubmatch(status, -1) { +	for _, m := range mentionFinderRegex.FindAllStringSubmatch(status, -1) {  		mentionedAccounts = append(mentionedAccounts, m[1])  	} -	return Lower(Unique(mentionedAccounts)) +	return lower(unique(mentionedAccounts))  }  // DeriveHashtags takes a plaintext (ie., not html-formatted) status, @@ -43,10 +43,10 @@ func DeriveMentions(status string) []string {  // tags will be lowered, for consistency.  func DeriveHashtags(status string) []string {  	tags := []string{} -	for _, m := range hashtagRegex.FindAllStringSubmatch(status, -1) { +	for _, m := range hashtagFinderRegex.FindAllStringSubmatch(status, -1) {  		tags = append(tags, m[1])  	} -	return Lower(Unique(tags)) +	return lower(unique(tags))  }  // DeriveEmojis takes a plaintext (ie., not html-formatted) status, @@ -55,14 +55,14 @@ func DeriveHashtags(status string) []string {  // emojis will be lowered, for consistency.  func DeriveEmojis(status string) []string {  	emojis := []string{} -	for _, m := range emojiRegex.FindAllStringSubmatch(status, -1) { +	for _, m := range emojiFinderRegex.FindAllStringSubmatch(status, -1) {  		emojis = append(emojis, m[1])  	} -	return Lower(Unique(emojis)) +	return lower(unique(emojis))  } -// Unique returns a deduplicated version of a given string slice. -func Unique(s []string) []string { +// unique returns a deduplicated version of a given string slice. +func unique(s []string) []string {  	keys := make(map[string]bool)  	list := []string{}  	for _, entry := range s { @@ -74,8 +74,8 @@ func Unique(s []string) []string {  	return list  } -// Lower lowercases all strings in a given string slice -func Lower(s []string) []string { +// lower lowercases all strings in a given string slice +func lower(s []string) []string {  	new := []string{}  	for _, i := range s {  		new = append(new, strings.ToLower(i)) diff --git a/internal/util/status_test.go b/internal/util/statustools_test.go index 72bd3e885..7c9af2cbd 100644 --- a/internal/util/status_test.go +++ b/internal/util/statustools_test.go @@ -16,13 +16,14 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package util +package util_test  import (  	"testing"  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/util"  )  type StatusTestSuite struct { @@ -41,7 +42,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() {  	here is a duplicate mention: @hello@test.lgbt  	` -	menchies := DeriveMentions(statusText) +	menchies := util.DeriveMentions(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]) @@ -51,7 +52,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() {  func (suite *StatusTestSuite) TestDeriveMentionsEmpty() {  	statusText := `` -	menchies := DeriveMentions(statusText) +	menchies := util.DeriveMentions(statusText)  	assert.Len(suite.T(), menchies, 0)  } @@ -66,7 +67,7 @@ func (suite *StatusTestSuite) TestDeriveHashtagsOK() {  #111111 thisalsoshouldn'twork#### ##` -	tags := DeriveHashtags(statusText) +	tags := util.DeriveHashtags(statusText)  	assert.Len(suite.T(), tags, 5)  	assert.Equal(suite.T(), "testing123", tags[0])  	assert.Equal(suite.T(), "also", tags[1]) @@ -89,7 +90,7 @@ Here's some normal text with an :emoji: at the end  :underscores_ok_too:  ` -	tags := DeriveEmojis(statusText) +	tags := util.DeriveEmojis(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 new file mode 100644 index 000000000..9b96edc61 --- /dev/null +++ b/internal/util/uri.go @@ -0,0 +1,218 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package util + +import ( +	"fmt" +	"net/url" +	"strings" +) + +const ( +	// UsersPath is for serving users info +	UsersPath = "users" +	// ActorsPath is for serving actors info +	ActorsPath = "actors" +	// StatusesPath is for serving statuses +	StatusesPath = "statuses" +	// InboxPath represents the webfinger inbox location +	InboxPath = "inbox" +	// OutboxPath represents the webfinger outbox location +	OutboxPath = "outbox" +	// FollowersPath represents the webfinger followers location +	FollowersPath = "followers" +	// FollowingPath represents the webfinger following location +	FollowingPath = "following" +	// LikedPath represents the webfinger liked location +	LikedPath = "liked" +	// CollectionsPath represents the webfinger collections location +	CollectionsPath = "collections" +	// FeaturedPath represents the webfinger featured location +	FeaturedPath = "featured" +	// PublicKeyPath is for serving an account's public key +	PublicKeyPath = "publickey" +) + +// APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains +type APContextKey string + +const ( +	// APActivity can be used to set and retrieve the actual go-fed pub.Activity within a context. +	APActivity APContextKey = "activity" +	// 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. +	APRequestingAccount APContextKey = "requestingAccount" +	// APRequestingPublicKeyID can be used to set and retrieve the public key ID of an incoming federation request. +	APRequestingPublicKeyID APContextKey = "requestingPublicKeyID" +) + +type ginContextKey struct{} + +// GinContextKey is used solely for setting and retrieving the gin context from a context.Context +var GinContextKey = &ginContextKey{} + +// UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc. +type UserURIs struct { +	// The web URL of the instance host, eg https://example.org +	HostURL string +	// The web URL of the user, eg., https://example.org/@example_user +	UserURL string +	// The web URL for statuses of this user, eg., https://example.org/@example_user/statuses +	StatusesURL string + +	// The webfinger URI of this user, eg., https://example.org/users/example_user +	UserURI string +	// The webfinger URI for this user's statuses, eg., https://example.org/users/example_user/statuses +	StatusesURI string +	// The webfinger URI for this user's activitypub inbox, eg., https://example.org/users/example_user/inbox +	InboxURI string +	// The webfinger URI for this user's activitypub outbox, eg., https://example.org/users/example_user/outbox +	OutboxURI string +	// The webfinger URI for this user's followers, eg., https://example.org/users/example_user/followers +	FollowersURI string +	// The webfinger URI for this user's following, eg., https://example.org/users/example_user/following +	FollowingURI string +	// The webfinger URI for this user's liked posts eg., https://example.org/users/example_user/liked +	LikedURI string +	// The webfinger URI for this user's featured collections, eg., https://example.org/users/example_user/collections/featured +	CollectionURI string +	// The URI for this user's public key, eg., https://example.org/users/example_user/publickey +	PublicKeyURI string +} + +// GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. +func GenerateURIsForAccount(username string, protocol string, host string) *UserURIs { +	// The below URLs are used for serving web requests +	hostURL := fmt.Sprintf("%s://%s", protocol, host) +	userURL := fmt.Sprintf("%s/@%s", hostURL, username) +	statusesURL := fmt.Sprintf("%s/%s", userURL, StatusesPath) + +	// the below URIs are used in ActivityPub and Webfinger +	userURI := fmt.Sprintf("%s/%s/%s", hostURL, UsersPath, username) +	statusesURI := fmt.Sprintf("%s/%s", userURI, StatusesPath) +	inboxURI := fmt.Sprintf("%s/%s", userURI, InboxPath) +	outboxURI := fmt.Sprintf("%s/%s", userURI, OutboxPath) +	followersURI := fmt.Sprintf("%s/%s", userURI, FollowersPath) +	followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath) +	likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath) +	collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath) +	publicKeyURI := fmt.Sprintf("%s/%s", userURI, PublicKeyPath) + +	return &UserURIs{ +		HostURL:     hostURL, +		UserURL:     userURL, +		StatusesURL: statusesURL, + +		UserURI:       userURI, +		StatusesURI:   statusesURI, +		InboxURI:      inboxURI, +		OutboxURI:     outboxURI, +		FollowersURI:  followersURI, +		FollowingURI:  followingURI, +		LikedURI:      likedURI, +		CollectionURI: collectionURI, +		PublicKeyURI:  publicKeyURI, +	} +} + +// IsUserPath returns true if the given URL path corresponds to eg /users/example_username +func IsUserPath(id *url.URL) bool { +	return userPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsInboxPath returns true if the given URL path corresponds to eg /users/example_username/inbox +func IsInboxPath(id *url.URL) bool { +	return inboxPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsOutboxPath returns true if the given URL path corresponds to eg /users/example_username/outbox +func IsOutboxPath(id *url.URL) bool { +	return outboxPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsInstanceActorPath returns true if the given URL path corresponds to eg /actors/example_username +func IsInstanceActorPath(id *url.URL) bool { +	return actorPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsFollowersPath returns true if the given URL path corresponds to eg /users/example_username/followers +func IsFollowersPath(id *url.URL) bool { +	return followersPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsFollowingPath returns true if the given URL path corresponds to eg /users/example_username/following +func IsFollowingPath(id *url.URL) bool { +	return followingPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsLikedPath returns true if the given URL path corresponds to eg /users/example_username/liked +func IsLikedPath(id *url.URL) bool { +	return likedPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS +func IsStatusesPath(id *url.URL) bool { +	return statusesPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// ParseStatusesPath returns the username and uuid from a path such as /users/example_username/statuses/SOME_UUID_OF_A_STATUS +func ParseStatusesPath(id *url.URL) (username string, uuid string, err error) { +	matches := statusesPathRegex.FindStringSubmatch(id.Path) +	if len(matches) != 3 { +		err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches)) +		return +	} +	username = matches[1] +	uuid = matches[2] +	return +} + +// ParseUserPath returns the username from a path such as /users/example_username +func ParseUserPath(id *url.URL) (username string, err error) { +	matches := userPathRegex.FindStringSubmatch(id.Path) +	if len(matches) != 2 { +		err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches)) +		return +	} +	username = matches[1] +	return +} + +// ParseInboxPath returns the username from a path such as /users/example_username/inbox +func ParseInboxPath(id *url.URL) (username string, err error) { +	matches := inboxPathRegex.FindStringSubmatch(id.Path) +	if len(matches) != 2 { +		err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches)) +		return +	} +	username = matches[1] +	return +} + +// ParseOutboxPath returns the username from a path such as /users/example_username/outbox +func ParseOutboxPath(id *url.URL) (username string, err error) { +	matches := outboxPathRegex.FindStringSubmatch(id.Path) +	if len(matches) != 2 { +		err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches)) +		return +	} +	username = matches[1] +	return +} diff --git a/internal/util/validation.go b/internal/util/validation.go index acf0e68cd..d392231bb 100644 --- a/internal/util/validation.go +++ b/internal/util/validation.go @@ -22,45 +22,22 @@ import (  	"errors"  	"fmt"  	"net/mail" -	"regexp"  	pwv "github.com/wagslane/go-password-validator"  	"golang.org/x/text/language"  ) -const ( -	// MinimumPasswordEntropy dictates password strength. See https://github.com/wagslane/go-password-validator -	MinimumPasswordEntropy = 60 -	// MinimumReasonLength is the length of chars we expect as a bare minimum effort -	MinimumReasonLength = 40 -	// MaximumReasonLength is the maximum amount of chars we're happy to accept -	MaximumReasonLength = 500 -	// MaximumEmailLength is the maximum length of an email address we're happy to accept -	MaximumEmailLength = 256 -	// MaximumUsernameLength is the maximum length of a username we're happy to accept -	MaximumUsernameLength = 64 -	// MaximumPasswordLength is the maximum length of a password we're happy to accept -	MaximumPasswordLength = 64 -	// NewUsernameRegexString is string representation of the regular expression for validating usernames -	NewUsernameRegexString = `^[a-z0-9_]+$` -) - -var ( -	// NewUsernameRegex is the compiled regex for validating new usernames -	NewUsernameRegex = regexp.MustCompile(NewUsernameRegexString) -) -  // ValidateNewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok.  func ValidateNewPassword(password string) error {  	if password == "" {  		return errors.New("no password provided")  	} -	if len(password) > MaximumPasswordLength { -		return fmt.Errorf("password should be no more than %d chars", MaximumPasswordLength) +	if len(password) > maximumPasswordLength { +		return fmt.Errorf("password should be no more than %d chars", maximumPasswordLength)  	} -	return pwv.Validate(password, MinimumPasswordEntropy) +	return pwv.Validate(password, minimumPasswordEntropy)  }  // ValidateUsername makes sure that a given username is valid (ie., letters, numbers, underscores, check length). @@ -70,11 +47,11 @@ func ValidateUsername(username string) error {  		return errors.New("no username provided")  	} -	if len(username) > MaximumUsernameLength { -		return fmt.Errorf("username should be no more than %d chars but '%s' was %d", MaximumUsernameLength, username, len(username)) +	if len(username) > maximumUsernameLength { +		return fmt.Errorf("username should be no more than %d chars but '%s' was %d", maximumUsernameLength, username, len(username))  	} -	if !NewUsernameRegex.MatchString(username) { +	if !usernameValidationRegex.MatchString(username) {  		return fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", username)  	} @@ -88,8 +65,8 @@ func ValidateEmail(email string) error {  		return errors.New("no email provided")  	} -	if len(email) > MaximumEmailLength { -		return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", MaximumEmailLength, email, len(email)) +	if len(email) > maximumEmailLength { +		return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", maximumEmailLength, email, len(email))  	}  	_, err := mail.ParseAddress(email) @@ -118,12 +95,12 @@ func ValidateSignUpReason(reason string, reasonRequired bool) error {  		return errors.New("no reason provided")  	} -	if len(reason) < MinimumReasonLength { -		return fmt.Errorf("reason should be at least %d chars but '%s' was %d", MinimumReasonLength, reason, len(reason)) +	if len(reason) < minimumReasonLength { +		return fmt.Errorf("reason should be at least %d chars but '%s' was %d", minimumReasonLength, reason, len(reason))  	} -	if len(reason) > MaximumReasonLength { -		return fmt.Errorf("reason should be no more than %d chars but given reason was %d", MaximumReasonLength, len(reason)) +	if len(reason) > maximumReasonLength { +		return fmt.Errorf("reason should be no more than %d chars but given reason was %d", maximumReasonLength, len(reason))  	}  	return nil  } @@ -150,7 +127,7 @@ func ValidatePrivacy(privacy string) error {  // for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters,  // lowercase a-z, numbers, and underscores.  func ValidateEmojiShortcode(shortcode string) error { -	if !emojiShortcodeRegex.MatchString(shortcode) { +	if !emojiShortcodeValidationRegex.MatchString(shortcode) {  		return fmt.Errorf("shortcode %s did not pass validation, must be between 2 and 30 characters, lowercase letters, numbers, and underscores only", shortcode)  	}  	return nil diff --git a/internal/util/validation_test.go b/internal/util/validation_test.go index dbac5e248..73f5cb977 100644 --- a/internal/util/validation_test.go +++ b/internal/util/validation_test.go @@ -16,7 +16,7 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -package util +package util_test  import (  	"errors" @@ -25,6 +25,7 @@ import (  	"github.com/stretchr/testify/assert"  	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/util"  )  type ValidationTestSuite struct { @@ -42,42 +43,42 @@ func (suite *ValidationTestSuite) TestCheckPasswordStrength() {  	strongPassword := "3dX5@Zc%mV*W2MBNEy$@"  	var err error -	err = ValidateNewPassword(empty) +	err = util.ValidateNewPassword(empty)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("no password provided"), err)  	} -	err = ValidateNewPassword(terriblePassword) +	err = util.ValidateNewPassword(terriblePassword)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"), err)  	} -	err = ValidateNewPassword(weakPassword) +	err = util.ValidateNewPassword(weakPassword)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using numbers or using a longer password"), err)  	} -	err = ValidateNewPassword(shortPassword) +	err = util.ValidateNewPassword(shortPassword)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err)  	} -	err = ValidateNewPassword(specialPassword) +	err = util.ValidateNewPassword(specialPassword)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err)  	} -	err = ValidateNewPassword(longPassword) +	err = util.ValidateNewPassword(longPassword)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateNewPassword(tooLong) +	err = util.ValidateNewPassword(tooLong)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("password should be no more than 64 chars"), err)  	} -	err = ValidateNewPassword(strongPassword) +	err = util.ValidateNewPassword(strongPassword)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} @@ -94,42 +95,42 @@ func (suite *ValidationTestSuite) TestValidateUsername() {  	goodUsername := "this_is_a_good_username"  	var err error -	err = ValidateUsername(empty) +	err = util.ValidateUsername(empty)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("no username provided"), err)  	} -	err = ValidateUsername(tooLong) +	err = util.ValidateUsername(tooLong)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), fmt.Errorf("username should be no more than 64 chars but '%s' was 66", tooLong), err)  	} -	err = ValidateUsername(withSpaces) +	err = util.ValidateUsername(withSpaces)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", withSpaces), err)  	} -	err = ValidateUsername(weirdChars) +	err = util.ValidateUsername(weirdChars)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", weirdChars), err)  	} -	err = ValidateUsername(leadingSpace) +	err = util.ValidateUsername(leadingSpace)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", leadingSpace), err)  	} -	err = ValidateUsername(trailingSpace) +	err = util.ValidateUsername(trailingSpace)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", trailingSpace), err)  	} -	err = ValidateUsername(newlines) +	err = util.ValidateUsername(newlines)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", newlines), err)  	} -	err = ValidateUsername(goodUsername) +	err = util.ValidateUsername(goodUsername)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} @@ -144,32 +145,32 @@ func (suite *ValidationTestSuite) TestValidateEmail() {  	emailAddress := "thisis.actually@anemail.address"  	var err error -	err = ValidateEmail(empty) +	err = util.ValidateEmail(empty)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("no email provided"), err)  	} -	err = ValidateEmail(notAnEmailAddress) +	err = util.ValidateEmail(notAnEmailAddress)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("mail: missing '@' or angle-addr"), err)  	} -	err = ValidateEmail(almostAnEmailAddress) +	err = util.ValidateEmail(almostAnEmailAddress)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("mail: no angle-addr"), err)  	} -	err = ValidateEmail(aWebsite) +	err = util.ValidateEmail(aWebsite)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("mail: missing '@' or angle-addr"), err)  	} -	err = ValidateEmail(tooLong) +	err = util.ValidateEmail(tooLong)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), fmt.Errorf("email address should be no more than 256 chars but '%s' was 286", tooLong), err)  	} -	err = ValidateEmail(emailAddress) +	err = util.ValidateEmail(emailAddress)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} @@ -187,47 +188,47 @@ func (suite *ValidationTestSuite) TestValidateLanguage() {  	german := "de"  	var err error -	err = ValidateLanguage(empty) +	err = util.ValidateLanguage(empty)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("no language provided"), err)  	} -	err = ValidateLanguage(notALanguage) +	err = util.ValidateLanguage(notALanguage)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("language: tag is not well-formed"), err)  	} -	err = ValidateLanguage(english) +	err = util.ValidateLanguage(english)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateLanguage(capitalEnglish) +	err = util.ValidateLanguage(capitalEnglish)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateLanguage(arabic3Letters) +	err = util.ValidateLanguage(arabic3Letters)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateLanguage(mixedCapsEnglish) +	err = util.ValidateLanguage(mixedCapsEnglish)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateLanguage(englishUS) +	err = util.ValidateLanguage(englishUS)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("language: tag is not well-formed"), err)  	} -	err = ValidateLanguage(dutch) +	err = util.ValidateLanguage(dutch)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateLanguage(german) +	err = util.ValidateLanguage(german)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} @@ -241,43 +242,43 @@ func (suite *ValidationTestSuite) TestValidateReason() {  	var err error  	// check with no reason required -	err = ValidateSignUpReason(empty, false) +	err = util.ValidateSignUpReason(empty, false)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateSignUpReason(badReason, false) +	err = util.ValidateSignUpReason(badReason, false)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateSignUpReason(tooLong, false) +	err = util.ValidateSignUpReason(tooLong, false)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} -	err = ValidateSignUpReason(goodReason, false) +	err = util.ValidateSignUpReason(goodReason, false)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	}  	// check with reason required -	err = ValidateSignUpReason(empty, true) +	err = util.ValidateSignUpReason(empty, true)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("no reason provided"), err)  	} -	err = ValidateSignUpReason(badReason, true) +	err = util.ValidateSignUpReason(badReason, true)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("reason should be at least 40 chars but 'because' was 7"), err)  	} -	err = ValidateSignUpReason(tooLong, true) +	err = util.ValidateSignUpReason(tooLong, true)  	if assert.Error(suite.T(), err) {  		assert.Equal(suite.T(), errors.New("reason should be no more than 500 chars but given reason was 600"), err)  	} -	err = ValidateSignUpReason(goodReason, true) +	err = util.ValidateSignUpReason(goodReason, true)  	if assert.NoError(suite.T(), err) {  		assert.Equal(suite.T(), nil, err)  	} diff --git a/testrig/actions.go b/testrig/actions.go index 1caa18581..7ed75b18f 100644 --- a/testrig/actions.go +++ b/testrig/actions.go @@ -19,24 +19,26 @@  package testrig  import ( +	"bytes"  	"context"  	"fmt" +	"io/ioutil" +	"net/http"  	"os"  	"os/signal"  	"syscall"  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/action" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/account" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/admin" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/app" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/auth" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" -	mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/security" -	"github.com/superseriousbusiness/gotosocial/internal/apimodule/status" -	"github.com/superseriousbusiness/gotosocial/internal/cache" +	"github.com/superseriousbusiness/gotosocial/internal/api" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/account" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/app" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/auth" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" +	mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/status" +	"github.com/superseriousbusiness/gotosocial/internal/api/security"  	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/federation"  	"github.com/superseriousbusiness/gotosocial/internal/gotosocial" @@ -44,33 +46,39 @@ import (  // Run creates and starts a gotosocial testrig server  var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logrus.Logger) error { +	c := NewTestConfig()  	dbService := NewTestDB()  	router := NewTestRouter()  	storageBackend := NewTestStorage() -	mediaHandler := NewTestMediaHandler(dbService, storageBackend) -	oauthServer := NewTestOauthServer(dbService) -	distributor := NewTestDistributor() -	if err := distributor.Start(); err != nil { -		return fmt.Errorf("error starting distributor: %s", err) -	} -	mastoConverter := NewTestMastoConverter(dbService) -	c := NewTestConfig() +	typeConverter := NewTestTypeConverter(dbService) +	transportController := NewTestTransportController(NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { +		r := ioutil.NopCloser(bytes.NewReader([]byte{})) +		return &http.Response{ +			StatusCode: 200, +			Body:       r, +		}, nil +	})) +	federator := federation.NewFederator(dbService, transportController, c, log, typeConverter) +	processor := NewTestProcessor(dbService, storageBackend, federator) +	if err := processor.Start(); err != nil { +		return fmt.Errorf("error starting processor: %s", err) +	}  	StandardDBSetup(dbService)  	StandardStorageSetup(storageBackend, "./testrig/media")  	// build client api modules -	authModule := auth.New(oauthServer, dbService, log) -	accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log) -	appsModule := app.New(oauthServer, dbService, mastoConverter, log) -	mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log) -	fileServerModule := fileserver.New(c, dbService, storageBackend, log) -	adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log) -	statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log) +	authModule := auth.New(c, dbService, NewTestOauthServer(dbService), log) +	accountModule := account.New(c, processor, log) +	appsModule := app.New(c, processor, log) +	mm := mediaModule.New(c, processor, log) +	fileServerModule := fileserver.New(c, processor, log) +	adminModule := admin.New(c, processor, log) +	statusModule := status.New(c, processor, log)  	securityModule := security.New(c, log) -	apiModules := []apimodule.ClientAPIModule{ +	apis := []api.ClientModule{  		// modules with middleware go first  		securityModule,  		authModule, @@ -84,20 +92,13 @@ var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logr  		statusModule,  	} -	for _, m := range apiModules { +	for _, m := range apis {  		if err := m.Route(router); err != nil {  			return fmt.Errorf("routing error: %s", err)  		} -		if err := m.CreateTables(dbService); err != nil { -			return fmt.Errorf("table creation error: %s", err) -		}  	} -	// if err := dbService.CreateInstanceAccount(); err != nil { -	// 	return fmt.Errorf("error creating instance account: %s", err) -	// } - -	gts, err := gotosocial.New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c) +	gts, err := gotosocial.New(dbService, router, federator, c)  	if err != nil {  		return fmt.Errorf("error creating gotosocial service: %s", err)  	} diff --git a/testrig/db.go b/testrig/db.go index 5974eae69..4d22ab3c8 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -23,7 +23,7 @@ import (  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/oauth"  ) @@ -54,7 +54,7 @@ func NewTestDB() db.DB {  	config := NewTestConfig()  	l := logrus.New()  	l.SetLevel(logrus.TraceLevel) -	testDB, err := db.New(context.Background(), config, l) +	testDB, err := db.NewPostgresService(context.Background(), config, l)  	if err != nil {  		panic(err)  	} diff --git a/testrig/federator.go b/testrig/federator.go new file mode 100644 index 000000000..63ad520db --- /dev/null +++ b/testrig/federator.go @@ -0,0 +1,29 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/federation" +	"github.com/superseriousbusiness/gotosocial/internal/transport" +) + +func NewTestFederator(db db.DB, tc transport.Controller) federation.Federator { +	return federation.NewFederator(db, tc, NewTestConfig(), NewTestLog(), NewTestTypeConverter(db)) +} diff --git a/testrig/media/test-jpeg.jpg b/testrig/media/test-jpeg.jpgBinary files differ new file mode 100644 index 000000000..a9ab154d4 --- /dev/null +++ b/testrig/media/test-jpeg.jpg diff --git a/testrig/processor.go b/testrig/processor.go new file mode 100644 index 000000000..9aa8e2509 --- /dev/null +++ b/testrig/processor.go @@ -0,0 +1,31 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/federation" +	"github.com/superseriousbusiness/gotosocial/internal/message" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +) + +// NewTestProcessor returns a Processor suitable for testing purposes +func NewTestProcessor(db db.DB, storage storage.Storage, federator federation.Federator) message.Processor { +	return message.NewProcessor(NewTestConfig(), NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, db, NewTestLog()) +} diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 0d95ef21d..e550c66f7 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -19,13 +19,26 @@  package testrig  import ( +	"bytes" +	"context" +	"crypto"  	"crypto/rand"  	"crypto/rsa" +	"crypto/x509" +	"encoding/json" +	"encoding/pem" +	"io/ioutil"  	"net" +	"net/http" +	"net/url"  	"time" -	"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" +	"github.com/go-fed/activity/pub" +	"github.com/go-fed/activity/streams" +	"github.com/go-fed/activity/streams/vocab" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  )  // NewTestTokens returns a map of tokens keyed according to which account the token belongs to. @@ -274,15 +287,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {  			URI:                     "http://localhost:8080/users/weed_lord420",  			URL:                     "http://localhost:8080/@weed_lord420",  			LastWebfingeredAt:       time.Time{}, -			InboxURL:                "http://localhost:8080/users/weed_lord420/inbox", -			OutboxURL:               "http://localhost:8080/users/weed_lord420/outbox", -			SharedInboxURL:          "", -			FollowersURL:            "http://localhost:8080/users/weed_lord420/followers", -			FeaturedCollectionURL:   "http://localhost:8080/users/weed_lord420/collections/featured", +			InboxURI:                "http://localhost:8080/users/weed_lord420/inbox", +			OutboxURI:               "http://localhost:8080/users/weed_lord420/outbox", +			FollowersURI:            "http://localhost:8080/users/weed_lord420/followers", +			FollowingURI:            "http://localhost:8080/users/weed_lord420/following", +			FeaturedCollectionURI:   "http://localhost:8080/users/weed_lord420/collections/featured",  			ActorType:               gtsmodel.ActivityStreamsPerson,  			AlsoKnownAs:             "",  			PrivateKey:              &rsa.PrivateKey{},  			PublicKey:               &rsa.PublicKey{}, +			PublicKeyURI:            "http://localhost:8080/users/weed_lord420#main-key",  			SensitizedAt:            time.Time{},  			SilencedAt:              time.Time{},  			SuspendedAt:             time.Time{}, @@ -310,12 +324,13 @@ func NewTestAccounts() map[string]*gtsmodel.Account {  			Language:                "en",  			URI:                     "http://localhost:8080/users/admin",  			URL:                     "http://localhost:8080/@admin", +			PublicKeyURI:            "http://localhost:8080/users/admin#main-key",  			LastWebfingeredAt:       time.Time{}, -			InboxURL:                "http://localhost:8080/users/admin/inbox", -			OutboxURL:               "http://localhost:8080/users/admin/outbox", -			SharedInboxURL:          "", -			FollowersURL:            "http://localhost:8080/users/admin/followers", -			FeaturedCollectionURL:   "http://localhost:8080/users/admin/collections/featured", +			InboxURI:                "http://localhost:8080/users/admin/inbox", +			OutboxURI:               "http://localhost:8080/users/admin/outbox", +			FollowersURI:            "http://localhost:8080/users/admin/followers", +			FollowingURI:            "http://localhost:8080/users/admin/following", +			FeaturedCollectionURI:   "http://localhost:8080/users/admin/collections/featured",  			ActorType:               gtsmodel.ActivityStreamsPerson,  			AlsoKnownAs:             "",  			PrivateKey:              &rsa.PrivateKey{}, @@ -348,15 +363,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {  			URI:                     "http://localhost:8080/users/the_mighty_zork",  			URL:                     "http://localhost:8080/@the_mighty_zork",  			LastWebfingeredAt:       time.Time{}, -			InboxURL:                "http://localhost:8080/users/the_mighty_zork/inbox", -			OutboxURL:               "http://localhost:8080/users/the_mighty_zork/outbox", -			SharedInboxURL:          "", -			FollowersURL:            "http://localhost:8080/users/the_mighty_zork/followers", -			FeaturedCollectionURL:   "http://localhost:8080/users/the_mighty_zork/collections/featured", +			InboxURI:                "http://localhost:8080/users/the_mighty_zork/inbox", +			OutboxURI:               "http://localhost:8080/users/the_mighty_zork/outbox", +			FollowersURI:            "http://localhost:8080/users/the_mighty_zork/followers", +			FollowingURI:            "http://localhost:8080/users/the_mighty_zork/following", +			FeaturedCollectionURI:   "http://localhost:8080/users/the_mighty_zork/collections/featured",  			ActorType:               gtsmodel.ActivityStreamsPerson,  			AlsoKnownAs:             "",  			PrivateKey:              &rsa.PrivateKey{},  			PublicKey:               &rsa.PublicKey{}, +			PublicKeyURI:            "http://localhost:8080/users/the_mighty_zork#main-key",  			SensitizedAt:            time.Time{},  			SilencedAt:              time.Time{},  			SuspendedAt:             time.Time{}, @@ -385,15 +401,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {  			URI:                     "http://localhost:8080/users/1happyturtle",  			URL:                     "http://localhost:8080/@1happyturtle",  			LastWebfingeredAt:       time.Time{}, -			InboxURL:                "http://localhost:8080/users/1happyturtle/inbox", -			OutboxURL:               "http://localhost:8080/users/1happyturtle/outbox", -			SharedInboxURL:          "", -			FollowersURL:            "http://localhost:8080/users/1happyturtle/followers", -			FeaturedCollectionURL:   "http://localhost:8080/users/1happyturtle/collections/featured", +			InboxURI:                "http://localhost:8080/users/1happyturtle/inbox", +			OutboxURI:               "http://localhost:8080/users/1happyturtle/outbox", +			FollowersURI:            "http://localhost:8080/users/1happyturtle/followers", +			FollowingURI:            "http://localhost:8080/users/1happyturtle/following", +			FeaturedCollectionURI:   "http://localhost:8080/users/1happyturtle/collections/featured",  			ActorType:               gtsmodel.ActivityStreamsPerson,  			AlsoKnownAs:             "",  			PrivateKey:              &rsa.PrivateKey{},  			PublicKey:               &rsa.PublicKey{}, +			PublicKeyURI:            "http://localhost:8080/users/1happyturtle#main-key",  			SensitizedAt:            time.Time{},  			SilencedAt:              time.Time{},  			SuspendedAt:             time.Time{}, @@ -426,18 +443,19 @@ func NewTestAccounts() map[string]*gtsmodel.Account {  			Discoverable:          true,  			Sensitive:             false,  			Language:              "en", -			URI:                   "https://fossbros-anonymous.io/users/foss_satan", -			URL:                   "https://fossbros-anonymous.io/@foss_satan", +			URI:                   "http://fossbros-anonymous.io/users/foss_satan", +			URL:                   "http://fossbros-anonymous.io/@foss_satan",  			LastWebfingeredAt:     time.Time{}, -			InboxURL:              "https://fossbros-anonymous.io/users/foss_satan/inbox", -			OutboxURL:             "https://fossbros-anonymous.io/users/foss_satan/outbox", -			SharedInboxURL:        "", -			FollowersURL:          "https://fossbros-anonymous.io/users/foss_satan/followers", -			FeaturedCollectionURL: "https://fossbros-anonymous.io/users/foss_satan/collections/featured", +			InboxURI:              "http://fossbros-anonymous.io/users/foss_satan/inbox", +			OutboxURI:             "http://fossbros-anonymous.io/users/foss_satan/outbox", +			FollowersURI:          "http://fossbros-anonymous.io/users/foss_satan/followers", +			FollowingURI:          "http://fossbros-anonymous.io/users/foss_satan/following", +			FeaturedCollectionURI: "http://fossbros-anonymous.io/users/foss_satan/collections/featured",  			ActorType:             gtsmodel.ActivityStreamsPerson,  			AlsoKnownAs:           "", -			PrivateKey:            &rsa.PrivateKey{}, -			PublicKey:             nil, +			PrivateKey:            nil, +			PublicKey:             &rsa.PublicKey{}, +			PublicKeyURI:          "http://fossbros-anonymous.io/users/foss_satan#main-key",  			SensitizedAt:          time.Time{},  			SilencedAt:            time.Time{},  			SuspendedAt:           time.Time{}, @@ -468,10 +486,10 @@ func NewTestAccounts() map[string]*gtsmodel.Account {  		}  		pub := &priv.PublicKey -		// only local accounts get a private key -		if v.Domain == "" { -			v.PrivateKey = priv -		} +		// normally only local accounts get a private key (obviously) +		// but for testing purposes and signing requests, we'll give +		// remote accounts a private key as well +		v.PrivateKey = priv  		v.PublicKey = pub  	}  	return accounts @@ -676,25 +694,26 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {  func NewTestEmojis() map[string]*gtsmodel.Emoji {  	return map[string]*gtsmodel.Emoji{  		"rainbow": { -			ID:                   "a96ec4f3-1cae-47e4-a508-f9d66a6b221b", -			Shortcode:            "rainbow", -			Domain:               "", -			CreatedAt:            time.Now(), -			UpdatedAt:            time.Now(), -			ImageRemoteURL:       "", -			ImageStaticRemoteURL: "", -			ImageURL:             "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", -			ImagePath:            "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", -			ImageStaticURL:       "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", -			ImageStaticPath:      "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", -			ImageContentType:     "image/png", -			ImageFileSize:        36702, -			ImageStaticFileSize:  10413, -			ImageUpdatedAt:       time.Now(), -			Disabled:             false, -			URI:                  "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b", -			VisibleInPicker:      true, -			CategoryID:           "", +			ID:                     "a96ec4f3-1cae-47e4-a508-f9d66a6b221b", +			Shortcode:              "rainbow", +			Domain:                 "", +			CreatedAt:              time.Now(), +			UpdatedAt:              time.Now(), +			ImageRemoteURL:         "", +			ImageStaticRemoteURL:   "", +			ImageURL:               "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", +			ImagePath:              "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", +			ImageStaticURL:         "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", +			ImageStaticPath:        "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", +			ImageContentType:       "image/png", +			ImageStaticContentType: "image/png", +			ImageFileSize:          36702, +			ImageStaticFileSize:    10413, +			ImageUpdatedAt:         time.Now(), +			Disabled:               false, +			URI:                    "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b", +			VisibleInPicker:        true, +			CategoryID:             "",  		},  	}  } @@ -993,3 +1012,436 @@ func NewTestFaves() map[string]*gtsmodel.StatusFave {  		},  	}  } + +type ActivityWithSignature struct { +	Activity        pub.Activity +	SignatureHeader string +	DigestHeader    string +	DateHeader      string +} + +// NewTestActivities returns a bunch of pub.Activity types for use in testing the federation protocols. +// A struct of accounts needs to be passed in because the activities will also be bundled along with +// their requesting signatures. +func NewTestActivities(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature { +	dmForZork := newNote( +		URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6"), +		URLMustParse("https://fossbros-anonymous.io/@foss_satan/5424b153-4553-4f30-9358-7b92f7cd42f6"), +		"hey zork here's a new private note for you", +		"new note for zork", +		URLMustParse("https://fossbros-anonymous.io/users/foss_satan"), +		[]*url.URL{URLMustParse("http://localhost:8080/users/the_mighty_zork")}, +		nil, +		true) +	createDmForZork := wrapNoteInCreate( +		URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6/activity"), +		URLMustParse("https://fossbros-anonymous.io/users/foss_satan"), +		time.Now(), +		dmForZork) +	sig, digest, date := getSignatureForActivity(createDmForZork, accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].InboxURI)) + +	return map[string]ActivityWithSignature{ +		"dm_for_zork": { +			Activity:        createDmForZork, +			SignatureHeader: sig, +			DigestHeader:    digest, +			DateHeader:      date, +		}, +	} +} + +// NewTestFediPeople returns a bunch of activity pub Person representations for testing converters and so on. +func NewTestFediPeople() map[string]typeutils.Accountable { +	new_person_1priv, err := rsa.GenerateKey(rand.Reader, 2048) +	if err != nil { +		panic(err) +	} +	new_person_1pub := &new_person_1priv.PublicKey + +	return map[string]typeutils.Accountable{ +		"new_person_1": newPerson( +			URLMustParse("https://unknown-instance.com/users/brand_new_person"), +			URLMustParse("https://unknown-instance.com/users/brand_new_person/following"), +			URLMustParse("https://unknown-instance.com/users/brand_new_person/followers"), +			URLMustParse("https://unknown-instance.com/users/brand_new_person/inbox"), +			URLMustParse("https://unknown-instance.com/users/brand_new_person/outbox"), +			URLMustParse("https://unknown-instance.com/users/brand_new_person/collections/featured"), +			"brand_new_person", +			"Geoff Brando New Personson", +			"hey I'm a new person, your instance hasn't seen me yet uwu", +			URLMustParse("https://unknown-instance.com/@brand_new_person"), +			true, +			URLMustParse("https://unknown-instance.com/users/brand_new_person#main-key"), +			new_person_1pub, +			URLMustParse("https://unknown-instance.com/media/some_avatar_filename.jpeg"), +			"image/jpeg", +			URLMustParse("https://unknown-instance.com/media/some_header_filename.jpeg"), +			"image/png", +		), +	} +} + +func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature { +	sig, digest, date := getSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].URI)) +	return map[string]ActivityWithSignature{ +		"foss_satan_dereference_zork": { +			SignatureHeader: sig, +			DigestHeader:    digest, +			DateHeader:      date, +		}, +	} +} + +// getSignatureForActivity does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive +// the HTTP Signature for the given activity, public key ID, private key, and destination. +func getSignatureForActivity(activity pub.Activity, pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { +	// create a client that basically just pulls the signature out of the request and sets it +	client := &mockHTTPClient{ +		do: func(req *http.Request) (*http.Response, error) { +			signatureHeader = req.Header.Get("Signature") +			digestHeader = req.Header.Get("Digest") +			dateHeader = req.Header.Get("Date") +			r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out +			return &http.Response{ +				StatusCode: 200, +				Body:       r, +			}, nil +		}, +	} + +	// use the client to create a new transport +	c := NewTestTransportController(client) +	tp, err := c.NewTransport(pubKeyID, privkey) +	if err != nil { +		panic(err) +	} + +	// convert the activity into json bytes +	m, err := activity.Serialize() +	if err != nil { +		panic(err) +	} +	bytes, err := json.Marshal(m) +	if err != nil { +		panic(err) +	} + +	// trigger the delivery function, which will trigger the 'do' function of the recorder above +	if err := tp.Deliver(context.Background(), bytes, destination); err != nil { +		panic(err) +	} + +	// headers should now be populated +	return +} + +// getSignatureForDereference does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive +// the HTTP Signature for the given derefence GET request using public key ID, private key, and destination. +func getSignatureForDereference(pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { +	// create a client that basically just pulls the signature out of the request and sets it +	client := &mockHTTPClient{ +		do: func(req *http.Request) (*http.Response, error) { +			signatureHeader = req.Header.Get("Signature") +			digestHeader = req.Header.Get("Digest") +			dateHeader = req.Header.Get("Date") +			r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out +			return &http.Response{ +				StatusCode: 200, +				Body:       r, +			}, nil +		}, +	} + +	// use the client to create a new transport +	c := NewTestTransportController(client) +	tp, err := c.NewTransport(pubKeyID, privkey) +	if err != nil { +		panic(err) +	} + +	// trigger the delivery function, which will trigger the 'do' function of the recorder above +	if _, err := tp.Dereference(context.Background(), destination); err != nil { +		panic(err) +	} + +	// headers should now be populated +	return +} + +func newPerson( +	profileIDURI *url.URL, +	followingURI *url.URL, +	followersURI *url.URL, +	inboxURI *url.URL, +	outboxURI *url.URL, +	featuredURI *url.URL, +	username string, +	displayName string, +	note string, +	profileURL *url.URL, +	discoverable bool, +	publicKeyURI *url.URL, +	pkey *rsa.PublicKey, +	avatarURL *url.URL, +	avatarContentType string, +	headerURL *url.URL, +	headerContentType string) typeutils.Accountable { +	person := streams.NewActivityStreamsPerson() + +	// id should be the activitypub URI of this user +	// something like https://example.org/users/example_user +	idProp := streams.NewJSONLDIdProperty() +	idProp.SetIRI(profileIDURI) +	person.SetJSONLDId(idProp) + +	// following +	// The URI for retrieving a list of accounts this user is following +	followingProp := streams.NewActivityStreamsFollowingProperty() +	followingProp.SetIRI(followingURI) +	person.SetActivityStreamsFollowing(followingProp) + +	// followers +	// The URI for retrieving a list of this user's followers +	followersProp := streams.NewActivityStreamsFollowersProperty() +	followersProp.SetIRI(followersURI) +	person.SetActivityStreamsFollowers(followersProp) + +	// inbox +	// the activitypub inbox of this user for accepting messages +	inboxProp := streams.NewActivityStreamsInboxProperty() +	inboxProp.SetIRI(inboxURI) +	person.SetActivityStreamsInbox(inboxProp) + +	// outbox +	// the activitypub outbox of this user for serving messages +	outboxProp := streams.NewActivityStreamsOutboxProperty() +	outboxProp.SetIRI(outboxURI) +	person.SetActivityStreamsOutbox(outboxProp) + +	// featured posts +	// Pinned posts. +	featuredProp := streams.NewTootFeaturedProperty() +	featuredProp.SetIRI(featuredURI) +	person.SetTootFeatured(featuredProp) + +	// featuredTags +	// NOT IMPLEMENTED + +	// preferredUsername +	// Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI. +	preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty() +	preferredUsernameProp.SetXMLSchemaString(username) +	person.SetActivityStreamsPreferredUsername(preferredUsernameProp) + +	// name +	// Used as profile display name. +	nameProp := streams.NewActivityStreamsNameProperty() +	if displayName != "" { +		nameProp.AppendXMLSchemaString(displayName) +	} else { +		nameProp.AppendXMLSchemaString(username) +	} +	person.SetActivityStreamsName(nameProp) + +	// summary +	// Used as profile bio. +	if note != "" { +		summaryProp := streams.NewActivityStreamsSummaryProperty() +		summaryProp.AppendXMLSchemaString(note) +		person.SetActivityStreamsSummary(summaryProp) +	} + +	// url +	// Used as profile link. +	urlProp := streams.NewActivityStreamsUrlProperty() +	urlProp.AppendIRI(profileURL) +	person.SetActivityStreamsUrl(urlProp) + +	// manuallyApprovesFollowers +	// Will be shown as a locked account. +	// TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + +	// discoverable +	// Will be shown in the profile directory. +	discoverableProp := streams.NewTootDiscoverableProperty() +	discoverableProp.Set(discoverable) +	person.SetTootDiscoverable(discoverableProp) + +	// devices +	// NOT IMPLEMENTED, probably won't implement + +	// alsoKnownAs +	// Required for Move activity. +	// TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + +	// publicKey +	// Required for signatures. +	publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty() + +	// create the public key +	publicKey := streams.NewW3IDSecurityV1PublicKey() + +	// set ID for the public key +	publicKeyIDProp := streams.NewJSONLDIdProperty() +	publicKeyIDProp.SetIRI(publicKeyURI) +	publicKey.SetJSONLDId(publicKeyIDProp) + +	// set owner for the public key +	publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty() +	publicKeyOwnerProp.SetIRI(profileIDURI) +	publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp) + +	// set the pem key itself +	encodedPublicKey, err := x509.MarshalPKIXPublicKey(pkey) +	if err != nil { +		panic(err) +	} +	publicKeyBytes := pem.EncodeToMemory(&pem.Block{ +		Type:  "PUBLIC KEY", +		Bytes: encodedPublicKey, +	}) +	publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty() +	publicKeyPEMProp.Set(string(publicKeyBytes)) +	publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp) + +	// append the public key to the public key property +	publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey) + +	// set the public key property on the Person +	person.SetW3IDSecurityV1PublicKey(publicKeyProp) + +	// tag +	// TODO: Any tags used in the summary of this profile + +	// attachment +	// Used for profile fields. +	// TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue + +	// endpoints +	// NOT IMPLEMENTED -- this is for shared inbox which we don't use + +	// icon +	// Used as profile avatar. +	iconProperty := streams.NewActivityStreamsIconProperty() +	iconImage := streams.NewActivityStreamsImage() +	mediaType := streams.NewActivityStreamsMediaTypeProperty() +	mediaType.Set(avatarContentType) +	iconImage.SetActivityStreamsMediaType(mediaType) +	avatarURLProperty := streams.NewActivityStreamsUrlProperty() +	avatarURLProperty.AppendIRI(avatarURL) +	iconImage.SetActivityStreamsUrl(avatarURLProperty) +	iconProperty.AppendActivityStreamsImage(iconImage) +	person.SetActivityStreamsIcon(iconProperty) + +	// image +	// Used as profile header. +	headerProperty := streams.NewActivityStreamsImageProperty() +	headerImage := streams.NewActivityStreamsImage() +	headerMediaType := streams.NewActivityStreamsMediaTypeProperty() +	mediaType.Set(headerContentType) +	headerImage.SetActivityStreamsMediaType(headerMediaType) +	headerURLProperty := streams.NewActivityStreamsUrlProperty() +	headerURLProperty.AppendIRI(headerURL) +	headerImage.SetActivityStreamsUrl(headerURLProperty) +	headerProperty.AppendActivityStreamsImage(headerImage) + +	return person +} + +// newNote returns a new activity streams note for the given parameters +func newNote( +	noteID *url.URL, +	noteURL *url.URL, +	noteContent string, +	noteSummary string, +	noteAttributedTo *url.URL, +	noteTo []*url.URL, +	noteCC []*url.URL, +	noteSensitive bool) vocab.ActivityStreamsNote { + +	// create the note itself +	note := streams.NewActivityStreamsNote() + +	// set id +	if noteID != nil { +		id := streams.NewJSONLDIdProperty() +		id.Set(noteID) +		note.SetJSONLDId(id) +	} + +	// set noteURL +	if noteURL != nil { +		url := streams.NewActivityStreamsUrlProperty() +		url.AppendIRI(noteURL) +		note.SetActivityStreamsUrl(url) +	} + +	// set noteContent +	if noteContent != "" { +		content := streams.NewActivityStreamsContentProperty() +		content.AppendXMLSchemaString(noteContent) +		note.SetActivityStreamsContent(content) +	} + +	// set noteSummary (aka content warning) +	if noteSummary != "" { +		summary := streams.NewActivityStreamsSummaryProperty() +		summary.AppendXMLSchemaString(noteSummary) +		note.SetActivityStreamsSummary(summary) +	} + +	// set noteAttributedTo (the url of the author of the note) +	if noteAttributedTo != nil { +		attributedTo := streams.NewActivityStreamsAttributedToProperty() +		attributedTo.AppendIRI(noteAttributedTo) +		note.SetActivityStreamsAttributedTo(attributedTo) +	} + +	return note +} + +// wrapNoteInCreate wraps the given activity streams note in a Create activity streams action +func wrapNoteInCreate(createID *url.URL, createActor *url.URL, createPublished time.Time, createNote vocab.ActivityStreamsNote) vocab.ActivityStreamsCreate { +	// create the.... create +	create := streams.NewActivityStreamsCreate() + +	// set createID +	if createID != nil { +		id := streams.NewJSONLDIdProperty() +		id.Set(createID) +		create.SetJSONLDId(id) +	} + +	// set createActor +	if createActor != nil { +		actor := streams.NewActivityStreamsActorProperty() +		actor.AppendIRI(createActor) +		create.SetActivityStreamsActor(actor) +	} + +	// set createPublished (time) +	if !createPublished.IsZero() { +		published := streams.NewActivityStreamsPublishedProperty() +		published.Set(createPublished) +		create.SetActivityStreamsPublished(published) +	} + +	// setCreateTo +	if createNote.GetActivityStreamsTo() != nil { +		create.SetActivityStreamsTo(createNote.GetActivityStreamsTo()) +	} + +	// setCreateCC +	if createNote.GetActivityStreamsCc() != nil { +		create.SetActivityStreamsCc(createNote.GetActivityStreamsCc()) +	} + +	// set createNote +	if createNote != nil { +		note := streams.NewActivityStreamsObjectProperty() +		note.AppendActivityStreamsNote(createNote) +		create.SetActivityStreamsObject(note) +	} + +	return create +} diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go new file mode 100644 index 000000000..f2b5b93f7 --- /dev/null +++ b/testrig/transportcontroller.go @@ -0,0 +1,73 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( +	"bytes" +	"io/ioutil" +	"net/http" + +	"github.com/go-fed/activity/pub" +	"github.com/superseriousbusiness/gotosocial/internal/federation" +	"github.com/superseriousbusiness/gotosocial/internal/transport" +) + +// NewTestTransportController returns a test transport controller with the given http client. +// +// Obviously for testing purposes you should not be making actual http calls to other servers. +// To obviate this, use the function NewMockHTTPClient in this package to return a mock http +// client that doesn't make any remote calls but just returns whatever you tell it to. +// +// Unlike the other test interfaces provided in this package, you'll probably want to call this function +// PER TEST rather than per suite, so that the do function can be set on a test by test (or even more granular) +// basis. +func NewTestTransportController(client pub.HttpClient) transport.Controller { +	return transport.NewController(NewTestConfig(), &federation.Clock{}, client, NewTestLog()) +} + +// NewMockHTTPClient returns a client that conforms to the pub.HttpClient interface, +// but will always just execute the given `do` function, allowing responses to be mocked. +// +// If 'do' is nil, then a no-op function will be used instead, that just returns status 200. +// +// Note that you should never ever make ACTUAL http calls with this thing. +func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error)) pub.HttpClient { +	if do == nil { +		return &mockHTTPClient{ +			do: func(req *http.Request) (*http.Response, error) { +				r := ioutil.NopCloser(bytes.NewReader([]byte{})) +				return &http.Response{ +					StatusCode: 200, +					Body:       r, +				}, nil +			}, +		} +	} +	return &mockHTTPClient{ +		do: do, +	} +} + +type mockHTTPClient struct { +	do func(req *http.Request) (*http.Response, error) +} + +func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { +	return m.do(req) +} diff --git a/testrig/mastoconverter.go b/testrig/typeconverter.go index 10bdbdc95..9d49e6c99 100644 --- a/testrig/mastoconverter.go +++ b/testrig/typeconverter.go @@ -20,10 +20,10 @@ package testrig  import (  	"github.com/superseriousbusiness/gotosocial/internal/db" -	"github.com/superseriousbusiness/gotosocial/internal/mastotypes" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  ) -// NewTestMastoConverter returned a mastotypes converter with the given db and the default test config -func NewTestMastoConverter(db db.DB) mastotypes.Converter { -	return mastotypes.New(NewTestConfig(), db) +// NewTestTypeConverter returned a type converter with the given db and the default test config +func NewTestTypeConverter(db db.DB) typeutils.TypeConverter { +	return typeutils.NewConverter(NewTestConfig(), db)  } diff --git a/testrig/util.go b/testrig/util.go index 96a979342..0fb8aa887 100644 --- a/testrig/util.go +++ b/testrig/util.go @@ -22,6 +22,7 @@ import (  	"bytes"  	"io"  	"mime/multipart" +	"net/url"  	"os"  ) @@ -62,3 +63,13 @@ func CreateMultipartFormData(fieldName string, fileName string, extraFields map[  	}  	return b, w, nil  } + +// URLMustParse tries to parse the given URL and panics if it can't. +// Should only be used in tests. +func URLMustParse(stringURL string) *url.URL { +	u, err := url.Parse(stringURL) +	if err != nil { +		panic(err) +	} +	return u +} | 
