diff --git a/assets/scss/style.scss b/assets/scss/style.scss index 806ff8c..84195a0 100644 --- a/assets/scss/style.scss +++ b/assets/scss/style.scss @@ -24,3 +24,7 @@ body { flex-direction: column; } } + +.hidden { + display: none; +} diff --git a/cmd/seed/seed.go b/cmd/seed/seed.go index 6ea7067..711d0fc 100644 --- a/cmd/seed/seed.go +++ b/cmd/seed/seed.go @@ -30,7 +30,7 @@ func run(c *cli.Context) error { return errors.Wrap(err, "creating postgres database") } - a, err := app.NewApp(cfg, db) + a, err := app.NewApp(c.Context, cfg, db) if err != nil { return errors.Wrap(err, "creating app") } diff --git a/cmd/web/cmd.go b/cmd/web/cmd.go index e607e7e..428854b 100644 --- a/cmd/web/cmd.go +++ b/cmd/web/cmd.go @@ -39,7 +39,7 @@ func run(c *cli.Context) error { return errors.Wrap(err, "creating postgres database") } - a, err := app.NewApp(cfg, db) + a, err := app.NewApp(c.Context, cfg, db) if err != nil { return errors.Wrap(err, "creating app") } diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..1df3b6c --- /dev/null +++ b/config.example.toml @@ -0,0 +1,8 @@ +[core] +postgres = "postgresql://mercury:password@localhost/mercury" +dev = true +secret_key = "" # generate with `openssl rand -base64 48` + +[web] +domain = "http://mercury.local" +port = 8000 diff --git a/config/config.go b/config/config.go index 22550b9..7ce05b3 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type WebConfig struct { } type CoreConfig struct { - Postgres string `toml:"postgres"` - Dev bool `toml:"dev"` + Postgres string `toml:"postgres"` + Dev bool `toml:"dev"` + SecretKey string `toml:"secret_key"` } diff --git a/go.mod b/go.mod index c95dc37..2b2506c 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/georgysavva/scany/v2 v2.0.0 github.com/go-chi/chi/v5 v5.0.8 github.com/go-chi/render v1.0.2 + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/jackc/pgx/v5 v5.0.0 github.com/keegancsmith/sqlf v1.1.1 github.com/oklog/ulid/v2 v2.1.0 diff --git a/go.sum b/go.sum index 8364a8a..8a8509b 100644 --- a/go.sum +++ b/go.sum @@ -138,6 +138,8 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/internal/concurrent/value.go b/internal/concurrent/value.go new file mode 100644 index 0000000..8e79e24 --- /dev/null +++ b/internal/concurrent/value.go @@ -0,0 +1,30 @@ +package concurrent + +import "sync" + +// Value is a struct providing concurrent access to another type. +// Note: T should be a concrete type, not a pointer. +type Value[T any] struct { + val T + mu sync.RWMutex +} + +func NewValue[T any](value T) *Value[T] { + return &Value[T]{ + val: value, + } +} + +func (v *Value[T]) Get() T { + v.mu.RLock() + val := v.val + v.mu.RUnlock() + + return val +} + +func (v *Value[T]) Set(new T) { + v.mu.Lock() + v.val = new + v.mu.Unlock() +} diff --git a/internal/database/config.go b/internal/database/config.go index 767a0af..35cc0e6 100644 --- a/internal/database/config.go +++ b/internal/database/config.go @@ -3,8 +3,12 @@ package database import "github.com/oklog/ulid/v2" type Config struct { - Name string - AdminID *ulid.ULID + Name string + AdminID *ulid.ULID + InternalApplication *ulid.ULID + + // ID is always 1 + ID int } var DefaultConfig = Config{ diff --git a/internal/database/migrations/1680618019_create_initial_tables.sql b/internal/database/migrations/1680618019_create_initial_tables.sql index e03c941..cd2337c 100644 --- a/internal/database/migrations/1680618019_create_initial_tables.sql +++ b/internal/database/migrations/1680618019_create_initial_tables.sql @@ -38,7 +38,31 @@ create table posts ( visibility post_visibility not null ); +create table applications ( + id text primary key, + name text not null +); + +create table tokens ( + id text primary key, + app_id text not null references applications (id) on delete cascade, + user_id text not null references accounts (id) on delete cascade, + scopes text[] not null default array[]::text[], + expires timestamptz not null +); + +create table config ( + id int primary key not null default 1, + name text not null, -- instance name + admin_id text references blogs (id) on delete set null, -- admin contact + internal_application text references applications (id) on delete set null, + constraint singleton check (id = 1) -- only one config table entry +); + -- +migrate Down +drop table config; +drop table tokens; +drop table applications; drop table accounts; drop table blogs; drop table posts; diff --git a/internal/database/sql/account.go b/internal/database/sql/account.go index fc4c47f..08a906a 100644 --- a/internal/database/sql/account.go +++ b/internal/database/sql/account.go @@ -37,8 +37,13 @@ func (s *AccountStore) ByID(ctx context.Context, id ulid.ULID) (a database.Accou } // ByUsername gets an account by its username. -func (s *AccountStore) ByUsername(ctx context.Context, username string, host *string) (a database.Account, err error) { - q := sqlf.Sprintf("SELECT * FROM accounts WHERE username = %s AND host = %v", username, host) +func (s *AccountStore) ByUsername(ctx context.Context, username, domain string) (a database.Account, err error) { + q := sqlf.Sprintf("SELECT * FROM accounts WHERE username = %s", username) + if domain == "" { + q = sqlf.Sprintf("%v AND domain IS NULL", q) + } else { + q = sqlf.Sprintf("%v AND domain = %s", q, domain) + } a, err = Get[database.Account](ctx, s.q, q) if err != nil { @@ -64,7 +69,7 @@ func (s *AccountStore) CreateLocal( } q := sqlf.Sprintf( - "INSERT INTO accounts (id, username, host, email, password) VALUES (%s, %v, NULL, %v, %v) RETURNING *", + "INSERT INTO accounts (id, username, domain, email, password) VALUES (%s, %v, NULL, %v, %v) RETURNING *", makeULID(), username, email, hash) a, err = Get[database.Account](ctx, s.q, q) diff --git a/internal/database/sql/config.go b/internal/database/sql/config.go new file mode 100644 index 0000000..20770f5 --- /dev/null +++ b/internal/database/sql/config.go @@ -0,0 +1,51 @@ +package sql + +import ( + "context" + + "emperror.dev/errors" + "git.sleepycat.moe/sam/mercury/internal/database" + "github.com/keegancsmith/sqlf" +) + +// ConfigStore is the interface to configs in the database. +type ConfigStore struct { + q Querier +} + +// NewConfigStore creates a new ConfigStore instance. +func NewConfigStore(q Querier) *ConfigStore { + return &ConfigStore{q: q} +} + +func (s *ConfigStore) initConfig(ctx context.Context) error { + q := sqlf.Sprintf(`INSERT INTO config + (id, name) + VALUES (1, %s) + ON CONFLICT (id) DO NOTHING`, database.DefaultConfig.Name) + + err := Exec(ctx, s.q, q) + return errors.Wrap(err, "executing query") +} + +func (s *ConfigStore) Get(ctx context.Context) (database.Config, error) { + q := sqlf.Sprintf("SELECT * FROM config WHERE id = 1") + + return Get[database.Config](ctx, s.q, q) +} + +func (s *ConfigStore) Set(ctx context.Context, cur, new database.Config) (database.Config, error) { + q := sqlf.Sprintf("UPDATE config SET") + if cur.Name != new.Name { + q = sqlf.Sprintf("%v name = %v,", q, new.Name) + } + if cur.AdminID != new.AdminID { + q = sqlf.Sprintf("%v admin_id = %v,", q, new.AdminID) + } + if cur.InternalApplication != new.InternalApplication { + q = sqlf.Sprintf("%v internal_application = %v,", q, new.InternalApplication) + } + q = sqlf.Sprintf("%v id = %v WHERE id = %v RETURNING *", q, cur.ID, cur.ID) + + return Get[database.Config](ctx, s.q, q) +} diff --git a/internal/database/sql/database.go b/internal/database/sql/database.go index c76a4cb..766dd07 100644 --- a/internal/database/sql/database.go +++ b/internal/database/sql/database.go @@ -5,6 +5,7 @@ import ( "context" "emperror.dev/errors" + "git.sleepycat.moe/sam/mercury/internal/database" "github.com/jackc/pgx/v5/pgxpool" "github.com/oklog/ulid/v2" ) @@ -24,9 +25,55 @@ func NewBase(ctx context.Context, connString string) (*Base, error) { base := &Base{ pool: pool, } + + // create configuration + if err := base.initSingletons(ctx); err != nil { + return nil, errors.Wrap(err, "initializing configuration") + } + return base, nil } +func (base *Base) initSingletons(ctx context.Context) error { + err := NewConfigStore(base.pool).initConfig(ctx) + if err != nil { + return errors.Wrap(err, "initializing configuration") + } + + cfg, err := NewConfigStore(base.pool).Get(ctx) + if err != nil { + return errors.Wrap(err, "getting configuration") + } + + if cfg.InternalApplication == nil { + tx, err := base.BeginTx(ctx) + if err != nil { + return errors.Wrap(err, "creating transaction") + } + defer tx.Rollback(ctx) + + app, err := NewTokenStore(tx).CreateApplication(ctx, database.InternalApplicationName) + if err != nil { + return errors.Wrap(err, "creating internal application") + } + + newCfg := cfg + newCfg.InternalApplication = &app.ID + + cfg, err = NewConfigStore(tx).Set(ctx, cfg, newCfg) + if err != nil { + return errors.Wrap(err, "updating configuration") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + } + + return nil +} + // Acquire acquires a connection from the database pool. // It is the caller's responsibility to call the Release method. func (base *Base) Acquire(ctx context.Context) (ReleaseableQuerier, error) { diff --git a/internal/database/sql/queries.go b/internal/database/sql/queries.go index ccebc9c..823af59 100644 --- a/internal/database/sql/queries.go +++ b/internal/database/sql/queries.go @@ -48,3 +48,8 @@ func Get[T any](ctx context.Context, querier Querier, query *sqlf.Query) (T, err } return dst, nil } + +func Exec(ctx context.Context, querier Querier, query *sqlf.Query) error { + _, err := querier.Exec(ctx, query.Query(sqlf.PostgresBindVar), query.Args()...) + return err +} diff --git a/internal/database/sql/token.go b/internal/database/sql/token.go new file mode 100644 index 0000000..9f224b2 --- /dev/null +++ b/internal/database/sql/token.go @@ -0,0 +1,52 @@ +package sql + +import ( + "context" + "time" + + "emperror.dev/errors" + "git.sleepycat.moe/sam/mercury/internal/database" + "github.com/keegancsmith/sqlf" + "github.com/oklog/ulid/v2" +) + +// TokenStore is the interface to tokens in the database. +type TokenStore struct { + q Querier +} + +// NewTokenStore creates a new TokenStore instance. +func NewTokenStore(q Querier) *TokenStore { + return &TokenStore{q: q} +} + +func (s *TokenStore) Get(ctx context.Context, id ulid.ULID) (database.Token, error) { + q := sqlf.Sprintf("SELECT * FROM tokens WHERE id = %s", id) + + t, err := Get[database.Token](ctx, s.q, q) + return t, errors.Wrap(err, "executing query") +} + +func (s *TokenStore) GetApplication(ctx context.Context, id ulid.ULID) (database.Application, error) { + q := sqlf.Sprintf("SELECT * FROM applications WHERE id = %s", id) + + app, err := Get[database.Application](ctx, s.q, q) + return app, errors.Wrap(err, "executing query") +} + +func (s *TokenStore) Create(ctx context.Context, userID, appID ulid.ULID, scopes []string, expires time.Time) (database.Token, error) { + q := sqlf.Sprintf(`INSERT INTO tokens + (id, user_id, app_id, scopes, expires) + values (%s, %s, %s, %v, %v) + RETURNING *`, makeULID(), userID, appID, scopes, expires) + + t, err := Get[database.Token](ctx, s.q, q) + return t, errors.Wrap(err, "executing query") +} + +func (s *TokenStore) CreateApplication(ctx context.Context, name string) (database.Application, error) { + q := sqlf.Sprintf("INSERT INTO applications (id, name) VALUES (%s, %s) RETURNING *", makeULID(), name) + + app, err := Get[database.Application](ctx, s.q, q) + return app, errors.Wrap(err, "executing query") +} diff --git a/internal/database/token.go b/internal/database/token.go new file mode 100644 index 0000000..0477d66 --- /dev/null +++ b/internal/database/token.go @@ -0,0 +1,45 @@ +package database + +import ( + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/oklog/ulid/v2" +) + +const InternalApplicationName = "mercury_internal" +const TokenCookieName = "mercury_token" + +type Application struct { + ID ulid.ULID + Name string +} + +type Token struct { + ID ulid.ULID + AppID ulid.ULID + UserID ulid.ULID + Scopes []string + Expires time.Time +} + +func (t Token) ToClaims() TokenClaims { + createdAt := ulid.Time(t.ID.Time()) + + return TokenClaims{ + TokenID: t.ID, + UserID: t.UserID, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "mercury", + ExpiresAt: jwt.NewNumericDate(t.Expires), + IssuedAt: jwt.NewNumericDate(createdAt), + NotBefore: jwt.NewNumericDate(createdAt), + }, + } +} + +type TokenClaims struct { + TokenID ulid.ULID `json:"jti"` + UserID ulid.ULID `json:"sub"` + jwt.RegisteredClaims +} diff --git a/web/app/app.go b/web/app/app.go index bd97e2b..2a388de 100644 --- a/web/app/app.go +++ b/web/app/app.go @@ -1,8 +1,13 @@ package app import ( + "context" + "encoding/base64" + "emperror.dev/errors" "git.sleepycat.moe/sam/mercury/config" + "git.sleepycat.moe/sam/mercury/internal/concurrent" + "git.sleepycat.moe/sam/mercury/internal/database" "git.sleepycat.moe/sam/mercury/internal/database/sql" "git.sleepycat.moe/sam/mercury/web/templates" "github.com/flosch/pongo2/v6" @@ -10,31 +15,54 @@ import ( "github.com/go-chi/chi/v5/middleware" ) +const ( + ErrSecretKeyEmpty = errors.Sentinel("core.secret_key cannot be empty") +) + type App struct { Router chi.Router - Config config.Config - Database *sql.Base + AppConfig config.Config + DBConfig *concurrent.Value[database.Config] + Database *sql.Base - tmpl *pongo2.TemplateSet + tmpl *pongo2.TemplateSet + tokenKey []byte } -func NewApp(cfg config.Config, db *sql.Base) (*App, error) { +func NewApp(ctx context.Context, cfg config.Config, db *sql.Base) (*App, error) { app := &App{ - Router: chi.NewRouter(), - Config: cfg, - Database: db, + Router: chi.NewRouter(), + AppConfig: cfg, + Database: db, } + if cfg.Core.SecretKey == "" { + return nil, ErrSecretKeyEmpty + } + tokenKey, err := base64.RawStdEncoding.DecodeString(cfg.Core.SecretKey) + if err != nil { + return nil, errors.Wrap(err, "decoding core.secret_key") + } + app.tokenKey = tokenKey + tmpl, err := templates.New(cfg.Core.Dev) if err != nil { return nil, errors.Wrap(err, "creating templates") } app.tmpl = tmpl - app.Router.Use(app.Logger) + if cfg.Core.Dev { + app.Router.Use(app.Logger) + } app.Router.Use(middleware.Recoverer) + dbCfg, err := app.Config().Get(ctx) + if err != nil { + return nil, errors.Wrap(err, "getting database config") + } + app.DBConfig = concurrent.NewValue(dbCfg) + return app, nil } @@ -52,9 +80,23 @@ func (a *App) Blog(q ...sql.Querier) *sql.BlogStore { return sql.NewBlogStore(q[0]) } +func (a *App) Config(q ...sql.Querier) *sql.ConfigStore { + if len(q) == 0 || q[0] == nil { + return sql.NewConfigStore(a.Database.PoolQuerier()) + } + return sql.NewConfigStore(q[0]) +} + func (a *App) Post(q ...sql.Querier) *sql.PostStore { if len(q) == 0 || q[0] == nil { return sql.NewPostStore(a.Database.PoolQuerier()) } return sql.NewPostStore(q[0]) } + +func (a *App) Token(q ...sql.Querier) *sql.TokenStore { + if len(q) == 0 || q[0] == nil { + return sql.NewTokenStore(a.Database.PoolQuerier()) + } + return sql.NewTokenStore(q[0]) +} diff --git a/web/app/log.go b/web/app/log.go index 1bb3f6d..7e00dc1 100644 --- a/web/app/log.go +++ b/web/app/log.go @@ -29,7 +29,7 @@ func (a *App) Logger(next http.Handler) http.Handler { } // log end request - log.Info(). + log.Debug(). Timestamp(). Str("remote_ip", r.RemoteAddr). Str("url", r.URL.Path). diff --git a/web/app/template.go b/web/app/template.go index cf5d7ec..f1e3023 100644 --- a/web/app/template.go +++ b/web/app/template.go @@ -14,6 +14,7 @@ func (app *App) Template(w http.ResponseWriter, r *http.Request, tmplName string } tctx := pongo2.Context{ + "config": app.DBConfig.Get(), "flash_message": app.getFlash(w, r), } tctx.Update(ctx) diff --git a/web/app/token.go b/web/app/token.go new file mode 100644 index 0000000..b8f4eec --- /dev/null +++ b/web/app/token.go @@ -0,0 +1,41 @@ +package app + +import ( + "context" + "fmt" + + "emperror.dev/errors" + "git.sleepycat.moe/sam/mercury/internal/database" + "github.com/golang-jwt/jwt/v4" +) + +func (app *App) TokenToJWT(token database.Token) (string, error) { + t := jwt.NewWithClaims(jwt.SigningMethodHS256, token.ToClaims()) + + return t.SignedString(app.tokenKey) +} + +func (app *App) ParseToken(ctx context.Context, token string) (t database.Token, err error) { + parsed, err := jwt.ParseWithClaims(token, &database.TokenClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf(`unexpected signing method "%v"`, t.Header["alg"]) + } + + return app.tokenKey, nil + }) + + if err != nil { + return t, errors.Wrap(err, "parsing token") + } + + c, ok := parsed.Claims.(*database.TokenClaims) + if !ok || !parsed.Valid { + return t, fmt.Errorf("unknown claims type %T", parsed.Claims) + } + + t, err = app.Token().Get(ctx, c.TokenID) + if err != nil { + return t, errors.Wrap(err, "getting token from database") + } + return t, nil +} diff --git a/web/auth/login.go b/web/auth/login.go index 6be1e2f..194a633 100644 --- a/web/auth/login.go +++ b/web/auth/login.go @@ -1,11 +1,92 @@ package auth import ( + "math" "net/http" + "time" + "git.sleepycat.moe/sam/mercury/internal/database" "github.com/flosch/pongo2/v6" + "github.com/rs/zerolog/log" ) func (app *Auth) GetLogin(w http.ResponseWriter, r *http.Request) { - app.Template(w, r, "auth/login.tpl", pongo2.Context{}) + app.Template(w, r, "auth/login.tpl", pongo2.Context{ + "totp": false, + }) +} + +func (app *Auth) PostLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + username := r.FormValue("username") + password := r.FormValue("password") + if username == "" { + app.Flash(w, "Username cannot be empty.") + app.Template(w, r, "auth/login.tpl", pongo2.Context{ + "totp": false, + "flash_message": "Username cannot be empty.", + }) + return + } else if password == "" { + app.Flash(w, "Password cannot be empty.") + app.Template(w, r, "auth/login.tpl", pongo2.Context{ + "totp": false, + "flash_message": "Password cannot be empty.", + }) + return + } + + conn, err := app.Database.Acquire(ctx) + if err != nil { + log.Err(err).Msg("acquiring database connection") + return + } + defer conn.Release() + + acct, err := app.Account(conn).ByUsername(ctx, username, "") + if err != nil { + log.Err(err).Msg("finding account") + + app.Flash(w, "Username or password is invalid.") + app.Template(w, r, "auth/login.tpl", pongo2.Context{ + "totp": false, + "flash_message": "Username or password is invalid.", + }) + return + } + + passwordValid, _ := acct.PasswordValid(password) + + if !passwordValid { + app.Template(w, r, "auth/login.tpl", pongo2.Context{ + "totp": false, + "flash_message": "Username or password is invalid.", + }) + return + } + + // TODO: totp + + // create a new token + token, err := app.Token(conn).Create( + ctx, acct.ID, *app.DBConfig.Get().InternalApplication, []string{"all"}, time.Now().Add(math.MaxInt64)) + if err != nil { + log.Err(err).Msg("creating token") + return + } + + ts, err := app.TokenToJWT(token) + if err != nil { + log.Err(err).Msg("signing token string") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: database.TokenCookieName, + Value: ts, + Path: "/", + Expires: token.Expires, + }) + http.Redirect(w, r, "/web", http.StatusSeeOther) } diff --git a/web/auth/signup.go b/web/auth/signup.go new file mode 100644 index 0000000..7360fae --- /dev/null +++ b/web/auth/signup.go @@ -0,0 +1,67 @@ +package auth + +import ( + "net/http" + + "git.sleepycat.moe/sam/mercury/internal/database/sql" + "github.com/flosch/pongo2/v6" + "github.com/rs/zerolog/log" +) + +func (app *Auth) GetSignup(w http.ResponseWriter, r *http.Request) { + app.Template(w, r, "auth/signup.tpl", pongo2.Context{}) +} + +func (app *Auth) PostSignup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + username := r.FormValue("username") + email := r.FormValue("email") + password := r.FormValue("password") + password2 := r.FormValue("repeat_password") + if username == "" { + app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Username cannot be empty."}) + return + } else if password == "" { + app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Password cannot be empty."}) + return + } else if password != password2 { + app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Passwords don't match."}) + return + } else if email == "" { + app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Email address cannot be empty."}) + return + } + + tx, err := app.Database.BeginTx(ctx) + if err != nil { + log.Err(err).Msg("acquiring database connection") + return + } + defer tx.Rollback(ctx) + + acct, err := app.Account(tx).CreateLocal(ctx, username, email, []byte(password)) + if err != nil { + if err == sql.ErrUsernameTaken { + app.Template(w, r, "auth/signup.tpl", pongo2.Context{"flash_message": "Username is already taken."}) + return + } + log.Err(err).Msg("creating account") + return + } + + _, err = app.Blog(tx).Create(ctx, acct.ID, username) + if err != nil { + log.Err(err).Msg("creating blog") + return + } + + err = tx.Commit(ctx) + if err != nil { + log.Err(err).Msg("committing transaction") + return + } + + app.Flash(w, "Successfully created account!") + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) +} diff --git a/web/frontend/frontend.go b/web/frontend/frontend.go index fad5cae..2788422 100644 --- a/web/frontend/frontend.go +++ b/web/frontend/frontend.go @@ -34,7 +34,7 @@ func New(app *app.App) *Frontend { App: app, } - if app.Config.Core.Dev { + if app.AppConfig.Core.Dev { glue, err := vueglue.NewVueGlue(&vueglue.ViteConfig{ Environment: "development", AssetsPath: "frontend", @@ -100,7 +100,7 @@ func (app *Frontend) ServeFrontend(w http.ResponseWriter, r *http.Request) { } func (app *Frontend) ServeStaticAssets(w http.ResponseWriter, r *http.Request) { - if app.Config.Core.Dev { + if app.AppConfig.Core.Dev { // TODO: this is unsafe path := filepath.Join("web/frontend/assets/", chi.URLParam(r, "*")) http.ServeFile(w, r, path) diff --git a/web/routes.go b/web/routes.go index 0f780f3..8518bb6 100644 --- a/web/routes.go +++ b/web/routes.go @@ -12,13 +12,19 @@ func Routes(app *app.App) { app.Router.Route("/auth", func(r chi.Router) { auth := auth.New(app) r.Get("/login", auth.GetLogin) + r.Post("/login", auth.PostLogin) + + r.Get("/sign_up", auth.GetSignup) + r.Post("/sign_up", auth.PostSignup) }) // web app handlers // also assets - frontend := frontend.New(app) - app.Router.HandleFunc(frontend.AssetsPath(), frontend.ServeAssets) - app.Router.HandleFunc("/static/*", frontend.ServeStaticAssets) - app.Router.HandleFunc("/web", frontend.ServeFrontend) - app.Router.HandleFunc("/web/*", frontend.ServeFrontend) + app.Router.Group(func(r chi.Router) { + frontend := frontend.New(app) + r.HandleFunc(frontend.AssetsPath(), frontend.ServeAssets) + r.HandleFunc("/static/*", frontend.ServeStaticAssets) + r.HandleFunc("/web", frontend.ServeFrontend) + r.HandleFunc("/web/*", frontend.ServeFrontend) + }) } diff --git a/web/templates/auth/login.tpl b/web/templates/auth/login.tpl index 0cc94f3..bf39aa9 100644 --- a/web/templates/auth/login.tpl +++ b/web/templates/auth/login.tpl @@ -1,19 +1,33 @@ {% extends 'base.tpl' %} {% block title %} - Log in + Log in • {{ config.Name }} {% endblock %} {% block content %}
-
-

- - -

-

- - -

- -
+ {% if flash_message %} +

{{ flash_message }}

+ {% endif %} + {% if not totp %} +
+

+ + +

+

+ + +

+ +
+ {% else %} +
+

+ + +

+ + +
+ {% endif %}
{% endblock %} diff --git a/web/templates/auth/signup.tpl b/web/templates/auth/signup.tpl new file mode 100644 index 0000000..849ddf1 --- /dev/null +++ b/web/templates/auth/signup.tpl @@ -0,0 +1,30 @@ +{% extends 'base.tpl' %} +{% block title %} + Sign up • {{ config.Name }} +{% endblock %} +{% block content %} +
+ {% if flash_message %} +

{{ flash_message }}

+ {% endif %} +
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +
+
+{% endblock %} diff --git a/web/templates/base.tpl b/web/templates/base.tpl index 97d42d8..4f190df 100644 --- a/web/templates/base.tpl +++ b/web/templates/base.tpl @@ -4,7 +4,7 @@ - {% block title %}Mercury{% endblock %} + {% block title %}{{ config.Name }}{% endblock %} {% block content %}