M components.templ => components.templ +63 -2
@@ 1,6 1,12 @@
package main
-templ base(title string) {
+import "git.src.quest/~skye/felu-ddns/config"
+
+func serviceName() string {
+ return config.FeluConfig.ServiceName
+}
+
+templ baseBase(title string) {
<!DOCTYPE html>
<html>
<head>
@@ 16,12 22,67 @@ templ base(title string) {
<main>
{ children... }
</main>
+ <script src="/static/felu.js"></script>
</body>
</html>
}
+templ base(title string) {
+ @baseBase(title) {
+ <div class="flex flex-col w-full items-center p-4">
+ <div class="flex flex-col items-center gap-4">
+ <a class="text-4xl" href="/">{ serviceName() }</a>
+ </div>
+ <div class="flex flex-col w-full max-w-5xl items-center gap-2">
+ { children... }
+ </div>
+ </div>
+ }
+}
+
+templ manageBase(title string) {
+ @baseBase(title) {
+ <div class="flex flex-col w-full items-center p-4">
+ <div class="flex w-full max-w-5xl items-center gap-4">
+ <a href="/manage">{ serviceName() }</a>
+ </div>
+ <div class="flex flex-col w-full max-w-5xl items-center gap-2">
+ { children... }
+ </div>
+ </div>
+ }
+}
+
templ index() {
@base("Index") {
- <div class="bg-rose-200">testing, testing</div>
+ <div class="bg-rose-200">
+ have info about the service here,
+ with a login button somewhere.
+ <br/>and also something something, basic index page info, bla bla
+ </div>
+ <!-- some kind of if logged_in thing, and then show a "manage" button instead of a login button -->
+ }
+}
+
+templ pageLogin() {
+ @manageBase("Login") {
+ <div class="bg-lime-200">
+ <form class="flex flex-col p-2 gap-2" hx-post="/auth/login" hx-target="#login_error">
+ <label for="login_email">Email</label>
+ <input class="border" type="text" placeholder="..." name="email" id="login_email"/>
+ <label for="login_password">Password</label>
+ <input class="border" type="password" placeholder="..." name="password" id="login_password"/>
+ <div class="text-rose-600 text-center" id="login_error"></div>
+ <button class="border p-1" type="submit">Login</button>
+ </form>
+ </div>
+ }
+}
+
+templ pageManage() {
+ @manageBase("Manage") {
+ <div>
+ something something manaag
+ </div>
}
}
M components_templ.go => components_templ.go +277 -9
@@ 9,7 9,13 @@ import "context"
import "io"
import "bytes"
-func base(title string) templ.Component {
+import "git.src.quest/~skye/felu-ddns/config"
+
+func serviceName() string {
+ return config.FeluConfig.ServiceName
+}
+
+func baseBase(title string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
templBuffer, templIsBuffer := w.(*bytes.Buffer)
if !templIsBuffer {
@@ 48,7 54,128 @@ func base(title string) templ.Component {
if err != nil {
return err
}
- _, err = templBuffer.WriteString("</main></body></html>")
+ _, err = templBuffer.WriteString("</main><script src=\"/static/felu.js\">")
+ if err != nil {
+ return err
+ }
+ var_4 := ``
+ _, err = templBuffer.WriteString(var_4)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</script></body></html>")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func base(title string) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_5 := templ.GetChildren(ctx)
+ if var_5 == nil {
+ var_5 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var_6 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ _, err = templBuffer.WriteString("<div class=\"flex flex-col w-full items-center p-4\"><div class=\"flex flex-col items-center gap-4\"><a class=\"text-4xl\" href=\"/\">")
+ if err != nil {
+ return err
+ }
+ var var_7 string = serviceName()
+ _, err = templBuffer.WriteString(templ.EscapeString(var_7))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</a></div><div class=\"flex flex-col w-full max-w-5xl items-center gap-2\">")
+ if err != nil {
+ return err
+ }
+ err = var_5.Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</div></div>")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = io.Copy(w, templBuffer)
+ }
+ return err
+ })
+ err = baseBase(title).Render(templ.WithChildren(ctx, var_6), templBuffer)
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func manageBase(title string) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_8 := templ.GetChildren(ctx)
+ if var_8 == nil {
+ var_8 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var_9 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ _, err = templBuffer.WriteString("<div class=\"flex flex-col w-full items-center p-4\"><div class=\"flex w-full max-w-5xl items-center gap-4\"><a href=\"/manage\">")
+ if err != nil {
+ return err
+ }
+ var var_10 string = serviceName()
+ _, err = templBuffer.WriteString(templ.EscapeString(var_10))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</a></div><div class=\"flex flex-col w-full max-w-5xl items-center gap-2\">")
+ if err != nil {
+ return err
+ }
+ err = var_8.Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</div></div>")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = io.Copy(w, templBuffer)
+ }
+ return err
+ })
+ err = baseBase(title).Render(templ.WithChildren(ctx, var_9), templBuffer)
if err != nil {
return err
}
@@ 67,12 194,12 @@ func index() templ.Component {
defer templ.ReleaseBuffer(templBuffer)
}
ctx = templ.InitializeContext(ctx)
- var_4 := templ.GetChildren(ctx)
- if var_4 == nil {
- var_4 = templ.NopComponent
+ var_11 := templ.GetChildren(ctx)
+ if var_11 == nil {
+ var_11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- var_5 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ var_12 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
templBuffer, templIsBuffer := w.(*bytes.Buffer)
if !templIsBuffer {
templBuffer = templ.GetBuffer()
@@ 82,8 209,149 @@ func index() templ.Component {
if err != nil {
return err
}
- var_6 := `testing, testing`
- _, err = templBuffer.WriteString(var_6)
+ var_13 := `have info about the service here,`
+ _, err = templBuffer.WriteString(var_13)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString(" ")
+ if err != nil {
+ return err
+ }
+ var_14 := `with a login button somewhere.`
+ _, err = templBuffer.WriteString(var_14)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString(" <br>")
+ if err != nil {
+ return err
+ }
+ var_15 := `and also something something, basic index page info, bla bla`
+ _, err = templBuffer.WriteString(var_15)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</div> <!--")
+ if err != nil {
+ return err
+ }
+ var_16 := ` some kind of if logged_in thing, and then show a "manage" button instead of a login button `
+ _, err = templBuffer.WriteString(var_16)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("-->")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = io.Copy(w, templBuffer)
+ }
+ return err
+ })
+ err = base("Index").Render(templ.WithChildren(ctx, var_12), templBuffer)
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func pageLogin() templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_17 := templ.GetChildren(ctx)
+ if var_17 == nil {
+ var_17 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var_18 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ _, err = templBuffer.WriteString("<div class=\"bg-lime-200\"><form class=\"flex flex-col p-2 gap-2\" hx-post=\"/auth/login\" hx-target=\"#login_error\"><label for=\"login_email\">")
+ if err != nil {
+ return err
+ }
+ var_19 := `Email`
+ _, err = templBuffer.WriteString(var_19)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</label><input class=\"border\" type=\"text\" placeholder=\"...\" name=\"email\" id=\"login_email\"><label for=\"login_password\">")
+ if err != nil {
+ return err
+ }
+ var_20 := `Password`
+ _, err = templBuffer.WriteString(var_20)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</label><input class=\"border\" type=\"password\" placeholder=\"...\" name=\"password\" id=\"login_password\"><div class=\"text-rose-600 text-center\" id=\"login_error\"></div><button class=\"border p-1\" type=\"submit\">")
+ if err != nil {
+ return err
+ }
+ var_21 := `Login`
+ _, err = templBuffer.WriteString(var_21)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</button></form></div>")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = io.Copy(w, templBuffer)
+ }
+ return err
+ })
+ err = manageBase("Login").Render(templ.WithChildren(ctx, var_18), templBuffer)
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func pageManage() templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_22 := templ.GetChildren(ctx)
+ if var_22 == nil {
+ var_22 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var_23 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ _, err = templBuffer.WriteString("<div>")
+ if err != nil {
+ return err
+ }
+ var_24 := `something something manaag`
+ _, err = templBuffer.WriteString(var_24)
if err != nil {
return err
}
@@ 96,7 364,7 @@ func index() templ.Component {
}
return err
})
- err = base("Index").Render(templ.WithChildren(ctx, var_5), templBuffer)
+ err = manageBase("Manage").Render(templ.WithChildren(ctx, var_23), templBuffer)
if err != nil {
return err
}
M config/config.go => config/config.go +8 -0
@@ 13,6 13,11 @@ var FeluConfig *config
type config struct {
ServiceName string
+ // Initial email for the admin user, only used if no admin account (e.g. first boot)
+ InitialAdminEmail string
+ // Initial password for the admin user, only used if no admin account (e.g. first boot)
+ InitialAdminPwd string
+
// Data directory, with trailing slash
DataDir string
@@ 29,6 34,9 @@ func InitConfig() {
FeluConfig = &config {
ServiceName: util.LoadEnvStr("FELU_SERVICE_NAME", "FeluDDNS"),
+ InitialAdminEmail: util.LoadEnvStr("FELU_INITIAL_ADMIN_EMAIL", "admin@example.com"),
+ InitialAdminPwd: util.LoadEnvStr("FELU_INITIAL_ADMIN_PWD", "feluadmin"),
+
DataDir: util.LoadEnvStr("FELU_DB_PATH", "/var/felu/"),
FrontendBindAddr: util.LoadEnvStr("FELU_FRONTEND_BIND_ADDR", "0.0.0.0:8080"),
M db/db.go => db/db.go +19 -0
@@ 26,3 26,22 @@ func InitDB() error {
return nil
}
+
+func InitAdminUser() error {
+ rows, err := DBConn.Query(`SELECT id FROM users WHERE is_admin = TRUE`)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ if rows.Next() {
+ // There is at least one...
+ return nil
+ }
+
+ // Since we're here, it's assumed no admin accounts exist
+ err = CreateAdmin(config.FeluConfig.InitialAdminEmail, config.FeluConfig.InitialAdminPwd)
+ if err != nil {
+ return err
+ }
+ return nil
+}
A db/users.go => db/users.go +79 -0
@@ 0,0 1,79 @@
+/*
+ * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
+ * more information.
+ */
+package db
+
+import (
+ "database/sql"
+ "errors"
+
+ "github.com/matthewhartstonge/argon2"
+ "github.com/oklog/ulid/v2"
+)
+
+func CreateUser(email string, pwd string) error {
+ argon := argon2.DefaultConfig()
+ encoded, err := argon.HashEncoded([]byte(pwd))
+ if err != nil {
+ return err
+ }
+
+ ulid := ulid.Make().String()
+
+ _, err = DBConn.Exec(`INSERT INTO users(ulid, email, pwd) VALUES ($1, $2, $3)`,
+ ulid, email, string(encoded))
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func CreateAdmin(email string, pwd string) error {
+ argon := argon2.DefaultConfig()
+ encoded, err := argon.HashEncoded([]byte(pwd))
+ if err != nil {
+ return err
+ }
+
+ ulid := ulid.Make().String()
+
+ _, err = DBConn.Exec(`INSERT INTO users(ulid, email, pwd, is_admin) VALUES ($1, $2, $3, TRUE)`,
+ ulid, email, string(encoded))
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+type User struct {
+ Ulid string
+ Email string
+}
+
+func FetchUser(email string, pwd string) (*User, error) {
+ user := User{ Email: email }
+ var encodedPwd string
+ err := DBConn.QueryRow(`SELECT ulid, pwd FROM users WHERE email = $1`,
+ email).Scan(&user.Ulid, &encodedPwd)
+ if err == sql.ErrNoRows {
+ return nil, errors.New("User not found")
+ }
+ if err != nil {
+ return nil, errors.New("User query failed")
+ }
+
+ ok, err := argon2.VerifyEncoded([]byte(pwd), []byte(encodedPwd))
+ if err != nil {
+ return nil, errors.New("User not found")
+ }
+ if !ok {
+ return nil, errors.New("User not found")
+ }
+
+ return &user, nil
+}
M felu.go => felu.go +31 -1
@@ 16,6 16,9 @@ import (
"git.src.quest/~skye/felu-ddns/config"
"git.src.quest/~skye/felu-ddns/db"
"git.src.quest/~skye/felu-ddns/dns"
+ "git.src.quest/~skye/felu-ddns/handlers"
+ "git.src.quest/~skye/felu-ddns/middlewares"
+ "github.com/alexedwards/scs/v2"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
@@ 24,6 27,7 @@ var version = "notset-builtin"
var (
g errgroup.Group
+ sessionManager *scs.SessionManager
)
func ginLogFormat(param gin.LogFormatterParams, router string) string {
@@ 62,6 66,26 @@ func setupFrontendRouter() *gin.Engine {
c.HTML(http.StatusOK, "", index())
})
+ r.GET("/login", middlewares.SessionExists(sessionManager), func(c *gin.Context) {
+ c.HTML(http.StatusOK, "", pageLogin())
+ })
+
+ auth := r.Group("/auth")
+ {
+ auth.POST("/login", func(c *gin.Context) {
+ handlers.AuthLogin(sessionManager, c)
+ })
+ }
+
+ // routes for the actual application frontend
+ manage := r.Group("/manage").Use(middlewares.SessionExists(sessionManager))
+ {
+ manage.GET("/", func(c *gin.Context) {
+ c.HTML(http.StatusOK, "", pageManage())
+ })
+ manage.GET("/settings")
+ }
+
return r
}
@@ 96,10 120,16 @@ func main() {
log.Fatalf("[felu] Failed to initialize database: '%s'", err)
}
defer db.DBConn.Close()
+ if err := db.InitAdminUser(); err != nil {
+ log.Fatalf("[felu] Failed to initialize admin user: '%s'", err)
+ }
+
+ sessionManager = scs.New()
+ sessionManager.Lifetime = 6 * time.Hour
frontend := &http.Server{
Addr: config.FeluConfig.FrontendBindAddr,
- Handler: setupFrontendRouter(),
+ Handler: sessionManager.LoadAndSave(setupFrontendRouter()),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
A handlers/login.go => handlers/login.go +41 -0
@@ 0,0 1,41 @@
+/*
+ * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
+ * more information.
+ */
+package handlers
+
+import (
+ "log"
+ "net/http"
+
+ "git.src.quest/~skye/felu-ddns/db"
+ "github.com/alexedwards/scs/v2"
+ "github.com/gin-gonic/gin"
+)
+
+type postLoginDetails struct {
+ Email string `form:"email"`
+ Password string `form:"password"`
+}
+
+func AuthLogin(sm *scs.SessionManager, c *gin.Context) {
+ data := &postLoginDetails{}
+ if err := c.Bind(data); err != nil {
+ log.Printf("[felu] ERROR: Could not bind login details: %v", err)
+ c.String(http.StatusBadRequest, "Could not bind login details")
+ return
+ }
+
+ user, err := db.FetchUser(data.Email, data.Password)
+ if err != nil {
+ c.String(http.StatusUnauthorized, err.Error())
+ return
+ }
+
+ sm.Put(c.Request.Context(), "user_id", user.Ulid)
+
+ c.Header("HX-Redirect", "/manage")
+ //c.Redirect(http.StatusFound, "/manage")
+}
A middlewares/auth.go => middlewares/auth.go +36 -0
@@ 0,0 1,36 @@
+/*
+ * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
+ * more information.
+ */
+package middlewares
+
+import (
+ "net/http"
+
+ "github.com/alexedwards/scs/v2"
+ "github.com/gin-gonic/gin"
+)
+
+func SessionExists(sm *scs.SessionManager) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ user_id := sm.Get(c.Request.Context(), "user_id")
+ if user_id != nil {
+ if c.Request.URL.Path == "/login" {
+ c.Redirect(http.StatusTemporaryRedirect, "/manage")
+ c.Abort()
+ } else {
+ // TODO: Validate in db?
+ c.Next()
+ }
+ } else {
+ if c.Request.URL.Path == "/login" {
+ c.Next()
+ } else {
+ c.Redirect(http.StatusTemporaryRedirect, "/login")
+ c.Abort()
+ }
+ }
+ }
+}
A static/felu.js => static/felu.js +9 -0
@@ 0,0 1,9 @@
+document.body.addEventListener('htmx:beforeSwap', function(evt) {
+ if (evt.detail.xhr.status === 400) {
+ evt.detail.shouldSwap = true;
+ evt.detail.isError = false;
+ } else if (evt.detail.xhr.status === 401) {
+ evt.detail.shouldSwap = true;
+ evt.detail.isError = false;
+ }
+})
M static/styles.css => static/styles.css +63 -0
@@ 534,7 534,70 @@ video {
--tw-backdrop-sepia: ;
}
+.flex {
+ display: flex;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.max-w-5xl {
+ max-width: 64rem;
+}
+
+.flex-col {
+ flex-direction: column;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.gap-2 {
+ gap: 0.5rem;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.border {
+ border-width: 1px;
+}
+
+.bg-lime-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(217 249 157 / var(--tw-bg-opacity));
+}
+
.bg-rose-200 {
--tw-bg-opacity: 1;
background-color: rgb(254 205 211 / var(--tw-bg-opacity));
}
+
+.p-1 {
+ padding: 0.25rem;
+}
+
+.p-2 {
+ padding: 0.5rem;
+}
+
+.p-4 {
+ padding: 1rem;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-4xl {
+ font-size: 2.25rem;
+ line-height: 2.5rem;
+}
+
+.text-rose-600 {
+ --tw-text-opacity: 1;
+ color: rgb(225 29 72 / var(--tw-text-opacity));
+}