DEVELOPMENT ENVIRONMENT

~liljamo/felu

02d555983e1078a6d2d5d9e96a747f55e632e519 — Jonni Liljamo 11 months ago d4e9980
feat: sessions, login, default admin user
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));
}