[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
parent a483bd9e38
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

@ -186,9 +186,13 @@ var Confirm action.GTSAction = func(ctx context.Context) error {
user.Approved = func() *bool { a := true; return &a }()
user.Email = user.UnconfirmedEmail
user.ConfirmedAt = time.Now()
user.SignUpIP = nil
return state.DB.UpdateUser(
ctx, user,
"approved", "email", "confirmed_at",
"approved",
"email",
"confirmed_at",
"sign_up_ip",
)
}

View file

@ -100,6 +100,10 @@ var Start action.GTSAction = func(ctx context.Context) error {
return fmt.Errorf("error creating instance instance: %s", err)
}
if err := dbService.CreateInstanceApplication(ctx); err != nil {
return fmt.Errorf("error creating instance application: %s", err)
}
// Get the instance account
// (we'll need this later).
instanceAccount, err := dbService.GetInstanceAccount(ctx, "")

View file

@ -439,8 +439,8 @@ definitions:
x-go-name: ID
invite_request:
description: |-
The reason given when requesting an invite.
Null if not known / remote account.
The reason given when signing up.
Null if no reason / remote account.
example: Pleaaaaaaaaaaaaaaase!!
type: string
x-go-name: InviteRequest
@ -1842,13 +1842,14 @@ definitions:
type:
description: |-
The type of event that resulted in the notification.
follow = Someone followed you
follow_request = Someone requested to follow you
mention = Someone mentioned you in their status
reblog = Someone boosted one of your statuses
favourite = Someone favourited one of your statuses
poll = A poll you have voted in or created has ended
status = Someone you enabled notifications for has posted a status
follow = Someone followed you. `account` will be set.
follow_request = Someone requested to follow you. `account` will be set.
mention = Someone mentioned you in their status. `status` will be set. `account` will be set.
reblog = Someone boosted one of your statuses. `status` will be set. `account` will be set.
favourite = Someone favourited one of your statuses. `status` will be set. `account` will be set.
poll = A poll you have voted in or created has ended. `status` will be set. `account` will be set.
status = Someone you enabled notifications for has posted a status. `status` will be set. `account` will be set.
admin.sign_up = Someone has signed up for a new account on the instance. `account` will be set.
type: string
x-go-name: Type
title: Notification represents a notification of an event relevant to the user.
@ -2773,6 +2774,8 @@ paths:
description: not found
"406":
description: not acceptable
"422":
description: Unprocessable. Your account creation request cannot be processed because either too many accounts have been created on this instance in the last 24h, or the pending account backlog is full.
"500":
description: internal server error
security:

View file

@ -14,11 +14,6 @@
# Default: true
accounts-registration-open: true
# Bool. Do sign up requests require approval from an admin/moderator before an account can sign in/use the server?
# Options: [true, false]
# Default: true
accounts-approval-required: true
# Bool. Are sign up requests required to submit a reason for the request (eg., an explanation of why they want to join the instance)?
# Options: [true, false]
# Default: true

View file

@ -411,11 +411,6 @@ instance-inject-mastodon-version: false
# Default: true
accounts-registration-open: true
# Bool. Do sign up requests require approval from an admin/moderator before an account can sign in/use the server?
# Options: [true, false]
# Default: true
accounts-approval-required: true
# Bool. Are sign up requests required to submit a reason for the request (eg., an explanation of why they want to join the instance)?
# Options: [true, false]
# Default: true

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,12 +101,20 @@ 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,
@ -82,23 +124,38 @@ func (p *Processor) Create(
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,32 +60,15 @@ 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)
visible, err := p.notifVisible(ctx, n, authed.Account)
if err != nil {
log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %s", n.ID, err)
log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %v", n.ID, err)
continue
}
if !visible {
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
}
}
item, err := p.converter.NotificationToAPINotification(ctx, n)
if err != nil {
@ -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)
}
if user.Account == nil {
a, err := p.state.DB.GetAccountByID(ctx, user.AccountID)
if err != nil {
// No user found for this token.
return nil, gtserror.NewErrorNotFound(err)
}
user.Account = a
if user.Account == nil {
user.Account, err = p.state.DB.GetAccountByID(ctx, user.AccountID)
if err != nil {
// We need the account for a local user.
return nil, gtserror.NewErrorInternalError(err)
}
}
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
}
if user.ConfirmationSentAt.Before(time.Now().Add(-oneWeek)) {
return nil, gtserror.NewErrorForbidden(errors.New("ConfirmEmail: confirmation token expired"))
// 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
}
// 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"}
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)) {
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,
// 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,7 +1172,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
// registrations
instance.Registrations.Enabled = config.GetAccountsRegistrationOpen()
instance.Registrations.ApprovalRequired = config.GetAccountsApprovalRequired()
instance.Registrations.ApprovalRequired = true // always required
instance.Registrations.Message = nil // todo: not implemented
// contact

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

View file

@ -6,7 +6,6 @@ EXPECT=$(cat << "EOF"
{
"account-domain": "peepee",
"accounts-allow-custom-css": true,
"accounts-approval-required": false,
"accounts-custom-css-length": 5000,
"accounts-reason-required": false,
"accounts-registration-open": true,
@ -224,7 +223,6 @@ GTS_INSTANCE_LANGUAGES="nl,en-gb" \
GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \
GTS_ACCOUNTS_CUSTOM_CSS_LENGTH=5000 \
GTS_ACCOUNTS_REGISTRATION_OPEN=true \
GTS_ACCOUNTS_APPROVAL_REQUIRED=false \
GTS_ACCOUNTS_REASON_REQUIRED=false \
GTS_MEDIA_IMAGE_MAX_SIZE=420 \
GTS_MEDIA_VIDEO_MAX_SIZE=420 \

View file

@ -89,7 +89,6 @@ var testDefaults = config.Configuration{
},
AccountsRegistrationOpen: true,
AccountsApprovalRequired: true,
AccountsReasonRequired: true,
AccountsAllowCustomCSS: true,
AccountsCustomCSSLength: 10000,

View file

@ -20,6 +20,7 @@ package testrig
import (
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/log"
)
// NewEmailSender returns a noop email sender that won't make any remote calls.
@ -38,6 +39,10 @@ func NewEmailSender(templateBaseDir string, sentEmails map[string]string) email.
sendCallback = func(toAddress string, message string) {
sentEmails[toAddress] = message
}
} else {
sendCallback = func(toAddress string, message string) {
log.Infof(nil, "Sent email to %s: %s", toAddress, message)
}
}
s, err := email.NewNoopSender(sendCallback)

View file

@ -100,6 +100,12 @@ func NewTestTokens() map[string]*gtsmodel.Token {
// NewTestClients returns a map of Clients keyed according to which account they are used by.
func NewTestClients() map[string]*gtsmodel.Client {
clients := map[string]*gtsmodel.Client{
"instance_application": {
ID: "01AY6P665V14JJR0AFVRT7311Y",
Secret: "baedee87-6d00-4cf5-87b9-4d78ee58ef01",
Domain: "http://localhost:8080",
UserID: "",
},
"admin_account": {
ID: "01F8MGWSJCND9BWBD4WGJXBM93",
Secret: "dda8e835-2c9c-4bd2-9b8b-77c2e26d7a7a",
@ -125,6 +131,15 @@ func NewTestClients() map[string]*gtsmodel.Client {
// NewTestApplications returns a map of applications keyed to which number application they are.
func NewTestApplications() map[string]*gtsmodel.Application {
apps := map[string]*gtsmodel.Application{
"instance_application": {
ID: "01HT5P2YHDMPAAD500NDAY8JW1",
Name: "localhost:8080 instance application",
Website: "http://localhost:8080",
RedirectURI: "http://localhost:8080",
ClientID: "01AY6P665V14JJR0AFVRT7311Y", // instance account ID
ClientSecret: "baedee87-6d00-4cf5-87b9-4d78ee58ef01",
Scopes: "write:accounts",
},
"admin_account": {
ID: "01F8MGXQRHYF5QPMTMXP78QC2F",
Name: "superseriousbusiness",
@ -167,14 +182,8 @@ func NewTestUsers() map[string]*gtsmodel.User {
CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
SignUpIP: net.ParseIP("199.222.111.89"),
UpdatedAt: time.Time{},
CurrentSignInAt: time.Time{},
CurrentSignInIP: nil,
LastSignInAt: time.Time{},
LastSignInIP: nil,
SignInCount: 0,
InviteID: "",
ChosenLanguages: []string{},
FilteredLanguages: []string{},
Reason: "hi, please let me in! I'm looking for somewhere neato bombeato to hang out.",
Locale: "en",
CreatedByApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG",
LastEmailedAt: time.Time{},
@ -195,16 +204,9 @@ func NewTestUsers() map[string]*gtsmodel.User {
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password'
CreatedAt: TimeMustParse("2022-06-01T13:12:00Z"),
SignUpIP: net.ParseIP("89.22.189.19"),
SignUpIP: nil,
UpdatedAt: TimeMustParse("2022-06-01T13:12:00Z"),
CurrentSignInAt: TimeMustParse("2022-06-04T13:12:00Z"),
CurrentSignInIP: net.ParseIP("89.122.255.1"),
LastSignInAt: TimeMustParse("2022-06-03T13:12:00Z"),
LastSignInIP: net.ParseIP("89.122.255.1"),
SignInCount: 78,
InviteID: "",
ChosenLanguages: []string{"en"},
FilteredLanguages: []string{},
Locale: "en",
CreatedByApplicationID: "01F8MGXQRHYF5QPMTMXP78QC2F",
LastEmailedAt: TimeMustParse("2022-06-03T13:12:00Z"),
@ -225,16 +227,10 @@ func NewTestUsers() map[string]*gtsmodel.User {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password'
CreatedAt: TimeMustParse("2022-06-01T13:12:00Z"),
SignUpIP: net.ParseIP("59.99.19.172"),
SignUpIP: nil,
UpdatedAt: TimeMustParse("2022-06-01T13:12:00Z"),
CurrentSignInAt: TimeMustParse("2022-06-04T13:12:00Z"),
CurrentSignInIP: net.ParseIP("88.234.118.16"),
LastSignInAt: TimeMustParse("2022-06-03T13:12:00Z"),
LastSignInIP: net.ParseIP("147.111.231.154"),
SignInCount: 9,
InviteID: "",
ChosenLanguages: []string{"en"},
FilteredLanguages: []string{},
Reason: "I wanna be on this damned webbed site so bad! Please! Wow",
Locale: "en",
CreatedByApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG",
LastEmailedAt: TimeMustParse("2022-06-02T13:12:00Z"),
@ -255,16 +251,9 @@ func NewTestUsers() map[string]*gtsmodel.User {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password'
CreatedAt: TimeMustParse("2022-05-23T13:12:00Z"),
SignUpIP: net.ParseIP("59.99.19.172"),
SignUpIP: nil,
UpdatedAt: TimeMustParse("2022-05-23T13:12:00Z"),
CurrentSignInAt: TimeMustParse("2022-06-05T13:12:00Z"),
CurrentSignInIP: net.ParseIP("118.44.18.196"),
LastSignInAt: TimeMustParse("2022-06-06T13:12:00Z"),
LastSignInIP: net.ParseIP("198.98.21.15"),
SignInCount: 9,
InviteID: "",
ChosenLanguages: []string{"en"},
FilteredLanguages: []string{},
Locale: "en",
CreatedByApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG",
LastEmailedAt: TimeMustParse("2022-06-06T13:12:00Z"),
@ -664,7 +653,6 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
AccountID: "01F8MH0BBE4FHXPH513MBVFHB0",
CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Reason: "hi, please let me in! I'm looking for somewhere neato bombeato to hang out.",
Privacy: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(false),
Language: "en",
@ -675,7 +663,6 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
CreatedAt: TimeMustParse("2022-05-17T13:10:59Z"),
UpdatedAt: TimeMustParse("2022-05-17T13:10:59Z"),
Reason: "",
Privacy: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(false),
Language: "en",
@ -686,7 +673,6 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
CreatedAt: TimeMustParse("2022-05-20T11:09:18Z"),
UpdatedAt: TimeMustParse("2022-05-20T11:09:18Z"),
Reason: "I wanna be on this damned webbed site so bad! Please! Wow",
Privacy: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(false),
Language: "en",
@ -697,7 +683,6 @@ func NewTestAccountSettings() map[string]*gtsmodel.AccountSettings {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Reason: "",
Privacy: gtsmodel.VisibilityFollowersOnly,
Sensitive: util.Ptr(true),
Language: "fr",
@ -2428,6 +2413,15 @@ func NewTestNotifications() map[string]*gtsmodel.Notification {
StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R",
Read: util.Ptr(false),
},
"new_signup": {
ID: "01HTM9TETMB3YQCBKZ7KD4KV02",
NotificationType: gtsmodel.NotificationSignup,
CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
OriginAccountID: "01F8MH0BBE4FHXPH513MBVFHB0",
StatusID: "",
Read: util.Ptr(false),
},
}
}

View file

@ -407,6 +407,57 @@ pre, pre[class*="language-"] {
}
}
/*
Forms and sign-in / sign-up / confirm pages.
*/
section.with-form {
form {
display: flex;
flex-direction: column;
gap: 1rem;
padding-bottom: 1rem;
padding-top: 1rem;
p {
/*
We use gap so we don't
need top + bottom margins.
*/
margin-top: 0;
margin-bottom: 0;
}
label, input {
padding-left: 0.2rem;
}
.labelinput {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.checkbox {
display: flex;
flex-direction: row-reverse;
gap: 0.4rem;
& > input {
height: 100%;
width: 5%;
min-width: 1.2rem;
align-self: center;
}
}
.btn {
/* Visually separate buttons a bit */
margin-top: 1rem;
}
}
}
/***********************************
***** SECTION 4: SHAMEFUL MESS *****
************************************/
@ -419,33 +470,8 @@ pre, pre[class*="language-"] {
/*
Below section stylings are used
in transient/error templates.
in transient pages + error templates.
*/
section.sign-in {
form {
display: flex;
flex-direction: column;
gap: 1rem;
padding-bottom: 1rem;
padding-top: 1rem;
label, input {
padding-left: 0.2rem;
}
.labelinput {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.btn {
margin-top: 1rem;
}
}
}
section.error {
word-break: break-word;
@ -470,25 +496,6 @@ section.oob-token {
}
}
/*
TODO: This is only used in the "finalize"
template for new signups; move this elsewhere
when that stuff is finished up.
*/
.callout {
margin: 1.5rem 0;
border: .05rem solid $border-accent;
border-radius: .2rem;
padding: 0 .6rem .6rem;
.callout-title {
margin: 0 -.6rem;
padding: .6rem;
font-weight: bold;
background-color: $border-accent;
color: $gray1;
}
}
/*
TODO: list and blocklist are only used
in settings panel and on blocklist page;

View file

@ -59,37 +59,26 @@
{{- end }}
{{- end -}}
{{- define "registrationLimits" -}}
{{- if .instance.Registrations -}}
Registration is enabled; new signups can be submitted to this instance.<br/>
{{- if .instance.ApprovalRequired -}}
Admin approval is required for new registrations.
{{- else -}}
Admin approval is not required for registrations; new signups will be automatically approved (pending email confirmation).
{{- end -}}
{{- else -}}
Registration is disabled; new signups are currently closed for this instance.
{{- end -}}
{{- end -}}
{{- define "customCSSLimits" -}}
<a href="https://docs.gotosocial.org/en/latest/user_guide/settings/#custom-css" target="_blank" rel="noopener noreferrer">Custom CSS</a> is&nbsp;
{{- if .instance.Configuration.Accounts.AllowCustomCSS -}}
Users are allowed to set <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css/" target="_blank" rel="noopener noreferrer">Custom CSS</a> for their profiles.
<b>enabled</b>
{{- else -}}
<a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css/" target="_blank" rel="noopener noreferrer">Custom CSS</a> is not enabled for user profiles.
</b>disabled</b>
{{- end -}}
&nbsp;on account profiles.
{{- end -}}
{{- define "statusLimits" -}}
Statuses can contain up to&nbsp;
{{- .instance.Configuration.Statuses.MaxCharacters }} characters, and&nbsp;
{{- .instance.Configuration.Statuses.MaxMediaAttachments }} media attachments.
Statuses can contain up to
<b>{{- .instance.Configuration.Statuses.MaxCharacters }} characters</b>, and
<b>{{- .instance.Configuration.Statuses.MaxMediaAttachments }} media attachments</b>.
{{- end -}}
{{- define "pollLimits" -}}
Polls can have up to&nbsp;
{{- .instance.Configuration.Polls.MaxOptions }} options, with&nbsp;
{{- .instance.Configuration.Polls.MaxCharactersPerOption }} characters per option.
Polls can have up to
<b>{{- .instance.Configuration.Polls.MaxOptions }} options</b>, with
<b>{{- .instance.Configuration.Polls.MaxCharactersPerOption }} characters per option</b>.
{{- end -}}
{{- with . }}
@ -102,6 +91,7 @@ Polls can have up to&nbsp;
<li><a href="#contact">Contact</a></li>
<li><a href="#features">Features</a></li>
<li><a href="#languages">Languages</a></li>
<li><a href="#signup">Register an Account on {{ .instance.Title -}}</li>
<li><a href="#rules">Rules</a></li>
<li><a href="#terms">Terms and Conditions</a></li>
<li><a href="#moderated-servers">Moderated Servers</a></li>
@ -145,10 +135,9 @@ Polls can have up to&nbsp;
<h3 id="features">Instance Features</h3>
<div class="about-section-contents">
<ul>
<li>{{- template "registrationLimits" . -}}</li>
<li>{{- template "customCSSLimits" . -}}</li>
<li>{{- template "statusLimits" . -}}</li>
<li>{{- template "pollLimits" . -}}</li>
<li>{{- template "customCSSLimits" . -}}</li>
</ul>
</div>
</section>
@ -160,6 +149,7 @@ Polls can have up to&nbsp;
{{- end }}
</div>
</section>
{{- include "index_register.tmpl" . | indent 1 }}
<section class="about-section" role="region" aria-labelledby="rules">
<h3 id="rules">Instance Rules</h3>
<div class="about-section-contents">

View file

@ -19,8 +19,10 @@
{{- with . }}
<main>
<section class="with-form" aria-labelledby="authorize">
<h2 id="authorize">Authorize app</h2>
<form action="/oauth/authorize" method="POST">
<h1>Hi {{ .user -}}!</h1>
<p>Hi <b>{{- .user -}}</b>!</p>
<p>
Application
{{- if .appwebsite }}
@ -34,7 +36,8 @@
<p>
To continue, the application will redirect to: <code>{{- .redirect -}}</code>
</p>
<button type="submit" style="width:200px;">Allow</button>
<button type="submit" class="btn btn-success">Allow</button>
</form>
</section>
</main>
{{- end }}

View file

@ -0,0 +1,33 @@
{{- /*
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
{{- with . }}
<main>
<section class="with-form" aria-labelledby="confirm">
<h2 id="confirm">Confirm email address</h2>
<form action="/confirm_email?token={{ .token }}" method="POST">
<p>
Hi <b>{{- .username -}}</b>!
Please click the button to confirm your email address <b>{{- .email -}}</b>.
</p>
<button type="submit" class="btn btn-success">Confirm</button>
</form>
</section>
</main>
{{- end }}

View file

@ -19,9 +19,12 @@
{{- with . }}
<main>
<section>
<h1>Email Address Confirmed</h1>
<p>Thanks {{ .username -}}! Your email address <b>{{- .email -}}</b> has been confirmed.<p>
<section aria-labelledby="confirmed">
<h2 id="confirmed">Email address confirmed</h2>
<p>Email address <b>{{- .email -}}</b> is now confirmed!</p>
{{- if not .approved }}
<p>Once an admin has approved your sign-up, you will be able to log in and use your account.</p>
{{- end }}
</section>
</main>
{{- end }}

View file

@ -17,12 +17,14 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
Hello {{.Username}}!
Hello {{ .Username -}}!
You are receiving this mail because you've requested an account on {{.InstanceURL}}.
You are receiving this mail because you've requested an account on {{ .InstanceURL -}}.
We just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:
To use your account, you must confirm that this is your email address.
To confirm your email, paste the following in your browser's address bar:
{{ .ConfirmLink }}
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceURL}}
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{ .InstanceURL -}}.

View file

@ -0,0 +1,32 @@
{{- /*
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
Hello moderator of {{ .InstanceName }} ({{ .InstanceURL }})!
Someone has submitted a new account sign-up to your instance.
They provided the following details:
Email address: {{ .SignupEmail }}
Username: {{ .SignupUsername }}
{{- if .SignupReason }}
Reason: {{ .SignupReason }}
{{- end }}
To view the sign-up, paste the following link into your browser: {{ .SignupURL }}

View file

@ -19,29 +19,35 @@
{{- with . }}
<main>
<section class="with-form" aria-labelledby="finalize">
<h2 id="finalize">Finalize sign-in to {{ .instance.Title -}}</h2>
<form action="/oauth/finalize" method="POST">
<h1>Hi {{ .name -}}!</h1>
<p>
You are about to sign-up to {{ .instance.Title -}}.
To ensure the best experience for you, we need you to provide some additional details.
Hi <b>{{- .name -}}</b>!
</p>
<p>
You are about to create an account on <b>{{- .instance.Title -}}</b>.
To finish the process, you must select your username.
</p>
<div class="callout">
<p class="callout-title">Important</p>
<p>Due to the way the ActivityPub standard works, you <strong>cannot</strong> change your username after it has been set.</p>
</div>
<div class="labelinput">
<label for="username">Username <small>(must contain only lowercase letters, numbers, and underscores)</small></label>
<label for="username">
Username (lowercase a-z, numbers, and underscores; max 64 characters).<br/>
<small>Your username will be part of your fediverse handle, and cannot be changed later, so choose thoughtfully!</small>
</label>
<input
id="username"
type="text"
class="form-control"
name="username"
required
placeholder="Please enter your desired username"
pattern="^[a-z0-9_]{1,64}$"
title="lowercase a-z, numbers, and underscores; max 64 characters"
value="{{- .preferredUsername -}}"
>
</div>
<input type="hidden" name="name" value="{{- .name -}}">
<button type="submit" style="width: 100%; margin-top: 1rem;" class="btn btn-success">Submit</button>
<button type="submit" class="btn btn-success">Submit</button>
</form>
</section>
</main>
{{- end }}

View file

@ -35,6 +35,7 @@
</div>
</section>
{{- include "index_what_is_this.tmpl" . | indent 1 }}
{{- include "index_register.tmpl" . | indent 1 }}
{{- include "index_apps.tmpl" . | indent 1 }}
</main>
{{- end }}

View file

@ -22,8 +22,9 @@
<h3 id="apps">Client applications</h3>
<div class="about-section-contents">
<p>
Have an account on this instance and want to log in?
GoToSocial does not provide its own webclient, but implements the Mastodon client API.
You can use this server through a variety of other clients:
You can use a variety of clients to log in to your account here:
</p>
<ul class="applist nodot" role="group">
<li class="applist-entry">

View file

@ -0,0 +1,41 @@
{{- /*
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
{{- define "registrationLimits" -}}
New account registration is currently&nbsp;
{{- if .instance.Registrations -}}
<b>open</b>.
{{- else -}}
<b>closed</b>.
{{- end -}}
{{- end -}}
{{- with . }}
<section class="about-section" role="region" aria-labelledby="signup">
<h3 id="signup">Register an Account on {{ .instance.Title -}}</h3>
<div class="about-section-contents">
<p>{{- template "registrationLimits" . -}}</p>
{{- if .instance.Registrations }}
<p>To register a new account, please first read the <a href="/about#rules">rules</a> and <a href="/about#terms">terms</a>.</p>
<p>Then, use the <a href="/signup">sign-up page</a> to register an account.</p>
<p>Manual admin approval is <b>required</b> for new accounts.</p>
{{- end }}
</div>
</section>
{{- end }}

View file

@ -44,7 +44,7 @@
<p>
You can join the fediverse by running your own instance of an ActivityPub software,
or by finding an existing instance that aligns with your values and expectations,
and registering an account there.
and registering an account.
</p>
<p>
To help you find an instance that suits you, you can try one of the following tools:
@ -53,6 +53,9 @@
<li><a href="https://fediverse.observer" rel="nofollow noreferrer noopener" target="_blank">Fediverse Observer (opens in a new tab)</a></li>
<li><a href="https://fedidb.org/network" rel="nofollow noreferrer noopener" target="_blank">FediDB (opens in a new tab)</a></li>
</ul>
{{- if .instance.Registrations }}
<p>Or, just <a href="#signup">register for an account on this instance</a>!</p>
{{- end }}
</div>
</section>
{{- end }}

View file

@ -20,7 +20,7 @@
{{- with . }}
<main>
<section class="oob-token">
<h1>Hi {{ .user -}}!</h1>
<h1>Hi <b>{{- .user -}}</b>!</h1>
<p>Here's your out-of-band token with scope "<em>{{- .scope -}}</em>", use it wisely:</p>
<code>{{- .oobToken -}}</code>
</section>

View file

@ -19,16 +19,16 @@
{{- with . }}
<main>
<section class="sign-in" aria-labelledby="sign-in">
<section class="with-form" aria-labelledby="sign-in">
<h2 id="sign-in">Sign in</h2>
<form action="/auth/sign_in" method="POST">
<div class="labelinput">
<label for="email">Email</label>
<input type="email" class="form-control" name="username" required placeholder="Please enter your email address">
<input type="email" name="username" required placeholder="Please enter your email address">
</div>
<div class="labelinput">
<label for="password">Password</label>
<input type="password" class="form-control" name="password" required placeholder="Please enter your password">
<input type="password" name="password" required placeholder="Please enter your password">
</div>
<button type="submit" class="btn btn-success">Sign in</button>
</form>

93
web/template/sign-up.tmpl Normal file
View file

@ -0,0 +1,93 @@
{{- /*
// 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/>.
*/ -}}
{{- with . }}
<main>
<section class="with-form" aria-labelledby="sign-up">
<h2 id="sign-up">Sign up for an account on {{ .instance.Title -}}</h2>
<form action="/signup" method="POST">
<div class="labelinput">
<label for="email">Email</label>
<input
id="email"
type="email"
name="email"
required
placeholder="Email address"
>
</div>
<div class="labelinput">
<label for="password">Password</label>
<input
id="password"
type="password"
name="password"
required
placeholder="Please enter your desired password"
>
</div>
<div class="labelinput">
<label for="username">
Username (lowercase a-z, numbers, and underscores; max 64 characters).<br/>
<small>Your username will be part of your fediverse handle, and cannot be changed later, so choose thoughtfully!</small>
</label>
<input
id="username"
type="text"
name="username"
required
placeholder="Please enter your desired username"
pattern="^[a-z0-9_]{1,64}$"
title="lowercase a-z, numbers, and underscores; max 64 characters"
>
</div>
{{- if .reasonRequired }}
<div class="labelinput">
<label for="reason">
Reason you want to join {{ .instance.Title }} (40-500 characters).<br/>
<small>The admin(s) will use this text to decide whether or not to approve your sign-up.</small>
</label>
<textarea
id="reason"
name="reason"
required
placeholder="Enter a few sentences about why you want to join this instance. If you know someone on the instance already, you may want to mention them here. You might want to link to any other accounts you have elsewhere too."
rows="8"
minlength="40"
maxlength="500"
title="40-500 characters"
></textarea>
</div>
{{- end }}
<div class="checkbox">
<label for="agreement">I have read and accept the <a href="/about#terms">terms and conditions</a> of {{ .instance.Title }}, and I agree to abide by the <a href="/about#rules">instance rules</a>.</label>
<input
id="agreement"
type="checkbox"
name="agreement"
required
value="true"
>
</div>
<input type="hidden" name="locale" value="en">
<button type="submit" class="btn btn-success">Submit</button>
</form>
</section>
</main>
{{- end }}

View file

@ -0,0 +1,30 @@
{{- /*
// 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/>.
*/ -}}
{{- with . }}
<main>
<section aria-labelledby="signed-up">
<h2 id="signed-up">Thanks for signing up to {{ .instance.Title -}}!</h2>
<p>Hi <b>{{- .username -}}</b>!</p>
<p>Your sign-up has been registered, and a confirmation email has been sent to <b>{{- .email -}}</b>.<p>
<p>Please check your email inbox and click the link to confirm your email.</p>
<p>Once an admin has approved your sign-up, you will be able to log in and use your account.</p>
</section>
</main>
{{- end }}