[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
This commit is contained in:
tobi 2024-04-11 11:45:53 +02:00 committed by GitHub
commit 9fb8a78f91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 1456 additions and 437 deletions

View file

@ -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())
}

View file

@ -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,

View file

@ -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.

View file

@ -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"`

View file

@ -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,

View file

@ -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."`

View file

@ -67,7 +67,6 @@ var Defaults = Configuration{
InstanceLanguages: make(language.Languages, 0),
AccountsRegistrationOpen: true,
AccountsApprovalRequired: true,
AccountsReasonRequired: true,
AccountsAllowCustomCSS: false,
AccountsCustomCSSLength: 10000,

View file

@ -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"))

View file

@ -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()

View file

@ -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)

View file

@ -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
*/

View file

@ -27,6 +27,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -121,7 +122,6 @@ func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (
settings := &gtsmodel.AccountSettings{
AccountID: accountID,
Reason: newSignup.Reason,
Privacy: gtsmodel.VisibilityDefault,
}
@ -197,6 +197,7 @@ func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (
Account: account,
EncryptedPassword: string(encryptedPassword),
SignUpIP: newSignup.SignUpIP.To4(),
Reason: newSignup.Reason,
Locale: newSignup.Locale,
UnconfirmedEmail: newSignup.Email,
CreatedByApplicationID: newSignup.AppID,
@ -331,6 +332,113 @@ func (a *adminDB) CreateInstanceInstance(ctx context.Context) error {
return nil
}
func (a *adminDB) CreateInstanceApplication(ctx context.Context) error {
// Check if instance application already exists.
// Instance application client_id always = the
// instance account's ID so this is an easy check.
instanceAcct, err := a.state.DB.GetInstanceAccount(ctx, "")
if err != nil {
return err
}
exists, err := exists(
ctx,
a.db.
NewSelect().
Column("application.id").
TableExpr("? AS ?", bun.Ident("applications"), bun.Ident("application")).
Where("? = ?", bun.Ident("application.client_id"), instanceAcct.ID),
)
if err != nil {
return err
}
if exists {
log.Infof(ctx, "instance application already exists")
return nil
}
// Generate new IDs for this
// application and its client.
protocol := config.GetProtocol()
host := config.GetHost()
url := protocol + "://" + host
clientID := instanceAcct.ID
clientSecret := uuid.NewString()
appID, err := id.NewRandomULID()
if err != nil {
return err
}
// Generate the application
// to put in the database.
app := &gtsmodel.Application{
ID: appID,
Name: host + " instance application",
Website: url,
RedirectURI: url,
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: "write:accounts",
}
// Store it.
if err := a.state.DB.PutApplication(ctx, app); err != nil {
return err
}
// Model an oauth client
// from the application.
oc := &gtsmodel.Client{
ID: clientID,
Secret: clientSecret,
Domain: url,
}
// Store it.
return a.state.DB.Put(ctx, oc)
}
func (a *adminDB) GetInstanceApplication(ctx context.Context) (*gtsmodel.Application, error) {
// Instance app clientID == instanceAcct.ID,
// so get the instance account first.
instanceAcct, err := a.state.DB.GetInstanceAccount(ctx, "")
if err != nil {
return nil, err
}
app := new(gtsmodel.Application)
if err := a.db.
NewSelect().
Model(app).
Where("? = ?", bun.Ident("application.client_id"), instanceAcct.ID).
Scan(ctx); err != nil {
return nil, err
}
return app, nil
}
func (a *adminDB) CountApprovedSignupsSince(ctx context.Context, since time.Time) (int, error) {
return a.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("users"), bun.Ident("user")).
Where("? > ?", bun.Ident("user.created_at"), since).
Where("? = ?", bun.Ident("user.approved"), true).
Count(ctx)
}
func (a *adminDB) CountUnhandledSignups(ctx context.Context) (int, error) {
return a.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("users"), bun.Ident("user")).
// Approved is false by default.
// Explicitly rejected sign-ups end up elsewhere.
Where("? = ?", bun.Ident("user.approved"), false).
Count(ctx)
}
/*
ACTION FUNCS
*/

View file

@ -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)
}

View file

@ -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"

View file

