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 @@
// 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 @@
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 @@
// 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 @@
"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 @@
//
// 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 @@
// 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 @@
// 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 @@
"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 @@
"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 @@
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 @@
// 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 @@
"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 @@
"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 @@
// 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 @@
"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 @@
"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 @@
// 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 @@
"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 @@
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 @@
"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 @@
"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 @@
"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 @@
"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 @@ type StatusCreateRequest struct {
// 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 @@
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 @@
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 @@
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 @@
"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 @@
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 @@
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 @@
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 @@
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 @@
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 Thumbnail struct {
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 @@
"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 @@
"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 @@
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 @@
"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 @@
"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 @@
"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 @@
// 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 @@ func (suite *StatusTestSuite) TestDeriveEmojiOK() {
: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 @@
"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 @@
"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 @@
// 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 @@
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 @@
"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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