From 9fb8a78f91adffd5f4d28df1270e407c25a7a16e Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Thu, 11 Apr 2024 11:45:53 +0200 Subject: [PATCH] [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 --- .../action/admin/account/account.go | 6 +- cmd/gotosocial/action/server/server.go | 4 + docs/api/swagger.yaml | 21 +-- docs/configuration/accounts.md | 5 - example/config.yaml | 5 - internal/api/client/accounts/accountcreate.go | 65 ++++----- internal/api/client/admin/reportsget_test.go | 12 +- internal/api/model/admin.go | 4 +- internal/api/model/notification.go | 15 +- internal/cache/size.go | 14 +- internal/config/config.go | 1 - internal/config/defaults.go | 1 - internal/config/flags.go | 1 - internal/config/helpers.gen.go | 25 ---- internal/db/account.go | 3 + internal/db/admin.go | 20 ++- internal/db/bundb/admin.go | 110 +++++++++++++- internal/db/bundb/instance.go | 30 ++++ .../20240318115336_account_settings.go | 2 +- .../accountsettings.go | 38 +++++ .../migrations/20240401130338_sign_up.go | 124 ++++++++++++++++ internal/db/bundb/notification_test.go | 2 +- internal/db/instance.go | 4 + internal/email/email_test.go | 2 +- internal/email/noopsender.go | 4 + internal/email/sender.go | 7 + internal/email/signup.go | 42 ++++++ internal/gtsmodel/accountsettings.go | 1 - internal/gtsmodel/notification.go | 1 + internal/gtsmodel/user.go | 47 ++++-- internal/processing/account/create.go | 99 ++++++++++--- internal/processing/account/delete.go | 10 -- internal/processing/account/delete_test.go | 5 - internal/processing/timeline/notification.go | 70 ++++++--- internal/processing/user/email.go | 65 ++++++--- internal/processing/user/email_test.go | 2 +- internal/processing/workers/fromclientapi.go | 23 +-- internal/processing/workers/fromfediapi.go | 2 +- internal/processing/workers/surfaceemail.go | 118 ++++++++++----- internal/processing/workers/surfacenotify.go | 41 +++++- internal/trans/model/user.go | 5 +- internal/typeutils/internaltofrontend.go | 12 +- internal/typeutils/internaltofrontend_test.go | 12 +- internal/validate/formvalidation.go | 39 +++++ internal/web/confirmemail.go | 68 ++++++++- internal/web/robots.go | 1 + internal/web/signup.go | 138 ++++++++++++++++++ internal/web/web.go | 4 + test/envparsing.sh | 2 - testrig/config.go | 1 - testrig/email.go | 5 + testrig/testmodels.go | 64 ++++---- web/source/css/base.css | 97 ++++++------ web/template/about.tmpl | 36 ++--- web/template/authorize.tmpl | 37 ++--- web/template/confirm_email.tmpl | 33 +++++ .../{confirmed.tmpl => confirmed_email.tmpl} | 9 +- web/template/email_confirm.tmpl | 12 +- web/template/email_new_signup.tmpl | 32 ++++ web/template/finalize.tmpl | 54 ++++--- web/template/index.tmpl | 1 + web/template/index_apps.tmpl | 3 +- web/template/index_register.tmpl | 41 ++++++ web/template/index_what_is_this.tmpl | 5 +- web/template/oob.tmpl | 2 +- web/template/sign-in.tmpl | 6 +- web/template/sign-up.tmpl | 93 ++++++++++++ web/template/signed-up.tmpl | 30 ++++ 68 files changed, 1456 insertions(+), 437 deletions(-) create mode 100644 internal/db/bundb/migrations/20240318115336_account_settings/accountsettings.go create mode 100644 internal/db/bundb/migrations/20240401130338_sign_up.go create mode 100644 internal/email/signup.go create mode 100644 internal/web/signup.go create mode 100644 web/template/confirm_email.tmpl rename web/template/{confirmed.tmpl => confirmed_email.tmpl} (71%) create mode 100644 web/template/email_new_signup.tmpl create mode 100644 web/template/index_register.tmpl create mode 100644 web/template/sign-up.tmpl create mode 100644 web/template/signed-up.tmpl diff --git a/cmd/gotosocial/action/admin/account/account.go b/cmd/gotosocial/action/admin/account/account.go index e2fd82642..42b324107 100644 --- a/cmd/gotosocial/action/admin/account/account.go +++ b/cmd/gotosocial/action/admin/account/account.go @@ -186,9 +186,13 @@ var Confirm action.GTSAction = func(ctx context.Context) error { user.Approved = func() *bool { a := true; return &a }() user.Email = user.UnconfirmedEmail user.ConfirmedAt = time.Now() + user.SignUpIP = nil return state.DB.UpdateUser( ctx, user, - "approved", "email", "confirmed_at", + "approved", + "email", + "confirmed_at", + "sign_up_ip", ) } diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go index fab88fe21..5aaccd1c4 100644 --- a/cmd/gotosocial/action/server/server.go +++ b/cmd/gotosocial/action/server/server.go @@ -100,6 +100,10 @@ var Start action.GTSAction = func(ctx context.Context) error { return fmt.Errorf("error creating instance instance: %s", err) } + if err := dbService.CreateInstanceApplication(ctx); err != nil { + return fmt.Errorf("error creating instance application: %s", err) + } + // Get the instance account // (we'll need this later). instanceAccount, err := dbService.GetInstanceAccount(ctx, "") diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index b5eca23c6..2215bddc6 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -439,8 +439,8 @@ definitions: x-go-name: ID invite_request: description: |- - 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!! type: string x-go-name: InviteRequest @@ -1842,13 +1842,14 @@ definitions: type: description: |- 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 x-go-name: Type title: Notification represents a notification of an event relevant to the user. @@ -2773,6 +2774,8 @@ paths: 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 security: diff --git a/docs/configuration/accounts.md b/docs/configuration/accounts.md index 0f4fecde0..4826cfb7a 100644 --- a/docs/configuration/accounts.md +++ b/docs/configuration/accounts.md @@ -14,11 +14,6 @@ # Default: true accounts-registration-open: true -# Bool. Do sign up requests require approval from an admin/moderator before an account can sign in/use the server? -# Options: [true, false] -# Default: true -accounts-approval-required: true - # Bool. Are sign up requests required to submit a reason for the request (eg., an explanation of why they want to join the instance)? # Options: [true, false] # Default: true diff --git a/example/config.yaml b/example/config.yaml index 28518bf14..1127b6d46 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -411,11 +411,6 @@ instance-inject-mastodon-version: false # Default: true accounts-registration-open: true -# Bool. Do sign up requests require approval from an admin/moderator before an account can sign in/use the server? -# Options: [true, false] -# Default: true -accounts-approval-required: true - # Bool. Are sign up requests required to submit a reason for the request (eg., an explanation of why they want to join the instance)? # Options: [true, false] # Default: true 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,7 +105,25 @@ 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 + } + + // 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 @@ -109,40 +131,3 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { 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 - } - form.Locale = locale - - return validate.SignUpReason(form.Reason, config.GetAccountsReasonRequired()) -} 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 := >smodel.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 := >smodel.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 := >smodel.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 . + +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 . + +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(>smodel.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 . + +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()) } - if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { - // no pending email confirmations so just return OK + 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 { + // 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) } - // 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) + // 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) } - if err := p.surface.emailPleaseConfirm(ctx, user, account.Username); err != nil { + // 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) + } + + // 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 != "" { + if i := user.SignUpIP.String(); i != "" { 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 . + +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) }) diff --git a/test/envparsing.sh b/test/envparsing.sh index ed136064d..19b86a818 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -6,7 +6,6 @@ EXPECT=$(cat << "EOF" { "account-domain": "peepee", "accounts-allow-custom-css": true, - "accounts-approval-required": false, "accounts-custom-css-length": 5000, "accounts-reason-required": false, "accounts-registration-open": true, @@ -224,7 +223,6 @@ GTS_INSTANCE_LANGUAGES="nl,en-gb" \ GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \ GTS_ACCOUNTS_CUSTOM_CSS_LENGTH=5000 \ GTS_ACCOUNTS_REGISTRATION_OPEN=true \ -GTS_ACCOUNTS_APPROVAL_REQUIRED=false \ GTS_ACCOUNTS_REASON_REQUIRED=false \ GTS_MEDIA_IMAGE_MAX_SIZE=420 \ GTS_MEDIA_VIDEO_MAX_SIZE=420 \ diff --git a/testrig/config.go b/testrig/config.go index d33c4ac8c..8b69b714c 100644 --- a/testrig/config.go +++ b/testrig/config.go @@ -89,7 +89,6 @@ var testDefaults = config.Configuration{ }, AccountsRegistrationOpen: true, - AccountsApprovalRequired: true, AccountsReasonRequired: true, AccountsAllowCustomCSS: true, AccountsCustomCSSLength: 10000, diff --git a/testrig/email.go b/testrig/email.go index 1107dfd26..a80054f30 100644 --- a/testrig/email.go +++ b/testrig/email.go @@ -20,6 +20,7 @@ package testrig import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/log" ) // NewEmailSender returns a noop email sender that won't make any remote calls. @@ -38,6 +39,10 @@ func NewEmailSender(templateBaseDir string, sentEmails map[string]string) email. sendCallback = func(toAddress string, message string) { sentEmails[toAddress] = message } + } else { + sendCallback = func(toAddress string, message string) { + log.Infof(nil, "Sent email to %s: %s", toAddress, message) + } } s, err := email.NewNoopSender(sendCallback) diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 50b15b04e..9ebd400e4 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -100,6 +100,12 @@ func NewTestTokens() map[string]*gtsmodel.Token { // NewTestClients returns a map of Clients keyed according to which account they are used by. func NewTestClients() map[string]*gtsmodel.Client { clients := map[string]*gtsmodel.Client{ + "instance_application": { + ID: "01AY6P665V14JJR0AFVRT7311Y", + Secret: "baedee87-6d00-4cf5-87b9-4d78ee58ef01", + Domain: "http://localhost:8080", + UserID: "", + }, "admin_account": { ID: "01F8MGWSJCND9BWBD4WGJXBM93", Secret: "dda8e835-2c9c-4bd2-9b8b-77c2e26d7a7a", @@ -125,6 +131,15 @@ func NewTestClients() map[string]*gtsmodel.Client { // NewTestApplications returns a map of applications keyed to which number application they are. func NewTestApplications() map[string]*gtsmodel.Application { apps := map[string]*gtsmodel.Application{ + "instance_application": { + ID: "01HT5P2YHDMPAAD500NDAY8JW1", + Name: "localhost:8080 instance application", + Website: "http://localhost:8080", + RedirectURI: "http://localhost:8080", + ClientID: "01AY6P665V14JJR0AFVRT7311Y", // instance account ID + ClientSecret: "baedee87-6d00-4cf5-87b9-4d78ee58ef01", + Scopes: "write:accounts", + }, "admin_account": { ID: "01F8MGXQRHYF5QPMTMXP78QC2F", Name: "superseriousbusiness", @@ -167,14 +182,8 @@ func NewTestUsers() map[string]*gtsmodel.User { CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), SignUpIP: net.ParseIP("199.222.111.89"), UpdatedAt: time.Time{}, - CurrentSignInAt: time.Time{}, - CurrentSignInIP: nil, - LastSignInAt: time.Time{}, - LastSignInIP: nil, - SignInCount: 0, InviteID: "", - ChosenLanguages: []string{}, - FilteredLanguages: []string{}, + Reason: "hi, please let me in! I'm looking for somewhere neato bombeato to hang out.", Locale: "en", CreatedByApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG", LastEmailedAt: time.Time{}, @@ -195,16 +204,9 @@ func NewTestUsers() map[string]*gtsmodel.User { AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password' CreatedAt: TimeMustParse("2022-06-01T13:12:00Z"), - SignUpIP: net.ParseIP("89.22.189.19"), + SignUpIP: nil, UpdatedAt: TimeMustParse("2022-06-01T13:12:00Z"), - CurrentSignInAt: TimeMustParse("2022-06-04T13:12:00Z"), - CurrentSignInIP: net.ParseIP("89.122.255.1"), - LastSignInAt: TimeMustParse("2022-06-03T13:12:00Z"), - LastSignInIP: net.ParseIP("89.122.255.1"), - SignInCount: 78, InviteID: "", - ChosenLanguages: []string{"en"}, - FilteredLanguages: []string{}, Locale: "en", CreatedByApplicationID: "01F8MGXQRHYF5QPMTMXP78QC2F", LastEmailedAt: TimeMustParse("2022-06-03T13:12:00Z"), @@ -225,16 +227,10 @@ func NewTestUsers() map[string]*gtsmodel.User { AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password' CreatedAt: TimeMustParse("2022-06-01T13:12:00Z"), - SignUpIP: net.ParseIP("59.99.19.172"), + SignUpIP: nil, UpdatedAt: TimeMustParse("2022-06-01T13:12:00Z"), - CurrentSignInAt: TimeMustParse("2022-06-04T13:12:00Z"), - CurrentSignInIP: net.ParseIP("88.234.118.16"), - LastSignInAt: TimeMustParse("2022-06-03T13:12:00Z"), - LastSignInIP: net.ParseIP("147.111.231.154"), - SignInCount: 9, InviteID: "", - ChosenLanguages: []string{"en"}, - FilteredLanguages: []string{}, + Reason: "I wanna be on this damned webbed site so bad! Please! Wow", Locale: "en", CreatedByApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG", LastEmailedAt: TimeMustParse("2022-06-02T13:12:00Z"), @@ -255,16 +251,9 @@ func NewTestUsers() map[string]*gtsmodel.User { AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password' CreatedAt: TimeMustParse("2022-05-23T13:12:00Z"), - SignUpIP: net.ParseIP("59.99.19.172"), + SignUpIP: nil, UpdatedAt: TimeMustParse("2022-05-23T13:12:00Z"), - CurrentSignInAt: TimeMustParse("2022-06-05T13:12:00Z"), - CurrentSignInIP: net.ParseIP("118.44.18.196"), - LastSignInAt: TimeMustParse("2022-06-06T13:12:00Z"), - LastSignInIP: net.ParseIP("198.98.21.15"), - SignInCount: 9, InviteID: "", - ChosenLanguages: []string{"en"}, - FilteredLanguages: []string{}, Locale: "en", CreatedByApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG", LastEmailedAt: TimeMustParse("2022-06-06T13:12:00Z"), @@ -664,7 +653,6 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { AccountID: "01F8MH0BBE4FHXPH513MBVFHB0", CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), - Reason: "hi, please let me in! I'm looking for somewhere neato bombeato to hang out.", Privacy: gtsmodel.VisibilityPublic, Sensitive: util.Ptr(false), Language: "en", @@ -675,7 +663,6 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", CreatedAt: TimeMustParse("2022-05-17T13:10:59Z"), UpdatedAt: TimeMustParse("2022-05-17T13:10:59Z"), - Reason: "", Privacy: gtsmodel.VisibilityPublic, Sensitive: util.Ptr(false), Language: "en", @@ -686,7 +673,6 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", CreatedAt: TimeMustParse("2022-05-20T11:09:18Z"), UpdatedAt: TimeMustParse("2022-05-20T11:09:18Z"), - Reason: "I wanna be on this damned webbed site so bad! Please! Wow", Privacy: gtsmodel.VisibilityPublic, Sensitive: util.Ptr(false), Language: "en", @@ -697,7 +683,6 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings { AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), - Reason: "", Privacy: gtsmodel.VisibilityFollowersOnly, Sensitive: util.Ptr(true), Language: "fr", @@ -2428,6 +2413,15 @@ func NewTestNotifications() map[string]*gtsmodel.Notification { StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R", Read: util.Ptr(false), }, + "new_signup": { + ID: "01HTM9TETMB3YQCBKZ7KD4KV02", + NotificationType: gtsmodel.NotificationSignup, + CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), + TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF", + OriginAccountID: "01F8MH0BBE4FHXPH513MBVFHB0", + StatusID: "", + Read: util.Ptr(false), + }, } } diff --git a/web/source/css/base.css b/web/source/css/base.css index 54754ff8c..ae9724661 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -407,6 +407,57 @@ pre, pre[class*="language-"] { } } +/* + Forms and sign-in / sign-up / confirm pages. +*/ +section.with-form { + form { + display: flex; + flex-direction: column; + gap: 1rem; + + padding-bottom: 1rem; + padding-top: 1rem; + + p { + /* + We use gap so we don't + need top + bottom margins. + */ + margin-top: 0; + margin-bottom: 0; + } + + label, input { + padding-left: 0.2rem; + } + + .labelinput { + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + .checkbox { + display: flex; + flex-direction: row-reverse; + gap: 0.4rem; + + & > input { + height: 100%; + width: 5%; + min-width: 1.2rem; + align-self: center; + } + } + + .btn { + /* Visually separate buttons a bit */ + margin-top: 1rem; + } + } +} + /*********************************** ***** SECTION 4: SHAMEFUL MESS ***** ************************************/ @@ -419,33 +470,8 @@ pre, pre[class*="language-"] { /* Below section stylings are used - in transient/error templates. + in transient pages + error templates. */ -section.sign-in { - form { - display: flex; - flex-direction: column; - gap: 1rem; - - - padding-bottom: 1rem; - padding-top: 1rem; - - label, input { - padding-left: 0.2rem; - } - - .labelinput { - display: flex; - flex-direction: column; - gap: 0.4rem; - } - - .btn { - margin-top: 1rem; - } - } -} section.error { word-break: break-word; @@ -470,25 +496,6 @@ section.oob-token { } } -/* - TODO: This is only used in the "finalize" - template for new signups; move this elsewhere - when that stuff is finished up. -*/ -.callout { - margin: 1.5rem 0; - border: .05rem solid $border-accent; - border-radius: .2rem; - padding: 0 .6rem .6rem; - .callout-title { - margin: 0 -.6rem; - padding: .6rem; - font-weight: bold; - background-color: $border-accent; - color: $gray1; - } -} - /* TODO: list and blocklist are only used in settings panel and on blocklist page; diff --git a/web/template/about.tmpl b/web/template/about.tmpl index 04b0b095f..d8a540d19 100644 --- a/web/template/about.tmpl +++ b/web/template/about.tmpl @@ -59,37 +59,26 @@ {{- end }} {{- end -}} -{{- define "registrationLimits" -}} -{{- if .instance.Registrations -}} - Registration is enabled; new signups can be submitted to this instance.
- {{- if .instance.ApprovalRequired -}} - Admin approval is required for new registrations. - {{- else -}} - Admin approval is not required for registrations; new signups will be automatically approved (pending email confirmation). - {{- end -}} -{{- else -}} - Registration is disabled; new signups are currently closed for this instance. -{{- end -}} -{{- end -}} - {{- define "customCSSLimits" -}} +Custom CSS is  {{- if .instance.Configuration.Accounts.AllowCustomCSS -}} -Users are allowed to set Custom CSS for their profiles. +enabled {{- else -}} -Custom CSS is not enabled for user profiles. +disabled {{- end -}} + on account profiles. {{- end -}} {{- define "statusLimits" -}} -Statuses can contain up to  -{{- .instance.Configuration.Statuses.MaxCharacters }} characters, and  -{{- .instance.Configuration.Statuses.MaxMediaAttachments }} media attachments. +Statuses can contain up to +{{- .instance.Configuration.Statuses.MaxCharacters }} characters, and +{{- .instance.Configuration.Statuses.MaxMediaAttachments }} media attachments. {{- end -}} {{- define "pollLimits" -}} -Polls can have up to  -{{- .instance.Configuration.Polls.MaxOptions }} options, with  -{{- .instance.Configuration.Polls.MaxCharactersPerOption }} characters per option. +Polls can have up to +{{- .instance.Configuration.Polls.MaxOptions }} options, with +{{- .instance.Configuration.Polls.MaxCharactersPerOption }} characters per option. {{- end -}} {{- with . }} @@ -102,6 +91,7 @@ Polls can have up to 
  • Contact
  • Features
  • Languages
  • +
  • Register an Account on {{ .instance.Title -}}
  • Rules
  • Terms and Conditions
  • Moderated Servers
  • @@ -145,10 +135,9 @@ Polls can have up to 

    Instance Features

      -
    • {{- template "registrationLimits" . -}}
    • -
    • {{- template "customCSSLimits" . -}}
    • {{- template "statusLimits" . -}}
    • {{- template "pollLimits" . -}}
    • +
    • {{- template "customCSSLimits" . -}}
    @@ -160,6 +149,7 @@ Polls can have up to  {{- end }} + {{- include "index_register.tmpl" . | indent 1 }}

    Instance Rules

    diff --git a/web/template/authorize.tmpl b/web/template/authorize.tmpl index 9be094137..2767c4efb 100644 --- a/web/template/authorize.tmpl +++ b/web/template/authorize.tmpl @@ -19,22 +19,25 @@ {{- with . }}
    -
    -

    Hi {{ .user -}}!

    -

    - Application - {{- if .appwebsite }} - {{- .appname -}} - {{- else }} - {{- .appname -}} - {{- end }} - would like to perform actions on your behalf, with scope - {{- .scope -}}. -

    -

    - To continue, the application will redirect to: {{- .redirect -}} -

    - -
    +
    +

    Authorize app

    +
    +

    Hi {{- .user -}}!

    +

    + Application + {{- if .appwebsite }} + {{- .appname -}} + {{- else }} + {{- .appname -}} + {{- end }} + would like to perform actions on your behalf, with scope + {{- .scope -}}. +

    +

    + To continue, the application will redirect to: {{- .redirect -}} +

    + +
    +
    {{- end }} \ No newline at end of file diff --git a/web/template/confirm_email.tmpl b/web/template/confirm_email.tmpl new file mode 100644 index 000000000..d1932acd9 --- /dev/null +++ b/web/template/confirm_email.tmpl @@ -0,0 +1,33 @@ +{{- /* +// 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 . +*/ -}} + +{{- with . }} +
    +
    +

    Confirm email address

    +
    +

    + Hi {{- .username -}}! + Please click the button to confirm your email address {{- .email -}}. +

    + +
    +
    +
    +{{- end }} \ No newline at end of file diff --git a/web/template/confirmed.tmpl b/web/template/confirmed_email.tmpl similarity index 71% rename from web/template/confirmed.tmpl rename to web/template/confirmed_email.tmpl index c1633a8fb..2dc605e4b 100644 --- a/web/template/confirmed.tmpl +++ b/web/template/confirmed_email.tmpl @@ -19,9 +19,12 @@ {{- with . }}
    -
    -

    Email Address Confirmed

    -

    Thanks {{ .username -}}! Your email address {{- .email -}} has been confirmed.

    +

    +

    Email address confirmed

    +

    Email address {{- .email -}} is now confirmed!

    + {{- if not .approved }} +

    Once an admin has approved your sign-up, you will be able to log in and use your account.

    + {{- end }}
    {{- end }} \ No newline at end of file diff --git a/web/template/email_confirm.tmpl b/web/template/email_confirm.tmpl index 17926fdde..7963cf631 100644 --- a/web/template/email_confirm.tmpl +++ b/web/template/email_confirm.tmpl @@ -17,12 +17,14 @@ // along with this program. If not, see . */ -}} -Hello {{.Username}}! +Hello {{ .Username -}}! -You are receiving this mail because you've requested an account on {{.InstanceURL}}. +You are receiving this mail because you've requested an account on {{ .InstanceURL -}}. -We just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar: +To use your account, you must confirm that this is your email address. -{{.ConfirmLink}} +To confirm your email, paste the following in your browser's address bar: -If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceURL}} +{{ .ConfirmLink }} + +If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{ .InstanceURL -}}. diff --git a/web/template/email_new_signup.tmpl b/web/template/email_new_signup.tmpl new file mode 100644 index 000000000..b01d577d6 --- /dev/null +++ b/web/template/email_new_signup.tmpl @@ -0,0 +1,32 @@ +{{- /* +// 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 . +*/ -}} + +Hello moderator of {{ .InstanceName }} ({{ .InstanceURL }})! + +Someone has submitted a new account sign-up to your instance. + +They provided the following details: + +Email address: {{ .SignupEmail }} +Username: {{ .SignupUsername }} +{{- if .SignupReason }} +Reason: {{ .SignupReason }} +{{- end }} + +To view the sign-up, paste the following link into your browser: {{ .SignupURL }} diff --git a/web/template/finalize.tmpl b/web/template/finalize.tmpl index 56ab677e5..861dc635f 100644 --- a/web/template/finalize.tmpl +++ b/web/template/finalize.tmpl @@ -19,29 +19,35 @@ {{- with . }}
    -
    -

    Hi {{ .name -}}!

    -

    - You are about to sign-up to {{ .instance.Title -}}. - To ensure the best experience for you, we need you to provide some additional details. -

    -
    -

    Important

    -

    Due to the way the ActivityPub standard works, you cannot change your username after it has been set.

    -
    -
    - - -
    - - -
    +
    +

    Finalize sign-in to {{ .instance.Title -}}

    +
    +

    + Hi {{- .name -}}! +

    +

    + You are about to create an account on {{- .instance.Title -}}. + To finish the process, you must select your username. +

    +
    + + +
    + + +
    +
    {{- end }} \ No newline at end of file diff --git a/web/template/index.tmpl b/web/template/index.tmpl index 80245fe5a..358bc081e 100644 --- a/web/template/index.tmpl +++ b/web/template/index.tmpl @@ -35,6 +35,7 @@
    {{- include "index_what_is_this.tmpl" . | indent 1 }} + {{- include "index_register.tmpl" . | indent 1 }} {{- include "index_apps.tmpl" . | indent 1 }} {{- end }} \ No newline at end of file diff --git a/web/template/index_apps.tmpl b/web/template/index_apps.tmpl index 8f1e434e0..19a474692 100644 --- a/web/template/index_apps.tmpl +++ b/web/template/index_apps.tmpl @@ -22,8 +22,9 @@

    Client applications

    + Have an account on this instance and want to log in? GoToSocial does not provide its own webclient, but implements the Mastodon client API. - You can use this server through a variety of other clients: + You can use a variety of clients to log in to your account here:

    • diff --git a/web/template/index_register.tmpl b/web/template/index_register.tmpl new file mode 100644 index 000000000..38a00f47b --- /dev/null +++ b/web/template/index_register.tmpl @@ -0,0 +1,41 @@ +{{- /* +// 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 . +*/ -}} + +{{- define "registrationLimits" -}} +New account registration is currently  +{{- if .instance.Registrations -}} + open. +{{- else -}} + closed. +{{- end -}} +{{- end -}} + +{{- with . }} +
      +

      Register an Account on {{ .instance.Title -}}

      +
      +

      {{- template "registrationLimits" . -}}

      + {{- if .instance.Registrations }} +

      To register a new account, please first read the rules and terms.

      +

      Then, use the sign-up page to register an account.

      +

      Manual admin approval is required for new accounts.

      + {{- end }} +
      +
      +{{- end }} \ No newline at end of file diff --git a/web/template/index_what_is_this.tmpl b/web/template/index_what_is_this.tmpl index ff6eb4886..687e33cff 100644 --- a/web/template/index_what_is_this.tmpl +++ b/web/template/index_what_is_this.tmpl @@ -44,7 +44,7 @@

      You can join the fediverse by running your own instance of an ActivityPub software, or by finding an existing instance that aligns with your values and expectations, - and registering an account there. + and registering an account.

      To help you find an instance that suits you, you can try one of the following tools: @@ -53,6 +53,9 @@

    • Fediverse Observer (opens in a new tab)
    • FediDB (opens in a new tab)
    + {{- if .instance.Registrations }} +

    Or, just register for an account on this instance!

    + {{- end }}
    {{- end }} \ No newline at end of file diff --git a/web/template/oob.tmpl b/web/template/oob.tmpl index ff36582e7..05a639ec0 100644 --- a/web/template/oob.tmpl +++ b/web/template/oob.tmpl @@ -20,7 +20,7 @@ {{- with . }}
    -

    Hi {{ .user -}}!

    +

    Hi {{- .user -}}!

    Here's your out-of-band token with scope "{{- .scope -}}", use it wisely:

    {{- .oobToken -}}
    diff --git a/web/template/sign-in.tmpl b/web/template/sign-in.tmpl index 916d6942f..6362359cb 100644 --- a/web/template/sign-in.tmpl +++ b/web/template/sign-in.tmpl @@ -19,16 +19,16 @@ {{- with . }}
    -