From 6f5c045284d34ba580d3007f70b97e05d6760527 Mon Sep 17 00:00:00 2001
From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date: Sat, 8 May 2021 14:25:55 +0200
Subject: [PATCH] Ap (#14)
Big restructuring and initial work on activitypub
---
go.mod | 1 +
internal/{apimodule => api}/apimodule.go | 16 +-
.../client}/account/account.go | 50 +-
internal/api/client/account/account_test.go | 40 ++
.../client}/account/accountcreate.go | 58 +-
.../api/client/account/accountcreate_test.go | 388 ++++++++++++
.../client}/account/accountget.go | 23 +-
internal/api/client/account/accountupdate.go | 71 +++
.../api/client/account/accountupdate_test.go | 106 ++++
.../client}/account/accountverify.go | 10 +-
.../client/account}/accountverify_test.go | 2 +-
.../{apimodule => api/client}/admin/admin.go | 50 +-
.../client}/admin/emojicreate.go | 50 +-
internal/{apimodule => api/client}/app/app.go | 43 +-
.../app/test => api/client/app}/app_test.go | 2 +-
internal/api/client/app/appcreate.go | 79 +++
.../{apimodule => api/client}/auth/auth.go | 35 +-
.../test => api/client/auth}/auth_test.go | 6 +-
.../client}/auth/authorize.go | 6 +-
.../client}/auth/middleware.go | 4 +-
.../{apimodule => api/client}/auth/signin.go | 2 +-
.../{apimodule => api/client}/auth/token.go | 0
.../client}/fileserver/fileserver.go | 16 +-
internal/api/client/fileserver/servefile.go | 94 +++
.../client/fileserver}/servefile_test.go | 36 +-
.../{apimodule => api/client}/media/media.go | 25 +-
internal/api/client/media/mediacreate.go | 91 +++
.../client/media}/mediacreate_test.go | 44 +-
.../client}/status/status.go | 78 +--
internal/api/client/status/status_test.go | 58 ++
internal/api/client/status/statuscreate.go | 130 ++++
.../client/status}/statuscreate_test.go | 107 +---
internal/api/client/status/statusdelete.go | 60 ++
internal/api/client/status/statusfave.go | 60 ++
.../client/status}/statusfave_test.go | 89 +--
internal/api/client/status/statusfavedby.go | 60 ++
.../client/status}/statusfavedby_test.go | 79 +--
internal/api/client/status/statusget.go | 60 ++
.../client/status}/statusget_test.go | 91 +--
internal/api/client/status/statusunfave.go | 60 ++
.../client/status}/statusunfave_test.go | 89 +--
.../mastomodel => api/model}/account.go | 9 +-
.../mastomodel => api/model}/activity.go | 2 +-
.../mastomodel => api/model}/admin.go | 2 +-
.../mastomodel => api/model}/announcement.go | 2 +-
.../model}/announcementreaction.go | 2 +-
.../mastomodel => api/model}/application.go | 6 +-
.../mastomodel => api/model}/attachment.go | 2 +-
.../mastomodel => api/model}/card.go | 2 +-
internal/api/model/content.go | 41 ++
.../mastomodel => api/model}/context.go | 2 +-
.../mastomodel => api/model}/conversation.go | 2 +-
.../mastomodel => api/model}/emoji.go | 2 +-
.../mastomodel => api/model}/error.go | 2 +-
.../mastomodel => api/model}/featuredtag.go | 2 +-
.../mastomodel => api/model}/field.go | 2 +-
.../mastomodel => api/model}/filter.go | 2 +-
.../mastomodel => api/model}/history.go | 2 +-
.../mastomodel => api/model}/identityproof.go | 2 +-
.../mastomodel => api/model}/instance.go | 2 +-
.../mastomodel => api/model}/list.go | 2 +-
.../mastomodel => api/model}/marker.go | 2 +-
.../mastomodel => api/model}/mention.go | 2 +-
.../mastomodel => api/model}/notification.go | 2 +-
.../mastomodel => api/model}/oauth.go | 2 +-
.../mastomodel => api/model}/poll.go | 2 +-
.../mastomodel => api/model}/preferences.go | 2 +-
.../model}/pushsubscription.go | 2 +-
.../mastomodel => api/model}/relationship.go | 2 +-
.../mastomodel => api/model}/results.go | 2 +-
.../model}/scheduledstatus.go | 2 +-
.../mastomodel => api/model}/source.go | 2 +-
.../mastomodel => api/model}/status.go | 20 +-
.../mastomodel => api/model}/tag.go | 2 +-
.../mastomodel => api/model}/token.go | 2 +-
internal/api/s2s/user/user.go | 70 +++
internal/api/s2s/user/user_test.go | 40 ++
internal/api/s2s/user/userget.go | 67 +++
internal/api/s2s/user/userget_test.go | 155 +++++
.../{apimodule => api}/security/flocblock.go | 0
.../{apimodule => api}/security/security.go | 10 +-
internal/apimodule/account/accountupdate.go | 260 --------
.../account/test/accountcreate_test.go | 551 -----------------
.../account/test/accountupdate_test.go | 303 ----------
internal/apimodule/app/appcreate.go | 119 ----
internal/apimodule/fileserver/servefile.go | 243 --------
internal/apimodule/media/mediacreate.go | 193 ------
internal/apimodule/mock_ClientAPIModule.go | 43 --
internal/apimodule/status/statuscreate.go | 462 ---------------
internal/apimodule/status/statusdelete.go | 107 ----
internal/apimodule/status/statusfave.go | 137 -----
internal/apimodule/status/statusfavedby.go | 129 ----
internal/apimodule/status/statusget.go | 112 ----
internal/apimodule/status/statusunfave.go | 137 -----
internal/cache/mock_Cache.go | 47 --
internal/config/mock_KeyedFlags.go | 66 ---
internal/db/db.go | 25 +-
internal/db/federating_db.go | 119 +++-
internal/db/mock_DB.go | 484 ---------------
internal/db/pg.go | 43 +-
internal/db/pg_test.go | 2 +-
internal/distributor/distributor.go | 110 ----
internal/distributor/mock_Distributor.go | 70 ---
.../federation/clock.go | 26 +-
internal/federation/commonbehavior.go | 152 +++++
internal/federation/federatingactor.go | 136 +++++
.../{federation.go => federatingprotocol.go} | 218 +++----
internal/federation/federator.go | 79 +++
internal/federation/federator_test.go | 190 ++++++
internal/federation/util.go | 237 ++++++++
internal/gotosocial/actions.go | 64 +-
internal/gotosocial/gotosocial.go | 23 +-
internal/gotosocial/mock_Gotosocial.go | 42 --
internal/{db => }/gtsmodel/README.md | 0
internal/{db => }/gtsmodel/account.go | 20 +-
internal/{db => }/gtsmodel/activitystreams.go | 0
internal/{db => }/gtsmodel/application.go | 0
internal/{db => }/gtsmodel/block.go | 0
internal/{db => }/gtsmodel/domainblock.go | 0
.../{db => }/gtsmodel/emaildomainblock.go | 0
internal/{db => }/gtsmodel/emoji.go | 2 +
internal/{db => }/gtsmodel/follow.go | 0
internal/{db => }/gtsmodel/followrequest.go | 0
internal/{db => }/gtsmodel/mediaattachment.go | 10 +-
internal/{db => }/gtsmodel/mention.go | 0
internal/{db => }/gtsmodel/poll.go | 0
internal/{db => }/gtsmodel/status.go | 0
internal/{db => }/gtsmodel/statusbookmark.go | 0
internal/{db => }/gtsmodel/statusfave.go | 0
internal/{db => }/gtsmodel/statusmute.go | 0
internal/{db => }/gtsmodel/statuspin.go | 0
internal/{db => }/gtsmodel/tag.go | 0
internal/{db => }/gtsmodel/user.go | 0
internal/mastotypes/mastomodel/README.md | 5 -
internal/mastotypes/mock_Converter.go | 148 -----
internal/media/media.go | 148 ++---
internal/media/media_test.go | 4 +-
internal/media/mock_MediaHandler.go | 2 +-
internal/media/util.go | 88 ++-
internal/media/util_test.go | 4 +-
internal/message/accountprocess.go | 168 ++++++
internal/message/adminprocess.go | 48 ++
internal/message/appprocess.go | 59 ++
internal/message/error.go | 106 ++++
internal/message/fediprocess.go | 102 ++++
internal/message/mediaprocess.go | 188 ++++++
internal/message/processor.go | 215 +++++++
internal/message/processorutil.go | 304 ++++++++++
internal/message/statusprocess.go | 350 +++++++++++
internal/oauth/clientstore.go | 3 +-
internal/oauth/clientstore_test.go | 13 +-
internal/oauth/oauth_test.go | 2 +-
internal/oauth/server.go | 178 ++----
internal/oauth/tokenstore_test.go | 2 +-
internal/oauth/util.go | 86 +++
internal/storage/inmem.go | 2 +-
internal/transport/controller.go | 71 +++
internal/typeutils/accountable.go | 101 ++++
internal/typeutils/asextractionutil.go | 216 +++++++
internal/typeutils/astointernal.go | 164 +++++
internal/typeutils/astointernal_test.go | 206 +++++++
internal/typeutils/converter.go | 113 ++++
internal/typeutils/converter_test.go | 40 ++
internal/typeutils/frontendtointernal.go | 39 ++
internal/typeutils/internaltoas.go | 260 ++++++++
internal/typeutils/internaltoas_test.go | 76 +++
.../internaltofrontend.go} | 147 ++---
internal/util/parse.go | 96 ---
internal/util/regexes.go | 79 ++-
internal/util/{status.go => statustools.go} | 20 +-
.../{status_test.go => statustools_test.go} | 11 +-
internal/util/uri.go | 218 +++++++
internal/util/validation.go | 49 +-
internal/util/validation_test.go | 81 +--
testrig/actions.go | 71 +--
testrig/db.go | 4 +-
testrig/federator.go | 29 +
testrig/media/test-jpeg.jpg | Bin 0 -> 269739 bytes
testrig/processor.go | 31 +
testrig/testmodels.go | 558 ++++++++++++++++--
testrig/transportcontroller.go | 73 +++
.../{mastoconverter.go => typeconverter.go} | 8 +-
testrig/util.go | 11 +
183 files changed, 7391 insertions(+), 5414 deletions(-)
rename internal/{apimodule => api}/apimodule.go (65%)
rename internal/{apimodule => api/client}/account/account.go (62%)
create mode 100644 internal/api/client/account/account_test.go
rename internal/{apimodule => api/client}/account/accountcreate.go (59%)
create mode 100644 internal/api/client/account/accountcreate_test.go
rename internal/{apimodule => api/client}/account/accountget.go (69%)
create mode 100644 internal/api/client/account/accountupdate.go
create mode 100644 internal/api/client/account/accountupdate_test.go
rename internal/{apimodule => api/client}/account/accountverify.go (75%)
rename internal/{apimodule/account/test => api/client/account}/accountverify_test.go (97%)
rename internal/{apimodule => api/client}/admin/admin.go (52%)
rename internal/{apimodule => api/client}/admin/emojicreate.go (60%)
rename internal/{apimodule => api/client}/app/app.go (54%)
rename internal/{apimodule/app/test => api/client/app}/app_test.go (97%)
create mode 100644 internal/api/client/app/appcreate.go
rename internal/{apimodule => api/client}/auth/auth.go (74%)
rename internal/{apimodule/auth/test => api/client/auth}/auth_test.go (96%)
rename internal/{apimodule => api/client}/auth/authorize.go (97%)
rename internal/{apimodule => api/client}/auth/middleware.go (96%)
rename internal/{apimodule => api/client}/auth/signin.go (98%)
rename internal/{apimodule => api/client}/auth/token.go (100%)
rename internal/{apimodule => api/client}/fileserver/fileserver.go (85%)
create mode 100644 internal/api/client/fileserver/servefile.go
rename internal/{apimodule/fileserver/test => api/client/fileserver}/servefile_test.go (80%)
rename internal/{apimodule => api/client}/media/media.go (71%)
create mode 100644 internal/api/client/media/mediacreate.go
rename internal/{apimodule/media/test => api/client/media}/mediacreate_test.go (82%)
rename internal/{apimodule => api/client}/status/status.go (62%)
create mode 100644 internal/api/client/status/status_test.go
create mode 100644 internal/api/client/status/statuscreate.go
rename internal/{apimodule/status/test => api/client/status}/statuscreate_test.go (79%)
create mode 100644 internal/api/client/status/statusdelete.go
create mode 100644 internal/api/client/status/statusfave.go
rename internal/{apimodule/status/test => api/client/status}/statusfave_test.go (67%)
create mode 100644 internal/api/client/status/statusfavedby.go
rename internal/{apimodule/status/test => api/client/status}/statusfavedby_test.go (62%)
create mode 100644 internal/api/client/status/statusget.go
rename internal/{apimodule/status/test => api/client/status}/statusget_test.go (62%)
create mode 100644 internal/api/client/status/statusunfave.go
rename internal/{apimodule/status/test => api/client/status}/statusunfave_test.go (70%)
rename internal/{mastotypes/mastomodel => api/model}/account.go (97%)
rename internal/{mastotypes/mastomodel => api/model}/activity.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/admin.go (99%)
rename internal/{mastotypes/mastomodel => api/model}/announcement.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/announcementreaction.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/application.go (94%)
rename internal/{mastotypes/mastomodel => api/model}/attachment.go (99%)
rename internal/{mastotypes/mastomodel => api/model}/card.go (99%)
create mode 100644 internal/api/model/content.go
rename internal/{mastotypes/mastomodel => api/model}/context.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/conversation.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/emoji.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/error.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/featuredtag.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/field.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/filter.go (99%)
rename internal/{mastotypes/mastomodel => api/model}/history.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/identityproof.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/instance.go (99%)
rename internal/{mastotypes/mastomodel => api/model}/list.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/marker.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/mention.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/notification.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/oauth.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/poll.go (99%)
rename internal/{mastotypes/mastomodel => api/model}/preferences.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/pushsubscription.go (99%)
rename internal/{mastotypes/mastomodel => api/model}/relationship.go (99%)
rename internal/{mastotypes/mastomodel => api/model}/results.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/scheduledstatus.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/source.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/status.go (90%)
rename internal/{mastotypes/mastomodel => api/model}/tag.go (98%)
rename internal/{mastotypes/mastomodel => api/model}/token.go (98%)
create mode 100644 internal/api/s2s/user/user.go
create mode 100644 internal/api/s2s/user/user_test.go
create mode 100644 internal/api/s2s/user/userget.go
create mode 100644 internal/api/s2s/user/userget_test.go
rename internal/{apimodule => api}/security/flocblock.go (100%)
rename internal/{apimodule => api}/security/security.go (78%)
delete mode 100644 internal/apimodule/account/accountupdate.go
delete mode 100644 internal/apimodule/account/test/accountcreate_test.go
delete mode 100644 internal/apimodule/account/test/accountupdate_test.go
delete mode 100644 internal/apimodule/app/appcreate.go
delete mode 100644 internal/apimodule/fileserver/servefile.go
delete mode 100644 internal/apimodule/media/mediacreate.go
delete mode 100644 internal/apimodule/mock_ClientAPIModule.go
delete mode 100644 internal/apimodule/status/statuscreate.go
delete mode 100644 internal/apimodule/status/statusdelete.go
delete mode 100644 internal/apimodule/status/statusfave.go
delete mode 100644 internal/apimodule/status/statusfavedby.go
delete mode 100644 internal/apimodule/status/statusget.go
delete mode 100644 internal/apimodule/status/statusunfave.go
delete mode 100644 internal/cache/mock_Cache.go
delete mode 100644 internal/config/mock_KeyedFlags.go
delete mode 100644 internal/db/mock_DB.go
delete mode 100644 internal/distributor/distributor.go
delete mode 100644 internal/distributor/mock_Distributor.go
rename testrig/distributor.go => internal/federation/clock.go (69%)
create mode 100644 internal/federation/commonbehavior.go
create mode 100644 internal/federation/federatingactor.go
rename internal/federation/{federation.go => federatingprotocol.go} (55%)
create mode 100644 internal/federation/federator.go
create mode 100644 internal/federation/federator_test.go
create mode 100644 internal/federation/util.go
delete mode 100644 internal/gotosocial/mock_Gotosocial.go
rename internal/{db => }/gtsmodel/README.md (100%)
rename internal/{db => }/gtsmodel/account.go (90%)
rename internal/{db => }/gtsmodel/activitystreams.go (100%)
rename internal/{db => }/gtsmodel/application.go (100%)
rename internal/{db => }/gtsmodel/block.go (100%)
rename internal/{db => }/gtsmodel/domainblock.go (100%)
rename internal/{db => }/gtsmodel/emaildomainblock.go (100%)
rename internal/{db => }/gtsmodel/emoji.go (97%)
rename internal/{db => }/gtsmodel/follow.go (100%)
rename internal/{db => }/gtsmodel/followrequest.go (100%)
rename internal/{db => }/gtsmodel/mediaattachment.go (96%)
rename internal/{db => }/gtsmodel/mention.go (100%)
rename internal/{db => }/gtsmodel/poll.go (100%)
rename internal/{db => }/gtsmodel/status.go (100%)
rename internal/{db => }/gtsmodel/statusbookmark.go (100%)
rename internal/{db => }/gtsmodel/statusfave.go (100%)
rename internal/{db => }/gtsmodel/statusmute.go (100%)
rename internal/{db => }/gtsmodel/statuspin.go (100%)
rename internal/{db => }/gtsmodel/tag.go (100%)
rename internal/{db => }/gtsmodel/user.go (100%)
delete mode 100644 internal/mastotypes/mastomodel/README.md
delete mode 100644 internal/mastotypes/mock_Converter.go
create mode 100644 internal/message/accountprocess.go
create mode 100644 internal/message/adminprocess.go
create mode 100644 internal/message/appprocess.go
create mode 100644 internal/message/error.go
create mode 100644 internal/message/fediprocess.go
create mode 100644 internal/message/mediaprocess.go
create mode 100644 internal/message/processor.go
create mode 100644 internal/message/processorutil.go
create mode 100644 internal/message/statusprocess.go
create mode 100644 internal/oauth/util.go
create mode 100644 internal/transport/controller.go
create mode 100644 internal/typeutils/accountable.go
create mode 100644 internal/typeutils/asextractionutil.go
create mode 100644 internal/typeutils/astointernal.go
create mode 100644 internal/typeutils/astointernal_test.go
create mode 100644 internal/typeutils/converter.go
create mode 100644 internal/typeutils/converter_test.go
create mode 100644 internal/typeutils/frontendtointernal.go
create mode 100644 internal/typeutils/internaltoas.go
create mode 100644 internal/typeutils/internaltoas_test.go
rename internal/{mastotypes/converter.go => typeutils/internaltofrontend.go} (73%)
delete mode 100644 internal/util/parse.go
rename internal/util/{status.go => statustools.go} (84%)
rename internal/util/{status_test.go => statustools_test.go} (91%)
create mode 100644 internal/util/uri.go
create mode 100644 testrig/federator.go
create mode 100644 testrig/media/test-jpeg.jpg
create mode 100644 testrig/processor.go
create mode 100644 testrig/transportcontroller.go
rename testrig/{mastoconverter.go => typeconverter.go} (75%)
diff --git a/go.mod b/go.mod
index 07edd0a97..d1cefcf78 100644
--- a/go.mod
+++ b/go.mod
@@ -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
similarity index 65%
rename from internal/apimodule/apimodule.go
rename to 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 .
*/
-// 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
similarity index 62%
rename from internal/apimodule/account/account.go
rename to 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
similarity index 59%
rename from internal/apimodule/account/accountcreate.go
rename to 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 .
+// */
+
+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
similarity index 69%
rename from internal/apimodule/account/accountget.go
rename to 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) {
+ authed, err := oauth.Authed(c, false, false, false, false)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ return
+ }
+
targetAcctID := c.Param(IDKey)
if targetAcctID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"})
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()})
- 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 .
+*/
+
+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 .
+*/
+
+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
similarity index 75%
rename from internal/apimodule/account/accountverify.go
rename to 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
similarity index 97%
rename from internal/apimodule/account/test/accountverify_test.go
rename to 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 .
*/
-package account
+package account_test
diff --git a/internal/apimodule/admin/admin.go b/internal/api/client/admin/admin.go
similarity index 52%
rename from internal/apimodule/admin/admin.go
rename to 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
similarity index 60%
rename from internal/apimodule/admin/emojicreate.go
rename to 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
similarity index 54%
rename from internal/apimodule/app/app.go
rename to 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
similarity index 97%
rename from internal/apimodule/app/test/app_test.go
rename to 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 .
*/
-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 .
+*/
+
+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
similarity index 74%
rename from internal/apimodule/auth/auth.go
rename to 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
similarity index 96%
rename from internal/apimodule/auth/test/auth_test.go
rename to 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 .
*/
-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
similarity index 97%
rename from internal/apimodule/auth/authorize.go
rename to 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
similarity index 96%
rename from internal/apimodule/auth/middleware.go
rename to 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
similarity index 98%
rename from internal/apimodule/auth/signin.go
rename to 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
similarity index 100%
rename from internal/apimodule/auth/token.go
rename to internal/api/client/auth/token.go
diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go
similarity index 85%
rename from internal/apimodule/fileserver/fileserver.go
rename to 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 .
+*/
+
+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
similarity index 80%
rename from internal/apimodule/fileserver/test/servefile_test.go
rename to 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 .
*/
-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
similarity index 71%
rename from internal/apimodule/media/media.go
rename to 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 .
+*/
+
+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
similarity index 82%
rename from internal/apimodule/media/test/mediacreate_test.go
rename to 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 .
*/
-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
similarity index 62%
rename from internal/apimodule/status/status.go
rename to 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 .
+*/
+
+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 .
+*/
+
+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
similarity index 79%
rename from internal/apimodule/status/test/statuscreate_test.go
rename to 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 .
*/
-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 .
+*/
+
+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 .
+*/
+
+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
similarity index 67%
rename from internal/apimodule/status/test/statusfave_test.go
rename to 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 .
*/
-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 .
+*/
+
+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
similarity index 62%
rename from internal/apimodule/status/test/statusfavedby_test.go
rename to 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 .
*/
-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 .
+*/
+
+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
similarity index 62%
rename from internal/apimodule/status/test/statusget_test.go
rename to 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 .
*/
-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 .
+*/
+
+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
similarity index 70%
rename from internal/apimodule/status/test/statusunfave_test.go
rename to 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 .
*/
-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
similarity index 97%
rename from internal/mastotypes/mastomodel/account.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/activity.go
rename to 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 .
*/
-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
similarity index 99%
rename from internal/mastotypes/mastomodel/admin.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/announcement.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/announcementreaction.go
rename to 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 .
*/
-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
similarity index 94%
rename from internal/mastotypes/mastomodel/application.go
rename to 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 .
*/
-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
similarity index 99%
rename from internal/mastotypes/mastomodel/attachment.go
rename to 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 .
*/
-package mastotypes
+package model
import "mime/multipart"
diff --git a/internal/mastotypes/mastomodel/card.go b/internal/api/model/card.go
similarity index 99%
rename from internal/mastotypes/mastomodel/card.go
rename to 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 .
*/
-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 .
+*/
+
+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
similarity index 98%
rename from internal/mastotypes/mastomodel/context.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/conversation.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/emoji.go
rename to 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 .
*/
-package mastotypes
+package model
import "mime/multipart"
diff --git a/internal/mastotypes/mastomodel/error.go b/internal/api/model/error.go
similarity index 98%
rename from internal/mastotypes/mastomodel/error.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/featuredtag.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/field.go
rename to 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 .
*/
-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
similarity index 99%
rename from internal/mastotypes/mastomodel/filter.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/history.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/identityproof.go
rename to 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 .
*/
-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
similarity index 99%
rename from internal/mastotypes/mastomodel/instance.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/list.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/marker.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/mention.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/notification.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/oauth.go
rename to 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 .
*/
-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
similarity index 99%
rename from internal/mastotypes/mastomodel/poll.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/preferences.go
rename to 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 .
*/
-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
similarity index 99%
rename from internal/mastotypes/mastomodel/pushsubscription.go
rename to 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 .
*/
-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
similarity index 99%
rename from internal/mastotypes/mastomodel/relationship.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/results.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/scheduledstatus.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/source.go
rename to 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 .
*/
-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
similarity index 90%
rename from internal/mastotypes/mastomodel/status.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/tag.go
rename to 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 .
*/
-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
similarity index 98%
rename from internal/mastotypes/mastomodel/token.go
rename to 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 .
*/
-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 .
+*/
+
+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 .
+*/
+
+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
similarity index 100%
rename from internal/apimodule/security/flocblock.go
rename to internal/api/security/flocblock.go
diff --git a/internal/apimodule/security/security.go b/internal/api/security/security.go
similarity index 78%
rename from internal/apimodule/security/security.go
rename to 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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
-*/
-
-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 .
*/
-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 .
-*/
-
-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
similarity index 69%
rename from testrig/distributor.go
rename to 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 .
*/
-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 .
+*/
+
+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 .
+*/
+
+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
similarity index 55%
rename from internal/federation/federation.go
rename to 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 .
*/
-// 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 .
+*/
+
+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 .
+*/
+
+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 .
+*/
+
+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
similarity index 100%
rename from internal/db/gtsmodel/README.md
rename to internal/gtsmodel/README.md
diff --git a/internal/db/gtsmodel/account.go b/internal/gtsmodel/account.go
similarity index 90%
rename from internal/db/gtsmodel/account.go
rename to 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
similarity index 100%
rename from internal/db/gtsmodel/activitystreams.go
rename to internal/gtsmodel/activitystreams.go
diff --git a/internal/db/gtsmodel/application.go b/internal/gtsmodel/application.go
similarity index 100%
rename from internal/db/gtsmodel/application.go
rename to internal/gtsmodel/application.go
diff --git a/internal/db/gtsmodel/block.go b/internal/gtsmodel/block.go
similarity index 100%
rename from internal/db/gtsmodel/block.go
rename to internal/gtsmodel/block.go
diff --git a/internal/db/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go
similarity index 100%
rename from internal/db/gtsmodel/domainblock.go
rename to internal/gtsmodel/domainblock.go
diff --git a/internal/db/gtsmodel/emaildomainblock.go b/internal/gtsmodel/emaildomainblock.go
similarity index 100%
rename from internal/db/gtsmodel/emaildomainblock.go
rename to internal/gtsmodel/emaildomainblock.go
diff --git a/internal/db/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go
similarity index 97%
rename from internal/db/gtsmodel/emoji.go
rename to 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
similarity index 100%
rename from internal/db/gtsmodel/follow.go
rename to internal/gtsmodel/follow.go
diff --git a/internal/db/gtsmodel/followrequest.go b/internal/gtsmodel/followrequest.go
similarity index 100%
rename from internal/db/gtsmodel/followrequest.go
rename to internal/gtsmodel/followrequest.go
diff --git a/internal/db/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go
similarity index 96%
rename from internal/db/gtsmodel/mediaattachment.go
rename to 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
similarity index 100%
rename from internal/db/gtsmodel/mention.go
rename to internal/gtsmodel/mention.go
diff --git a/internal/db/gtsmodel/poll.go b/internal/gtsmodel/poll.go
similarity index 100%
rename from internal/db/gtsmodel/poll.go
rename to internal/gtsmodel/poll.go
diff --git a/internal/db/gtsmodel/status.go b/internal/gtsmodel/status.go
similarity index 100%
rename from internal/db/gtsmodel/status.go
rename to internal/gtsmodel/status.go
diff --git a/internal/db/gtsmodel/statusbookmark.go b/internal/gtsmodel/statusbookmark.go
similarity index 100%
rename from internal/db/gtsmodel/statusbookmark.go
rename to internal/gtsmodel/statusbookmark.go
diff --git a/internal/db/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go
similarity index 100%
rename from internal/db/gtsmodel/statusfave.go
rename to internal/gtsmodel/statusfave.go
diff --git a/internal/db/gtsmodel/statusmute.go b/internal/gtsmodel/statusmute.go
similarity index 100%
rename from internal/db/gtsmodel/statusmute.go
rename to internal/gtsmodel/statusmute.go
diff --git a/internal/db/gtsmodel/statuspin.go b/internal/gtsmodel/statuspin.go
similarity index 100%
rename from internal/db/gtsmodel/statuspin.go
rename to internal/gtsmodel/statuspin.go
diff --git a/internal/db/gtsmodel/tag.go b/internal/gtsmodel/tag.go
similarity index 100%
rename from internal/db/gtsmodel/tag.go
rename to internal/gtsmodel/tag.go
diff --git a/internal/db/gtsmodel/user.go b/internal/gtsmodel/user.go
similarity index 100%
rename from internal/db/gtsmodel/user.go
rename to 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 .
+*/
+
+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 .
*/
-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 .
*/
-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
-}
+// 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)
-// 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
+ 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(SessionAuthorizedToken)
- if ok {
- parsed, ok := i.(oauth2.TokenInfo)
- if !ok {
- return nil, errors.New("could not parse token from session context")
+ 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")
}
- a.Token = parsed
+ return userID, nil
+ })
+ srv.SetClientInfoHandler(server.ClientFormHandler)
+ return &s{
+ server: srv,
+ log: log,
}
-
- 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 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 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 .
*/
-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 .
+*/
+
+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 .
+*/
+
+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 .
+*/
+
+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 .
+*/
+
+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 .
+*/
+
+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": "
Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.
",
+ "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": "https://www.patreon.com/mastodon"
+ },
+ {
+ "type": "PropertyValue",
+ "name": "Homepage",
+ "value": "https://zeonfederated.com"
+ },
+ {
+ "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 .
+*/
+
+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 .
+*/
+
+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 .
+*/
+
+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 .
+*/
+
+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 .
+*/
+
+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
similarity index 73%
rename from internal/mastotypes/converter.go
rename to 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 .
*/
-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 .
-*/
-
-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
similarity index 84%
rename from internal/util/status.go
rename to 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
similarity index 91%
rename from internal/util/status_test.go
rename to 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 .
*/
-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 .
+*/
+
+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 .
*/
-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 .
+*/
+
+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.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..a9ab154d4226809d1a9b276da80dcd86d41f0c5c
GIT binary patch
literal 269739
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