summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2024-04-11 11:45:53 +0200
committerLibravatar GitHub <noreply@github.com>2024-04-11 11:45:53 +0200
commit9fb8a78f91adffd5f4d28df1270e407c25a7a16e (patch)
treed68200744e28d07e75a52bb0c9f6593c86a38a91 /internal
parent[performance] massively improved ActivityPub delivery worker efficiency (#2812) (diff)
downloadgotosocial-9fb8a78f91adffd5f4d28df1270e407c25a7a16e.tar.xz
[feature] New user sign-up via web page (#2796)
* [feature] User sign-up form and admin notifs * add chosen + filtered languages to migration * remove stray comment * chosen languages schmosen schmanguages * proper error on local account missing
Diffstat (limited to 'internal')
-rw-r--r--internal/api/client/accounts/accountcreate.go63
-rw-r--r--internal/api/client/admin/reportsget_test.go12
-rw-r--r--internal/api/model/admin.go4
-rw-r--r--internal/api/model/notification.go15
-rw-r--r--internal/cache/size.go14
-rw-r--r--internal/config/config.go1
-rw-r--r--internal/config/defaults.go1
-rw-r--r--internal/config/flags.go1
-rw-r--r--internal/config/helpers.gen.go25
-rw-r--r--internal/db/account.go3
-rw-r--r--internal/db/admin.go20
-rw-r--r--internal/db/bundb/admin.go110
-rw-r--r--internal/db/bundb/instance.go30
-rw-r--r--internal/db/bundb/migrations/20240318115336_account_settings.go2
-rw-r--r--internal/db/bundb/migrations/20240318115336_account_settings/accountsettings.go38
-rw-r--r--internal/db/bundb/migrations/20240401130338_sign_up.go124
-rw-r--r--internal/db/bundb/notification_test.go2
-rw-r--r--internal/db/instance.go4
-rw-r--r--internal/email/email_test.go2
-rw-r--r--internal/email/noopsender.go4
-rw-r--r--internal/email/sender.go7
-rw-r--r--internal/email/signup.go42
-rw-r--r--internal/gtsmodel/accountsettings.go1
-rw-r--r--internal/gtsmodel/notification.go1
-rw-r--r--internal/gtsmodel/user.go47
-rw-r--r--internal/processing/account/create.go99
-rw-r--r--internal/processing/account/delete.go10
-rw-r--r--internal/processing/account/delete_test.go5
-rw-r--r--internal/processing/timeline/notification.go70
-rw-r--r--internal/processing/user/email.go65
-rw-r--r--internal/processing/user/email_test.go2
-rw-r--r--internal/processing/workers/fromclientapi.go23
-rw-r--r--internal/processing/workers/fromfediapi.go2
-rw-r--r--internal/processing/workers/surfaceemail.go118
-rw-r--r--internal/processing/workers/surfacenotify.go41
-rw-r--r--internal/trans/model/user.go5
-rw-r--r--internal/typeutils/internaltofrontend.go12
-rw-r--r--internal/typeutils/internaltofrontend_test.go12
-rw-r--r--internal/validate/formvalidation.go39
-rw-r--r--internal/web/confirmemail.go68
-rw-r--r--internal/web/robots.go1
-rw-r--r--internal/web/signup.go138
-rw-r--r--internal/web/web.go4
43 files changed, 1032 insertions, 255 deletions
diff --git a/internal/api/client/accounts/accountcreate.go b/internal/api/client/accounts/accountcreate.go
index 061c66b57..920b6d4d8 100644
--- a/internal/api/client/accounts/accountcreate.go
+++ b/internal/api/client/accounts/accountcreate.go
@@ -25,7 +25,6 @@ import (
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
- "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/validate"
@@ -67,6 +66,11 @@ import (
// description: not found
// '406':
// description: not acceptable
+// '422':
+// description: >-
+// Unprocessable. Your account creation request cannot be processed
+// because either too many accounts have been created on this instance
+// in the last 24h, or the pending account backlog is full.
// '500':
// description: internal server error
func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
@@ -87,7 +91,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
return
}
- if err := validateNormalizeCreateAccount(form); err != nil {
+ if err := validate.CreateAccount(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
@@ -101,48 +105,29 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
}
form.IP = signUpIP
- ti, errWithCode := m.processor.Account().Create(c.Request.Context(), authed.Token, authed.Application, form)
+ // Create the new account + user.
+ ctx := c.Request.Context()
+ user, errWithCode := m.processor.Account().Create(
+ ctx,
+ authed.Application,
+ form,
+ )
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
- apiutil.JSON(c, http.StatusOK, ti)
-}
-
-// validateNormalizeCreateAccount 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.
-// Side effect: normalizes the provided language tag for the user's locale.
-func validateNormalizeCreateAccount(form *apimodel.AccountCreateRequest) error {
- if form == nil {
- return errors.New("form was nil")
- }
-
- if !config.GetAccountsRegistrationOpen() {
- return errors.New("registration is not open for this server")
- }
-
- if err := validate.Username(form.Username); err != nil {
- return err
- }
-
- if err := validate.Email(form.Email); err != nil {
- return err
- }
-
- if err := validate.Password(form.Password); err != nil {
- return err
- }
-
- if !form.Agreement {
- return errors.New("agreement to terms and conditions not given")
- }
-
- locale, err := validate.Language(form.Locale)
- if err != nil {
- return err
+ // Get a token for the new user.
+ ti, errWithCode := m.processor.Account().TokenForNewUser(
+ ctx,
+ authed.Token,
+ authed.Application,
+ user,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
}
- form.Locale = locale
- return validate.SignUpReason(form.Reason, config.GetAccountsReasonRequired())
+ apiutil.JSON(c, http.StatusOK, ti)
}
diff --git a/internal/api/client/admin/reportsget_test.go b/internal/api/client/admin/reportsget_test.go
index f2b6ff62a..b20921b36 100644
--- a/internal/api/client/admin/reportsget_test.go
+++ b/internal/api/client/admin/reportsget_test.go
@@ -192,7 +192,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
- "ip": "118.44.18.196",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -249,7 +249,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
- "ip": "89.122.255.1",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -295,7 +295,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
- "ip": "89.122.255.1",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -354,7 +354,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
- "ip": "118.44.18.196",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -576,7 +576,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
- "ip": "118.44.18.196",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -798,7 +798,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
- "ip": "118.44.18.196",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go
index 60036c19f..ca84ffd88 100644
--- a/internal/api/model/admin.go
+++ b/internal/api/model/admin.go
@@ -50,8 +50,8 @@ type AdminAccountInfo struct {
// The locale of the account. (ISO 639 Part 1 two-letter language code)
// example: en
Locale string `json:"locale"`
- // The reason given when requesting an invite.
- // Null if not known / remote account.
+ // The reason given when signing up.
+ // Null if no reason / remote account.
// example: Pleaaaaaaaaaaaaaaase!!
InviteRequest *string `json:"invite_request"`
// The current role of the account.
diff --git a/internal/api/model/notification.go b/internal/api/model/notification.go
index 6f9a31b07..7fccb0a2c 100644
--- a/internal/api/model/notification.go
+++ b/internal/api/model/notification.go
@@ -26,13 +26,14 @@ type Notification struct {
// The id of the notification in the database.
ID string `json:"id"`
// The type of event that resulted in the notification.
- // follow = Someone followed you
- // follow_request = Someone requested to follow you
- // mention = Someone mentioned you in their status
- // reblog = Someone boosted one of your statuses
- // favourite = Someone favourited one of your statuses
- // poll = A poll you have voted in or created has ended
- // status = Someone you enabled notifications for has posted a status
+ // follow = Someone followed you. `account` will be set.
+ // follow_request = Someone requested to follow you. `account` will be set.
+ // mention = Someone mentioned you in their status. `status` will be set. `account` will be set.
+ // reblog = Someone boosted one of your statuses. `status` will be set. `account` will be set.
+ // favourite = Someone favourited one of your statuses. `status` will be set. `account` will be set.
+ // poll = A poll you have voted in or created has ended. `status` will be set. `account` will be set.
+ // status = Someone you enabled notifications for has posted a status. `status` will be set. `account` will be set.
+ // admin.sign_up = Someone has signed up for a new account on the instance. `account` will be set.
Type string `json:"type"`
// The timestamp of the notification (ISO 8601 Datetime)
CreatedAt string `json:"created_at"`
diff --git a/internal/cache/size.go b/internal/cache/size.go
index 080fefea3..83b0da046 100644
--- a/internal/cache/size.go
+++ b/internal/cache/size.go
@@ -252,7 +252,6 @@ func sizeofAccountSettings() uintptr {
AccountID: exampleID,
CreatedAt: exampleTime,
UpdatedAt: exampleTime,
- Reason: exampleText,
Privacy: gtsmodel.VisibilityFollowersOnly,
Sensitive: util.Ptr(true),
Language: "fr",
@@ -629,11 +628,8 @@ func sizeofUser() uintptr {
Email: exampleURI,
AccountID: exampleID,
EncryptedPassword: exampleTextSmall,
- CurrentSignInAt: exampleTime,
- LastSignInAt: exampleTime,
InviteID: exampleID,
- ChosenLanguages: []string{"en", "fr", "jp"},
- FilteredLanguages: []string{"en", "fr", "jp"},
+ Reason: exampleText,
Locale: "en",
CreatedByApplicationID: exampleID,
LastEmailedAt: exampleTime,
@@ -641,10 +637,10 @@ func sizeofUser() uintptr {
ConfirmationSentAt: exampleTime,
ConfirmedAt: exampleTime,
UnconfirmedEmail: exampleURI,
- Moderator: func() *bool { ok := true; return &ok }(),
- Admin: func() *bool { ok := true; return &ok }(),
- Disabled: func() *bool { ok := true; return &ok }(),
- Approved: func() *bool { ok := true; return &ok }(),
+ Moderator: util.Ptr(false),
+ Admin: util.Ptr(false),
+ Disabled: util.Ptr(false),
+ Approved: util.Ptr(false),
ResetPasswordToken: exampleTextSmall,
ResetPasswordSentAt: exampleTime,
ExternalID: exampleID,
diff --git a/internal/config/config.go b/internal/config/config.go
index a6d27217f..dee9e99de 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -88,7 +88,6 @@ type Configuration struct {
InstanceLanguages language.Languages `name:"instance-languages" usage:"BCP47 language tags for the instance. Used to indicate the preferred languages of instance residents (in order from most-preferred to least-preferred)."`
AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."`
- AccountsApprovalRequired bool `name:"accounts-approval-required" usage:"Do account signups require approval by an admin or moderator before user can log in? If false, new registrations will be automatically approved."`
AccountsReasonRequired bool `name:"accounts-reason-required" usage:"Do new account signups require a reason to be submitted on registration?"`
AccountsAllowCustomCSS bool `name:"accounts-allow-custom-css" usage:"Allow accounts to enable custom CSS for their profile pages and statuses."`
AccountsCustomCSSLength int `name:"accounts-custom-css-length" usage:"Maximum permitted length (characters) of custom CSS for accounts."`
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 99a2e24cb..ceb8068b7 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -67,7 +67,6 @@ var Defaults = Configuration{
InstanceLanguages: make(language.Languages, 0),
AccountsRegistrationOpen: true,
- AccountsApprovalRequired: true,
AccountsReasonRequired: true,
AccountsAllowCustomCSS: false,
AccountsCustomCSSLength: 10000,
diff --git a/internal/config/flags.go b/internal/config/flags.go
index 516ba0101..042621afe 100644
--- a/internal/config/flags.go
+++ b/internal/config/flags.go
@@ -93,7 +93,6 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
// Accounts
cmd.Flags().Bool(AccountsRegistrationOpenFlag(), cfg.AccountsRegistrationOpen, fieldtag("AccountsRegistrationOpen", "usage"))
- cmd.Flags().Bool(AccountsApprovalRequiredFlag(), cfg.AccountsApprovalRequired, fieldtag("AccountsApprovalRequired", "usage"))
cmd.Flags().Bool(AccountsReasonRequiredFlag(), cfg.AccountsReasonRequired, fieldtag("AccountsReasonRequired", "usage"))
cmd.Flags().Bool(AccountsAllowCustomCSSFlag(), cfg.AccountsAllowCustomCSS, fieldtag("AccountsAllowCustomCSS", "usage"))
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index 39c163d20..39d26d13e 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -1000,31 +1000,6 @@ func GetAccountsRegistrationOpen() bool { return global.GetAccountsRegistrationO
// SetAccountsRegistrationOpen safely sets the value for global configuration 'AccountsRegistrationOpen' field
func SetAccountsRegistrationOpen(v bool) { global.SetAccountsRegistrationOpen(v) }
-// GetAccountsApprovalRequired safely fetches the Configuration value for state's 'AccountsApprovalRequired' field
-func (st *ConfigState) GetAccountsApprovalRequired() (v bool) {
- st.mutex.RLock()
- v = st.config.AccountsApprovalRequired
- st.mutex.RUnlock()
- return
-}
-
-// SetAccountsApprovalRequired safely sets the Configuration value for state's 'AccountsApprovalRequired' field
-func (st *ConfigState) SetAccountsApprovalRequired(v bool) {
- st.mutex.Lock()
- defer st.mutex.Unlock()
- st.config.AccountsApprovalRequired = v
- st.reloadToViper()
-}
-
-// AccountsApprovalRequiredFlag returns the flag name for the 'AccountsApprovalRequired' field
-func AccountsApprovalRequiredFlag() string { return "accounts-approval-required" }
-
-// GetAccountsApprovalRequired safely fetches the value for global configuration 'AccountsApprovalRequired' field
-func GetAccountsApprovalRequired() bool { return global.GetAccountsApprovalRequired() }
-
-// SetAccountsApprovalRequired safely sets the value for global configuration 'AccountsApprovalRequired' field
-func SetAccountsApprovalRequired(v bool) { global.SetAccountsApprovalRequired(v) }
-
// GetAccountsReasonRequired safely fetches the Configuration value for state's 'AccountsReasonRequired' field
func (st *ConfigState) GetAccountsReasonRequired() (v bool) {
st.mutex.RLock()
diff --git a/internal/db/account.go b/internal/db/account.go
index 3de72c5a8..45276f41f 100644
--- a/internal/db/account.go
+++ b/internal/db/account.go
@@ -29,6 +29,9 @@ type Account interface {
// GetAccountByID returns one account with the given ID, or an error if something goes wrong.
GetAccountByID(ctx context.Context, id string) (*gtsmodel.Account, error)
+ // GetAccountsByIDs returns accounts corresponding to given IDs.
+ GetAccountsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Account, error)
+
// GetAccountByURI returns one account with the given URI, or an error if something goes wrong.
GetAccountByURI(ctx context.Context, uri string) (*gtsmodel.Account, error)
diff --git a/internal/db/admin.go b/internal/db/admin.go
index fcae928f6..1f24c7932 100644
--- a/internal/db/admin.go
+++ b/internal/db/admin.go
@@ -19,6 +19,7 @@ package db
import (
"context"
+ "time"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
@@ -36,7 +37,7 @@ type Admin interface {
// C) something went wrong in the db
IsEmailAvailable(ctx context.Context, email string) (bool, error)
- // NewSignup creates a new user in the database with the given parameters.
+ // NewSignup creates a new user + account in the database with the given parameters.
// By the time this function is called, it should be assumed that all the parameters have passed validation!
NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (*gtsmodel.User, error)
@@ -50,6 +51,23 @@ type Admin interface {
// This is needed for things like serving instance information through /api/v1/instance
CreateInstanceInstance(ctx context.Context) error
+ // CreateInstanceApplication creates an application in the database
+ // for use in processing signups etc through the sign-up form.
+ CreateInstanceApplication(ctx context.Context) error
+
+ // GetInstanceApplication gets the instance application
+ // (ie., the application owned by the instance account).
+ GetInstanceApplication(ctx context.Context) (*gtsmodel.Application, error)
+
+ // CountApprovedSignupsSince counts the number of new account
+ // sign-ups approved on this instance since the given time.
+ CountApprovedSignupsSince(ctx context.Context, since time.Time) (int, error)
+
+ // CountUnhandledSignups counts the number of account sign-ups
+ // that have not yet been approved or denied. In other words,
+ // the number of pending sign-ups sitting in the backlog.
+ CountUnhandledSignups(ctx context.Context) (int, error)
+
/*
ACTION FUNCS
*/
diff --git a/internal/db/bundb/admin.go b/internal/db/bundb/admin.go
index 832db1d8f..e52467b9b 100644
--- a/internal/db/bundb/admin.go
+++ b/internal/db/bundb/admin.go
@@ -27,6 +27,7 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
@@ -121,7 +122,6 @@ func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (
settings := &gtsmodel.AccountSettings{
AccountID: accountID,
- Reason: newSignup.Reason,
Privacy: gtsmodel.VisibilityDefault,
}
@@ -197,6 +197,7 @@ func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (
Account: account,
EncryptedPassword: string(encryptedPassword),
SignUpIP: newSignup.SignUpIP.To4(),
+ Reason: newSignup.Reason,
Locale: newSignup.Locale,
UnconfirmedEmail: newSignup.Email,
CreatedByApplicationID: newSignup.AppID,
@@ -331,6 +332,113 @@ func (a *adminDB) CreateInstanceInstance(ctx context.Context) error {
return nil
}
+func (a *adminDB) CreateInstanceApplication(ctx context.Context) error {
+ // Check if instance application already exists.
+ // Instance application client_id always = the
+ // instance account's ID so this is an easy check.
+ instanceAcct, err := a.state.DB.GetInstanceAccount(ctx, "")
+ if err != nil {
+ return err
+ }
+
+ exists, err := exists(
+ ctx,
+ a.db.
+ NewSelect().
+ Column("application.id").
+ TableExpr("? AS ?", bun.Ident("applications"), bun.Ident("application")).
+ Where("? = ?", bun.Ident("application.client_id"), instanceAcct.ID),
+ )
+ if err != nil {
+ return err
+ }
+
+ if exists {
+ log.Infof(ctx, "instance application already exists")
+ return nil
+ }
+
+ // Generate new IDs for this
+ // application and its client.
+ protocol := config.GetProtocol()
+ host := config.GetHost()
+ url := protocol + "://" + host
+
+ clientID := instanceAcct.ID
+ clientSecret := uuid.NewString()
+ appID, err := id.NewRandomULID()
+ if err != nil {
+ return err
+ }
+
+ // Generate the application
+ // to put in the database.
+ app := &gtsmodel.Application{
+ ID: appID,
+ Name: host + " instance application",
+ Website: url,
+ RedirectURI: url,
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ Scopes: "write:accounts",
+ }
+
+ // Store it.
+ if err := a.state.DB.PutApplication(ctx, app); err != nil {
+ return err
+ }
+
+ // Model an oauth client
+ // from the application.
+ oc := &gtsmodel.Client{
+ ID: clientID,
+ Secret: clientSecret,
+ Domain: url,
+ }
+
+ // Store it.
+ return a.state.DB.Put(ctx, oc)
+}
+
+func (a *adminDB) GetInstanceApplication(ctx context.Context) (*gtsmodel.Application, error) {
+ // Instance app clientID == instanceAcct.ID,
+ // so get the instance account first.
+ instanceAcct, err := a.state.DB.GetInstanceAccount(ctx, "")
+ if err != nil {
+ return nil, err
+ }
+
+ app := new(gtsmodel.Application)
+ if err := a.db.
+ NewSelect().
+ Model(app).
+ Where("? = ?", bun.Ident("application.client_id"), instanceAcct.ID).
+ Scan(ctx); err != nil {
+ return nil, err
+ }
+
+ return app, nil
+}
+
+func (a *adminDB) CountApprovedSignupsSince(ctx context.Context, since time.Time) (int, error) {
+ return a.db.
+ NewSelect().
+ TableExpr("? AS ?", bun.Ident("users"), bun.Ident("user")).
+ Where("? > ?", bun.Ident("user.created_at"), since).
+ Where("? = ?", bun.Ident("user.approved"), true).
+ Count(ctx)
+}
+
+func (a *adminDB) CountUnhandledSignups(ctx context.Context) (int, error) {
+ return a.db.
+ NewSelect().
+ TableExpr("? AS ?", bun.Ident("users"), bun.Ident("user")).
+ // Approved is false by default.
+ // Explicitly rejected sign-ups end up elsewhere.
+ Where("? = ?", bun.Ident("user.approved"), false).
+ Count(ctx)
+}
+
/*
ACTION FUNCS
*/
diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go
index 5f96f9a26..73bbcea8b 100644
--- a/internal/db/bundb/instance.go
+++ b/internal/db/bundb/instance.go
@@ -380,3 +380,33 @@ func (i *instanceDB) GetInstanceModeratorAddresses(ctx context.Context) ([]strin
return addresses, nil
}
+
+func (i *instanceDB) GetInstanceModerators(ctx context.Context) ([]*gtsmodel.Account, error) {
+ accountIDs := []string{}
+
+ // Select account IDs of approved, confirmed,
+ // and enabled moderators or admins.
+
+ q := i.db.
+ NewSelect().
+ TableExpr("? AS ?", bun.Ident("users"), bun.Ident("user")).
+ Column("user.account_id").
+ Where("? = ?", bun.Ident("user.approved"), true).
+ Where("? IS NOT NULL", bun.Ident("user.confirmed_at")).
+ Where("? = ?", bun.Ident("user.disabled"), false).
+ WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
+ return q.
+ Where("? = ?", bun.Ident("user.moderator"), true).
+ WhereOr("? = ?", bun.Ident("user.admin"), true)
+ })
+
+ if err := q.Scan(ctx, &accountIDs); err != nil {
+ return nil, err
+ }
+
+ if len(accountIDs) == 0 {
+ return nil, db.ErrNoEntries
+ }
+
+ return i.state.DB.GetAccountsByIDs(ctx, accountIDs)
+}
diff --git a/internal/db/bundb/migrations/20240318115336_account_settings.go b/internal/db/bundb/migrations/20240318115336_account_settings.go
index 90d3ff420..25c64e826 100644
--- a/internal/db/bundb/migrations/20240318115336_account_settings.go
+++ b/internal/db/bundb/migrations/20240318115336_account_settings.go
@@ -21,7 +21,7 @@ import (
"context"
oldgtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20230328203024_migration_fix"
- newgtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ newgtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20240318115336_account_settings"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
diff --git a/internal/db/bundb/migrations/20240318115336_account_settings/accountsettings.go b/internal/db/bundb/migrations/20240318115336_account_settings/accountsettings.go
new file mode 100644
index 000000000..9ce142694
--- /dev/null
+++ b/internal/db/bundb/migrations/20240318115336_account_settings/accountsettings.go
@@ -0,0 +1,38 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package gtsmodel
+
+import "time"
+
+type Visibility string
+
+// AccountSettings models settings / preferences for a local, non-instance account.
+type AccountSettings struct {
+ AccountID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // AccountID that owns this settings.
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
+ UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item was last updated.
+ Reason string `bun:",nullzero"` // What reason was given for signing up when this account was created?
+ Privacy Visibility `bun:",nullzero"` // Default post privacy for this account
+ Sensitive *bool `bun:",nullzero,notnull,default:false"` // Set posts from this account to sensitive by default?
+ Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
+ StatusContentType string `bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts).
+ Theme string `bun:",nullzero"` // Preset CSS theme filename selected by this Account (empty string if nothing set).
+ CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses.
+ EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
+ HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections.
+}
diff --git a/internal/db/bundb/migrations/20240401130338_sign_up.go b/internal/db/bundb/migrations/20240401130338_sign_up.go
new file mode 100644
index 000000000..51317fd9f
--- /dev/null
+++ b/internal/db/bundb/migrations/20240401130338_sign_up.go
@@ -0,0 +1,124 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package migrations
+
+import (
+ "context"
+ "strings"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ // Add reason to users table.
+ _, err := db.ExecContext(ctx,
+ "ALTER TABLE ? ADD COLUMN ? TEXT",
+ bun.Ident("users"), bun.Ident("reason"),
+ )
+ if err != nil {
+ e := err.Error()
+ if !(strings.Contains(e, "already exists") ||
+ strings.Contains(e, "duplicate column name") ||
+ strings.Contains(e, "SQLSTATE 42701")) {
+ return err
+ }
+ }
+
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ // Get reasons from
+ // account settings.
+ type idReason struct {
+ AccountID string
+ Reason string
+ }
+
+ reasons := []idReason{}
+ if err := tx.
+ NewSelect().
+ Table("account_settings").
+ Column("account_id", "reason").
+ Scan(ctx, &reasons); err != nil {
+ return err
+ }
+
+ // Add each reason to appropriate user.
+ for _, r := range reasons {
+ if _, err := tx.
+ NewUpdate().
+ Table("users").
+ Set("? = ?", bun.Ident("reason"), r.Reason).
+ Where("? = ?", bun.Ident("account_id"), r.AccountID).
+ Exec(ctx, &reasons); err != nil {
+ return err
+ }
+ }
+
+ // Remove now-unused column
+ // from account settings.
+ if _, err := tx.
+ NewDropColumn().
+ Table("account_settings").
+ Column("reason").
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ // Remove now-unused columns from users.
+ for _, column := range []string{
+ "current_sign_in_at",
+ "current_sign_in_ip",
+ "last_sign_in_at",
+ "last_sign_in_ip",
+ "sign_in_count",
+ "chosen_languages",
+ "filtered_languages",
+ } {
+ if _, err := tx.
+ NewDropColumn().
+ Table("users").
+ Column(column).
+ Exec(ctx); err != nil {
+ return err
+ }
+ }
+
+ // Create new UsersDenied table.
+ if _, err := tx.
+ NewCreateTable().
+ Model(&gtsmodel.DeniedUser{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ return nil
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/notification_test.go b/internal/db/bundb/notification_test.go
index e27475476..83c3ef041 100644
--- a/internal/db/bundb/notification_test.go
+++ b/internal/db/bundb/notification_test.go
@@ -176,7 +176,7 @@ func (suite *NotificationTestSuite) TestDeleteNotificationsOriginatingFromAndTar
}
for _, n := range notif {
- if n.OriginAccountID == originAccount.ID || n.TargetAccountID == targetAccount.ID {
+ if n.OriginAccountID == originAccount.ID && n.TargetAccountID == targetAccount.ID {
suite.FailNowf(
"",
"no notifications with origin account id %s and target account %s should remain",
diff --git a/internal/db/instance.go b/internal/db/instance.go
index 3b9588fef..23a2fc8dc 100644
--- a/internal/db/instance.go
+++ b/internal/db/instance.go
@@ -58,4 +58,8 @@ type Instance interface {
// GetInstanceModeratorAddresses returns a slice of email addresses belonging to active
// (as in, not suspended) moderators + admins on this instance.
GetInstanceModeratorAddresses(ctx context.Context) ([]string, error)
+
+ // GetInstanceModerators returns a slice of accounts belonging to active
+ // (as in, non suspended) moderators + admins on this instance.
+ GetInstanceModerators(ctx context.Context) ([]*gtsmodel.Account, error)
}
diff --git a/internal/email/email_test.go b/internal/email/email_test.go
index 702b62075..ee9efeef8 100644
--- a/internal/email/email_test.go
+++ b/internal/email/email_test.go
@@ -50,7 +50,7 @@ func (suite *EmailTestSuite) TestTemplateConfirm() {
suite.sender.SendConfirmEmail("user@example.org", confirmData)
suite.Len(suite.sentEmails, 1)
- suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nWe just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org\r\n\r\n", suite.sentEmails["user@example.org"])
+ suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nTo use your account, you must confirm that this is your email address.\r\n\r\nTo confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReset() {
diff --git a/internal/email/noopsender.go b/internal/email/noopsender.go
index 0ed7ff747..44aa86dba 100644
--- a/internal/email/noopsender.go
+++ b/internal/email/noopsender.go
@@ -68,6 +68,10 @@ func (s *noopSender) SendReportClosedEmail(toAddress string, data ReportClosedDa
return s.sendTemplate(reportClosedTemplate, reportClosedSubject, data, toAddress)
}
+func (s *noopSender) SendNewSignupEmail(toAddresses []string, data NewSignupData) error {
+ return s.sendTemplate(newSignupTemplate, newSignupSubject, data, toAddresses...)
+}
+
func (s *noopSender) sendTemplate(template string, subject string, data any, toAddresses ...string) error {
buf := &bytes.Buffer{}
if err := s.template.ExecuteTemplate(buf, template, data); err != nil {
diff --git a/internal/email/sender.go b/internal/email/sender.go
index b0d883d9d..78338a0dd 100644
--- a/internal/email/sender.go
+++ b/internal/email/sender.go
@@ -46,6 +46,13 @@ type Sender interface {
// SendReportClosedEmail sends an email notification to the given address, letting them
// know that a report that they created has been closed / resolved by an admin.
SendReportClosedEmail(toAddress string, data ReportClosedData) error
+
+ // SendNewSignupEmail sends an email notification to the given addresses,
+ // letting them know that a new sign-up has been submitted to the instance.
+ //
+ // It is expected that the toAddresses have already been filtered to ensure
+ // that they all belong to active admins + moderators.
+ SendNewSignupEmail(toAddress []string, data NewSignupData) error
}
// NewSender returns a new email Sender interface with the given configuration, or an error if something goes wrong.
diff --git a/internal/email/signup.go b/internal/email/signup.go
new file mode 100644
index 000000000..84162c21e
--- /dev/null
+++ b/internal/email/signup.go
@@ -0,0 +1,42 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package email
+
+var (
+ newSignupTemplate = "email_new_signup.tmpl"
+ newSignupSubject = "GoToSocial New Sign-Up"
+)
+
+type NewSignupData struct {
+ // URL of the instance to present to the receiver.
+ InstanceURL string
+ // Name of the instance to present to the receiver.
+ InstanceName string
+ // Email address sign-up was created with.
+ SignupEmail string
+ // Username submitted on the sign-up form.
+ SignupUsername string
+ // Reason given on the sign-up form.
+ SignupReason string
+ // URL to open the sign-up in the settings panel.
+ SignupURL string
+}
+
+func (s *sender) SendNewSignupEmail(toAddresses []string, data NewSignupData) error {
+ return s.sendTemplate(newSignupTemplate, newSignupSubject, data, toAddresses...)
+}
diff --git a/internal/gtsmodel/accountsettings.go b/internal/gtsmodel/accountsettings.go
index 218767023..109d90ad9 100644
--- a/internal/gtsmodel/accountsettings.go
+++ b/internal/gtsmodel/accountsettings.go
@@ -24,7 +24,6 @@ type AccountSettings struct {
AccountID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // AccountID that owns this settings.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item was last updated.
- Reason string `bun:",nullzero"` // What reason was given for signing up when this account was created?
Privacy Visibility `bun:",nullzero"` // Default post privacy for this account
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Set posts from this account to sensitive by default?
Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
diff --git a/internal/gtsmodel/notification.go b/internal/gtsmodel/notification.go
index 5e2eff167..0f946ed0f 100644
--- a/internal/gtsmodel/notification.go
+++ b/internal/gtsmodel/notification.go
@@ -46,4 +46,5 @@ const (
NotificationFave NotificationType = "favourite" // NotificationFave -- someone faved/liked one of your statuses
NotificationPoll NotificationType = "poll" // NotificationPoll -- a poll you voted in or created has ended
NotificationStatus NotificationType = "status" // NotificationStatus -- someone you enabled notifications for has posted a status.
+ NotificationSignup NotificationType = "admin.sign_up" // NotificationSignup -- someone has submitted a new account sign-up to the instance.
)
diff --git a/internal/gtsmodel/user.go b/internal/gtsmodel/user.go
index 7d3da555c..1fea2aeb6 100644
--- a/internal/gtsmodel/user.go
+++ b/internal/gtsmodel/user.go
@@ -22,8 +22,14 @@ import (
"time"
)
-// User represents an actual human user of gotosocial. Note, this is a LOCAL gotosocial user, not a remote account.
-// To cross reference this local user with their account (which can be local or remote), use the AccountID field.
+// User represents one signed-up user of this GoToSocial instance.
+//
+// User may not necessarily be approved yet; in other words, this
+// model is used for both active users and signed-up but not yet
+// approved users.
+//
+// Sign-ups that have been denied rather than
+// approved are stored as DeniedUser instead.
type User struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
@@ -32,15 +38,9 @@ type User struct {
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique"` // The id of the local gtsmodel.Account entry for this user.
Account *Account `bun:"rel:belongs-to"` // Pointer to the account of this user that corresponds to AccountID.
EncryptedPassword string `bun:",nullzero,notnull"` // The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables.
- SignUpIP net.IP `bun:",nullzero"` // From what IP was this user created?
- CurrentSignInAt time.Time `bun:"type:timestamptz,nullzero"` // When did the user sign in with their current session.
- CurrentSignInIP net.IP `bun:",nullzero"` // What's the most recent IP of this user
- LastSignInAt time.Time `bun:"type:timestamptz,nullzero"` // When did this user last sign in?
- LastSignInIP net.IP `bun:",nullzero"` // What's the previous IP of this user?
- SignInCount int `bun:",notnull,default:0"` // How many times has this user signed in?
+ SignUpIP net.IP `bun:",nullzero"` // IP this user used to sign up. Only stored for pending sign-ups.
InviteID string `bun:"type:CHAR(26),nullzero"` // id of the user who invited this user (who let this joker in?)
- ChosenLanguages []string `bun:",nullzero"` // What languages does this user want to see?
- FilteredLanguages []string `bun:",nullzero"` // What languages does this user not want to see?
+ Reason string `bun:",nullzero"` // What reason was given for signing up when this user was created?
Locale string `bun:",nullzero"` // In what timezone/locale is this user located?
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application id created this user? See gtsmodel.Application
CreatedByApplication *Application `bun:"rel:belongs-to"` // Pointer to the application corresponding to createdbyapplicationID.
@@ -58,15 +58,36 @@ type User struct {
ExternalID string `bun:",nullzero,unique"` // If the login for the user is managed externally (e.g OIDC), we need to keep a stable reference to the external object (e.g OIDC sub claim)
}
+// DeniedUser represents one user sign-up that
+// was submitted to the instance and denied.
+type DeniedUser struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
+ UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
+ Email string `bun:",nullzero,notnull"` // Email address provided on the sign-up form.
+ Username string `bun:",nullzero,notnull"` // Username provided on the sign-up form.
+ SignUpIP net.IP `bun:",nullzero"` // IP address the sign-up originated from.
+ InviteID string `bun:"type:CHAR(26),nullzero"` // Invite ID provided on the sign-up form (if applicable).
+ Locale string `bun:",nullzero"` // Locale provided on the sign-up form.
+ CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"` // ID of application used to create this sign-up.
+ SignUpReason string `bun:",nullzero"` // Reason provided by user on the sign-up form.
+ PrivateComment string `bun:",nullzero"` // Comment from instance admin about why this sign-up was denied.
+ SendEmail *bool `bun:",nullzero,notnull,default:false"` // Send an email informing user that their sign-up has been denied.
+ Message string `bun:",nullzero"` // Message to include when sending an email to the denied user's email address, if SendEmail is true.
+}
+
// NewSignup models parameters for the creation
// of a new user + account on this instance.
//
// Aside from username, email, and password, it is
// fine to use zero values on fields of this struct.
+//
+// This struct is not stored in the database,
+// it's just for passing around parameters.
type NewSignup struct {
- Username string // Username of the new account.
- Email string // Email address of the user.
- Password string // Plaintext (not yet hashed) password for the user.
+ Username string // Username of the new account (required).
+ Email string // Email address of the user (required).
+ Password string // Plaintext (not yet hashed) password for the user (required).
Reason string // Reason given by the user when submitting a sign up request (optional).
PreApproved bool // Mark the new user/account as preapproved (optional)
diff --git a/internal/processing/account/create.go b/internal/processing/account/create.go
index 1925feb63..12b2d5e57 100644
--- a/internal/processing/account/create.go
+++ b/internal/processing/account/create.go
@@ -20,6 +20,7 @@ package account
import (
"context"
"fmt"
+ "time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
@@ -32,15 +33,48 @@ import (
)
// Create processes the given form for creating a new account,
-// returning an oauth token for that account if successful.
+// returning a new user (with attached account) if successful.
//
-// Precondition: the form's fields should have already been validated and normalized by the caller.
+// App should be the app used to create the account.
+// If nil, the instance app will be used.
+//
+// Precondition: the form's fields should have already been
+// validated and normalized by the caller.
func (p *Processor) Create(
ctx context.Context,
- appToken oauth2.TokenInfo,
app *gtsmodel.Application,
form *apimodel.AccountCreateRequest,
-) (*apimodel.Token, gtserror.WithCode) {
+) (*gtsmodel.User, gtserror.WithCode) {
+ const (
+ usersPerDay = 10
+ regBacklog = 20
+ )
+
+ // Ensure no more than usersPerDay
+ // have registered in the last 24h.
+ newUsersCount, err := p.state.DB.CountApprovedSignupsSince(ctx, time.Now().Add(-24*time.Hour))
+ if err != nil {
+ err := fmt.Errorf("db error counting new users: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ if newUsersCount >= usersPerDay {
+ err := fmt.Errorf("this instance has hit its limit of new sign-ups for today; you can try again tomorrow")
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Ensure the new users backlog isn't full.
+ backlogLen, err := p.state.DB.CountUnhandledSignups(ctx)
+ if err != nil {
+ err := fmt.Errorf("db error counting registration backlog length: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ if backlogLen >= regBacklog {
+ err := fmt.Errorf("this instance's sign-up backlog is currently full; you must wait until pending sign-ups are handled by the admin(s)")
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
emailAvailable, err := p.state.DB.IsEmailAvailable(ctx, form.Email)
if err != nil {
err := fmt.Errorf("db error checking email availability: %w", err)
@@ -67,38 +101,61 @@ func (p *Processor) Create(
reason = form.Reason
}
+ // Use instance app if no app provided.
+ if app == nil {
+ app, err = p.state.DB.GetInstanceApplication(ctx)
+ if err != nil {
+ err := fmt.Errorf("db error getting instance app: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ }
+
user, err := p.state.DB.NewSignup(ctx, gtsmodel.NewSignup{
- Username: form.Username,
- Email: form.Email,
- Password: form.Password,
- Reason: text.SanitizeToPlaintext(reason),
- PreApproved: !config.GetAccountsApprovalRequired(), // Mark as approved if no approval required.
- SignUpIP: form.IP,
- Locale: form.Locale,
- AppID: app.ID,
+ Username: form.Username,
+ Email: form.Email,
+ Password: form.Password,
+ Reason: text.SanitizeToPlaintext(reason),
+ SignUpIP: form.IP,
+ Locale: form.Locale,
+ AppID: app.ID,
})
if err != nil {
err := fmt.Errorf("db error creating new signup: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
- // Generate access token *before* doing side effects; we
- // don't want to process side effects if something borks.
- accessToken, err := p.oauthServer.GenerateUserAccessToken(ctx, appToken, app.ClientSecret, user.ID)
- if err != nil {
- err := fmt.Errorf("error creating new access token for user %s: %w", user.ID, err)
- return nil, gtserror.NewErrorInternalError(err)
- }
-
// There are side effects for creating a new account
// (confirmation emails etc), perform these async.
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ObjectProfile,
APActivityType: ap.ActivityCreate,
- GTSModel: user.Account,
+ GTSModel: user,
OriginAccount: user.Account,
})
+ return user, nil
+}
+
+// TokenForNewUser generates an OAuth Bearer token
+// for a new user (with account) created by Create().
+func (p *Processor) TokenForNewUser(
+ ctx context.Context,
+ appToken oauth2.TokenInfo,
+ app *gtsmodel.Application,
+ user *gtsmodel.User,
+) (*apimodel.Token, gtserror.WithCode) {
+ // Generate access token.
+ accessToken, err := p.oauthServer.GenerateUserAccessToken(
+ ctx,
+ appToken,
+ app.ClientSecret,
+ user.ID,
+ )
+ if err != nil {
+ err := fmt.Errorf("error creating new access token for user %s: %w", user.ID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
return &apimodel.Token{
AccessToken: accessToken.GetAccess(),
TokenType: "Bearer",
diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go
index 2ae00194e..858e42d36 100644
--- a/internal/processing/account/delete.go
+++ b/internal/processing/account/delete.go
@@ -569,11 +569,6 @@ func stubbifyUser(user *gtsmodel.User) ([]string, error) {
user.EncryptedPassword = string(dummyPassword)
user.SignUpIP = net.IPv4zero
- user.CurrentSignInAt = never
- user.CurrentSignInIP = net.IPv4zero
- user.LastSignInAt = never
- user.LastSignInIP = net.IPv4zero
- user.SignInCount = 1
user.Locale = ""
user.CreatedByApplicationID = ""
user.LastEmailedAt = never
@@ -585,11 +580,6 @@ func stubbifyUser(user *gtsmodel.User) ([]string, error) {
return []string{
"encrypted_password",
"sign_up_ip",
- "current_sign_in_at",
- "current_sign_in_ip",
- "last_sign_in_at",
- "last_sign_in_ip",
- "sign_in_count",
"locale",
"created_by_application_id",
"last_emailed_at",
diff --git a/internal/processing/account/delete_test.go b/internal/processing/account/delete_test.go
index de7c8e08c..ee6fe1dfc 100644
--- a/internal/processing/account/delete_test.go
+++ b/internal/processing/account/delete_test.go
@@ -78,11 +78,6 @@ func (suite *AccountDeleteTestSuite) TestAccountDeleteLocal() {
suite.WithinDuration(time.Now(), updatedUser.UpdatedAt, 1*time.Minute)
suite.NotEqual(updatedUser.EncryptedPassword, ogUser.EncryptedPassword)
suite.Equal(net.IPv4zero, updatedUser.SignUpIP)
- suite.Zero(updatedUser.CurrentSignInAt)
- suite.Equal(net.IPv4zero, updatedUser.CurrentSignInIP)
- suite.Zero(updatedUser.LastSignInAt)
- suite.Equal(net.IPv4zero, updatedUser.LastSignInIP)
- suite.Equal(1, updatedUser.SignInCount)
suite.Zero(updatedUser.Locale)
suite.Zero(updatedUser.CreatedByApplicationID)
suite.Zero(updatedUser.LastEmailedAt)
diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go
index 09febdb46..42f708999 100644
--- a/internal/processing/timeline/notification.go
+++ b/internal/processing/timeline/notification.go
@@ -60,31 +60,14 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
prevMinIDValue = n.ID
}
- // Ensure this notification should be shown to requester.
- if n.OriginAccount != nil {
- // Account is set, ensure it's visible to notif target.
- visible, err := p.filter.AccountVisible(ctx, authed.Account, n.OriginAccount)
- if err != nil {
- log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %s", n.ID, err)
- continue
- }
-
- if !visible {
- continue
- }
+ visible, err := p.notifVisible(ctx, n, authed.Account)
+ if err != nil {
+ log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %v", n.ID, err)
+ continue
}
- if n.Status != nil {
- // Status is set, ensure it's visible to notif target.
- visible, err := p.filter.StatusVisible(ctx, authed.Account, n.Status)
- if err != nil {
- log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %s", n.ID, err)
- continue
- }
-
- if !visible {
- continue
- }
+ if !visible {
+ continue
}
item, err := p.converter.NotificationToAPINotification(ctx, n)
@@ -142,3 +125,44 @@ func (p *Processor) NotificationsClear(ctx context.Context, authed *oauth.Auth)
return nil
}
+
+func (p *Processor) notifVisible(
+ ctx context.Context,
+ n *gtsmodel.Notification,
+ acct *gtsmodel.Account,
+) (bool, error) {
+ // If account is set, ensure it's
+ // visible to notif target.
+ if n.OriginAccount != nil {
+ // If this is a new local account sign-up,
+ // skip normal visibility checking because
+ // origin account won't be confirmed yet.
+ if n.NotificationType == gtsmodel.NotificationSignup {
+ return true, nil
+ }
+
+ visible, err := p.filter.AccountVisible(ctx, acct, n.OriginAccount)
+ if err != nil {
+ return false, err
+ }
+
+ if !visible {
+ return false, nil
+ }
+ }
+
+ // If status is set, ensure it's
+ // visible to notif target.
+ if n.Status != nil {
+ visible, err := p.filter.StatusVisible(ctx, acct, n.Status)
+ if err != nil {
+ return false, err
+ }
+
+ if !visible {
+ return false, nil
+ }
+ }
+
+ return true, nil
+}
diff --git a/internal/processing/user/email.go b/internal/processing/user/email.go
index dd2a96ae3..2b27c6c92 100644
--- a/internal/processing/user/email.go
+++ b/internal/processing/user/email.go
@@ -28,53 +28,78 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
-var oneWeek = 168 * time.Hour
-
-// EmailConfirm processes an email confirmation request, usually initiated as a result of clicking on a link
-// in a 'confirm your email address' type email.
-func (p *Processor) EmailConfirm(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) {
+// EmailGetUserForConfirmToken retrieves the user (with account) from
+// the database for the given "confirm your email" token string.
+func (p *Processor) EmailGetUserForConfirmToken(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) {
if token == "" {
- return nil, gtserror.NewErrorNotFound(errors.New("no token provided"))
+ err := errors.New("no token provided")
+ return nil, gtserror.NewErrorNotFound(err)
}
user, err := p.state.DB.GetUserByConfirmationToken(ctx, token)
if err != nil {
- if err == db.ErrNoEntries {
- return nil, gtserror.NewErrorNotFound(err)
+ if !errors.Is(err, db.ErrNoEntries) {
+ // Real error.
+ return nil, gtserror.NewErrorInternalError(err)
}
- return nil, gtserror.NewErrorInternalError(err)
+
+ // No user found for this token.
+ return nil, gtserror.NewErrorNotFound(err)
}
if user.Account == nil {
- a, err := p.state.DB.GetAccountByID(ctx, user.AccountID)
+ user.Account, err = p.state.DB.GetAccountByID(ctx, user.AccountID)
if err != nil {
- return nil, gtserror.NewErrorNotFound(err)
+ // We need the account for a local user.
+ return nil, gtserror.NewErrorInternalError(err)
}
- user.Account = a
}
if !user.Account.SuspendedAt.IsZero() {
- return nil, gtserror.NewErrorForbidden(fmt.Errorf("ConfirmEmail: account %s is suspended", user.AccountID))
+ err := fmt.Errorf("account %s is suspended", user.AccountID)
+ return nil, gtserror.NewErrorForbidden(err, err.Error())
+ }
+
+ return user, nil
+}
+
+// EmailConfirm processes an email confirmation request,
+// usually initiated as a result of clicking on a link
+// in a 'confirm your email address' type email.
+func (p *Processor) EmailConfirm(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) {
+ user, errWithCode := p.EmailGetUserForConfirmToken(ctx, token)
+ if errWithCode != nil {
+ return nil, errWithCode
}
- if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email {
- // no pending email confirmations so just return OK
+ if user.UnconfirmedEmail == "" ||
+ user.UnconfirmedEmail == user.Email {
+ // Confirmed already, just return.
return user, nil
}
+ // Ensure token not expired.
+ const oneWeek = 168 * time.Hour
if user.ConfirmationSentAt.Before(time.Now().Add(-oneWeek)) {
- return nil, gtserror.NewErrorForbidden(errors.New("ConfirmEmail: confirmation token expired"))
+ err := errors.New("confirmation token expired (older than one week)")
+ return nil, gtserror.NewErrorForbidden(err, err.Error())
}
- // mark the user's email address as confirmed + remove the unconfirmed address and the token
- updatingColumns := []string{"email", "unconfirmed_email", "confirmed_at", "confirmation_token", "updated_at"}
+ // Mark the user's email address as confirmed,
+ // and remove the unconfirmed address and the token.
user.Email = user.UnconfirmedEmail
user.UnconfirmedEmail = ""
user.ConfirmedAt = time.Now()
user.ConfirmationToken = ""
- user.UpdatedAt = time.Now()
- if err := p.state.DB.UpdateByID(ctx, user, user.ID, updatingColumns...); err != nil {
+ if err := p.state.DB.UpdateUser(
+ ctx,
+ user,
+ "email",
+ "unconfirmed_email",
+ "confirmed_at",
+ "confirmation_token",
+ ); err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
diff --git a/internal/processing/user/email_test.go b/internal/processing/user/email_test.go
index b42446991..23d448a84 100644
--- a/internal/processing/user/email_test.go
+++ b/internal/processing/user/email_test.go
@@ -76,7 +76,7 @@ func (suite *EmailConfirmTestSuite) TestConfirmEmailOldToken() {
// confirm with the token set above
updatedUser, errWithCode := suite.user.EmailConfirm(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6")
suite.Nil(updatedUser)
- suite.EqualError(errWithCode, "ConfirmEmail: confirmation token expired")
+ suite.EqualError(errWithCode, "confirmation token expired (older than one week)")
}
func TestEmailConfirmTestSuite(t *testing.T) {
diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go
index c7e78fee2..ed513c331 100644
--- a/internal/processing/workers/fromclientapi.go
+++ b/internal/processing/workers/fromclientapi.go
@@ -209,18 +209,23 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.From
}
func (p *clientAPI) CreateAccount(ctx context.Context, cMsg messages.FromClientAPI) error {
- account, ok := cMsg.GTSModel.(*gtsmodel.Account)
+ newUser, ok := cMsg.GTSModel.(*gtsmodel.User)
if !ok {
- return gtserror.Newf("%T not parseable as *gtsmodel.Account", cMsg.GTSModel)
+ return gtserror.Newf("%T not parseable as *gtsmodel.User", cMsg.GTSModel)
+ }
+
+ // Notify mods of the new signup.
+ if err := p.surface.notifySignup(ctx, newUser); err != nil {
+ log.Errorf(ctx, "error notifying mods of new sign-up: %v", err)
}
- // Send a confirmation email to the newly created account.
- user, err := p.state.DB.GetUserByAccountID(ctx, account.ID)
- if err != nil {
- return gtserror.Newf("db error getting user for account id %s: %w", account.ID, err)
+ // Send "new sign up" email to mods.
+ if err := p.surface.emailAdminNewSignup(ctx, newUser); err != nil {
+ log.Errorf(ctx, "error emailing new signup: %v", err)
}
- if err := p.surface.emailPleaseConfirm(ctx, user, account.Username); err != nil {
+ // Send "please confirm your address" email to the new user.
+ if err := p.surface.emailUserPleaseConfirm(ctx, newUser); err != nil {
log.Errorf(ctx, "error emailing confirm: %v", err)
}
@@ -458,7 +463,7 @@ func (p *clientAPI) UpdateReport(ctx context.Context, cMsg messages.FromClientAP
return nil
}
- if err := p.surface.emailReportClosed(ctx, report); err != nil {
+ if err := p.surface.emailUserReportClosed(ctx, report); err != nil {
log.Errorf(ctx, "error emailing report closed: %v", err)
}
@@ -644,7 +649,7 @@ func (p *clientAPI) ReportAccount(ctx context.Context, cMsg messages.FromClientA
}
}
- if err := p.surface.emailReportOpened(ctx, report); err != nil {
+ if err := p.surface.emailAdminReportOpened(ctx, report); err != nil {
log.Errorf(ctx, "error emailing report opened: %v", err)
}
diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go
index 2fc3b4b26..7b0e72490 100644
--- a/internal/processing/workers/fromfediapi.go
+++ b/internal/processing/workers/fromfediapi.go
@@ -473,7 +473,7 @@ func (p *fediAPI) CreateFlag(ctx context.Context, fMsg messages.FromFediAPI) err
// TODO: handle additional side effects of flag creation:
// - notify admins by dm / notification
- if err := p.surface.emailReportOpened(ctx, incomingReport); err != nil {
+ if err := p.surface.emailAdminReportOpened(ctx, incomingReport); err != nil {
log.Errorf(ctx, "error emailing report opened: %v", err)
}
diff --git a/internal/processing/workers/surfaceemail.go b/internal/processing/workers/surfaceemail.go
index a6c97f48f..c00b22c86 100644
--- a/internal/processing/workers/surfaceemail.go
+++ b/internal/processing/workers/surfaceemail.go
@@ -31,41 +31,9 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/uris"
)
-func (s *surface) emailReportOpened(ctx context.Context, report *gtsmodel.Report) error {
- instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
- if err != nil {
- return gtserror.Newf("error getting instance: %w", err)
- }
-
- toAddresses, err := s.state.DB.GetInstanceModeratorAddresses(ctx)
- if err != nil {
- if errors.Is(err, db.ErrNoEntries) {
- // No registered moderator addresses.
- return nil
- }
- return gtserror.Newf("error getting instance moderator addresses: %w", err)
- }
-
- if err := s.state.DB.PopulateReport(ctx, report); err != nil {
- return gtserror.Newf("error populating report: %w", err)
- }
-
- reportData := email.NewReportData{
- InstanceURL: instance.URI,
- InstanceName: instance.Title,
- ReportURL: instance.URI + "/settings/admin/reports/" + report.ID,
- ReportDomain: report.Account.Domain,
- ReportTargetDomain: report.TargetAccount.Domain,
- }
-
- if err := s.emailSender.SendNewReportEmail(toAddresses, reportData); err != nil {
- return gtserror.Newf("error emailing instance moderators: %w", err)
- }
-
- return nil
-}
-
-func (s *surface) emailReportClosed(ctx context.Context, report *gtsmodel.Report) error {
+// emailUserReportClosed emails the user who created the
+// given report, to inform them the report has been closed.
+func (s *surface) emailUserReportClosed(ctx context.Context, report *gtsmodel.Report) error {
user, err := s.state.DB.GetUserByAccountID(ctx, report.Account.ID)
if err != nil {
return gtserror.Newf("db error getting user: %w", err)
@@ -104,7 +72,9 @@ func (s *surface) emailReportClosed(ctx context.Context, report *gtsmodel.Report
return s.emailSender.SendReportClosedEmail(user.Email, reportClosedData)
}
-func (s *surface) emailPleaseConfirm(ctx context.Context, user *gtsmodel.User, username string) error {
+// emailUserPleaseConfirm emails the given user
+// to ask them to confirm their email address.
+func (s *surface) emailUserPleaseConfirm(ctx context.Context, user *gtsmodel.User) error {
if user.UnconfirmedEmail == "" ||
user.UnconfirmedEmail == user.Email {
// User has already confirmed this
@@ -130,7 +100,7 @@ func (s *surface) emailPleaseConfirm(ctx context.Context, user *gtsmodel.User, u
if err := s.emailSender.SendConfirmEmail(
user.UnconfirmedEmail,
email.ConfirmData{
- Username: username,
+ Username: user.Account.Username,
InstanceURL: instance.URI,
InstanceName: instance.Title,
ConfirmLink: confirmLink,
@@ -158,3 +128,77 @@ func (s *surface) emailPleaseConfirm(ctx context.Context, user *gtsmodel.User, u
return nil
}
+
+// emailAdminReportOpened emails all active moderators/admins
+// of this instance that a new report has been created.
+func (s *surface) emailAdminReportOpened(ctx context.Context, report *gtsmodel.Report) error {
+ instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
+ if err != nil {
+ return gtserror.Newf("error getting instance: %w", err)
+ }
+
+ toAddresses, err := s.state.DB.GetInstanceModeratorAddresses(ctx)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ // No registered moderator addresses.
+ return nil
+ }
+ return gtserror.Newf("error getting instance moderator addresses: %w", err)
+ }
+
+ if err := s.state.DB.PopulateReport(ctx, report); err != nil {
+ return gtserror.Newf("error populating report: %w", err)
+ }
+
+ reportData := email.NewReportData{
+ InstanceURL: instance.URI,
+ InstanceName: instance.Title,
+ ReportURL: instance.URI + "/settings/admin/reports/" + report.ID,
+ ReportDomain: report.Account.Domain,
+ ReportTargetDomain: report.TargetAccount.Domain,
+ }
+
+ if err := s.emailSender.SendNewReportEmail(toAddresses, reportData); err != nil {
+ return gtserror.Newf("error emailing instance moderators: %w", err)
+ }
+
+ return nil
+}
+
+// emailAdminNewSignup emails all active moderators/admins of this
+// instance that a new account sign-up has been submitted to the instance.
+func (s *surface) emailAdminNewSignup(ctx context.Context, newUser *gtsmodel.User) error {
+ instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
+ if err != nil {
+ return gtserror.Newf("error getting instance: %w", err)
+ }
+
+ toAddresses, err := s.state.DB.GetInstanceModeratorAddresses(ctx)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ // No registered moderator addresses.
+ return nil
+ }
+ return gtserror.Newf("error getting instance moderator addresses: %w", err)
+ }
+
+ // Ensure user populated.
+ if err := s.state.DB.PopulateUser(ctx, newUser); err != nil {
+ return gtserror.Newf("error populating user: %w", err)
+ }
+
+ newSignupData := email.NewSignupData{
+ InstanceURL: instance.URI,
+ InstanceName: instance.Title,
+ SignupEmail: newUser.UnconfirmedEmail,
+ SignupUsername: newUser.Account.Username,
+ SignupReason: newUser.Reason,
+ SignupURL: "TODO",
+ }
+
+ if err := s.emailSender.SendNewSignupEmail(toAddresses, newSignupData); err != nil {
+ return gtserror.Newf("error emailing instance moderators: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go
index a8c36248c..9c82712f2 100644
--- a/internal/processing/workers/surfacenotify.go
+++ b/internal/processing/workers/surfacenotify.go
@@ -333,6 +333,45 @@ func (s *surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status)
return errs.Combine()
}
+func (s *surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) error {
+ modAccounts, err := s.state.DB.GetInstanceModerators(ctx)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ // No registered
+ // mod accounts.
+ return nil
+ }
+
+ // Real error.
+ return gtserror.Newf("error getting instance moderator accounts: %w", err)
+ }
+
+ // Ensure user + account populated.
+ if err := s.state.DB.PopulateUser(ctx, newUser); err != nil {
+ return gtserror.Newf("db error populating new user: %w", err)
+ }
+
+ if err := s.state.DB.PopulateAccount(ctx, newUser.Account); err != nil {
+ return gtserror.Newf("db error populating new user's account: %w", err)
+ }
+
+ // Notify each moderator.
+ var errs gtserror.MultiError
+ for _, mod := range modAccounts {
+ if err := s.notify(ctx,
+ gtsmodel.NotificationSignup,
+ mod,
+ newUser.Account,
+ "",
+ ); err != nil {
+ errs.Appendf("error notifying moderator %s: %w", mod.ID, err)
+ continue
+ }
+ }
+
+ return errs.Combine()
+}
+
// notify creates, inserts, and streams a new
// notification to the target account if it
// doesn't yet exist with the given parameters.
@@ -342,7 +381,7 @@ func (s *surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status)
// targets into this function without filtering
// for non-local first.
//
-// targetAccountID and originAccountID must be
+// targetAccount and originAccount must be
// set, but statusID can be an empty string.
func (s *surface) notify(
ctx context.Context,
diff --git a/internal/trans/model/user.go b/internal/trans/model/user.go
index 4d5c1a6fc..9ba4e7630 100644
--- a/internal/trans/model/user.go
+++ b/internal/trans/model/user.go
@@ -29,11 +29,8 @@ type User struct {
Email string `json:"email,omitempty" bun:",nullzero"`
AccountID string `json:"accountID" bun:",nullzero"`
EncryptedPassword string `json:"encryptedPassword" bun:",nullzero"`
- CurrentSignInAt *time.Time `json:"currentSignInAt,omitempty" bun:",nullzero"`
- LastSignInAt *time.Time `json:"lastSignInAt,omitempty" bun:",nullzero"`
+ Reason string `json:"reason" bun:",nullzero"`
InviteID string `json:"inviteID,omitempty" bun:",nullzero"`
- ChosenLanguages []string `json:"chosenLanguages,omitempty" bun:",nullzero"`
- FilteredLanguages []string `json:"filteredLanguage,omitempty" bun:",nullzero"`
Locale string `json:"locale" bun:",nullzero"`
LastEmailedAt time.Time `json:"lastEmailedAt,omitempty" bun:",nullzero"`
ConfirmationToken string `json:"confirmationToken,omitempty" bun:",nullzero"`
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index daa3568a7..e3786a9ae 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -414,13 +414,13 @@ func (c *Converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac
email = user.UnconfirmedEmail
}
- if i := user.CurrentSignInIP.String(); i != "<nil>" {
+ if i := user.SignUpIP.String(); i != "<nil>" {
ip = &i
}
locale = user.Locale
- if a.Settings.Reason != "" {
- inviteRequest = &a.Settings.Reason
+ if user.Reason != "" {
+ inviteRequest = &user.Reason
}
if *user.Admin {
@@ -1003,7 +1003,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
Version: config.GetSoftwareVersion(),
Languages: config.GetInstanceLanguages().TagStrs(),
Registrations: config.GetAccountsRegistrationOpen(),
- ApprovalRequired: config.GetAccountsApprovalRequired(),
+ ApprovalRequired: true, // approval always required
InvitesEnabled: false, // todo: not supported yet
MaxTootChars: uint(config.GetStatusesMaxChars()),
Rules: c.InstanceRulesToAPIRules(i.Rules),
@@ -1172,8 +1172,8 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
// registrations
instance.Registrations.Enabled = config.GetAccountsRegistrationOpen()
- instance.Registrations.ApprovalRequired = config.GetAccountsApprovalRequired()
- instance.Registrations.Message = nil // todo: not implemented
+ instance.Registrations.ApprovalRequired = true // always required
+ instance.Registrations.Message = nil // todo: not implemented
// contact
instance.Contact.Email = i.ContactEmail
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index 329d66425..77ea80fcc 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -1386,7 +1386,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend1() {
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
- "ip": "118.44.18.196",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -1443,7 +1443,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend1() {
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
- "ip": "89.122.255.1",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -1489,7 +1489,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend1() {
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
- "ip": "89.122.255.1",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -1558,7 +1558,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() {
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
- "ip": "118.44.18.196",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -1880,7 +1880,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontendSuspendedLoca
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
- "ip": "89.122.255.1",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
@@ -1926,7 +1926,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontendSuspendedLoca
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
- "ip": "89.122.255.1",
+ "ip": null,
"ips": [],
"locale": "en",
"invite_request": null,
diff --git a/internal/validate/formvalidation.go b/internal/validate/formvalidation.go
index b0332a572..3c16dd86e 100644
--- a/internal/validate/formvalidation.go
+++ b/internal/validate/formvalidation.go
@@ -348,3 +348,42 @@ func FilterContexts(contexts []apimodel.FilterContext) error {
}
return nil
}
+
+// CreateAccount checks through all the prerequisites for
+// creating a new account, according to the provided form.
+// If the account isn't eligible, an error will be returned.
+//
+// Side effect: normalizes the provided language tag for the user's locale.
+func CreateAccount(form *apimodel.AccountCreateRequest) error {
+ if form == nil {
+ return errors.New("form was nil")
+ }
+
+ if !config.GetAccountsRegistrationOpen() {
+ return errors.New("registration is not open for this server")
+ }
+
+ if err := Username(form.Username); err != nil {
+ return err
+ }
+
+ if err := Email(form.Email); err != nil {
+ return err
+ }
+
+ if err := Password(form.Password); err != nil {
+ return err
+ }
+
+ if !form.Agreement {
+ return errors.New("agreement to terms and conditions not given")
+ }
+
+ locale, err := Language(form.Locale)
+ if err != nil {
+ return err
+ }
+ form.Locale = locale
+
+ return SignUpReason(form.Reason, config.GetAccountsReasonRequired())
+}
diff --git a/internal/web/confirmemail.go b/internal/web/confirmemail.go
index f15252bf7..e512761f4 100644
--- a/internal/web/confirmemail.go
+++ b/internal/web/confirmemail.go
@@ -56,18 +56,84 @@ func (m *Module) confirmEmailGETHandler(c *gin.Context) {
return
}
+ // Get user but don't confirm yet.
+ user, errWithCode := m.processor.User().EmailGetUserForConfirmToken(c.Request.Context(), token)
+ if errWithCode != nil {
+ apiutil.WebErrorHandler(c, errWithCode, instanceGet)
+ return
+ }
+
+ // They may have already confirmed before
+ // and are visiting the link again for
+ // whatever reason. This is fine, just make
+ // sure we have an email address to show them.
+ email := user.UnconfirmedEmail
+ if email == "" {
+ // Already confirmed, take
+ // that address instead.
+ email = user.Email
+ }
+
+ // Serve page where user can click button
+ // to POST confirmation to same endpoint.
+ page := apiutil.WebPage{
+ Template: "confirm_email.tmpl",
+ Instance: instance,
+ Extra: map[string]any{
+ "email": email,
+ "username": user.Account.Username,
+ "token": token,
+ },
+ }
+
+ apiutil.TemplateWebPage(c, page)
+}
+
+func (m *Module) confirmEmailPOSTHandler(c *gin.Context) {
+ instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
+ if errWithCode != nil {
+ apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ // Return instance we already got from the db,
+ // don't try to fetch it again when erroring.
+ instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
+ return instance, nil
+ }
+
+ // We only serve text/html at this endpoint.
+ if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
+ apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
+ return
+ }
+
+ // If there's no token in the query,
+ // just serve the 404 web handler.
+ token := c.Query("token")
+ if token == "" {
+ errWithCode := gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound)))
+ apiutil.WebErrorHandler(c, errWithCode, instanceGet)
+ return
+ }
+
+ // Confirm email address for real this time.
user, errWithCode := m.processor.User().EmailConfirm(c.Request.Context(), token)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
+ // Serve page informing user that their
+ // email address is now confirmed.
page := apiutil.WebPage{
- Template: "confirmed.tmpl",
+ Template: "confirmed_email.tmpl",
Instance: instance,
Extra: map[string]any{
"email": user.Email,
"username": user.Account.Username,
+ "token": token,
+ "approved": *user.Approved,
},
}
diff --git a/internal/web/robots.go b/internal/web/robots.go
index cfd1758a4..2511ee1d3 100644
--- a/internal/web/robots.go
+++ b/internal/web/robots.go
@@ -82,6 +82,7 @@ Disallow: /oauth/
Disallow: /check_your_email
Disallow: /wait_for_approval
Disallow: /account_disabled
+Disallow: /signup
# Well-known endpoints.
Disallow: /.well-known/
diff --git a/internal/web/signup.go b/internal/web/signup.go
new file mode 100644
index 000000000..691469dff
--- /dev/null
+++ b/internal/web/signup.go
@@ -0,0 +1,138 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package web
+
+import (
+ "context"
+ "errors"
+ "net"
+
+ "github.com/gin-gonic/gin"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/validate"
+)
+
+func (m *Module) signupGETHandler(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ // We'll need the instance later, and we can also use it
+ // before then to make it easier to return a web error.
+ instance, errWithCode := m.processor.InstanceGetV1(ctx)
+ if errWithCode != nil {
+ apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ // Return instance we already got from the db,
+ // don't try to fetch it again when erroring.
+ instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
+ return instance, nil
+ }
+
+ // We only serve text/html at this endpoint.
+ if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
+ apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
+ return
+ }
+
+ page := apiutil.WebPage{
+ Template: "sign-up.tmpl",
+ Instance: instance,
+ OGMeta: apiutil.OGBase(instance),
+ Extra: map[string]any{
+ "reasonRequired": config.GetAccountsReasonRequired(),
+ },
+ }
+
+ apiutil.TemplateWebPage(c, page)
+}
+
+func (m *Module) signupPOSTHandler(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ // We'll need the instance later, and we can also use it
+ // before then to make it easier to return a web error.
+ instance, errWithCode := m.processor.InstanceGetV1(ctx)
+ if errWithCode != nil {
+ apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ // Return instance we already got from the db,
+ // don't try to fetch it again when erroring.
+ instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
+ return instance, nil
+ }
+
+ // We only serve text/html at this endpoint.
+ if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
+ apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
+ return
+ }
+
+ form := &apimodel.AccountCreateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), instanceGet)
+ return
+ }
+
+ if err := validate.CreateAccount(form); err != nil {
+ apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), instanceGet)
+ return
+ }
+
+ clientIP := c.ClientIP()
+ signUpIP := net.ParseIP(clientIP)
+ if signUpIP == nil {
+ err := errors.New("ip address could not be parsed from request")
+ apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), instanceGet)
+ return
+ }
+ form.IP = signUpIP
+
+ // We have all the info we need, call account create
+ // (this will also trigger side effects like sending emails etc).
+ user, errWithCode := m.processor.Account().Create(
+ c.Request.Context(),
+ // nil to use
+ // instance app.
+ nil,
+ form,
+ )
+ if errWithCode != nil {
+ apiutil.WebErrorHandler(c, errWithCode, instanceGet)
+ return
+ }
+
+ // Serve a page informing the
+ // user that they've signed up.
+ page := apiutil.WebPage{
+ Template: "signed-up.tmpl",
+ Instance: instance,
+ OGMeta: apiutil.OGBase(instance),
+ Extra: map[string]any{
+ "email": user.UnconfirmedEmail,
+ "username": user.Account.Username,
+ },
+ }
+
+ apiutil.TemplateWebPage(c, page)
+}
diff --git a/internal/web/web.go b/internal/web/web.go
index 19df63332..185bf7120 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -49,6 +49,7 @@ const (
settingsPanelGlob = settingsPathPrefix + "/*panel"
userPanelPath = settingsPathPrefix + "/user"
adminPanelPath = settingsPathPrefix + "/admin"
+ signupPath = "/signup"
cacheControlHeader = "Cache-Control" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
cacheControlNoCache = "no-cache" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives
@@ -115,10 +116,13 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler)
r.AttachHandler(http.MethodGet, rssFeedPath, m.rssFeedGETHandler)
r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler)
+ r.AttachHandler(http.MethodPost, confirmEmailPath, m.confirmEmailPOSTHandler)
r.AttachHandler(http.MethodGet, robotsPath, m.robotsGETHandler)
r.AttachHandler(http.MethodGet, aboutPath, m.aboutGETHandler)
r.AttachHandler(http.MethodGet, domainBlockListPath, m.domainBlockListGETHandler)
r.AttachHandler(http.MethodGet, tagsPath, m.tagGETHandler)
+ r.AttachHandler(http.MethodGet, signupPath, m.signupGETHandler)
+ r.AttachHandler(http.MethodPost, signupPath, m.signupPOSTHandler)
// Attach redirects from old endpoints to current ones for backwards compatibility
r.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })