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