mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-11-22 05:27:29 -06:00
[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:
parent
a483bd9e38
commit
9fb8a78f91
68 changed files with 1456 additions and 437 deletions
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
14
internal/cache/size.go
vendored
14
internal/cache/size.go
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
|
|
|
|||
|
|
@ -67,7 +67,6 @@ var Defaults = Configuration{
|
|||
InstanceLanguages: make(language.Languages, 0),
|
||||
|
||||
AccountsRegistrationOpen: true,
|
||||
AccountsApprovalRequired: true,
|
||||
AccountsReasonRequired: true,
|
||||
AccountsAllowCustomCSS: false,
|
||||
AccountsCustomCSSLength: 10000,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
124
internal/db/bundb/migrations/20240401130338_sign_up.go
Normal file
124
internal/db/bundb/migrations/20240401130338_sign_up.go
Normal 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(>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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
42
internal/email/signup.go
Normal 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...)
|
||||
}
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
138
internal/web/signup.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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) })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue