~petersanchez/gohome

35ea1328243477b715e7b60c1515538cb9a568c5 — Peter Sanchez 10 months ago 8be05b1
Adding set password, login and basic crud routes
M cmd/gohome/main.go => cmd/gohome/main.go +27 -2
@@ 9,7 9,10 @@ import (
	"os"
	"strings"
	"text/template"
	"time"

	"github.com/alexedwards/scs/sqlite3store"
	"github.com/alexedwards/scs/v2"
	"github.com/labstack/echo/v4"
	"netlandish.com/x/gobwebs/config"
	"netlandish.com/x/gobwebs/crypto"


@@ 52,8 55,21 @@ func run() error {
	e := echo.New()

	mwConf := &server.MiddlewareConfig{
		Sessions:      false,
		ServerContext: false,
		Sessions: true,
		GetSessionManager: func(srv *server.Server) *scs.SessionManager {
			sm := scs.New()
			sm.Store = sqlite3store.New(srv.DB)
			sm.Lifetime = 24 * time.Hour
			sm.IdleTimeout = 24 * time.Hour
			sm.Cookie.Name = "session_id"
			sm.Cookie.HttpOnly = true
			sm.Cookie.Persist = true
			sm.Cookie.SameSite = http.SameSiteLaxMode
			sm.Cookie.Secure = true
			sm.Cookie.Path = root
			return sm

		},
	}
	srv := server.New(e, db, config).
		Initialize().


@@ 71,6 87,15 @@ func run() error {
			url, _ := url.JoinPath(config.StaticURL, path)
			return url
		},
		"truncate": func(s string, c int) string {
			if len(s) > c {
				return s[:c] + "..."
			}
			return s
		},
		"formatDate": func(date time.Time) string {
			return date.Format("02-01-2006 15:04")
		},
	})
	err = srv.LoadTemplatesFS(gohome.TemplateFS, "templates/*.html")
	if err != nil {

M go.mod => go.mod +2 -0
@@ 17,6 17,7 @@ require (
	github.com/agnivade/levenshtein v1.1.1 // indirect
	github.com/alexedwards/argon2id v1.0.0 // indirect
	github.com/alexedwards/scs/postgresstore v0.0.0-20211203064041-370cc303b69f // indirect
	github.com/alexedwards/scs/sqlite3store v0.0.0-20231113091146-cef4b05350c8 // indirect
	github.com/alexedwards/scs/v2 v2.7.0 // indirect
	github.com/beorn7/perks v1.0.1 // indirect
	github.com/cespare/xxhash/v2 v2.1.2 // indirect


@@ 63,6 64,7 @@ require (
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec // indirect
	github.com/vektah/gqlparser/v2 v2.5.1 // indirect
	golang.org/x/crypto v0.14.0 // indirect
	golang.org/x/exp v0.0.0-20231226003508-02704c960a9b // indirect
	golang.org/x/net v0.17.0 // indirect
	golang.org/x/sys v0.14.0 // indirect
	golang.org/x/text v0.13.0 // indirect

M go.sum => go.sum +5 -0
@@ 54,6 54,8 @@ github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHc
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/alexedwards/scs/postgresstore v0.0.0-20211203064041-370cc303b69f h1:5jiSGWqKk8pJrjaN/KEANWe/4I767+d6FiKoDGpChik=
github.com/alexedwards/scs/postgresstore v0.0.0-20211203064041-370cc303b69f/go.mod h1:TDDdV/xnjj+/4zBQ9a2k+i2AbuAdY7SQjPUh5zoTZ3M=
github.com/alexedwards/scs/sqlite3store v0.0.0-20231113091146-cef4b05350c8 h1:mnXnnXEjn8QIyv4KCN0+IjDlXA64qdq2hIVOmfNFeuY=
github.com/alexedwards/scs/sqlite3store v0.0.0-20231113091146-cef4b05350c8/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.7.0 h1:DY4rqLCM7UIR9iwxFS0++z1NhTzQlKV30aMHkJCDWKw=
github.com/alexedwards/scs/v2 v2.7.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=


@@ 224,6 226,7 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.11/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=


@@ 347,6 350,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4=
golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=

M gohome => gohome +0 -0
A input.go => input.go +140 -0
@@ 0,0 1,140 @@
package gohome

import (
	"fmt"
	"strings"

	sq "github.com/Masterminds/squirrel"
	"github.com/alexedwards/argon2id"
	"github.com/labstack/echo/v4"
	"golang.org/x/exp/slices"
	"netlandish.com/x/gobwebs/database"
	"netlandish.com/x/gobwebs/validate"
)

// SetPasswordForm ...
type SetPasswordForm struct {
	Password  string `form:"password" validate:"required"`
	Password2 string `form:"password2" validate:"required"`
}

// Validate ...
func (s *SetPasswordForm) Validate(c echo.Context) error {
	errs := validate.FormFieldBinder(c, s).
		FailFast(false).
		String("password", &s.Password).
		String("password2", &s.Password2).
		BindErrors()
	if errs != nil {
		return validate.GetInputErrors(errs)
	}
	err := c.Validate(s)
	if err != nil {
		return err
	}
	if s.Password != s.Password2 {
		err := fmt.Errorf("New passwords do not match")
		return validate.GetInputErrors([]error{err})

	}
	return nil
}

// LoginForm ...
type LoginForm struct {
	Password string `form:"password" validate:"required"`
}

// Validate ...
func (l *LoginForm) Validate(c echo.Context) error {
	errs := validate.FormFieldBinder(c, l).
		FailFast(false).
		String("password", &l.Password).
		BindErrors()
	if errs != nil {
		return validate.GetInputErrors(errs)
	}
	err := c.Validate(l)
	if err != nil {
		return err
	}
	pw, err := GetOnePassword(c.Request().Context())
	if err != nil {
		return err
	}
	ok, err := argon2id.ComparePasswordAndHash(l.Password, pw.Hash)
	if err != nil {
		return err
	}
	if !ok {
		err = fmt.Errorf("Invalid password")
		return validate.GetInputErrors([]error{err})
	}
	return nil
}

var RepoTypes = []string{
	"git",
	"hg",
}

// RepoForm ...
type RepoForm struct {
	ID          int    `form:"-"`
	RepoName    string `form:"reponame" validate:"required,max=50"`
	Name        string `form:"name" validate:"required,max=50"`
	Description string `form:"description" validate:"required"`
	RepoType    string `form:"repotype" validate:"required,max=25"`
	Homepage    string `form:"homepage" validate:"http_url"`
	RepoURL     string `form:"repourl" validate:"required,http_url"`
	IssuesURL   string `form:"issuesurl" validate:"http_url"`
	MailURL     string `form:"mailurl" validate:"http_url"`
	IsActive    bool   `form:"is_active"`
}

// Validate ...
func (r *RepoForm) Validate(c echo.Context, isEdit bool) error {
	errs := validate.FormFieldBinder(c, r).
		FailFast(false).
		String("reponame", &r.RepoName).
		String("name", &r.Name).
		String("description", &r.Description).
		String("repotype", &r.RepoType).
		String("homepage", &r.Homepage).
		String("repourl", &r.RepoURL).
		String("issuesurl", &r.IssuesURL).
		String("mailurl", &r.MailURL).
		Bool("is_active", &r.IsActive).
		BindErrors()
	if errs != nil {
		return validate.GetInputErrors(errs)
	}
	err := c.Validate(r)
	if err != nil {
		return err
	}
	if !slices.Contains(RepoTypes, r.RepoType) {
		err = fmt.Errorf("Invalid repo type given")
		return validate.GetInputErrors([]error{err})
	}

	r.RepoName = strings.ToLower(r.RepoName)
	opts := &database.FilterOptions{
		Filter: sq.Eq{"reponame": r.RepoName},
	}
	if isEdit {
		opts.Filter = sq.And{
			opts.Filter,
			sq.NotEq{"id": r.ID},
		}
	}
	repos, err := GetRepos(c.Request().Context(), opts)
	if err != nil {
		return err
	}
	if len(repos) != 0 {
		err = fmt.Errorf("There is already an existing repo with the name %s", r.RepoName)
		return validate.GetInputErrors([]error{err})
	}
	return nil
}

M middleware.go => middleware.go +5 -0
@@ 1,12 1,17 @@
package gohome

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

// AuthRequired will ensure that the user has entered the correct password
func AuthRequired(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		if !IsAuthenticated(c) {
			return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse("home:login"))
		}
		return next(c)
	}
}

M migrations/0001_initial.down.sql => migrations/0001_initial.down.sql +2 -0
@@ 1,1 1,3 @@
DROP TABLE repos;
DROP TABLE passwords;
DROP TABLE sessions;

M migrations/0001_initial.up.sql => migrations/0001_initial.up.sql +14 -1
@@ 2,7 2,7 @@ CREATE TABLE IF NOT EXISTS repos (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    reponame VARCHAR(50) UNIQUE NOT NULL,
    name VARCHAR(50) NOT NULL,
    description TEXT NOT NULL,
    description TEXT,
    repotype VARCHAR(25) NOT NULL,
    homepage VARCHAR(1024),
    repourl VARCHAR(1024) NOT NULL,


@@ 13,12 13,25 @@ CREATE TABLE IF NOT EXISTS repos (
    updated_on DATETIME NOT NULL DEFAULT (datetime('now','utc'))
);

CREATE INDEX repos_id  ON repos(id);
CREATE INDEX repos_reponame  ON repos(reponame);

CREATE TRIGGER update_repos_updated_on AFTER UPDATE ON repos
BEGIN
 UPDATE repos SET updated_on=DATETIME('now','utc') WHERE id = new.id;
END;


CREATE TABLE IF NOT EXISTS password (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    hash VARCHAR(1024) NOT NULL
);


CREATE TABLE sessions (
	token TEXT PRIMARY KEY,
	data BLOB NOT NULL,
	expiry REAL NOT NULL
);

CREATE INDEX sessions_expiry_idx ON sessions(expiry);

M passwords.go => passwords.go +18 -4
@@ 19,7 19,7 @@ func GetPasswords(ctx context.Context, opts *database.FilterOptions) ([]*Passwor
		q := opts.GetBuilder(nil)
		rows, err := q.
			Columns("id", "hash").
			From("passwords").
			From("password").
			RunWith(tx).
			QueryContext(ctx)
		if err != nil {


@@ 47,13 47,27 @@ func GetPasswords(ctx context.Context, opts *database.FilterOptions) ([]*Passwor
	return passwords, nil
}

// GetOnePassword is a simple helper function to return the first password in the database.
// There shouldn't ever be more than one anyway
func GetOnePassword(ctx context.Context) (*Password, error) {
	opts := &database.FilterOptions{Limit: 1}
	pws, err := GetPasswords(ctx, opts)
	if err != nil {
		return nil, err
	}
	if len(pws) == 0 {
		return nil, nil
	}
	return pws[0], nil
}

// Store ...
func (p *Password) Store(ctx context.Context) error {
	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		var err error
		if p.ID == 0 {
			err = sq.
				Insert("passwords").
				Insert("password").
				Columns("hash").
				Values(&p.Hash).
				Suffix(`RETURNING id`).


@@ 62,7 76,7 @@ func (p *Password) Store(ctx context.Context) error {
		} else {
			// Maybe we just remove name, lookup_name here.
			_, err = sq.
				Update("passwords").
				Update("password").
				Set("hash", p.Hash).
				Where("id = ?", p.ID).
				RunWith(tx).


@@ 80,7 94,7 @@ func (p *Password) Delete(ctx context.Context) error {
	}
	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		_, err := sq.
			Delete("passwords").
			Delete("password").
			Where("id = ?", p.ID).
			RunWith(tx).
			ExecContext(ctx)

M repos.go => repos.go +2 -3
@@ 105,13 105,12 @@ func (r *Repo) Store(ctx context.Context) error {
				RunWith(tx).
				ScanContext(ctx, &r.ID, &r.CreatedOn, &r.UpdatedOn)
		} else {
			// Maybe we just remove name, lookup_name here.
			err = sq.
				Update("repos").
				Set("reponame", r.RepoName).
				Set("name", r.Name).
				Set("Description", r.Description).
				Set("RepoType", r.RepoType).
				Set("description", r.Description).
				Set("repotype", r.RepoType).
				Set("homepage", r.Homepage).
				Set("repourl", r.RepoURL).
				Set("issuesurl", r.IssuesURL).

M routes.go => routes.go +200 -1
@@ 2,8 2,15 @@ package gohome

import (
	"fmt"
	"net/http"
	"strconv"

	"github.com/alexedwards/argon2id"
	"github.com/labstack/echo/v4"
	"netlandish.com/x/gobwebs"
	"netlandish.com/x/gobwebs/messages"
	"netlandish.com/x/gobwebs/server"
	"netlandish.com/x/gobwebs/validate"
)

// Service is the base accounts service struct


@@ 13,10 20,202 @@ type Service struct {
}

func (s *Service) RegisterRoutes() {
	s.eg.GET("/--admin--/set-password", s.SetPassword).Name = s.RouteName("set_password")
	s.eg.POST("/--admin--/set-password", s.SetPassword).Name = s.RouteName("set_password_post")
	s.eg.GET("/--admin--/login", s.Login).Name = s.RouteName("login")
	s.eg.POST("/--admin--/login", s.Login).Name = s.RouteName("login_post")
	s.eg.GET("/--admin--/logout", s.Logout).Name = s.RouteName("logout")

	s.eg.Use(AuthRequired)
	s.eg.GET("/--admin--", s.AdminHome).Name = s.RouteName("admin_home")
	s.eg.GET("/--admin--/add", s.RepoAddEdit).Name = s.RouteName("repo_add")
	s.eg.POST("/--admin--/add", s.RepoAddEdit).Name = s.RouteName("repo_add_post")
	s.eg.GET("/--admin--/edit/:id", s.RepoAddEdit).Name = s.RouteName("repo_edit")
	s.eg.POST("/--admin--/edit/:id", s.RepoAddEdit).Name = s.RouteName("repo_edit_post")
}

func (s *Service) SetPassword(c echo.Context) error {
	return nil
	pw, err := GetOnePassword(c.Request().Context())
	if err != nil {
		return err
	}
	if pw != nil {
		return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse(s.RouteName("login")))
	}

	req := c.Request()
	gctx := c.(*server.Context)
	form := &SetPasswordForm{}
	gmap := gobwebs.Map{
		"form":    form,
		"hideNav": true,
	}
	if req.Method == http.MethodPost {
		if err := form.Validate(c); err != nil {
			switch err.(type) {
			case validate.InputErrors:
				gmap["errors"] = err
				gmap["form"] = form
				return gctx.Render(http.StatusOK, "set_password.html", gmap)
			default:
				return err
			}
		}
		hash, err := argon2id.CreateHash(form.Password, argon2id.DefaultParams)
		if err != nil {
			return err
		}
		p := &Password{Hash: hash}
		err = p.Store(c.Request().Context())
		if err != nil {
			return err
		}
		messages.Success(c, "Successfully set password.")
		return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse(s.RouteName("login")))
	}

	return gctx.Render(http.StatusOK, "set_password.html", gmap)
}

func (s *Service) Login(c echo.Context) error {
	if IsAuthenticated(c) {
		return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse(s.RouteName("admin_home")))
	}

	pw, err := GetOnePassword(c.Request().Context())
	if err != nil {
		return err
	}
	if pw == nil {
		return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse(s.RouteName("set_password")))
	}

	req := c.Request()
	gctx := c.(*server.Context)
	form := &LoginForm{}
	gmap := gobwebs.Map{
		"form":    form,
		"hideNav": true,
	}
	if req.Method == http.MethodPost {
		if err := form.Validate(c); err != nil {
			switch err.(type) {
			case validate.InputErrors:
				gmap["errors"] = err
				gmap["form"] = form
				return gctx.Render(http.StatusOK, "login.html", gmap)
			default:
				return err
			}
		}
		if err := gctx.Server.Session.RenewToken(c.Request().Context()); err != nil {
			return err
		}
		AuthLogin(c)
		messages.Success(c, "Successfully logged in.")
		return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse(s.RouteName("admin_home")))
	}

	return gctx.Render(http.StatusOK, "login.html", gmap)
}

func (s *Service) Logout(c echo.Context) error {
	AuthLogout(c)
	return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse(s.RouteName("login")))
}

func (s *Service) AdminHome(c echo.Context) error {
	gctx := c.(*server.Context)
	repos, err := GetRepos(c.Request().Context(), nil)
	if err != nil {
		return err
	}

	gmap := gobwebs.Map{
		"repos": repos,
	}
	return gctx.Render(http.StatusOK, "home.html", gmap)
}

func (s *Service) RepoAddEdit(c echo.Context) error {
	req := c.Request()
	gctx := c.(*server.Context)
	form := &RepoForm{IsActive: true}

	var (
		rid    int
		err    error
		isEdit bool
		repo   *Repo = &Repo{}
	)
	id := c.Param("id")
	if id != "" {
		rid, err = strconv.Atoi(id)
		if err != nil {
			return echo.NotFoundHandler(c)
		}
		repo, err = GetRepo(c.Request().Context(), rid)
		if err != nil {
			return err
		}
		if repo.RepoName == "" {
			// Invalid ID given
			return echo.NotFoundHandler(c)
		}
		form.ID = repo.ID
		form.RepoName = repo.RepoName
		form.Name = repo.Name
		form.Description = repo.Description
		form.RepoType = repo.RepoType
		form.Homepage = repo.Homepage
		form.RepoURL = repo.RepoURL
		form.IssuesURL = repo.IssuesURL
		form.MailURL = repo.MailURL
		form.IsActive = repo.IsActive
		isEdit = true
	}

	gmap := gobwebs.Map{
		"form":      form,
		"isEdit":    isEdit,
		"repoTypes": RepoTypes,
	}
	if req.Method == http.MethodPost {
		if err := form.Validate(c, isEdit); err != nil {
			switch err.(type) {
			case validate.InputErrors:
				gmap["errors"] = err
				gmap["form"] = form
				return gctx.Render(http.StatusOK, "add_edit.html", gmap)
			default:
				return err
			}
		}

		repo.RepoName = form.RepoName
		repo.Name = form.Name
		repo.Description = form.Description
		repo.RepoType = form.RepoType
		repo.Homepage = form.Homepage
		repo.RepoURL = form.RepoURL
		repo.IssuesURL = form.IssuesURL
		repo.MailURL = form.MailURL
		repo.IsActive = form.IsActive
		err = repo.Store(c.Request().Context())
		if err != nil {
			return err
		}

		var msg string
		if isEdit {
			msg = "edited"
		} else {
			msg = "added"
		}
		messages.Success(c, fmt.Sprintf("Successfully %s repo %s", msg, repo.RepoName))
		return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse(s.RouteName("admin_home")))
	}
	return gctx.Render(http.StatusOK, "add_edit.html", gmap)
}

// RouteName ...

A templates/add_edit.html => templates/add_edit.html +88 -0
@@ 0,0 1,88 @@
{{template "base" .}}
<section class="app-header">
  <h1 class="app-header__title">Add Repo</h1>
</section>

<section class="card shadow-card">
  <form id="password-form" method="POST" action="{{ if .isEdit }}{{ reverse "home:repo_edit_post" .form.ID }}{{ else }}{{ reverse "home:repo_add_post" }}{{ end }}">
    <input type="hidden" name="csrf" value="{{ .CSRF }}">
    {{ if .errors._global_ -}}
    {{- range .errors._global_ -}}
    <p class="error">{{ . }}</p>
    {{- end }}
    {{- end }}
    <div>
      <label>Name</label>
      <p>Example: Foobar Module</p>
      <input type="text" name="name" value="{{ .form.Name }}" required>
      {{ with .errors.Name }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Repo name</label>
      <p>Example, if the module name is "yourdomain.com/x/foobar" then repo name is "foobar"</p>
      <input type="text" name="reponame" value="{{ .form.RepoName }}"required>
      {{ with .errors.RepoName }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Repo type</label>
      <select name="repotype">
      {{ range .repoTypes }}
        <option value="{{ . }}"{{ if eq $.form.RepoType . }}selected{{ end }}>{{ . }}</option>
      {{ end }}
      </select>
      {{ with .errors.RepoType }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Repo URL</label>
      <input type="text" name="repourl" value="{{ .form.RepoURL }}"required>
      {{ with .errors.RepoURL }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Repo Description</label>
      <textarea name="description" />{{ .form.Description }}</textarea>
      {{ with .errors.Description }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Homepage URL</label>
      <input type="text" name="homepage" value="{{ .form.Homepage }}">
      {{ with .errors.Homepage }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Issues URL</label>
      <input type="text" name="issuesurl" value="{{ .form.IssuesURL }}">
      {{ with .errors.IssuesURL }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Mailing List URL</label>
      <input type="text" name="mailurl" value="{{ .form.MailURL }}">
      {{ with .errors.MailURL }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Is Active?</label>
      <input type="checkbox" name="is_active"{{if .form.IsActive}} checked{{end}}/>
      {{ with .errors.IsActive }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
  </form>
  <footer class="is-right">
    <button class="button dark" form="password-form" type="submit">Save</button>
  </footer>
</section>
{{template "base_footer" .}}

M templates/base.html => templates/base.html +54 -0
@@ 0,0 1,54 @@
{{define "base"}}
<!DOCTYPE html>
<html>
  <head>
    <title>Go Home</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta charset="utf-8">
    <link href="{{staticURL "css/chota.min.css"}}?{{.serverVersion}}" rel="stylesheet">
    <link href="{{staticURL "css/style.css"}}?{{.serverVersion}}" rel="stylesheet">
  </head>
  <body class="container">
    <aside>
      <div>
        <h1>Go Home</h1>
      </div>
      {{if not .hideNav}}
          <nav class="sidebar">
            <ul class="list-unstyled pl-0">
              <li>
                  <a class="menu-item{{if eq .navFlag "recent"}} menu-item--active{{end}}" href="{{ reverse "home:admin_home" }}">
                  <svg class="menu-item__icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
                  </svg>
                  <span>Home</span>
                </a>
              </li>
              <li>
                <a class="menu-item{{if eq .navFlag "popular"}} menu-item--active{{end}}" href="{{ reverse "home:logout" }}">
                  <svg class="menu-item__icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
                  </svg>
                  <span>Logout</span>
                </a>
              </li>
            </ul>
          </nav>
      {{end}}
    </aside>
    <main class="app-content">
      {{if .messages }}
      <section>
        {{range .messages}}
        <div class="text-center alert{{if gt .Level 3 }} alert-error{{else}} alert-success{{end}}">
          {{ .Message }}
        </div>
        {{end}}
      </section>
      {{end}}
{{end}}
{{define "base_footer"}}
    </main>
  </body>
</html>
{{end}}

A templates/home.html => templates/home.html +48 -0
@@ 0,0 1,48 @@
{{template "base" .}}
<section class="app-header">
  <h1 class="app-header__title">Module Listing</h1>
  <a href="{{reverse "home:repo_add" }}" class="button primary is-small">Add</a>
</section>

<section class="card shadow-card">
  <ul class="list-unstyled pl-0">
    {{range .repos}}
    <li class="mb-1">
      <article>
        <div class="d-flex items-center">
          <h3 class="is-marginless mr-1">
            <a class="text-dark" href="{{ reverse "home:repo_edit" .ID }}" target="_blank">{{ .Name }} ({{ .RepoName }})</a>
          </h3>
            <a class="edit-element-icon" href="{{ reverse "home:repo_edit" .ID }}">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6" style="width:20px">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
                </svg>
            </a>
        </div>
        <p class="is-marginless">{{truncate .Description 200}}</p>
        <div class="link-tag mt-1">
          <span class="link-tag__item link-tag__item--simple no-underline">{{.RepoType}}</span>
          <span class="link-tag__item link-tag__item--simple no-underline">URL</span>
	  {{ if .Homepage }}
            <span class="link-tag__item link-tag__item--simple no-underline">Homepage</span>
	  {{ end }}
	  {{ if .IssuesURL }}
            <span class="link-tag__item link-tag__item--simple no-underline">Issues</span>
	  {{ end }}
	  {{ if .MailURL }}
            <span class="link-tag__item link-tag__item--simple no-underline">Mailing List</span>
	  {{ end }}
        </div>
        <p class="text-grey">
          <small>On</small>
          <time datetime="{{formatDate .CreatedOn}}">
            <small>{{formatDate .CreatedOn}}</small>
          </time>
	  <small>{{ if .IsActive }}ACTIVE{{ else }}INACTIVE{{ end }}</small>
        </p>
      </article>
    </li>
    {{end}}
  </ul>
</section>
{{template "base_footer" .}}

A templates/login.html => templates/login.html +26 -0
@@ 0,0 1,26 @@
{{template "base" .}}
<section class="app-header">
  <h1 class="app-header__title">Login</h1>
</section>

<section class="card shadow-card">
  <form id="password-form" method="POST" action="{{ reverse "home:login_post"}}">
    <input type="hidden" name="csrf" value="{{ .CSRF }}">
    {{ if .errors._global_ -}}
    {{- range .errors._global_ -}}
    <p class="error">{{ . }}</p>
    {{- end }}
    {{- end }}
    <div>
      <label>Password</label>
      <input type="password" name="password" required>
      {{ with .errors.Password }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
  </form>
  <footer class="is-right">
    <button class="button dark" form="password-form" type="submit">Login</button>
  </footer>
</section>
{{template "base_footer" .}}

A templates/set_password.html => templates/set_password.html +33 -0
@@ 0,0 1,33 @@
{{template "base" .}}
<section class="app-header">
  <h1 class="app-header__title">Set Password</h1>
</section>

<section class="card shadow-card">
  <form id="password-form" method="POST" action="{{ reverse "home:set_password_post"}}">
    <input type="hidden" name="csrf" value="{{ .CSRF }}">
    {{ if .errors._global_ -}}
    {{- range .errors._global_ -}}
    <p class="error">{{ . }}</p>
    {{- end }}
    {{- end }}
    <div>
      <label>Password</label>
      <input type="password" name="password" required>
      {{ with .errors.Password }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
    <div>
      <label>Confirm Password</label>
      <input type="password" name="password2" required>
      {{ with .errors.Password2 }}
      <p class="error">{{ . }}</p>
      {{ end }}
    </div>
  </form>
  <footer class="is-right">
    <button class="button dark" form="password-form" type="submit">Set Password!</button>
  </footer>
</section>
{{template "base_footer" .}}

A utils.go => utils.go +30 -0
@@ 0,0 1,30 @@
package gohome

import (
	"github.com/labstack/echo/v4"
	"netlandish.com/x/gobwebs/sessions"
)

const authKey string = "auth.LoggedIn"

// IsAuthenticated will do a simple session value check. If set, it's assumed
// the password was entered correctly.
func IsAuthenticated(c echo.Context) bool {
	sm := sessions.ForContext(c.Request().Context())
	val := sm.GetBool(c.Request().Context(), authKey)
	return val
}

// AuthLogin ...
func AuthLogin(c echo.Context) error {
	sm := sessions.ForContext(c.Request().Context())
	sm.Put(c.Request().Context(), authKey, true)
	return nil
}

// AuthLogout ...
func AuthLogout(c echo.Context) error {
	sm := sessions.ForContext(c.Request().Context())
	sm.Clear(c.Request().Context())
	return nil
}