diff --git a/.gitignore b/.gitignore
index fe72fe018..6f9b97023 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@
# exclude built documentation, since readthedocs will build it for us anyway
/docs/_build
+
+# exclude coverage report
+cp.out
diff --git a/cmd/gotosocial/main.go b/cmd/gotosocial/main.go
index 0b4110656..96798035e 100644
--- a/cmd/gotosocial/main.go
+++ b/cmd/gotosocial/main.go
@@ -95,6 +95,14 @@ func main() {
Value: "postgres",
EnvVars: []string{envNames.DbDatabase},
},
+
+ // TEMPLATE FLAGS
+ &cli.StringFlag{
+ Name: flagNames.TemplateBaseDir,
+ Usage: "Basedir for html templating files for rendering pages and composing emails",
+ Value: "./web/template/",
+ EnvVars: []string{envNames.TemplateBaseDir},
+ },
},
Commands: []*cli.Command{
{
@@ -137,12 +145,12 @@ func main() {
func runAction(c *cli.Context, a action.GTSAction) error {
// create a new *config.Config based on the config path provided...
- conf, err := config.New(c.String(config.GetFlagNames().ConfigPath))
+ conf, err := config.FromFile(c.String(config.GetFlagNames().ConfigPath))
if err != nil {
return fmt.Errorf("error creating config: %s", err)
}
// ... and the flags set on the *cli.Context by urfave
- conf.ParseFlags(c)
+ conf.ParseCLIFlags(c)
// create a logger with the log level, formatting, and output splitter already set
log, err := log.New(conf.LogLevel)
diff --git a/example/config.yaml b/example/config.yaml
index e4d05c634..b65149d9a 100644
--- a/example/config.yaml
+++ b/example/config.yaml
@@ -60,3 +60,10 @@ db:
# Examples: ["mydb","postgres","gotosocial"]
# Default: "postgres"
database: "postgres"
+
+# Config pertaining to templating of web pages/email notifications and the like
+template:
+ # String. Directory from which gotosocial will attempt to load html templates (.tmpl files).
+ # Examples: ["/some/absolute/path/", "./relative/path/", "../../some/weird/path/"]
+ # Default: "./web/template/"
+ baseDir: "./web/template/"
diff --git a/go.mod b/go.mod
index 6f1ede9ed..473ff3ea8 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
github.com/go-session/session v3.1.2+incompatible
github.com/golang/mock v1.4.4 // indirect
github.com/google/uuid v1.2.0 // indirect
- github.com/gotosocial/oauth2/v4 v4.2.1-0.20210316171520-7b12112bbb88
+ github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3
github.com/onsi/ginkgo v1.15.0 // indirect
github.com/onsi/gomega v1.10.5 // indirect
github.com/sirupsen/logrus v1.8.0
diff --git a/go.sum b/go.sum
index f482187af..0b12241f0 100644
--- a/go.sum
+++ b/go.sum
@@ -107,6 +107,12 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotosocial/oauth2/v4 v4.2.1-0.20210316171520-7b12112bbb88 h1:YJ//HmHOYJ4srm/LA6VPNjNisneMbY6TTM1xttV/ZQU=
github.com/gotosocial/oauth2/v4 v4.2.1-0.20210316171520-7b12112bbb88/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8=
+github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318132047-b7df44000ea6 h1:mWWMTK2Boy6FSCi45WB6GVCcXW3IoTVJKJiHmmdjywU=
+github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318132047-b7df44000ea6/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8=
+github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318132554-68b81fe90e62 h1:duqoA9NSY+BFY2IVveXx5lSvIQliVvPsaNMdspkTJPc=
+github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318132554-68b81fe90e62/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8=
+github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3 h1:CKRz5d7mRum+UMR88Ue33tCYcej14WjUsB59C02DDqY=
+github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
diff --git a/internal/api/server.go b/internal/api/server.go
index 321c932cd..8e22742bb 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -19,6 +19,10 @@
package api
import (
+ "fmt"
+ "os"
+ "path/filepath"
+
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/memstore"
"github.com/gin-gonic/gin"
@@ -71,6 +75,10 @@ func New(config *config.Config, logger *logrus.Logger) Server {
engine := gin.New()
store := memstore.NewStore([]byte("authentication-key"), []byte("encryption-keyencryption-key----"))
engine.Use(sessions.Sessions("gotosocial-session", store))
+ cwd, _ := os.Getwd()
+ tmPath := filepath.Join(cwd, fmt.Sprintf("%s*", config.TemplateConfig.BaseDir))
+ logger.Debugf("loading templates from %s", tmPath)
+ engine.LoadHTMLGlob(tmPath)
return &server{
APIGroup: engine.Group("/api").Group("/v1"),
logger: logger,
diff --git a/internal/config/config.go b/internal/config/config.go
index 5832ed53f..8e2656e3f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -27,25 +27,38 @@
// Config pulls together all the configuration needed to run gotosocial
type Config struct {
- LogLevel string `yaml:"logLevel"`
- ApplicationName string `yaml:"applicationName"`
- DBConfig *DBConfig `yaml:"db"`
+ LogLevel string `yaml:"logLevel"`
+ ApplicationName string `yaml:"applicationName"`
+ DBConfig *DBConfig `yaml:"db"`
+ TemplateConfig *TemplateConfig `yaml:"template"`
}
-// New returns a new config, or an error if something goes amiss.
-// The path parameter is optional, for loading a configuration json from the given path.
-func New(path string) (*Config, error) {
- config := &Config{
- DBConfig: &DBConfig{},
- }
- if path != "" {
- var err error
- if config, err = loadFromFile(path); err != nil {
- return nil, fmt.Errorf("error creating config: %s", err)
- }
+// FromFile returns a new config from a file, or an error if something goes amiss.
+func FromFile(path string) (*Config, error) {
+ c, err := loadFromFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("error creating config: %s", err)
}
+ return c, nil
+}
- return config, nil
+// Default returns a new config with default values.
+// Not yet implemented.
+func Default() *Config {
+ // TODO: find a way of doing this without code repetition, because having to
+ // repeat all values here and elsewhere is annoying and gonna be prone to mistakes.
+ return &Config{
+ DBConfig: &DBConfig{},
+ TemplateConfig: &TemplateConfig{},
+ }
+}
+
+// Empty just returns an empty config
+func Empty() *Config {
+ return &Config{
+ DBConfig: &DBConfig{},
+ TemplateConfig: &TemplateConfig{},
+ }
}
// loadFromFile takes a path to a yaml file and attempts to load a Config object from it
@@ -63,8 +76,8 @@ func loadFromFile(path string) (*Config, error) {
return config, nil
}
-// ParseFlags sets flags on the config using the provided Flags object
-func (c *Config) ParseFlags(f KeyedFlags) {
+// ParseCLIFlags sets flags on the config using the provided Flags object
+func (c *Config) ParseCLIFlags(f KeyedFlags) {
fn := GetFlagNames()
// For all of these flags, we only want to set them on the config if:
@@ -108,6 +121,11 @@ func (c *Config) ParseFlags(f KeyedFlags) {
if c.DBConfig.Database == "" || f.IsSet(fn.DbDatabase) {
c.DBConfig.Database = f.String(fn.DbDatabase)
}
+
+ // template flags
+ if c.TemplateConfig.BaseDir == "" || f.IsSet(fn.TemplateBaseDir) {
+ c.TemplateConfig.BaseDir = f.String(fn.TemplateBaseDir)
+ }
}
// KeyedFlags is a wrapper for any type that can store keyed flags and give them back.
@@ -130,6 +148,7 @@ type Flags struct {
DbUser string
DbPassword string
DbDatabase string
+ TemplateBaseDir string
}
// GetFlagNames returns a struct containing the names of the various flags used for
@@ -145,6 +164,7 @@ func GetFlagNames() Flags {
DbUser: "db-user",
DbPassword: "db-password",
DbDatabase: "db-database",
+ TemplateBaseDir: "template-basedir",
}
}
@@ -161,5 +181,6 @@ func GetEnvNames() Flags {
DbUser: "GTS_DB_USER",
DbPassword: "GTS_DB_PASSWORD",
DbDatabase: "GTS_DB_DATABASE",
+ TemplateBaseDir: "GTS_TEMPLATE_BASEDIR",
}
}
diff --git a/internal/config/template.go b/internal/config/template.go
new file mode 100644
index 000000000..eba86f8e6
--- /dev/null
+++ b/internal/config/template.go
@@ -0,0 +1,25 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package config
+
+// TemplateConfig pertains to templating of web pages/email notifications and the like
+type TemplateConfig struct {
+ // Directory from which gotosocial will attempt to load html templates (.tmpl files).
+ BaseDir string `yaml:"baseDir"`
+}
diff --git a/internal/oauth/html.go b/internal/oauth/html.go
index 06089aedd..a3ae4318a 100644
--- a/internal/oauth/html.go
+++ b/internal/oauth/html.go
@@ -2,67 +2,8 @@
const (
signInHTML = `
-
-
-
-
-`
+`
)
diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go
index 97913549a..c6d596761 100644
--- a/internal/oauth/oauth.go
+++ b/internal/oauth/oauth.go
@@ -27,6 +27,7 @@
"github.com/go-pg/pg/v10"
"github.com/gotosocial/gotosocial/internal/api"
"github.com/gotosocial/gotosocial/internal/gtsmodel"
+ "github.com/gotosocial/gotosocial/pkg/mastotypes"
"github.com/gotosocial/oauth2/v4"
"github.com/gotosocial/oauth2/v4/errors"
"github.com/gotosocial/oauth2/v4/manage"
@@ -45,16 +46,12 @@ type API struct {
}
type login struct {
- Email string `form:"username"`
+ Email string `form:"username"`
Password string `form:"password"`
}
-type authorize struct {
- ForceLogin string `form:"force_login,omitempty"`
- ResponseType string `form:"response_type"`
- ClientID string `form:"client_id"`
- RedirectURI string `form:"redirect_uri"`
- Scope string `form:"scope,omitempty"`
+type code struct {
+ Code string `form:"code"`
}
func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.Logger) *API {
@@ -79,6 +76,9 @@ func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.L
oauth2.AuthorizationCode,
oauth2.Refreshing,
},
+ AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{
+ oauth2.CodeChallengePlain,
+ },
}
srv := server.NewServer(sc, manager)
@@ -106,9 +106,13 @@ func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.L
func (a *API) AddRoutes(s api.Server) error {
s.AttachHandler(http.MethodGet, "/auth/sign_in", a.SignInGETHandler)
s.AttachHandler(http.MethodPost, "/auth/sign_in", a.SignInPOSTHandler)
+
s.AttachHandler(http.MethodPost, "/oauth/token", a.TokenHandler)
+
s.AttachHandler(http.MethodGet, "/oauth/authorize", a.AuthorizeGETHandler)
- s.AttachHandler(methodAny, "/auth", a.AuthHandler)
+ s.AttachHandler(http.MethodPost, "/oauth/authorize", a.AuthorizePOSTHandler)
+
+ // s.AttachHandler(http.MethodGet, "/auth", a.AuthGETHandler)
return nil
}
@@ -125,7 +129,7 @@ func incorrectPassword() (string, error) {
// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler
func (a *API) SignInGETHandler(c *gin.Context) {
a.log.WithField("func", "SignInGETHandler").Trace("serving sign in html")
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(signInHTML))
+ c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{})
}
// SignInPOSTHandler should be served at https://example.org/auth/sign_in.
@@ -135,15 +139,16 @@ func (a *API) SignInPOSTHandler(c *gin.Context) {
l := a.log.WithField("func", "SignInPOSTHandler")
s := sessions.Default(c)
form := &login{}
- if err := c.ShouldBind(form); err != nil || form.Email == "" || form.Password == "" {
+ if err := c.ShouldBind(form); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
l.Tracef("parsed form: %+v", form)
- userid, err := a.ValidatePassword(form.Email, form.Password);
+ userid, err := a.ValidatePassword(form.Email, form.Password)
if err != nil {
c.String(http.StatusForbidden, err.Error())
+ return
}
s.Set("username", userid)
@@ -151,11 +156,12 @@ func (a *API) SignInPOSTHandler(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+
l.Trace("redirecting to auth page")
- c.Redirect(http.StatusFound, "/auth")
+ c.Redirect(http.StatusFound, "/oauth/authorize")
}
-// TokenHandler should be served at https://example.org/oauth/token
+// TokenHandler should be served as a POST at https://example.org/oauth/token
// The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs.
// See https://docs.joinmastodon.org/methods/apps/oauth/#obtain-a-token
func (a *API) TokenHandler(c *gin.Context) {
@@ -166,50 +172,61 @@ func (a *API) TokenHandler(c *gin.Context) {
}
}
-// AuthorizeHandler should be served as GET at https://example.org/oauth/authorize
+// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize
// The idea here is to present an oauth authorize page to the user, with a button
// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
func (a *API) AuthorizeGETHandler(c *gin.Context) {
- l := a.log.WithField("func", "AuthorizeHandler")
+ l := a.log.WithField("func", "AuthorizeGETHandler")
s := sessions.Default(c)
- form := &authorize{}
-
- if err := c.ShouldBind(form); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- l.Tracef("parsed form: %+v", form)
-
- if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "missing one of: response_type, client_id or redirect_uri"})
- return
- }
-
- s.Set("force_login", form.ForceLogin)
- s.Set("response_type", form.ResponseType)
- s.Set("client_id", form.ClientID)
- s.Set("redirect_uri", form.RedirectURI)
- s.Set("scope", form.Scope)
- if err := s.Save(); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- }
v := s.Get("username")
if username, ok := v.(string); !ok || username == "" {
- l.Trace("username was empty, redirecting to sign in page")
+ l.Trace("username was empty, parsing form then redirecting to sign in page")
+
+ form := &mastotypes.OAuthAuthorize{}
+
+ if err := c.ShouldBind(form); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ l.Tracef("parsed form: %+v", form)
+
+ if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing one of: response_type, client_id or redirect_uri"})
+ return
+ }
+
+ // save these values from the form so we can use them elsewhere in the session
+ s.Set("force_login", form.ForceLogin)
+ s.Set("response_type", form.ResponseType)
+ s.Set("client_id", form.ClientID)
+ s.Set("redirect_uri", form.RedirectURI)
+ s.Set("scope", form.Scope)
+ if err := s.Save(); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
c.Redirect(http.StatusFound, "/auth/sign_in")
return
}
l.Trace("serving authorize html")
- c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(authorizeHTML))
+ c.HTML(http.StatusOK, "authorize.tmpl", gin.H{})
}
-// AuthHandler should be served at https://example.org/auth
-func (a *API) AuthHandler(c *gin.Context) {
- l := a.log.WithField("func", "AuthHandler")
+// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize
+// The idea here is to present an oauth authorize page to the user, with a button
+// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
+func (a *API) AuthorizePOSTHandler(c *gin.Context) {
+ l := a.log.WithField("func", "AuthorizePOSTHandler")
s := sessions.Default(c)
+ v := s.Get("username")
+ if username, ok := v.(string); !ok || username == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "you are not signed in"})
+ }
+
values := url.Values{}
if v, ok := s.Get("force_login").(string); !ok {
@@ -277,7 +294,13 @@ func (a *API) AuthHandler(c *gin.Context) {
// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db.
func (a *API) ValidatePassword(email string, password string) (userid string, err error) {
l := a.log.WithField("func", "PasswordAuthorizationHandler")
- l.Tracef("email %s password %s", email, password)
+
+ // make sure an email/password was provided and bail if not
+ if email == "" || password == "" {
+ l.Debug("email or password was not provided")
+ return incorrectPassword()
+ }
+
// first we select the user from the database based on email address, bail if no user found for that email
gtsUser := >smodel.User{}
if err := a.conn.Model(gtsUser).Where("email = ?", email).Select(); err != nil {
@@ -297,8 +320,7 @@ func (a *API) ValidatePassword(email string, password string) (userid string, er
return incorrectPassword()
}
- // If we've made it this far the email/password is correct so we need the oauth client-id of the user
- // This is, conveniently, the same as the user ID, so we can just return it.
+ // If we've made it this far the email/password is correct, so we can just return the id of the user.
userid = gtsUser.ID
l.Tracef("returning (%s, %s)", userid, err)
return
diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go
index 404ea5c20..901f99b64 100644
--- a/internal/oauth/oauth_test.go
+++ b/internal/oauth/oauth_test.go
@@ -40,17 +40,24 @@ func (suite *OauthTestSuite) SetupSuite() {
suite.testUser = >smodel.User{
ID: userID,
EncryptedPassword: string(encryptedPassword),
- Email: "user@localhost",
+ Email: "user@example.org",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
- AccountID: "some-account-id-it-doesn't-matter-really",
+ AccountID: "some-account-id-it-doesn't-matter-really-since-this-user-doesn't-actually-have-an-account!",
}
suite.testClient = &oauthClient{
ID: "a-known-client-id",
Secret: "some-secret",
- Domain: "http://localhost:8080",
+ Domain: "https://example.org",
UserID: userID,
}
+
+ // because go tests are run within the test package directory, we need to fiddle with the templateconfig
+ // basedir in a way that we wouldn't normally have to do when running the binary, in order to make
+ // the templates actually load
+ c := config.Empty()
+ c.TemplateConfig.BaseDir = "../../web/template/"
+ suite.config = c
}
// SetupTest creates a postgres connection and creates the oauth_clients table before each test
@@ -114,7 +121,7 @@ func (suite *OauthTestSuite) TestAPIInitialize() {
api.AddRoutes(r)
go r.Start()
time.Sleep(30 * time.Second)
- // http://localhost:8080/oauth/authorize?client_id=a-known-client-id&redirect_uri=''&response_type=code
+ // http://localhost:8080/oauth/authorize?client_id=a-known-client-id&response_type=code&redirect_uri=https://example.org
}
func TestOauthTestSuite(t *testing.T) {
diff --git a/pkg/mastotypes/oauth.go b/pkg/mastotypes/oauth.go
new file mode 100644
index 000000000..1b45b38e0
--- /dev/null
+++ b/pkg/mastotypes/oauth.go
@@ -0,0 +1,37 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package mastotypes
+
+// OAuthAuthorize represents a request sent to https://example.org/oauth/authorize
+// See here: https://docs.joinmastodon.org/methods/apps/oauth/
+type OAuthAuthorize struct {
+ // Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance.
+ ForceLogin string `form:"force_login,omitempty"`
+ // Should be set equal to `code`.
+ ResponseType string `form:"response_type"`
+ // Client ID, obtained during app registration.
+ ClientID string `form:"client_id"`
+ // Set a URI to redirect the user to.
+ // If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead.
+ // Must match one of the redirect URIs declared during app registration.
+ RedirectURI string `form:"redirect_uri"`
+ // List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters).
+ // Must be a subset of scopes declared during app registration. If not provided, defaults to read.
+ Scope string `form:"scope,omitempty"`
+}
diff --git a/web/template/authorize.tmpl b/web/template/authorize.tmpl
new file mode 100644
index 000000000..0043e21ba
--- /dev/null
+++ b/web/template/authorize.tmpl
@@ -0,0 +1,33 @@
+
+
+
+
+ Auth
+
+
+
+
+
+
+