@ -0,0 +1,38 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
type Visibility string
// AccountSettings models settings / preferences for a local, non-instance account.
type AccountSettings struct {
AccountID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // AccountID that owns this settings.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item was last updated.
Reason string `bun:",nullzero"` // What reason was given for signing up when this account was created?
Privacy Visibility `bun:",nullzero"` // Default post privacy for this account
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Set posts from this account to sensitive by default?
Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
StatusContentType string `bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts).
Theme string `bun:",nullzero"` // Preset CSS theme filename selected by this Account (empty string if nothing set).
CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses.
EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections.
}

View file

@ -0,0 +1,124 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"strings"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
// Add reason to users table.
_, err := db.ExecContext(ctx,
"ALTER TABLE ? ADD COLUMN ? TEXT",
bun.Ident("users"), bun.Ident("reason"),
)
if err != nil {
e := err.Error()
if !(strings.Contains(e, "already exists") ||
strings.Contains(e, "duplicate column name") ||
strings.Contains(e, "SQLSTATE 42701")) {
return err
}
}
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Get reasons from
// account settings.
type idReason struct {
AccountID string
Reason string
}
reasons := []idReason{}
if err := tx.
NewSelect().
Table("account_settings").
Column("account_id", "reason").
Scan(ctx, &reasons); err != nil {
return err
}
// Add each reason to appropriate user.
for _, r := range reasons {
if _, err := tx.
NewUpdate().
Table("users").
Set("? = ?", bun.Ident("reason"), r.Reason).
Where("? = ?", bun.Ident("account_id"), r.AccountID).
Exec(ctx, &reasons); err != nil {
return err
}
}
// Remove now-unused column
// from account settings.
if _, err := tx.
NewDropColumn().
Table("account_settings").
Column("reason").
Exec(ctx); err != nil {
return err
}
// Remove now-unused columns from users.
for _, column := range []string{
"current_sign_in_at",
"current_sign_in_ip",
"last_sign_in_at",
"last_sign_in_ip",
"sign_in_count",
"chosen_languages",
"filtered_languages",
} {
if _, err := tx.
NewDropColumn().
Table("users").
Column(column).
Exec(ctx); err != nil {
return err
}
}
// Create new UsersDenied table.
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.DeniedUser{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -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",

View file

@ -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)
}

View file

@ -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() {

View file

@ -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 {

View file

@ -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.

42
internal/email/signup.go Normal file
View file

@ -0,0 +1,42 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package email
var (
newSignupTemplate = "email_new_signup.tmpl"
newSignupSubject = "GoToSocial New Sign-Up"
)
type NewSignupData struct {
// URL of the instance to present to the receiver.
InstanceURL string
// Name of the instance to present to the receiver.
InstanceName string
// Email address sign-up was created with.
SignupEmail string
// Username submitted on the sign-up form.
SignupUsername string
// Reason given on the sign-up form.
SignupReason string
// URL to open the sign-up in the settings panel.
SignupURL string
}
func (s *sender) SendNewSignupEmail(toAddresses []string, data NewSignupData) error {
return s.sendTemplate(newSignupTemplate, newSignupSubject, data, toAddresses...)
}

View file

@ -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?

View file

@ -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.
)

View file

@ -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)

View file

@ -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",

View file

@ -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",

View file

@ -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)

View file

@ -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
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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,

View file

@ -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"`

View file

@ -414,13 +414,13 @@ func (c *Converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Ac
email = user.UnconfirmedEmail
}
if i := user.CurrentSignInIP.String(); i != "<nil>" {
if i := user.SignUpIP.String(); i != "<nil>" {
ip = &i
}
locale = user.Locale
if a.Settings.Reason != "" {
inviteRequest = &a.Settings.Reason
if user.Reason != "" {
inviteRequest = &user.Reason
}
if *user.Admin {
@ -1003,7 +1003,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
Version: config.GetSoftwareVersion(),
Languages: config.GetInstanceLanguages().TagStrs(),
Registrations: config.GetAccountsRegistrationOpen(),
ApprovalRequired: config.GetAccountsApprovalRequired(),
ApprovalRequired: true, // approval always required
InvitesEnabled: false, // todo: not supported yet
MaxTootChars: uint(config.GetStatusesMaxChars()),
Rules: c.InstanceRulesToAPIRules(i.Rules),
@ -1172,8 +1172,8 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
// registrations
instance.Registrations.Enabled = config.GetAccountsRegistrationOpen()
instance.Registrations.ApprovalRequired = config.GetAccountsApprovalRequired()
instance.Registrations.Message = nil // todo: not implemented
instance.Registrations.ApprovalRequired = true // always required
instance.Registrations.Message = nil // todo: not implemented
// contact
instance.Contact.Email = i.ContactEmail

View file

@ -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,

View file

@ -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())
}

View file

@ -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,
},
}

View file

@ -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/

138
internal/web/signup.go Normal file
View file

@ -0,0 +1,138 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package web
import (
"context"
"errors"
"net"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
func (m *Module) signupGETHandler(c *gin.Context) {
ctx := c.Request.Context()
// We'll need the instance later, and we can also use it
// before then to make it easier to return a web error.
instance, errWithCode := m.processor.InstanceGetV1(ctx)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Return instance we already got from the db,
// don't try to fetch it again when erroring.
instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
return instance, nil
}
// We only serve text/html at this endpoint.
if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
return
}
page := apiutil.WebPage{
Template: "sign-up.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Extra: map[string]any{
"reasonRequired": config.GetAccountsReasonRequired(),
},
}
apiutil.TemplateWebPage(c, page)
}
func (m *Module) signupPOSTHandler(c *gin.Context) {
ctx := c.Request.Context()
// We'll need the instance later, and we can also use it
// before then to make it easier to return a web error.
instance, errWithCode := m.processor.InstanceGetV1(ctx)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Return instance we already got from the db,
// don't try to fetch it again when erroring.
instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
return instance, nil
}
// We only serve text/html at this endpoint.
if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
return
}
form := &apimodel.AccountCreateRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), instanceGet)
return
}
if err := validate.CreateAccount(form); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), instanceGet)
return
}
clientIP := c.ClientIP()
signUpIP := net.ParseIP(clientIP)
if signUpIP == nil {
err := errors.New("ip address could not be parsed from request")
apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), instanceGet)
return
}
form.IP = signUpIP
// We have all the info we need, call account create
// (this will also trigger side effects like sending emails etc).
user, errWithCode := m.processor.Account().Create(
c.Request.Context(),
// nil to use
// instance app.
nil,
form,
)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
// Serve a page informing the
// user that they've signed up.
page := apiutil.WebPage{
Template: "signed-up.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Extra: map[string]any{
"email": user.UnconfirmedEmail,
"username": user.Account.Username,
},
}
apiutil.TemplateWebPage(c, page)
}

View file

@ -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) })