From 6ed368cbebcae087aec1f31ee8d69ac6c47ead9f Mon Sep 17 00:00:00 2001 From: Forest Johnson Date: Mon, 7 Feb 2022 11:04:31 +0000 Subject: [PATCH] [feature] add authorization to the already-existing authentication (#365) * add ensureUserIsAuthorizedOrRedirect to /oauth/authorize * adding authorization (email confirm, account approve, etc) to TokenCheck * revert un-needed changes to signin.go * oops what happened here * error css * add account.SuspendedAt check * remove redundant checks from oauth util Authed function * wip tests * tests passing * stop stripping useful information from ErrAlreadyExists * that feeling of scraping the dryer LINT off the screen * oops I didn't mean to get rid of this NewTestRouter function * make tests work with recorder * re-add ConfigureTemplatesWithGin to handle template path err Co-authored-by: tsmethurst --- internal/api/client/auth/auth.go | 13 ++ internal/api/client/auth/auth_test.go | 94 ++++++++++++++- internal/api/client/auth/authorize.go | 73 +++++++++-- internal/api/client/auth/authorize_test.go | 113 ++++++++++++++++++ internal/api/security/tokencheck.go | 22 ++++ internal/db/bundb/errors.go | 4 +- internal/db/error.go | 15 ++- .../federation/dereferencing/attachment.go | 4 +- internal/federation/federatingdb/create.go | 3 +- internal/oauth/util.go | 21 +--- internal/processing/status/util.go | 7 +- internal/router/router.go | 2 +- internal/router/session.go | 6 +- internal/router/template.go | 11 +- testrig/router.go | 30 +++++ web/assets/base.css | 19 +++ web/gotosocial-styling/templates/base.css | 18 +++ web/template/authorize.tmpl | 8 +- web/template/error.tmpl | 8 ++ 19 files changed, 424 insertions(+), 47 deletions(-) create mode 100644 internal/api/client/auth/authorize_test.go create mode 100644 web/template/error.tmpl diff --git a/internal/api/client/auth/auth.go b/internal/api/client/auth/auth.go index 67643244b..717d997a3 100644 --- a/internal/api/client/auth/auth.go +++ b/internal/api/client/auth/auth.go @@ -32,10 +32,23 @@ const ( // AuthSignInPath is the API path for users to sign in through AuthSignInPath = "/auth/sign_in" + + // CheckYourEmailPath users land here after registering a new account, instructs them to confirm thier email + CheckYourEmailPath = "/check_your_email" + + // WaitForApprovalPath users land here after confirming thier email but before an admin approves thier account + // (if such is required) + WaitForApprovalPath = "/wait_for_approval" + + // AccountDisabledPath users land here when thier account is suspended by an admin + AccountDisabledPath = "/account_disabled" + // OauthTokenPath is the API path to use for granting token requests to users with valid credentials OauthTokenPath = "/oauth/token" + // OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user) OauthAuthorizePath = "/oauth/authorize" + // CallbackPath is the API path for receiving callback tokens from external OIDC providers CallbackPath = oidc.CallbackPath diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go index a0ee8892d..fdf1b6baf 100644 --- a/internal/api/client/auth/auth_test.go +++ b/internal/api/client/auth/auth_test.go @@ -18,4 +18,96 @@ package auth_test -// TODO +import ( + "context" + "fmt" + "net/http/httptest" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/memstore" + "github.com/gin-gonic/gin" + "github.com/spf13/viper" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/oidc" + "github.com/superseriousbusiness/gotosocial/internal/router" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AuthStandardTestSuite struct { + suite.Suite + db db.DB + idp oidc.IDP + oauthServer oauth.Server + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + + // module being tested + authModule *auth.Module +} + +const ( + sessionUserID = "userid" + sessionClientID = "client_id" +) + +func (suite *AuthStandardTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() +} + +func (suite *AuthStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + suite.db = testrig.NewTestDB() + testrig.InitTestLog() + + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + var err error + suite.idp, err = oidc.NewIDP(context.Background()) + if err != nil { + panic(err) + } + suite.authModule = auth.New(suite.db, suite.oauthServer, suite.idp).(*auth.Module) + testrig.StandardDBSetup(suite.db, nil) +} + +func (suite *AuthStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath string) (*gin.Context, *httptest.ResponseRecorder) { + // create the recorder and gin test context + recorder := httptest.NewRecorder() + ctx, engine := gin.CreateTestContext(recorder) + + // load templates into the engine + testrig.ConfigureTemplatesWithGin(engine) + + // create the request + protocol := viper.GetString(config.Keys.Protocol) + host := viper.GetString(config.Keys.Host) + baseURI := fmt.Sprintf("%s://%s", protocol, host) + requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) + ctx.Request = httptest.NewRequest(requestMethod, requestURI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "text/html") + + // trigger the session middleware on the context + store := memstore.NewStore(make([]byte, 32), make([]byte, 32)) + store.Options(router.SessionOptions()) + sessionMiddleware := sessions.Sessions("gotosocial-localhost", store) + sessionMiddleware(ctx) + + return ctx, recorder +} diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go index 99f3cca68..387b83c1e 100644 --- a/internal/api/client/auth/authorize.go +++ b/internal/api/client/auth/authorize.go @@ -44,7 +44,7 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { s := sessions.Default(c) if _, err := api.NegotiateAccept(c, api.HTMLAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + c.HTML(http.StatusNotAcceptable, "error.tmpl", gin.H{"error": err.Error()}) return } @@ -57,7 +57,7 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { if err := c.Bind(form); err != nil { l.Debugf("invalid auth form: %s", err) m.clearSession(s) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"error": err.Error()}) return } l.Debugf("parsed auth form: %+v", form) @@ -65,7 +65,7 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { if err := extractAuthForm(s, form); err != nil { l.Debugf(fmt.Sprintf("error parsing form at /oauth/authorize: %s", err)) m.clearSession(s) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"error": err.Error()}) return } c.Redirect(http.StatusSeeOther, AuthSignInPath) @@ -75,28 +75,33 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { // We can use the client_id on the session to retrieve info about the app associated with the client_id clientID, ok := s.Get(sessionClientID).(string) if !ok || clientID == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"}) + c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": "no client_id found in session"}) return } app := >smodel.Application{} if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { m.clearSession(s) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)}) + c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{ + "error": fmt.Sprintf("no application found for client id %s", clientID), + }) return } - // we can also use the userid of the user to fetch their username from the db to greet them nicely <3 + // redirect the user if they have not confirmed their email yet, thier account has not been approved yet, + // or thier account has been disabled. user := >smodel.User{} if err := m.db.GetByID(c.Request.Context(), userID, user); err != nil { m.clearSession(s) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": err.Error()}) return } - acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) if err != nil { m.clearSession(s) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": err.Error()}) + return + } + if !ensureUserIsAuthorizedOrRedirect(c, user, acct) { return } @@ -104,13 +109,13 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { redirect, ok := s.Get(sessionRedirectURI).(string) if !ok || redirect == "" { m.clearSession(s) - c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"}) + c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": "no redirect_uri found in session"}) return } scope, ok := s.Get(sessionScope).(string) if !ok || scope == "" { m.clearSession(s) - c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"}) + c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": "no scope found in session"}) return } @@ -170,10 +175,28 @@ func (m *Module) AuthorizePOSTHandler(c *gin.Context) { errs = append(errs, "session missing userid") } + // redirect the user if they have not confirmed their email yet, thier account has not been approved yet, + // or thier account has been disabled. + user := >smodel.User{} + if err := m.db.GetByID(c.Request.Context(), userID, user); err != nil { + m.clearSession(s) + c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": err.Error()}) + return + } + acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) + if err != nil { + m.clearSession(s) + c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": err.Error()}) + return + } + if !ensureUserIsAuthorizedOrRedirect(c, user, acct) { + return + } + m.clearSession(s) if len(errs) != 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": strings.Join(errs, ": ")}) + c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"error": strings.Join(errs, ": ")}) return } @@ -190,7 +213,7 @@ func (m *Module) AuthorizePOSTHandler(c *gin.Context) { // and proceed with authorization using the oauth2 library if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"error": err.Error()}) } } @@ -216,3 +239,27 @@ func extractAuthForm(s sessions.Session, form *model.OAuthAuthorize) error { s.Set(sessionState, uuid.NewString()) return s.Save() } + +func ensureUserIsAuthorizedOrRedirect(ctx *gin.Context, user *gtsmodel.User, account *gtsmodel.Account) bool { + if user.ConfirmedAt.IsZero() { + ctx.Redirect(http.StatusSeeOther, CheckYourEmailPath) + return false + } + + if !user.Approved { + ctx.Redirect(http.StatusSeeOther, WaitForApprovalPath) + return false + } + + if user.Disabled { + ctx.Redirect(http.StatusSeeOther, AccountDisabledPath) + return false + } + + if !account.SuspendedAt.IsZero() { + ctx.Redirect(http.StatusSeeOther, AccountDisabledPath) + return false + } + + return true +} diff --git a/internal/api/client/auth/authorize_test.go b/internal/api/client/auth/authorize_test.go new file mode 100644 index 000000000..8f16702da --- /dev/null +++ b/internal/api/client/auth/authorize_test.go @@ -0,0 +1,113 @@ +package auth_test + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "codeberg.org/gruf/go-errors" + "github.com/gin-contrib/sessions" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type AuthAuthorizeTestSuite struct { + AuthStandardTestSuite +} + +type authorizeHandlerTestCase struct { + description string + mutateUserAccount func(*gtsmodel.User, *gtsmodel.Account) + expectedStatusCode int + expectedLocationHeader string +} + +func (suite *AuthAuthorizeTestSuite) TestAccountAuthorizeHandler() { + + var tests = []authorizeHandlerTestCase{ + { + description: "user has their email unconfirmed", + mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) { + // nothing to do, weed_lord420 already has their email unconfirmed + }, + expectedStatusCode: http.StatusSeeOther, + expectedLocationHeader: auth.CheckYourEmailPath, + }, + { + description: "user has their email confirmed but is not approved", + mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) { + user.ConfirmedAt = time.Now() + user.Email = user.UnconfirmedEmail + }, + expectedStatusCode: http.StatusSeeOther, + expectedLocationHeader: auth.WaitForApprovalPath, + }, + { + description: "user has their email confirmed and is approved, but User entity has been disabled", + mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) { + user.ConfirmedAt = time.Now() + user.Email = user.UnconfirmedEmail + user.Approved = true + user.Disabled = true + }, + expectedStatusCode: http.StatusSeeOther, + expectedLocationHeader: auth.AccountDisabledPath, + }, + { + description: "user has their email confirmed and is approved, but Account entity has been suspended", + mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) { + user.ConfirmedAt = time.Now() + user.Email = user.UnconfirmedEmail + user.Approved = true + user.Disabled = false + account.SuspendedAt = time.Now() + }, + expectedStatusCode: http.StatusSeeOther, + expectedLocationHeader: auth.AccountDisabledPath, + }, + } + + doTest := func(testCase authorizeHandlerTestCase) { + ctx, recorder := suite.newContext(http.MethodGet, auth.OauthAuthorizePath) + + user := suite.testUsers["unconfirmed_account"] + account := suite.testAccounts["unconfirmed_account"] + + testSession := sessions.Default(ctx) + testSession.Set(sessionUserID, user.ID) + testSession.Set(sessionClientID, suite.testApplications["application_1"].ClientID) + if err := testSession.Save(); err != nil { + panic(errors.WrapMsgf(err, "failed on case: %s", testCase.description)) + } + + testCase.mutateUserAccount(user, account) + + testCase.description = fmt.Sprintf("%s, %t, %s", user.Email, user.Disabled, account.SuspendedAt) + + user.UpdatedAt = time.Now() + err := suite.db.UpdateByPrimaryKey(context.Background(), user) + suite.NoError(err) + _, err = suite.db.UpdateAccount(context.Background(), account) + suite.NoError(err) + + // call the handler + suite.authModule.AuthorizeGETHandler(ctx) + + // 1. we should have a redirect + suite.Equal(testCase.expectedStatusCode, recorder.Code, fmt.Sprintf("failed on case: %s", testCase.description)) + + // 2. we should have a redirect to the check your email path, as this user has not confirmed their email yet. + suite.Equal(testCase.expectedLocationHeader, recorder.Header().Get("Location"), fmt.Sprintf("failed on case: %s", testCase.description)) + } + + for _, testCase := range tests { + doTest(testCase) + } +} + +func TestAccountUpdateTestSuite(t *testing.T) { + suite.Run(t, new(AuthAuthorizeTestSuite)) +} diff --git a/internal/api/security/tokencheck.go b/internal/api/security/tokencheck.go index b68f0b94f..e366af2ea 100644 --- a/internal/api/security/tokencheck.go +++ b/internal/api/security/tokencheck.go @@ -62,6 +62,22 @@ func (m *Module) TokenCheck(c *gin.Context) { l.Warnf("no user found for userID %s", userID) return } + + if user.ConfirmedAt.IsZero() { + l.Warnf("authenticated user %s has never confirmed thier email address", userID) + return + } + + if !user.Approved { + l.Warnf("authenticated user %s's account was never approved by an admin", userID) + return + } + + if user.Disabled { + l.Warnf("authenticated user %s's account was disabled'", userID) + return + } + c.Set(oauth.SessionAuthorizedUser, user) // fetch account for this token @@ -74,6 +90,12 @@ func (m *Module) TokenCheck(c *gin.Context) { l.Warnf("no account found for userID %s", userID) return } + + if !acct.SuspendedAt.IsZero() { + l.Warnf("authenticated user %s's account (accountId=%s) has been suspended", userID, user.AccountID) + return + } + c.Set(oauth.SessionAuthorizedAccount, acct) } diff --git a/internal/db/bundb/errors.go b/internal/db/bundb/errors.go index 7602d5e1d..d01720731 100644 --- a/internal/db/bundb/errors.go +++ b/internal/db/bundb/errors.go @@ -19,7 +19,7 @@ func processPostgresError(err error) db.Error { // (https://www.postgresql.org/docs/10/errcodes-appendix.html) switch pgErr.Code { case "23505" /* unique_violation */ : - return db.ErrAlreadyExists + return db.NewErrAlreadyExists(pgErr.Message) default: return err } @@ -36,7 +36,7 @@ func processSQLiteError(err error) db.Error { // Handle supplied error code: switch sqliteErr.Code() { case sqlite3.SQLITE_CONSTRAINT_UNIQUE: - return db.ErrAlreadyExists + return db.NewErrAlreadyExists(err.Error()) default: return err } diff --git a/internal/db/error.go b/internal/db/error.go index 984f96401..9ac0b6aa0 100644 --- a/internal/db/error.go +++ b/internal/db/error.go @@ -28,8 +28,19 @@ ErrNoEntries Error = fmt.Errorf("no entries") // ErrMultipleEntries is returned when a caller expected ONE entry for a query, but multiples were found. ErrMultipleEntries Error = fmt.Errorf("multiple entries") - // ErrAlreadyExists is returned when a caller tries to insert a database entry that already exists in the db. - ErrAlreadyExists Error = fmt.Errorf("already exists") // ErrUnknown denotes an unknown database error. ErrUnknown Error = fmt.Errorf("unknown error") ) + +// ErrAlreadyExists is returned when a caller tries to insert a database entry that already exists in the db. +type ErrAlreadyExists struct { + message string +} + +func (e *ErrAlreadyExists) Error() string { + return e.message +} + +func NewErrAlreadyExists(msg string) error { + return &ErrAlreadyExists{message: msg} +} diff --git a/internal/federation/dereferencing/attachment.go b/internal/federation/dereferencing/attachment.go index 0c7005e23..36ff2734c 100644 --- a/internal/federation/dereferencing/attachment.go +++ b/internal/federation/dereferencing/attachment.go @@ -20,6 +20,7 @@ import ( "context" + "errors" "fmt" "net/url" @@ -60,7 +61,8 @@ func (d *deref) GetRemoteAttachment(ctx context.Context, requestingUsername stri } if err := d.db.Put(ctx, a); err != nil { - if err != db.ErrAlreadyExists { + var alreadyExistsError *db.ErrAlreadyExists + if !errors.As(err, &alreadyExistsError) { return nil, fmt.Errorf("GetRemoteAttachment: error inserting attachment: %s", err) } } diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go index b07f4f04f..6c86151f3 100644 --- a/internal/federation/federatingdb/create.go +++ b/internal/federation/federatingdb/create.go @@ -231,7 +231,8 @@ func (f *federatingDB) createNote(ctx context.Context, note vocab.ActivityStream status.ID = statusID if err := f.db.PutStatus(ctx, status); err != nil { - if err == db.ErrAlreadyExists { + var alreadyExistsError *db.ErrAlreadyExists + if errors.As(err, &alreadyExistsError) { // the status already exists in the database, which means we've already handled everything else, // so we can just return nil here and be done with it. return nil diff --git a/internal/oauth/util.go b/internal/oauth/util.go index 540045f80..6f69f0ee4 100644 --- a/internal/oauth/util.go +++ b/internal/oauth/util.go @@ -78,25 +78,12 @@ func Authed(c *gin.Context, requireToken bool, requireApp bool, requireUser bool return nil, errors.New("application not supplied") } - if requireUser { - if a.User == nil { - return nil, errors.New("user not supplied") - } - if a.User.Disabled || !a.User.Approved { - return nil, errors.New("user disabled or not approved") - } - if a.User.Email == "" { - return nil, errors.New("user has no confirmed email address") - } + if requireUser && a.User == nil { + return nil, errors.New("user not supplied or not authorized") } - if requireAccount { - if a.Account == nil { - return nil, errors.New("account not supplied") - } - if !a.Account.SuspendedAt.IsZero() { - return nil, errors.New("account suspended") - } + if requireAccount && a.Account == nil { + return nil, errors.New("account not supplied or not authorized") } return a, nil diff --git a/internal/processing/status/util.go b/internal/processing/status/util.go index 05a3bf48e..f2640929d 100644 --- a/internal/processing/status/util.go +++ b/internal/processing/status/util.go @@ -223,8 +223,11 @@ func (p *processor) ProcessTags(ctx context.Context, form *apimodel.AdvancedStat return fmt.Errorf("error generating hashtags from status: %s", err) } for _, tag := range gtsTags { - if err := p.db.Put(ctx, tag); err != nil && err != db.ErrAlreadyExists { - return fmt.Errorf("error putting tags in db: %s", err) + if err := p.db.Put(ctx, tag); err != nil { + var alreadyExistsError *db.ErrAlreadyExists + if !errors.As(err, &alreadyExistsError) { + return fmt.Errorf("error putting tags in db: %s", err) + } } tags = append(tags, tag.ID) } diff --git a/internal/router/router.go b/internal/router/router.go index 88d900a9e..f1247d274 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -138,7 +138,7 @@ func New(ctx context.Context, db db.DB) (Router, error) { } // set template functions - loadTemplateFunctions(engine) + LoadTemplateFunctions(engine) // load templates onto the engine if err := loadTemplates(engine); err != nil { diff --git a/internal/router/session.go b/internal/router/session.go index 2127d70a7..066024601 100644 --- a/internal/router/session.go +++ b/internal/router/session.go @@ -33,8 +33,8 @@ "github.com/superseriousbusiness/gotosocial/internal/db" ) -// sessionOptions returns the standard set of options to use for each session. -func sessionOptions() sessions.Options { +// SessionOptions returns the standard set of options to use for each session. +func SessionOptions() sessions.Options { return sessions.Options{ Path: "/", Domain: viper.GetString(config.Keys.Host), @@ -75,7 +75,7 @@ func useSession(ctx context.Context, sessionDB db.Session, engine *gin.Engine) e } store := memstore.NewStore(rs.Auth, rs.Crypt) - store.Options(sessionOptions()) + store.Options(SessionOptions()) sessionName, err := SessionName() if err != nil { diff --git a/internal/router/template.go b/internal/router/template.go index 4cc9fde1d..1a0186d6d 100644 --- a/internal/router/template.go +++ b/internal/router/template.go @@ -31,7 +31,7 @@ "github.com/superseriousbusiness/gotosocial/internal/config" ) -// loadTemplates loads html templates for use by the given engine +// LoadTemplates loads html templates for use by the given engine func loadTemplates(engine *gin.Engine) error { cwd, err := os.Getwd() if err != nil { @@ -39,8 +39,13 @@ func loadTemplates(engine *gin.Engine) error { } templateBaseDir := viper.GetString(config.Keys.WebTemplateBaseDir) - tmPath := filepath.Join(cwd, fmt.Sprintf("%s*", templateBaseDir)) + _, err = os.Stat(filepath.Join(cwd, templateBaseDir, "index.tmpl")) + if err != nil { + return fmt.Errorf("%s doesn't seem to contain the templates; index.tmpl is missing: %s", filepath.Join(cwd, templateBaseDir), err) + } + + tmPath := filepath.Join(cwd, fmt.Sprintf("%s*", templateBaseDir)) engine.LoadHTMLGlob(tmPath) return nil } @@ -87,7 +92,7 @@ func visibilityIcon(visibility model.Visibility) template.HTML { return template.HTML(fmt.Sprintf(``, icon.label, icon.faIcon)) } -func loadTemplateFunctions(engine *gin.Engine) { +func LoadTemplateFunctions(engine *gin.Engine) { engine.SetFuncMap(template.FuncMap{ "noescape": noescape, "oddOrEven": oddOrEven, diff --git a/testrig/router.go b/testrig/router.go index 6842c0210..7f883cd6a 100644 --- a/testrig/router.go +++ b/testrig/router.go @@ -20,7 +20,14 @@ import ( "context" + "fmt" + "os" + "path/filepath" + "runtime" + "github.com/gin-gonic/gin" + "github.com/spf13/viper" + "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -33,3 +40,26 @@ func NewTestRouter(db db.DB) router.Router { } return r } + +// ConfigureTemplatesWithGin will panic on any errors related to template loading during tests +func ConfigureTemplatesWithGin(engine *gin.Engine) { + + router.LoadTemplateFunctions(engine) + + // https://stackoverflow.com/questions/31873396/is-it-possible-to-get-the-current-root-of-package-structure-as-a-string-in-golan + _, runtimeCallerLocation, _, _ := runtime.Caller(0) + projectRoot, err := filepath.Abs(filepath.Join(filepath.Dir(runtimeCallerLocation), "../")) + if err != nil { + panic(err) + } + + templateBaseDir := viper.GetString(config.Keys.WebTemplateBaseDir) + + _, err = os.Stat(filepath.Join(projectRoot, templateBaseDir, "index.tmpl")) + if err != nil { + panic(fmt.Errorf("%s doesn't seem to contain the templates; index.tmpl is missing: %s", filepath.Join(projectRoot, templateBaseDir), err)) + } + + tmPath := filepath.Join(projectRoot, fmt.Sprintf("%s*", templateBaseDir)) + engine.LoadHTMLGlob(tmPath) +} diff --git a/web/assets/base.css b/web/assets/base.css index b28cf2533..e105707c2 100644 --- a/web/assets/base.css +++ b/web/assets/base.css @@ -165,6 +165,25 @@ section.login form button { grid-column: 2; } +section.error { + display: flex; + flex-direction: row; + align-items: center; +} +section.error span { + font-size: 2em; +} +section.error pre { + border: 1px solid #ff000080; + margin-left: 1em; + padding: 0 0.7em; + border-radius: 0.5em; + background-color: #ff000010; + font-size: 1.3em; + white-space: pre-wrap; +} + + input, select, textarea { border: 1px solid #fafaff; color: #fafaff; diff --git a/web/gotosocial-styling/templates/base.css b/web/gotosocial-styling/templates/base.css index 8b2f14fbe..4ac639a38 100644 --- a/web/gotosocial-styling/templates/base.css +++ b/web/gotosocial-styling/templates/base.css @@ -165,6 +165,24 @@ section.login { } } +section.error { + display: flex; + flex-direction: row; + align-items: center; + span { + font-size: 2em; + } + pre { + border: 1px solid #ff000080; + margin-left: 1em; + padding: 0 0.7em; + border-radius: 0.5em; + background-color: #ff000010; + font-size: 1.3em; + white-space: pre-wrap; + } +} + input, select, textarea { border: 1px solid $fg; color: $fg; diff --git a/web/template/authorize.tmpl b/web/template/authorize.tmpl index b6eef9561..68a6a2f29 100644 --- a/web/template/authorize.tmpl +++ b/web/template/authorize.tmpl @@ -2,7 +2,13 @@

Hi {{.user}}!

-

Application {{.appname}} {{if len .appwebsite | eq 0 | not}}({{.appwebsite}}) {{end}}would like to perform actions on your behalf, with scope {{.scope}}.

+

+ Application {{.appname}} + {{if len .appwebsite | eq 0 | not}} + ({{.appwebsite}}) + {{end}} + would like to perform actions on your behalf, with scope {{.scope}}. +

The application will redirect to {{.redirect}} to continue.

+{{ template "footer.tmpl" .}} \ No newline at end of file