Added a basic homepage

This commit is contained in:
CamZawacki 2026-05-20 16:09:54 +01:00
parent d40640a648
commit 12fd3c04ca
113 changed files with 414 additions and 506 deletions

View file

@ -0,0 +1,62 @@
package context
import (
"context"
"errors"
"github.com/labstack/echo/v4"
)
const (
// AuthenticatedUserKey is the key used to store the authenticated user in context.
AuthenticatedUserKey = "auth_user"
// UserKey is the key used to store a user in context.
UserKey = "user"
// FormKey is the key used to store a form in context.
FormKey = "form"
// PasswordTokenKey is the key used to store a password token in context.
PasswordTokenKey = "password_token"
// LoggerKey is the key used to store a structured logger in context.
LoggerKey = "logger"
// SessionKey is the key used to store the session data in context.
SessionKey = "session"
// HTMXRequestKey is the key used to store the HTMX request data in context.
HTMXRequestKey = "htmx"
// CSRFKey is the key used to store the CSRF token in context.
CSRFKey = "csrf"
// ConfigKey is the key used to store the configuration in context.
ConfigKey = "config"
// AdminEntityKey is the key used to store the entity being operated on in the admin panel.
AdminEntityKey = "admin:entity"
// AdminEntityIDKey is the key used to store the ID of the entity being operated on in the admin panel.
AdminEntityIDKey = "admin:entity_id"
)
// IsCanceledError determines if an error is due to a context cancellation.
func IsCanceledError(err error) bool {
return errors.Is(err, context.Canceled)
}
// Cache checks if a value of a given type exists in the Echo context for a given key and returns that, otherwise
// it will use a callback to generate a value, which is stored in the context then returned. This allows you to
// only generate items only once for a given request.
func Cache[T any](ctx echo.Context, key string, gen func(echo.Context) T) T {
if val := ctx.Get(key); val != nil {
if v, ok := val.(T); ok {
return v
}
}
val := gen(ctx)
ctx.Set(key, val)
return val
}

View file

@ -0,0 +1,47 @@
package context
import (
"context"
"errors"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func TestIsCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
assert.False(t, IsCanceledError(ctx.Err()))
cancel()
assert.True(t, IsCanceledError(ctx.Err()))
ctx, cancel = context.WithTimeout(context.Background(), time.Microsecond*5)
<-ctx.Done()
cancel()
assert.False(t, IsCanceledError(ctx.Err()))
assert.False(t, IsCanceledError(errors.New("test error")))
}
func TestCache(t *testing.T) {
ctx := echo.New().NewContext(nil, nil)
key := "testing"
value := "hello"
called := 0
callback := func(ctx echo.Context) string {
called++
return value
}
assert.Nil(t, ctx.Get(key))
got := Cache(ctx, key, callback)
assert.Equal(t, value, got)
assert.Equal(t, 1, called)
got = Cache(ctx, key, callback)
assert.Equal(t, value, got)
assert.Equal(t, 1, called)
}

55
internal/form/form.go Normal file
View file

@ -0,0 +1,55 @@
package form
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/context"
)
// Form represents a form that can be submitted and validated.
type Form interface {
// Submit marks the form as submitted, stores a pointer to it in the context, binds the request
// values to the struct fields, and validates the input based on the struct tags.
// Returns a validator.ValidationErrors, if the form values were not valid, or an echo.HTTPError,
// if the request failed to process.
Submit(c echo.Context, form any) error
// IsSubmitted returns true if the form was submitted.
IsSubmitted() bool
// IsValid returns true if the form has no validation errors.
IsValid() bool
// IsDone returns true if the form was submitted and has no validation errors.
IsDone() bool
// FieldHasErrors returns true if a given struct field has validation errors.
FieldHasErrors(fieldName string) bool
// SetFieldError sets a validation error message for a given struct field.
SetFieldError(fieldName string, message string)
// GetFieldErrors returns the validation errors for a given struct field.
GetFieldErrors(fieldName string) []string
}
// Get gets a form from the context or initializes a new copy if one is not set.
func Get[T any](ctx echo.Context) *T {
if v := ctx.Get(context.FormKey); v != nil {
if form, ok := v.(*T); ok {
return form
}
}
var v T
return &v
}
// Clear removes the form set in the context.
func Clear(ctx echo.Context) {
ctx.Set(context.FormKey, nil)
}
// Submit submits a form.
// See Form.Submit().
func Submit(ctx echo.Context, form Form) error {
return form.Submit(ctx, form)
}

View file

@ -0,0 +1,67 @@
package form
import (
"testing"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockForm struct {
called bool
Submission
}
func (m *mockForm) Submit(_ echo.Context, _ any) error {
m.called = true
return nil
}
func TestSubmit(t *testing.T) {
m := mockForm{}
ctx, _ := tests.NewContext(echo.New(), "/")
err := Submit(ctx, &m)
require.NoError(t, err)
assert.True(t, m.called)
}
func TestGetClear(t *testing.T) {
e := echo.New()
type example struct {
Name string `form:"name"`
}
t.Run("get empty context", func(t *testing.T) {
// Empty context, still return a form.
ctx, _ := tests.NewContext(e, "/")
form := Get[example](ctx)
assert.NotNil(t, form)
})
t.Run("get non-empty context", func(t *testing.T) {
form := example{
Name: "test",
}
ctx, _ := tests.NewContext(e, "/")
ctx.Set(context.FormKey, &form)
// Get again and expect the values were stored.
got := Get[example](ctx)
require.NotNil(t, got)
assert.Equal(t, "test", got.Name)
// Attempt getting a different type to ensure there's no panic.
ret := Get[int](ctx)
require.NotNil(t, ret)
// Clear.
Clear(ctx)
got = Get[example](ctx)
require.NotNil(t, got)
assert.Empty(t, got.Name)
})
}

105
internal/form/submission.go Normal file
View file

@ -0,0 +1,105 @@
package form
import (
"fmt"
"net/http"
"github.com/go-playground/validator/v10"
"github.com/camzawacki/personal-site/internal/context"
"github.com/labstack/echo/v4"
)
// Submission represents the state of the submission of a form, not including the form itself.
// This satisfies the Form interface.
type Submission struct {
// isSubmitted indicates if the form has been submitted.
isSubmitted bool
// errors stores a slice of error message strings keyed by form struct field name.
errors map[string][]string
}
func (f *Submission) Submit(ctx echo.Context, form any) error {
f.isSubmitted = true
// Set in context so the form can later be retrieved.
ctx.Set(context.FormKey, form)
// Bind the values from the incoming request to the form struct.
if err := ctx.Bind(form); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("unable to bind form: %v", err))
}
// Validate the form.
if err := ctx.Validate(form); err != nil {
f.setErrorMessages(err)
return err
}
return nil
}
func (f *Submission) IsSubmitted() bool {
return f.isSubmitted
}
func (f *Submission) IsValid() bool {
if f.errors == nil {
return true
}
return len(f.errors) == 0
}
func (f *Submission) IsDone() bool {
return f.IsSubmitted() && f.IsValid()
}
func (f *Submission) FieldHasErrors(fieldName string) bool {
return len(f.GetFieldErrors(fieldName)) > 0
}
func (f *Submission) SetFieldError(fieldName string, message string) {
if f.errors == nil {
f.errors = make(map[string][]string)
}
f.errors[fieldName] = append(f.errors[fieldName], message)
}
func (f *Submission) GetFieldErrors(fieldName string) []string {
if f.errors == nil {
return []string{}
}
return f.errors[fieldName]
}
// setErrorMessages sets errors messages on the submission for all fields that failed validation.
func (f *Submission) setErrorMessages(err error) {
// Only this is supported right now
ves, ok := err.(validator.ValidationErrors)
if !ok {
return
}
for _, ve := range ves {
var message string
// Provide better error messages depending on the failed validation tag.
// This should be expanded as you use additional tags in your validation.
switch ve.Tag() {
case "required":
message = "This field is required."
case "email":
message = "Enter a valid email address."
case "eqfield":
message = "Does not match."
case "gte":
message = fmt.Sprintf("Must be greater than or equal to %v.", ve.Param())
default:
message = "Invalid value."
}
// Add the error.
f.SetFieldError(ve.Field(), message)
}
}

View file

@ -0,0 +1,57 @@
package form
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/services"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormSubmission(t *testing.T) {
type formTest struct {
Name string `form:"name" validate:"required"`
Email string `form:"email" validate:"required,email"`
Submission
}
e := echo.New()
e.Validator = services.NewValidator()
t.Run("valid request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("email=a@a.com"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ctx := e.NewContext(req, httptest.NewRecorder())
var form formTest
err := form.Submit(ctx, &form)
assert.IsType(t, validator.ValidationErrors{}, err)
assert.Empty(t, form.Name)
assert.Equal(t, "a@a.com", form.Email)
assert.False(t, form.IsValid())
assert.True(t, form.FieldHasErrors("Name"))
assert.False(t, form.FieldHasErrors("Email"))
require.Len(t, form.GetFieldErrors("Name"), 1)
assert.Len(t, form.GetFieldErrors("Email"), 0)
assert.Equal(t, "This field is required.", form.GetFieldErrors("Name")[0])
assert.False(t, form.IsDone())
formInCtx := Get[formTest](ctx)
require.NotNil(t, formInCtx)
assert.Equal(t, form.Email, formInCtx.Email)
})
t.Run("invalid request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("abc=abc"))
ctx := e.NewContext(req, httptest.NewRecorder())
var form formTest
err := form.Submit(ctx, &form)
assert.IsType(t, new(echo.HTTPError), err)
})
}

198
internal/handlers/admin.go Normal file
View file

@ -0,0 +1,198 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/backlite/ui"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/ent/admin"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/middleware"
"github.com/camzawacki/personal-site/internal/msg"
"github.com/camzawacki/personal-site/internal/pager"
"github.com/camzawacki/personal-site/internal/redirect"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/ui/pages"
)
type Admin struct {
orm *ent.Client
admin *admin.Handler
backlite *ui.Handler
}
func init() {
Register(new(Admin))
}
func (h *Admin) Init(c *services.Container) error {
var err error
h.orm = c.ORM
h.admin = admin.NewHandler(h.orm, admin.HandlerConfig{
ItemsPerPage: 25,
PageQueryKey: pager.QueryKey,
TimeFormat: time.DateTime,
})
h.backlite, err = ui.NewHandler(ui.Config{
DB: c.Database,
BasePath: "/admin/tasks",
ItemsPerPage: 25,
ReleaseAfter: c.Config.Tasks.ReleaseAfter,
})
return err
}
func (h *Admin) Routes(g *echo.Group) {
ag := g.Group("/admin", middleware.RequireAdmin)
entities := ag.Group("/entity")
for _, n := range admin.GetEntityTypes() {
ng := entities.Group(fmt.Sprintf("/%s", strings.ToLower(n.GetName())))
ng.GET("", h.EntityList(n)).
Name = routenames.AdminEntityList(n.GetName())
ng.GET("/add", h.EntityAdd(n)).
Name = routenames.AdminEntityAdd(n.GetName())
ng.POST("/add", h.EntityAddSubmit(n)).
Name = routenames.AdminEntityAddSubmit(n.GetName())
ng.GET("/:id/edit", h.EntityEdit(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityEdit(n.GetName())
ng.POST("/:id/edit", h.EntityEditSubmit(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityEditSubmit(n.GetName())
ng.GET("/:id/delete", h.EntityDelete(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityDelete(n.GetName())
ng.POST("/:id/delete", h.EntityDeleteSubmit(n), h.middlewareEntityLoad(n)).
Name = routenames.AdminEntityDeleteSubmit(n.GetName())
}
tasks := ag.Group("/tasks")
tasks.GET("", h.Backlite(h.backlite.Running)).Name = routenames.AdminTasks
tasks.GET("/succeeded", h.Backlite(h.backlite.Succeeded))
tasks.GET("/failed", h.Backlite(h.backlite.Failed))
tasks.GET("/upcoming", h.Backlite(h.backlite.Upcoming))
tasks.GET("/task/:id", h.Backlite(h.backlite.Task))
tasks.GET("/completed/:id", h.Backlite(h.backlite.TaskCompleted))
}
// middlewareEntityLoad is middleware to extract the entity ID and attempt to load the given entity.
func (h *Admin) middlewareEntityLoad(n admin.EntityType) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid entity ID")
}
entity, err := h.admin.Get(ctx, n, id)
switch {
case err == nil:
ctx.Set(context.AdminEntityIDKey, id)
ctx.Set(context.AdminEntityKey, map[string][]string(entity))
return next(ctx)
case ent.IsNotFound(err):
return echo.NewHTTPError(http.StatusNotFound, "entity not found")
default:
return echo.NewHTTPError(http.StatusInternalServerError, err)
}
}
}
}
func (h *Admin) EntityList(n admin.EntityType) echo.HandlerFunc {
return func(ctx echo.Context) error {
list, err := h.admin.List(ctx, n)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err)
}
return pages.AdminEntityList(ctx, n, list)
}
}
func (h *Admin) EntityAdd(n admin.EntityType) echo.HandlerFunc {
return func(ctx echo.Context) error {
return pages.AdminEntityInput(ctx, n, nil)
}
}
func (h *Admin) EntityAddSubmit(n admin.EntityType) echo.HandlerFunc {
return func(ctx echo.Context) error {
err := h.admin.Create(ctx, n)
if err != nil {
msg.Error(ctx, err.Error())
return h.EntityAdd(n)(ctx)
}
msg.Success(ctx, fmt.Sprintf("Successfully added %s.", n.GetName()))
return redirect.
New(ctx).
Route(routenames.AdminEntityList(n.GetName())).
StatusCode(http.StatusFound).
Go()
}
}
func (h *Admin) EntityEdit(n admin.EntityType) echo.HandlerFunc {
return func(ctx echo.Context) error {
v := ctx.Get(context.AdminEntityKey).(map[string][]string)
return pages.AdminEntityInput(ctx, n, v)
}
}
func (h *Admin) EntityEditSubmit(n admin.EntityType) echo.HandlerFunc {
return func(ctx echo.Context) error {
id := ctx.Get(context.AdminEntityIDKey).(int)
err := h.admin.Update(ctx, n, id)
if err != nil {
msg.Error(ctx, err.Error())
return h.EntityEdit(n)(ctx)
}
msg.Success(ctx, fmt.Sprintf("Updated %s.", n.GetName()))
return redirect.
New(ctx).
Route(routenames.AdminEntityList(n.GetName())).
StatusCode(http.StatusFound).
Go()
}
}
func (h *Admin) EntityDelete(n admin.EntityType) echo.HandlerFunc {
return func(ctx echo.Context) error {
return pages.AdminEntityDelete(ctx, n)
}
}
func (h *Admin) EntityDeleteSubmit(n admin.EntityType) echo.HandlerFunc {
return func(ctx echo.Context) error {
id := ctx.Get(context.AdminEntityIDKey).(int)
if err := h.admin.Delete(ctx, n, id); err != nil {
msg.Error(ctx, err.Error())
return h.EntityDelete(n)(ctx)
}
msg.Success(ctx, fmt.Sprintf("Successfully deleted %s (ID %d).", n.GetName(), id))
return redirect.
New(ctx).
Route(routenames.AdminEntityList(n.GetName())).
StatusCode(http.StatusFound).
Go()
}
}
func (h *Admin) Backlite(handler func(http.ResponseWriter, *http.Request) error) echo.HandlerFunc {
return func(c echo.Context) error {
if id := c.Param("id"); id != "" {
c.Request().SetPathValue("task", id)
}
return handler(c.Response().Writer, c.Request())
}
}

380
internal/handlers/auth.go Normal file
View file

@ -0,0 +1,380 @@
package handlers
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/ent/user"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/log"
"github.com/camzawacki/personal-site/internal/middleware"
"github.com/camzawacki/personal-site/internal/msg"
"github.com/camzawacki/personal-site/internal/redirect"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/ui/emails"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/pages"
)
type Auth struct {
config *config.Config
auth *services.AuthClient
mail *services.MailClient
orm *ent.Client
}
func init() {
Register(new(Auth))
}
func (h *Auth) Init(c *services.Container) error {
h.config = c.Config
h.orm = c.ORM
h.auth = c.Auth
h.mail = c.Mail
return nil
}
func (h *Auth) Routes(g *echo.Group) {
g.GET("/logout", h.Logout, middleware.RequireAuthentication).Name = routenames.Logout
g.GET("/email/verify/:token", h.VerifyEmail).Name = routenames.VerifyEmail
noAuth := g.Group("/user", middleware.RequireNoAuthentication)
noAuth.GET("/login", h.LoginPage).Name = routenames.Login
// noAuth.POST("/login", h.LoginSubmit).Name = routenames.LoginSubmit
// noAuth.GET("/register", h.RegisterPage).Name = routenames.Register
// noAuth.POST("/register", h.RegisterSubmit).Name = routenames.RegisterSubmit
noAuth.GET("/password", h.ForgotPasswordPage).Name = routenames.ForgotPassword
noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routenames.ForgotPasswordSubmit
resetGroup := noAuth.Group("/password/reset",
middleware.LoadUser(h.orm),
middleware.LoadValidPasswordToken(h.auth),
)
resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routenames.ResetPassword
resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routenames.ResetPasswordSubmit
}
func (h *Auth) ForgotPasswordPage(ctx echo.Context) error {
return pages.ForgotPassword(ctx, form.Get[forms.ForgotPassword](ctx))
}
func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
var input forms.ForgotPassword
succeed := func() error {
form.Clear(ctx)
msg.Success(ctx, "An email containing a link to reset your password will be sent to this address if it exists in our system.")
return h.ForgotPasswordPage(ctx)
}
err := form.Submit(ctx, &input)
switch err.(type) {
case nil:
case validator.ValidationErrors:
return h.ForgotPasswordPage(ctx)
default:
return err
}
// Attempt to load the user.
u, err := h.orm.User.
Query().
Where(user.Email(strings.ToLower(input.Email))).
Only(ctx.Request().Context())
switch err.(type) {
case *ent.NotFoundError:
return succeed()
case nil:
default:
return fail(err, "error querying user during forgot password")
}
// Generate the token.
token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID)
if err != nil {
return fail(err, "error generating password reset token")
}
log.Ctx(ctx).Info("generated password reset token",
"user_id", u.ID,
)
// Email the user.
url := ctx.Echo().Reverse(routenames.ResetPassword, u.ID, pt.ID, token)
err = h.mail.
Compose().
To(u.Email).
Subject("Reset your password").
Body(fmt.Sprintf("Go here to reset your password: %s", h.config.App.Host+url)).
Send(ctx)
if err != nil {
return fail(err, "error sending password reset email")
}
return succeed()
}
func (h *Auth) LoginPage(ctx echo.Context) error {
return pages.Login(ctx, form.Get[forms.Login](ctx))
}
func (h *Auth) LoginSubmit(ctx echo.Context) error {
var input forms.Login
authFailed := func() error {
input.SetFieldError("Email", "")
input.SetFieldError("Password", "")
msg.Error(ctx, "Invalid credentials. Please try again.")
return h.LoginPage(ctx)
}
err := form.Submit(ctx, &input)
switch err.(type) {
case nil:
case validator.ValidationErrors:
return h.LoginPage(ctx)
default:
return err
}
// Attempt to load the user.
u, err := h.orm.User.
Query().
Where(user.Email(strings.ToLower(input.Email))).
Only(ctx.Request().Context())
switch err.(type) {
case *ent.NotFoundError:
return authFailed()
case nil:
default:
return fail(err, "error querying user during login")
}
// Check if the password is correct.
err = h.auth.CheckPassword(input.Password, u.Password)
if err != nil {
return authFailed()
}
// Log the user in.
err = h.auth.Login(ctx, u.ID)
if err != nil {
return fail(err, "unable to log in user")
}
msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name))
return redirect.New(ctx).
Route(routenames.Home).
Go()
}
func (h *Auth) Logout(ctx echo.Context) error {
if err := h.auth.Logout(ctx); err == nil {
msg.Success(ctx, "You have been logged out successfully.")
} else {
msg.Error(ctx, "An error occurred. Please try again.")
}
return redirect.New(ctx).
Route(routenames.Home).
Go()
}
func (h *Auth) RegisterPage(ctx echo.Context) error {
return pages.Register(ctx, form.Get[forms.Register](ctx))
}
func (h *Auth) RegisterSubmit(ctx echo.Context) error {
var input forms.Register
err := form.Submit(ctx, &input)
switch err.(type) {
case nil:
case validator.ValidationErrors:
return h.RegisterPage(ctx)
default:
return err
}
// Attempt creating the user.
u, err := h.orm.User.
Create().
SetName(input.Name).
SetEmail(input.Email).
SetPassword(input.Password).
Save(ctx.Request().Context())
switch err.(type) {
case nil:
log.Ctx(ctx).Info("user created",
"user_name", u.Name,
"user_id", u.ID,
)
case *ent.ConstraintError:
msg.Warning(ctx, "A user with this email address already exists. Please log in.")
return redirect.New(ctx).
Route(routenames.Login).
Go()
default:
return fail(err, "unable to create user")
}
// Log the user in.
err = h.auth.Login(ctx, u.ID)
if err != nil {
log.Ctx(ctx).Error("unable to log user in",
"error", err,
"user_id", u.ID,
)
msg.Info(ctx, "Your account has been created.")
return redirect.New(ctx).
Route(routenames.Login).
Go()
}
msg.Success(ctx, "Your account has been created. You are now logged in.")
// Send the verification email.
h.sendVerificationEmail(ctx, u)
return redirect.New(ctx).
Route(routenames.Home).
Go()
}
func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
// Generate a token.
token, err := h.auth.GenerateEmailVerificationToken(usr.Email)
if err != nil {
log.Ctx(ctx).Error("unable to generate email verification token",
"user_id", usr.ID,
"error", err,
)
return
}
// Send the email.
err = h.mail.
Compose().
To(usr.Email).
Subject("Confirm your email address").
Component(emails.ConfirmEmailAddress(ctx, usr.Name, token)).
Send(ctx)
if err != nil {
log.Ctx(ctx).Error("unable to send email verification link",
"user_id", usr.ID,
"error", err,
)
return
}
msg.Info(ctx, "An email was sent to you to verify your email address.")
}
func (h *Auth) ResetPasswordPage(ctx echo.Context) error {
return pages.ResetPassword(ctx, form.Get[forms.ResetPassword](ctx))
}
func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
var input forms.ResetPassword
err := form.Submit(ctx, &input)
switch err.(type) {
case nil:
case validator.ValidationErrors:
return h.ResetPasswordPage(ctx)
default:
return err
}
// Get the requesting user.
usr := ctx.Get(context.UserKey).(*ent.User)
// Update the user.
_, err = usr.
Update().
SetPassword(input.Password).
Save(ctx.Request().Context())
if err != nil {
return fail(err, "unable to update password")
}
// Delete all password tokens for this user.
err = h.auth.DeletePasswordTokens(ctx, usr.ID)
if err != nil {
return fail(err, "unable to delete password tokens")
}
msg.Success(ctx, "Your password has been updated.")
return redirect.New(ctx).
Route(routenames.Login).
Go()
}
func (h *Auth) VerifyEmail(ctx echo.Context) error {
var usr *ent.User
// Validate the token.
token := ctx.Param("token")
email, err := h.auth.ValidateEmailVerificationToken(token)
if err != nil {
msg.Warning(ctx, "The link is either invalid or has expired.")
return redirect.New(ctx).
Route(routenames.Home).
Go()
}
// Check if it matches the authenticated user.
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
authUser := u.(*ent.User)
if authUser.Email == email {
usr = authUser
}
}
// Query to find a matching user, if needed.
if usr == nil {
usr, err = h.orm.User.
Query().
Where(user.Email(email)).
Only(ctx.Request().Context())
if err != nil {
return fail(err, "query failed loading email verification token user")
}
}
// Verify the user, if needed.
if !usr.Verified {
usr, err = usr.
Update().
SetVerified(true).
Save(ctx.Request().Context())
if err != nil {
return fail(err, "failed to set user as verified")
}
}
msg.Success(ctx, "Your email has been successfully verified.")
return redirect.New(ctx).
Route(routenames.Home).
Go()
}

View file

@ -0,0 +1,76 @@
package handlers
import (
"errors"
"time"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/pages"
)
type Cache struct {
cache *services.CacheClient
}
func init() {
Register(new(Cache))
}
func (h *Cache) Init(c *services.Container) error {
h.cache = c.Cache
return nil
}
func (h *Cache) Routes(g *echo.Group) {
g.GET("/cache", h.Page).Name = routenames.Cache
g.POST("/cache", h.Submit).Name = routenames.CacheSubmit
}
func (h *Cache) Page(ctx echo.Context) error {
f := form.Get[forms.Cache](ctx)
// Fetch the value from the cache.
value, err := h.cache.
Get().
Key("page_cache_example").
Fetch(ctx.Request().Context())
// Store the value in the form, so it can be rendered, if found.
switch {
case err == nil:
f.CurrentValue = value.(string)
case errors.Is(err, services.ErrCacheMiss):
default:
return fail(err, "failed to fetch from cache")
}
return pages.UpdateCache(ctx, f)
}
func (h *Cache) Submit(ctx echo.Context) error {
var input forms.Cache
if err := form.Submit(ctx, &input); err != nil {
return err
}
// Set the cache.
err := h.cache.
Set().
Key("page_cache_example").
Data(input.Value).
Expiration(30 * time.Minute).
Save(ctx.Request().Context())
if err != nil {
return fail(err, "unable to set cache")
}
form.Clear(ctx)
return h.Page(ctx)
}

View file

@ -0,0 +1,62 @@
package handlers
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/pages"
)
type Contact struct {
mail *services.MailClient
}
func init() {
Register(new(Contact))
}
func (h *Contact) Init(c *services.Container) error {
h.mail = c.Mail
return nil
}
func (h *Contact) Routes(g *echo.Group) {
g.GET("/contact", h.Page).Name = routenames.Contact
g.POST("/contact", h.Submit).Name = routenames.ContactSubmit
}
func (h *Contact) Page(ctx echo.Context) error {
return pages.ContactUs(ctx, form.Get[forms.Contact](ctx))
}
func (h *Contact) Submit(ctx echo.Context) error {
var input forms.Contact
err := form.Submit(ctx, &input)
switch err.(type) {
case nil:
case validator.ValidationErrors:
return h.Page(ctx)
default:
return err
}
err = h.mail.
Compose().
To(input.Email).
Subject("Contact form submitted").
Body(fmt.Sprintf("The message is: %s", input.Message)).
Send(ctx)
if err != nil {
return fail(err, "unable to send email")
}
return h.Page(ctx)
}

View file

@ -0,0 +1,43 @@
package handlers
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/log"
"github.com/camzawacki/personal-site/internal/ui/pages"
)
type Error struct{}
func (e *Error) Page(err error, ctx echo.Context) {
if ctx.Response().Committed || context.IsCanceledError(err) {
return
}
// Determine the error status code.
code := http.StatusInternalServerError
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
}
// Log the error.
logger := log.Ctx(ctx)
switch {
case code >= 500:
logger.Error(err.Error())
case code >= 400:
logger.Warn(err.Error())
}
// Set the status code.
ctx.Response().WriteHeader(code)
// Render the error page.
if err = pages.Error(ctx, code); err != nil {
log.Ctx(ctx).Error("failed to render error page",
"error", err,
)
}
}

View file

@ -0,0 +1,80 @@
package handlers
import (
"fmt"
"io"
"time"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/msg"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/ui/models"
"github.com/camzawacki/personal-site/internal/ui/pages"
"github.com/spf13/afero"
)
type Files struct {
files afero.Fs
}
func init() {
Register(new(Files))
}
func (h *Files) Init(c *services.Container) error {
h.files = c.Files
return nil
}
func (h *Files) Routes(g *echo.Group) {
g.GET("/files", h.Page).Name = routenames.Files
g.POST("/files", h.Submit).Name = routenames.FilesSubmit
}
func (h *Files) Page(ctx echo.Context) error {
// Compile a list of all uploaded files to be rendered.
info, err := afero.ReadDir(h.files, "")
if err != nil {
return err
}
files := make([]*models.File, 0)
for _, file := range info {
files = append(files, &models.File{
Name: file.Name(),
Size: file.Size(),
Modified: file.ModTime().Format(time.DateTime),
})
}
return pages.UploadFile(ctx, files)
}
func (h *Files) Submit(ctx echo.Context) error {
file, err := ctx.FormFile("file")
if err != nil {
msg.Error(ctx, "A file is required.")
return h.Page(ctx)
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
dst, err := h.files.Create(file.Filename)
if err != nil {
return err
}
defer dst.Close()
if _, err = io.Copy(dst, src); err != nil {
return err
}
msg.Success(ctx, fmt.Sprintf("%s was uploaded successfully.", file.Filename))
return h.Page(ctx)
}

View file

@ -0,0 +1,36 @@
package handlers
import (
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/services"
)
var handlers []Handler
// Handler handles one or more HTTP routes
type Handler interface {
// Routes allows for self-registration of HTTP routes on the router
Routes(g *echo.Group)
// Init provides the service container to initialize
Init(*services.Container) error
}
// Register registers a handler
func Register(h Handler) {
handlers = append(handlers, h)
}
// GetHandlers returns all handlers
func GetHandlers() []Handler {
return handlers
}
// fail is a helper to fail a request by returning a 500 error and logging the error
func fail(err error, log string) error {
// The error handler will handle logging
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%s: %v", log, err))
}

View file

@ -0,0 +1,29 @@
package handlers
import (
"errors"
"net/http"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetSetHandlers(t *testing.T) {
handlers = []Handler{}
assert.Empty(t, GetHandlers())
h := new(Pages)
Register(h)
got := GetHandlers()
require.Len(t, got, 1)
assert.Equal(t, h, got[0])
}
func TestFail(t *testing.T) {
err := fail(errors.New("err message"), "log message")
require.IsType(t, new(echo.HTTPError), err)
he := err.(*echo.HTTPError)
assert.Equal(t, http.StatusInternalServerError, he.Code)
assert.Equal(t, "log message: err message", he.Message)
}

View file

@ -0,0 +1,55 @@
package handlers
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/pager"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/ui/models"
"github.com/camzawacki/personal-site/internal/ui/pages"
)
type Pages struct{}
func init() {
Register(new(Pages))
}
func (h *Pages) Init(c *services.Container) error {
return nil
}
func (h *Pages) Routes(g *echo.Group) {
g.GET("/", h.Home).Name = routenames.Home
g.GET("/about", h.About).Name = routenames.About
}
func (h *Pages) Home(ctx echo.Context) error {
pgr := pager.NewPager(ctx, 4)
return pages.Home(ctx, &models.Posts{
Posts: h.fetchPosts(&pgr),
Pager: pgr,
})
}
// fetchPosts is a mock example of fetching posts to illustrate how paging works.
func (h *Pages) fetchPosts(pager *pager.Pager) []models.Post {
pager.SetItems(20)
posts := make([]models.Post, 20)
for k := range posts {
posts[k] = models.Post{
ID: k + 1,
Title: fmt.Sprintf("Post example #%d", k+1),
Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1),
}
}
return posts[pager.GetOffset() : pager.GetOffset()+pager.ItemsPerPage]
}
func (h *Pages) About(ctx echo.Context) error {
return pages.About(ctx)
}

View file

@ -0,0 +1,24 @@
package handlers
import (
"net/http"
"testing"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/stretchr/testify/assert"
)
// Simple example of how to test routes and their markup using the test HTTP server spun up within
// this test package
func TestPages__About(t *testing.T) {
doc := request(t).
setRoute(routenames.About).
get().
assertStatusCode(http.StatusOK).
toDoc()
// Goquery is an excellent package to use for testing HTML markup
h1 := doc.Find("h1")
assert.Len(t, h1.Nodes, 1)
assert.Equal(t, "About", h1.Text())
}

View file

@ -0,0 +1,93 @@
package handlers
import (
"net/http"
"strings"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
echomw "github.com/labstack/echo/v4/middleware"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/middleware"
"github.com/camzawacki/personal-site/internal/services"
files "github.com/camzawacki/personal-site/public"
)
// BuildRouter builds the router.
func BuildRouter(c *services.Container) error {
// Force HTTPS, if enabled.
if c.Config.HTTP.TLS.Enabled {
c.Web.Use(echomw.HTTPSRedirect())
}
// Serve public files with cache control.
c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.PublicFile)).
Static("files", "public/files")
// Serve static files.
// ui.StaticFile() should be used in ui components to append a cache key to the URL to break cache
// after each server reboot.
c.Web.Group(
"",
echomw.GzipWithConfig(echomw.GzipConfig{
Skipper: func(c echo.Context) bool {
for _, ext := range []string{
".js",
".css",
} {
if strings.HasSuffix(c.Request().URL.Path, ext) {
return false
}
}
return true
},
}),
middleware.CacheControl(c.Config.Cache.Expiration.PublicFile),
).StaticFS("static", echo.MustSubFS(files.Static, "static"))
// Non-static file route group.
g := c.Web.Group("")
// Create a cookie store for session data.
cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))
cookieStore.Options.HttpOnly = true
cookieStore.Options.SameSite = http.SameSiteStrictMode
g.Use(
echomw.RemoveTrailingSlashWithConfig(echomw.TrailingSlashConfig{
RedirectCode: http.StatusMovedPermanently,
}),
echomw.Recover(),
echomw.Secure(),
echomw.RequestID(),
middleware.SetLogger(),
middleware.LogRequest(),
echomw.Gzip(),
echomw.TimeoutWithConfig(echomw.TimeoutConfig{
Timeout: c.Config.App.Timeout,
}),
middleware.Config(c.Config),
middleware.Session(cookieStore),
middleware.LoadAuthenticatedUser(c.Auth),
echomw.CSRFWithConfig(echomw.CSRFConfig{
TokenLookup: "form:csrf",
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
ContextKey: context.CSRFKey,
}),
)
// Error handler.
c.Web.HTTPErrorHandler = new(Error).Page
// Initialize and register all handlers.
for _, h := range GetHandlers() {
if err := h.Init(c); err != nil {
return err
}
h.Routes(g)
}
return nil
}

View file

@ -0,0 +1,138 @@
package handlers
import (
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"testing"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/internal/services"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
srv *httptest.Server
c *services.Container
)
func TestMain(m *testing.M) {
// Set the environment to test
config.SwitchEnvironment(config.EnvTest)
// Start a new container
c = services.NewContainer()
// Start a test HTTP server
if err := BuildRouter(c); err != nil {
panic(err)
}
srv = httptest.NewServer(c.Web)
// Run tests
exitVal := m.Run()
// Shutdown the container and test server
if err := c.Shutdown(); err != nil {
panic(err)
}
srv.Close()
os.Exit(exitVal)
}
type httpRequest struct {
route string
client http.Client
body url.Values
t *testing.T
}
func request(t *testing.T) *httpRequest {
jar, err := cookiejar.New(nil)
require.NoError(t, err)
r := httpRequest{
t: t,
body: url.Values{},
client: http.Client{
Jar: jar,
},
}
return &r
}
func (h *httpRequest) setClient(client http.Client) *httpRequest {
h.client = client
return h
}
func (h *httpRequest) setRoute(route string, params ...any) *httpRequest {
h.route = srv.URL + c.Web.Reverse(route, params)
return h
}
func (h *httpRequest) setBody(body url.Values) *httpRequest {
h.body = body
return h
}
func (h *httpRequest) get() *httpResponse {
resp, err := h.client.Get(h.route)
require.NoError(h.t, err)
r := httpResponse{
t: h.t,
Response: resp,
}
return &r
}
func (h *httpRequest) post() *httpResponse {
// Make a get request to get the CSRF token
doc := h.get().
assertStatusCode(http.StatusOK).
toDoc()
// Extract the CSRF and include it in the POST request body
csrf := doc.Find(`input[name="csrf"]`).First()
token, exists := csrf.Attr("value")
assert.True(h.t, exists)
h.body["csrf"] = []string{token}
// Make the POST requests
resp, err := h.client.PostForm(h.route, h.body)
require.NoError(h.t, err)
r := httpResponse{
t: h.t,
Response: resp,
}
return &r
}
type httpResponse struct {
*http.Response
t *testing.T
}
func (h *httpResponse) assertStatusCode(code int) *httpResponse {
assert.Equal(h.t, code, h.Response.StatusCode)
return h
}
func (h *httpResponse) assertRedirect(t *testing.T, route string, params ...any) *httpResponse {
assert.Equal(t, c.Web.Reverse(route, params), h.Header.Get("Location"))
return h
}
func (h *httpResponse) toDoc() *goquery.Document {
doc, err := goquery.NewDocumentFromReader(h.Body)
require.NoError(h.t, err)
err = h.Body.Close()
assert.NoError(h.t, err)
return doc
}

View file

@ -0,0 +1,44 @@
package handlers
import (
"fmt"
"math/rand"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/ui/models"
"github.com/camzawacki/personal-site/internal/ui/pages"
)
type Search struct{}
func init() {
Register(new(Search))
}
func (h *Search) Init(c *services.Container) error {
return nil
}
func (h *Search) Routes(g *echo.Group) {
g.GET("/search", h.Page).Name = routenames.Search
}
func (h *Search) Page(ctx echo.Context) error {
// Fake search results.
results := make([]*models.SearchResult, 0, 5)
if search := ctx.QueryParam("query"); search != "" {
for i := 0; i < 5; i++ {
title := "Lorem ipsum example ddolor sit amet"
index := rand.Intn(len(title))
title = title[:index] + search + title[index:]
results = append(results, &models.SearchResult{
Title: title,
URL: fmt.Sprintf("https://www.%s.com", search),
})
}
}
return pages.SearchResults(ctx, results)
}

71
internal/handlers/task.go Normal file
View file

@ -0,0 +1,71 @@
package handlers
import (
"fmt"
"time"
"github.com/mikestefanello/backlite"
"github.com/camzawacki/personal-site/internal/msg"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/pages"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/tasks"
)
type Task struct {
tasks *backlite.Client
}
func init() {
Register(new(Task))
}
func (h *Task) Init(c *services.Container) error {
h.tasks = c.Tasks
return nil
}
func (h *Task) Routes(g *echo.Group) {
g.GET("/task", h.Page).Name = routenames.Task
g.POST("/task", h.Submit).Name = routenames.TaskSubmit
}
func (h *Task) Page(ctx echo.Context) error {
return pages.AddTask(ctx, form.Get[forms.Task](ctx))
}
func (h *Task) Submit(ctx echo.Context) error {
var input forms.Task
err := form.Submit(ctx, &input)
switch err.(type) {
case nil:
case validator.ValidationErrors:
return h.Page(ctx)
default:
return err
}
// Insert the task
_, err = h.tasks.
Add(tasks.ExampleTask{
Message: input.Message,
}).
Wait(time.Duration(input.Delay) * time.Second).
Save()
if err != nil {
return fail(err, "unable to create a task")
}
msg.Success(ctx, fmt.Sprintf("The task has been created. Check the logs in %d seconds.", input.Delay))
form.Clear(ctx)
return h.Page(ctx)
}

97
internal/htmx/htmx.go Normal file
View file

@ -0,0 +1,97 @@
package htmx
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/context"
)
// Request headers: https://htmx.org/docs/#request-headers
const (
HeaderBoosted = "HX-Boosted"
HeaderHistoryRestoreRequest = "HX-History-Restore-Request"
HeaderPrompt = "HX-Prompt"
HeaderRequest = "HX-Request"
HeaderTarget = "HX-Target"
HeaderTrigger = "HX-Trigger"
HeaderTriggerName = "HX-Trigger-Name"
)
// Response headers: https://htmx.org/docs/#response-headers
const (
HeaderPushURL = "HX-Push-Url"
HeaderRedirect = "HX-Redirect"
HeaderReplaceURL = "HX-Replace-Url"
HeaderRefresh = "HX-Refresh"
HeaderTriggerAfterSettle = "HX-Trigger-After-Settle"
HeaderTriggerAfterSwap = "HX-Trigger-After-Swap"
)
type (
// Request contains data that HTMX provides during requests.
Request struct {
Enabled bool
Boosted bool
HistoryRestore bool
Trigger string
TriggerName string
Target string
Prompt string
}
// Response contain data that the server can communicate back to HTMX.
Response struct {
PushURL string
Redirect string
Refresh bool
ReplaceURL string
Trigger string
TriggerAfterSwap string
TriggerAfterSettle string
NoContent bool
}
)
// GetRequest extracts HTMX data from the request,
func GetRequest(ctx echo.Context) *Request {
return context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request {
return &Request{
Enabled: ctx.Request().Header.Get(HeaderRequest) == "true",
Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true",
Trigger: ctx.Request().Header.Get(HeaderTrigger),
TriggerName: ctx.Request().Header.Get(HeaderTriggerName),
Target: ctx.Request().Header.Get(HeaderTarget),
Prompt: ctx.Request().Header.Get(HeaderPrompt),
HistoryRestore: ctx.Request().Header.Get(HeaderHistoryRestoreRequest) == "true",
}
})
}
// Apply applies data from a Response to a server response.
func (r Response) Apply(ctx echo.Context) {
if r.PushURL != "" {
ctx.Response().Header().Set(HeaderPushURL, r.PushURL)
}
if r.Redirect != "" {
ctx.Response().Header().Set(HeaderRedirect, r.Redirect)
}
if r.Refresh {
ctx.Response().Header().Set(HeaderRefresh, "true")
}
if r.Trigger != "" {
ctx.Response().Header().Set(HeaderTrigger, r.Trigger)
}
if r.TriggerAfterSwap != "" {
ctx.Response().Header().Set(HeaderTriggerAfterSwap, r.TriggerAfterSwap)
}
if r.TriggerAfterSettle != "" {
ctx.Response().Header().Set(HeaderTriggerAfterSettle, r.TriggerAfterSettle)
}
if r.ReplaceURL != "" {
ctx.Response().Header().Set(HeaderReplaceURL, r.ReplaceURL)
}
if r.NoContent {
ctx.Response().Status = http.StatusNoContent
}
}

View file

@ -0,0 +1,62 @@
package htmx
import (
"net/http"
"testing"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/labstack/echo/v4"
)
func TestSetRequest(t *testing.T) {
ctx, _ := tests.NewContext(echo.New(), "/")
ctx.Request().Header.Set(HeaderRequest, "true")
ctx.Request().Header.Set(HeaderBoosted, "true")
ctx.Request().Header.Set(HeaderTrigger, "a")
ctx.Request().Header.Set(HeaderTriggerName, "b")
ctx.Request().Header.Set(HeaderTarget, "c")
ctx.Request().Header.Set(HeaderPrompt, "d")
ctx.Request().Header.Set(HeaderHistoryRestoreRequest, "true")
r := GetRequest(ctx)
assert.Equal(t, true, r.Enabled)
assert.Equal(t, true, r.Boosted)
assert.Equal(t, true, r.HistoryRestore)
assert.Equal(t, "a", r.Trigger)
assert.Equal(t, "b", r.TriggerName)
assert.Equal(t, "c", r.Target)
assert.Equal(t, "d", r.Prompt)
cached := context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request {
return nil
})
assert.Equal(t, r, cached)
}
func TestResponse_Apply(t *testing.T) {
ctx, _ := tests.NewContext(echo.New(), "/")
r := Response{
PushURL: "a",
Redirect: "b",
ReplaceURL: "f",
Refresh: true,
Trigger: "c",
TriggerAfterSwap: "d",
TriggerAfterSettle: "e",
NoContent: true,
}
r.Apply(ctx)
assert.Equal(t, "a", ctx.Response().Header().Get(HeaderPushURL))
assert.Equal(t, "b", ctx.Response().Header().Get(HeaderRedirect))
assert.Equal(t, "true", ctx.Response().Header().Get(HeaderRefresh))
assert.Equal(t, "c", ctx.Response().Header().Get(HeaderTrigger))
assert.Equal(t, "d", ctx.Response().Header().Get(HeaderTriggerAfterSwap))
assert.Equal(t, "e", ctx.Response().Header().Get(HeaderTriggerAfterSettle))
assert.Equal(t, "f", ctx.Response().Header().Get(HeaderReplaceURL))
assert.Equal(t, http.StatusNoContent, ctx.Response().Status)
}

27
internal/log/log.go Normal file
View file

@ -0,0 +1,27 @@
package log
import (
"log/slog"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/context"
)
// Set sets a logger in the context.
func Set(ctx echo.Context, logger *slog.Logger) {
ctx.Set(context.LoggerKey, logger)
}
// Ctx returns the logger stored in context, or provides the default logger if one is not present.
func Ctx(ctx echo.Context) *slog.Logger {
if l, ok := ctx.Get(context.LoggerKey).(*slog.Logger); ok {
return l
}
return Default()
}
// Default returns the default logger.
func Default() *slog.Logger {
return slog.Default()
}

21
internal/log/log_test.go Normal file
View file

@ -0,0 +1,21 @@
package log
import (
"testing"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
)
func TestCtxSet(t *testing.T) {
ctx, _ := tests.NewContext(echo.New(), "/")
logger := Ctx(ctx)
assert.NotNil(t, logger)
logger = logger.With("a", "b")
Set(ctx, logger)
got := Ctx(ctx)
assert.Equal(t, got, logger)
}

120
internal/middleware/auth.go Normal file
View file

@ -0,0 +1,120 @@
package middleware
import (
"fmt"
"net/http"
"strconv"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/log"
"github.com/camzawacki/personal-site/internal/msg"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/services"
"github.com/labstack/echo/v4"
)
// LoadAuthenticatedUser loads the authenticated user, if one, and stores in context.
func LoadAuthenticatedUser(authClient *services.AuthClient) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
u, err := authClient.GetAuthenticatedUser(c)
switch err.(type) {
case *ent.NotFoundError:
log.Ctx(c).Warn("auth user not found")
case services.NotAuthenticatedError:
case nil:
c.Set(context.AuthenticatedUserKey, u)
default:
return echo.NewHTTPError(
http.StatusInternalServerError,
fmt.Sprintf("error querying for authenticated user: %v", err),
)
}
return next(c)
}
}
}
// LoadValidPasswordToken loads a valid password token entity that matches the user and token
// provided in path parameters
// If the token is invalid, the user will be redirected to the forgot password route
// This requires that the user owning the token is loaded in to context.
func LoadValidPasswordToken(authClient *services.AuthClient) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract the user parameter
if c.Get(context.UserKey) == nil {
return echo.NewHTTPError(http.StatusInternalServerError)
}
usr := c.Get(context.UserKey).(*ent.User)
// Extract the token ID.
tokenID, err := strconv.Atoi(c.Param("password_token"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound)
}
// Attempt to load a valid password token.
token, err := authClient.GetValidPasswordToken(
c,
usr.ID,
tokenID,
c.Param("token"),
)
switch err.(type) {
case nil:
c.Set(context.PasswordTokenKey, token)
return next(c)
case services.InvalidPasswordTokenError:
msg.Warning(c, "The link is either invalid or has expired. Please request a new one.")
return c.Redirect(http.StatusFound, c.Echo().Reverse(routenames.ForgotPassword))
default:
return echo.NewHTTPError(
http.StatusInternalServerError,
fmt.Sprintf("error loading password token: %v", err),
)
}
}
}
}
// RequireAuthentication requires that the user be authenticated in order to proceed.
func RequireAuthentication(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if u := c.Get(context.AuthenticatedUserKey); u == nil {
return echo.NewHTTPError(http.StatusUnauthorized)
}
return next(c)
}
}
// RequireNoAuthentication requires that the user not be authenticated in order to proceed.
func RequireNoAuthentication(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if u := c.Get(context.AuthenticatedUserKey); u != nil {
return echo.NewHTTPError(http.StatusForbidden)
}
return next(c)
}
}
// RequireAdmin requires that the authenticated user be an admin in order to proceed.
func RequireAdmin(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if u := c.Get(context.AuthenticatedUserKey); u != nil {
if user, ok := u.(*ent.User); ok {
if user.Admin {
return next(c)
}
}
}
return echo.NewHTTPError(http.StatusUnauthorized)
}
}

View file

@ -0,0 +1,145 @@
package middleware
import (
goctx "context"
"fmt"
"net/http"
"testing"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)
func TestLoadAuthenticatedUser(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
tests.InitSession(ctx)
mw := LoadAuthenticatedUser(c.Auth)
// Not authenticated
_ = tests.ExecuteMiddleware(ctx, mw)
assert.Nil(t, ctx.Get(context.AuthenticatedUserKey))
// Login
err := c.Auth.Login(ctx, usr.ID)
require.NoError(t, err)
// Verify the midldeware returns the authenticated user
_ = tests.ExecuteMiddleware(ctx, mw)
require.NotNil(t, ctx.Get(context.AuthenticatedUserKey))
ctxUsr, ok := ctx.Get(context.AuthenticatedUserKey).(*ent.User)
require.True(t, ok)
assert.Equal(t, usr.ID, ctxUsr.ID)
}
func TestRequireAuthentication(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
tests.InitSession(ctx)
// Not logged in
err := tests.ExecuteMiddleware(ctx, RequireAuthentication)
tests.AssertHTTPErrorCode(t, err, http.StatusUnauthorized)
// Login
err = c.Auth.Login(ctx, usr.ID)
require.NoError(t, err)
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
// Logged in
err = tests.ExecuteMiddleware(ctx, RequireAuthentication)
assert.Nil(t, err)
}
func TestRequireNoAuthentication(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
tests.InitSession(ctx)
// Not logged in
err := tests.ExecuteMiddleware(ctx, RequireNoAuthentication)
assert.Nil(t, err)
// Login
err = c.Auth.Login(ctx, usr.ID)
require.NoError(t, err)
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
// Logged in
err = tests.ExecuteMiddleware(ctx, RequireNoAuthentication)
tests.AssertHTTPErrorCode(t, err, http.StatusForbidden)
}
func TestLoadValidPasswordToken(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
tests.InitSession(ctx)
// Missing user context
err := tests.ExecuteMiddleware(ctx, LoadValidPasswordToken(c.Auth))
tests.AssertHTTPErrorCode(t, err, http.StatusInternalServerError)
// Add user and password token context but no token and expect a redirect
ctx.SetParamNames("user", "password_token")
ctx.SetParamValues(fmt.Sprintf("%d", usr.ID), "1")
_ = tests.ExecuteMiddleware(ctx, LoadUser(c.ORM))
err = tests.ExecuteMiddleware(ctx, LoadValidPasswordToken(c.Auth))
assert.NoError(t, err)
assert.Equal(t, http.StatusFound, ctx.Response().Status)
// Add user context and invalid password token and expect a redirect
ctx.SetParamNames("user", "password_token", "token")
ctx.SetParamValues(fmt.Sprintf("%d", usr.ID), "1", "faketoken")
_ = tests.ExecuteMiddleware(ctx, LoadUser(c.ORM))
err = tests.ExecuteMiddleware(ctx, LoadValidPasswordToken(c.Auth))
assert.NoError(t, err)
assert.Equal(t, http.StatusFound, ctx.Response().Status)
// Create a valid token
token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
require.NoError(t, err)
// Add user and valid password token
ctx.SetParamNames("user", "password_token", "token")
ctx.SetParamValues(fmt.Sprintf("%d", usr.ID), fmt.Sprintf("%d", pt.ID), token)
_ = tests.ExecuteMiddleware(ctx, LoadUser(c.ORM))
err = tests.ExecuteMiddleware(ctx, LoadValidPasswordToken(c.Auth))
assert.Nil(t, err)
ctxPt, ok := ctx.Get(context.PasswordTokenKey).(*ent.PasswordToken)
require.True(t, ok)
assert.Equal(t, pt.ID, ctxPt.ID)
}
func TestRequireAdmin(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
tests.InitSession(ctx)
// Not logged in
err := tests.ExecuteMiddleware(ctx, RequireAdmin)
tests.AssertHTTPErrorCode(t, err, http.StatusUnauthorized)
// Login as a non-admin
err = c.Auth.Login(ctx, usr.ID)
require.NoError(t, err)
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
// Logged in as a non-admin
err = tests.ExecuteMiddleware(ctx, RequireAdmin)
tests.AssertHTTPErrorCode(t, err, http.StatusUnauthorized)
// Create an admin and login
adm, err := tests.CreateUser(c.ORM)
require.NoError(t, err)
err = c.ORM.User.Update().
SetAdmin(true).
Exec(goctx.Background())
require.NoError(t, err)
err = c.Auth.Login(ctx, adm.ID)
require.NoError(t, err)
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
// Logged in as an admin
err = tests.ExecuteMiddleware(ctx, RequireAdmin)
assert.Nil(t, err)
}

View file

@ -0,0 +1,22 @@
package middleware
import (
"fmt"
"time"
"github.com/labstack/echo/v4"
)
// CacheControl sets a Cache-Control header with a given max age.
func CacheControl(maxAge time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
v := "no-cache, no-store"
if maxAge > 0 {
v = fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds())
}
ctx.Response().Header().Set("Cache-Control", v)
return next(ctx)
}
}
}

View file

@ -0,0 +1,18 @@
package middleware
import (
"testing"
"time"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
)
func TestCacheControl(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
_ = tests.ExecuteMiddleware(ctx, CacheControl(time.Second*5))
assert.Equal(t, "public, max-age=5", ctx.Response().Header().Get("Cache-Control"))
_ = tests.ExecuteMiddleware(ctx, CacheControl(0))
assert.Equal(t, "no-cache, no-store", ctx.Response().Header().Get("Cache-Control"))
}

View file

@ -0,0 +1,17 @@
package middleware
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/internal/context"
)
// Config stores the configuration in the request so it can be accessed by the ui.
func Config(cfg *config.Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
ctx.Set(context.ConfigKey, cfg)
return next(ctx)
}
}
}

View file

@ -0,0 +1,22 @@
package middleware
import (
"testing"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfig(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
cfg := &config.Config{}
err := tests.ExecuteMiddleware(ctx, Config(cfg))
require.NoError(t, err)
got, ok := ctx.Get(context.ConfigKey).(*config.Config)
require.True(t, ok)
assert.Same(t, got, cfg)
}

View file

@ -0,0 +1,43 @@
package middleware
import (
"fmt"
"net/http"
"strconv"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/ent/user"
"github.com/camzawacki/personal-site/internal/context"
"github.com/labstack/echo/v4"
)
// LoadUser loads the user based on the ID provided as a path parameter.
func LoadUser(orm *ent.Client) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("user"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound)
}
u, err := orm.User.
Query().
Where(user.ID(userID)).
Only(c.Request().Context())
switch err.(type) {
case nil:
c.Set(context.UserKey, u)
return next(c)
case *ent.NotFoundError:
return echo.NewHTTPError(http.StatusNotFound)
default:
return echo.NewHTTPError(
http.StatusInternalServerError,
fmt.Sprintf("error querying user: %v", err),
)
}
}
}
}

View file

@ -0,0 +1,23 @@
package middleware
import (
"fmt"
"testing"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadUser(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
ctx.SetParamNames("user")
ctx.SetParamValues(fmt.Sprintf("%d", usr.ID))
_ = tests.ExecuteMiddleware(ctx, LoadUser(c.ORM))
ctxUsr, ok := ctx.Get(context.UserKey).(*ent.User)
require.True(t, ok)
assert.Equal(t, usr.ID, ctxUsr.ID)
}

View file

@ -0,0 +1,73 @@
package middleware
import (
"fmt"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/log"
)
// SetLogger initializes a logger for the current request and stores it in the context.
// It's recommended to have this executed after Echo's RequestID() middleware because it will add
// the request ID to the logger so that all log messages produced from this request have the
// request ID in it. You can modify this code to include any other fields that you want to always
// appear.
func SetLogger() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
// Include the request ID in the logger
rID := ctx.Response().Header().Get(echo.HeaderXRequestID)
logger := log.Ctx(ctx).With("request_id", rID)
// TODO include other fields you may want in all logs for this request
log.Set(ctx, logger)
return next(ctx)
}
}
}
// LogRequest logs the current request
// Echo provides middleware similar to this, but we want to use our own logger
func LogRequest() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) (err error) {
req := ctx.Request()
res := ctx.Response()
// Track how long the request takes to complete
start := time.Now()
if err = next(ctx); err != nil {
ctx.Error(err)
}
stop := time.Now()
sub := log.Ctx(ctx).With(
"ip", ctx.RealIP(),
"host", req.Host,
"referer", req.Referer(),
"status", res.Status,
"bytes_in", func() string {
cl := req.Header.Get(echo.HeaderContentLength)
if cl == "" {
cl = "0"
}
return cl
}(),
"bytes_out", strconv.FormatInt(res.Size, 10),
"latency", stop.Sub(start).String(),
)
msg := fmt.Sprintf("%s %s", req.Method, req.URL.RequestURI())
if res.Status >= 500 {
sub.Error(msg)
} else {
sub.Info(msg)
}
return nil
}
}
}

View file

@ -0,0 +1,109 @@
package middleware
import (
"context"
"log/slog"
"testing"
"github.com/labstack/echo/v4"
echomw "github.com/labstack/echo/v4/middleware"
"github.com/camzawacki/personal-site/internal/log"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)
type mockLogHandler struct {
msg string
level string
group string
attr []slog.Attr
}
func (m *mockLogHandler) Enabled(_ context.Context, l slog.Level) bool {
return true
}
func (m *mockLogHandler) Handle(_ context.Context, r slog.Record) error {
m.level = r.Level.String()
m.msg = r.Message
return nil
}
func (m *mockLogHandler) WithAttrs(as []slog.Attr) slog.Handler {
if m.attr == nil {
m.attr = make([]slog.Attr, 0)
}
m.attr = append(m.attr, as...)
return m
}
func (m *mockLogHandler) WithGroup(name string) slog.Handler {
m.group = name
return m
}
func (m *mockLogHandler) GetAttr(key string) string {
if m.attr == nil {
return ""
}
for _, attr := range m.attr {
if attr.Key == key {
return attr.Value.String()
}
}
return ""
}
func TestLogRequestID(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
h := new(mockLogHandler)
logger := slog.New(h)
log.Set(ctx, logger)
require.NoError(t, tests.ExecuteMiddleware(ctx, echomw.RequestID()))
require.NoError(t, tests.ExecuteMiddleware(ctx, SetLogger()))
log.Ctx(ctx).Info("test")
rID := ctx.Response().Header().Get(echo.HeaderXRequestID)
assert.Equal(t, rID, h.GetAttr("request_id"))
}
func TestLogRequest(t *testing.T) {
statusCode := 200
h := new(mockLogHandler)
exec := func() {
ctx, _ := tests.NewContext(c.Web, "http://test.localhost/abc?d=1&e=2")
logger := slog.New(h).With("previous", "param")
log.Set(ctx, logger)
ctx.Request().Header.Set("Referer", "ref.com")
ctx.Request().Header.Set(echo.HeaderXRealIP, "21.12.12.21")
require.NoError(t, tests.ExecuteHandler(ctx, func(ctx echo.Context) error {
return ctx.String(statusCode, "hello")
},
SetLogger(),
LogRequest(),
))
}
exec()
assert.Equal(t, "param", h.GetAttr("previous"))
assert.Equal(t, "21.12.12.21", h.GetAttr("ip"))
assert.Equal(t, "test.localhost", h.GetAttr("host"))
assert.Equal(t, "ref.com", h.GetAttr("referer"))
assert.Equal(t, "200", h.GetAttr("status"))
assert.Equal(t, "0", h.GetAttr("bytes_in"))
assert.Equal(t, "5", h.GetAttr("bytes_out"))
assert.NotEmpty(t, h.GetAttr("latency"))
assert.Equal(t, "INFO", h.level)
assert.Equal(t, "GET /abc?d=1&e=2", h.msg)
statusCode = 500
exec()
assert.Equal(t, "ERROR", h.level)
}

View file

@ -0,0 +1,40 @@
package middleware
import (
"os"
"testing"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/services"
"github.com/camzawacki/personal-site/internal/tests"
)
var (
c *services.Container
usr *ent.User
)
func TestMain(m *testing.M) {
// Set the environment to test
config.SwitchEnvironment(config.EnvTest)
// Create a new container
c = services.NewContainer()
// Create a user
var err error
if usr, err = tests.CreateUser(c.ORM); err != nil {
panic(err)
}
// Run tests
exitVal := m.Run()
// Shutdown the container
if err = c.Shutdown(); err != nil {
panic(err)
}
os.Exit(exitVal)
}

View file

@ -0,0 +1,19 @@
package middleware
import (
"github.com/gorilla/context"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/session"
)
// Session sets the session storage in the request context
func Session(store sessions.Store) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
defer context.Clear(ctx.Request())
session.Store(ctx, store)
return next(ctx)
}
}
}

View file

@ -0,0 +1,24 @@
package middleware
import (
"testing"
"github.com/gorilla/sessions"
"github.com/camzawacki/personal-site/internal/session"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSession(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/")
_, err := session.Get(ctx, "test")
assert.Equal(t, session.ErrStoreNotFound, err)
store := sessions.NewCookieStore([]byte("secret"))
err = tests.ExecuteMiddleware(ctx, Session(store))
require.NoError(t, err)
_, err = session.Get(ctx, "test")
assert.NotEqual(t, session.ErrStoreNotFound, err)
}

97
internal/msg/msg.go Normal file
View file

@ -0,0 +1,97 @@
package msg
import (
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/log"
"github.com/camzawacki/personal-site/internal/session"
)
// Type is a message type.
type Type string
const (
// TypeSuccess represents a success message type.
TypeSuccess Type = "success"
// TypeInfo represents a info message type.
TypeInfo Type = "info"
// TypeWarning represents a warning message type.
TypeWarning Type = "warning"
// TypeError represents an error message type.
TypeError Type = "error"
)
const (
// sessionName stores the name of the session which contains flash messages.
sessionName = "msg"
)
// Success sets a success flash message.
func Success(ctx echo.Context, message string) {
Set(ctx, TypeSuccess, message)
}
// Info sets an info flash message.
func Info(ctx echo.Context, message string) {
Set(ctx, TypeInfo, message)
}
// Warning sets a warning flash message.
func Warning(ctx echo.Context, message string) {
Set(ctx, TypeWarning, message)
}
// Error sets an error flash message.
func Error(ctx echo.Context, message string) {
Set(ctx, TypeError, message)
}
// Set adds a new flash message of a given type into the session storage.
// Errors will be logged and not returned.
func Set(ctx echo.Context, typ Type, message string) {
if sess, err := getSession(ctx); err == nil {
sess.AddFlash(message, string(typ))
save(ctx, sess)
}
}
// Get gets flash messages of a given type from the session storage.
// Errors will be logged and not returned.
func Get(ctx echo.Context, typ Type) []string {
if sess, err := getSession(ctx); err == nil {
if flash := sess.Flashes(string(typ)); len(flash) > 0 {
save(ctx, sess)
msgs := make([]string, 0, len(flash))
for _, m := range flash {
msgs = append(msgs, m.(string))
}
return msgs
}
}
return nil
}
// getSession gets the flash message session.
func getSession(ctx echo.Context) (*sessions.Session, error) {
sess, err := session.Get(ctx, sessionName)
if err != nil {
log.Ctx(ctx).Error("cannot load flash message session",
"error", err,
)
}
return sess, err
}
// save saves the flash message session.
func save(ctx echo.Context, sess *sessions.Session) {
if err := sess.Save(ctx.Request(), ctx.Response()); err != nil {
log.Ctx(ctx).Error("failed to set flash message",
"error", err,
)
}
}

46
internal/msg/msg_test.go Normal file
View file

@ -0,0 +1,46 @@
package msg
import (
"testing"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/labstack/echo/v4"
)
func TestMsg(t *testing.T) {
e := echo.New()
ctx, _ := tests.NewContext(e, "/")
tests.InitSession(ctx)
assertMsg := func(typ Type, message string) {
ret := Get(ctx, typ)
require.Len(t, ret, 1)
assert.Equal(t, message, ret[0])
ret = Get(ctx, typ)
require.Len(t, ret, 0)
}
text := "aaa"
Success(ctx, text)
assertMsg(TypeSuccess, text)
text = "bbb"
Info(ctx, text)
assertMsg(TypeInfo, text)
text = "ccc"
Error(ctx, text)
assertMsg(TypeError, text)
text = "ddd"
Warning(ctx, text)
assertMsg(TypeWarning, text)
text = "eee"
Set(ctx, TypeSuccess, text)
assertMsg(TypeSuccess, text)
}

81
internal/pager/pager.go Normal file
View file

@ -0,0 +1,81 @@
package pager
import (
"math"
"strconv"
"github.com/labstack/echo/v4"
)
// QueryKey stores the query key used to indicate the current page.
const QueryKey = "page"
// Pager provides a mechanism to allow a user to page results via a query parameter.
type Pager struct {
// Items stores the total amount of items in the result set.
Items int
// Page stores the current page number.
Page int
// ItemsPerPage stores the amount of items to display per page.
ItemsPerPage int
// Pages stores the total amount of pages in the result set.
Pages int
}
// NewPager creates a new Pager.
func NewPager(ctx echo.Context, itemsPerPage int) Pager {
p := Pager{
ItemsPerPage: itemsPerPage,
Pages: 1,
Page: 1,
}
if page := ctx.QueryParam(QueryKey); page != "" {
if pageInt, err := strconv.Atoi(page); err == nil {
if pageInt > 0 {
p.Page = pageInt
}
}
}
return p
}
// SetItems sets the amount of items in total for the pager and calculate the amount
// of total pages based off on the item per page.
// This should be used rather than setting either items or pages directly.
func (p *Pager) SetItems(items int) {
p.Items = items
if items > 0 {
p.Pages = int(math.Ceil(float64(items) / float64(p.ItemsPerPage)))
} else {
p.Pages = 1
}
if p.Page > p.Pages {
p.Page = p.Pages
}
}
// IsBeginning determines if the pager is at the beginning of the pages
func (p *Pager) IsBeginning() bool {
return p.Page == 1
}
// IsEnd determines if the pager is at the end of the pages
func (p *Pager) IsEnd() bool {
return p.Page >= p.Pages
}
// GetOffset determines the offset of the results in order to get the items for
// the current page
func (p *Pager) GetOffset() int {
if p.Page == 0 {
p.Page = 1
}
return (p.Page - 1) * p.ItemsPerPage
}

View file

@ -0,0 +1,74 @@
package pager
import (
"fmt"
"testing"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
)
func TestNewPager(t *testing.T) {
e := echo.New()
ctx, _ := tests.NewContext(e, "/")
pgr := NewPager(ctx, 10)
assert.Equal(t, 10, pgr.ItemsPerPage)
assert.Equal(t, 1, pgr.Page)
assert.Equal(t, 0, pgr.Items)
assert.Equal(t, 1, pgr.Pages)
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", QueryKey, 2))
pgr = NewPager(ctx, 10)
assert.Equal(t, 2, pgr.Page)
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", QueryKey, -2))
pgr = NewPager(ctx, 10)
assert.Equal(t, 1, pgr.Page)
}
func TestPager_SetItems(t *testing.T) {
ctx, _ := tests.NewContext(echo.New(), "/")
pgr := NewPager(ctx, 20)
pgr.SetItems(100)
assert.Equal(t, 100, pgr.Items)
assert.Equal(t, 5, pgr.Pages)
pgr.SetItems(0)
assert.Equal(t, 0, pgr.Items)
assert.Equal(t, 1, pgr.Pages)
assert.Equal(t, 1, pgr.Page)
}
func TestPager_IsBeginning(t *testing.T) {
ctx, _ := tests.NewContext(echo.New(), "/")
pgr := NewPager(ctx, 20)
pgr.Pages = 10
assert.True(t, pgr.IsBeginning())
pgr.Page = 2
assert.False(t, pgr.IsBeginning())
pgr.Page = 1
assert.True(t, pgr.IsBeginning())
}
func TestPager_IsEnd(t *testing.T) {
ctx, _ := tests.NewContext(echo.New(), "/")
pgr := NewPager(ctx, 20)
pgr.Pages = 10
assert.False(t, pgr.IsEnd())
pgr.Page = 10
assert.True(t, pgr.IsEnd())
pgr.Page = 1
assert.False(t, pgr.IsEnd())
}
func TestPager_GetOffset(t *testing.T) {
ctx, _ := tests.NewContext(echo.New(), "/")
pgr := NewPager(ctx, 20)
assert.Equal(t, 0, pgr.GetOffset())
pgr.Page = 2
assert.Equal(t, 20, pgr.GetOffset())
pgr.Page = 3
assert.Equal(t, 40, pgr.GetOffset())
}

View file

@ -0,0 +1,91 @@
package redirect
import (
"errors"
"fmt"
"net/http"
"net/url"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/htmx"
)
// Redirect is a helper to perform HTTP redirects.
type Redirect struct {
ctx echo.Context
url string
routeName string
routeParams []any
status int
query url.Values
}
// New initializes a new Redirect
func New(ctx echo.Context) *Redirect {
return &Redirect{
ctx: ctx,
status: http.StatusTemporaryRedirect,
}
}
// Route sets the route name to redirect to.
// Use either this or URL()
func (r *Redirect) Route(name string) *Redirect {
r.routeName = name
return r
}
// Params sets the route params
func (r *Redirect) Params(params ...any) *Redirect {
r.routeParams = params
return r
}
// StatusCode sets the HTTP status code which defaults to http.StatusTemporaryRedirect.
// Does not apply to HTMX redirects.
func (r *Redirect) StatusCode(code int) *Redirect {
r.status = code
return r
}
// Query sets a URL query
func (r *Redirect) Query(query url.Values) *Redirect {
r.query = query
return r
}
// URL sets the URL to redirect to
// Use either this or Route()
func (r *Redirect) URL(url string) *Redirect {
r.url = url
return r
}
// Go performs the redirect
// If the request is HTMX boosted, an HTMX redirect will be performed instead of an HTTP redirect
func (r *Redirect) Go() error {
if r.routeName == "" && r.url == "" {
return errors.New("no redirect provided")
}
var dest string
if r.url != "" {
dest = r.url
} else {
dest = r.ctx.Echo().Reverse(r.routeName, r.routeParams...)
}
if len(r.query) > 0 {
dest = fmt.Sprintf("%s?%s", dest, r.query.Encode())
}
if htmx.GetRequest(r.ctx).Boosted {
htmx.Response{
Redirect: dest,
}.Apply(r.ctx)
return nil
} else {
return r.ctx.Redirect(r.status, dest)
}
}

View file

@ -0,0 +1,77 @@
package redirect
import (
"net/http"
"net/url"
"testing"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/htmx"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRedirect(t *testing.T) {
e := echo.New()
e.GET("/path/:first/and/:second", func(c echo.Context) error {
return nil
}).Name = "test"
redirect := func() (*Redirect, echo.Context) {
ctx, _ := tests.NewContext(e, "/")
return New(ctx), ctx
}
t.Run("route", func(t *testing.T) {
q := url.Values{}
q.Add("a", "1")
q.Add("b", "2")
r, ctx := redirect()
r.Route("test")
r.Params("one", "two")
r.Query(q)
r.StatusCode(http.StatusTemporaryRedirect)
require.NoError(t, r.Go())
assert.Equal(t, "/path/one/and/two?a=1&b=2", ctx.Response().Header().Get(echo.HeaderLocation))
assert.Equal(t, http.StatusTemporaryRedirect, ctx.Response().Status)
})
t.Run("route htmx", func(t *testing.T) {
q := url.Values{}
q.Add("a", "1")
q.Add("b", "2")
r, ctx := redirect()
ctx.Request().Header.Set(htmx.HeaderBoosted, "true")
r.Route("test")
r.Params("one", "two")
r.Query(q)
require.NoError(t, r.Go())
assert.Equal(t, "/path/one/and/two?a=1&b=2", ctx.Response().Header().Get(htmx.HeaderRedirect))
})
t.Run("url", func(t *testing.T) {
q := url.Values{}
q.Add("a", "1")
q.Add("b", "2")
r, ctx := redirect()
r.URL("https://localhost.dev")
r.Query(q)
r.StatusCode(http.StatusTemporaryRedirect)
require.NoError(t, r.Go())
assert.Equal(t, "https://localhost.dev?a=1&b=2", ctx.Response().Header().Get(echo.HeaderLocation))
assert.Equal(t, http.StatusTemporaryRedirect, ctx.Response().Status)
})
t.Run("url htmx", func(t *testing.T) {
q := url.Values{}
q.Add("a", "1")
q.Add("b", "2")
r, ctx := redirect()
ctx.Request().Header.Set(htmx.HeaderBoosted, "true")
r.URL("https://localhost.dev")
r.Query(q)
require.NoError(t, r.Go())
assert.Equal(t, "https://localhost.dev?a=1&b=2", ctx.Response().Header().Get(htmx.HeaderRedirect))
})
}

View file

@ -0,0 +1,58 @@
package routenames
import (
"fmt"
)
const (
Home = "home"
About = "about"
Contact = "contact"
ContactSubmit = "contact.submit"
Login = "login"
LoginSubmit = "login.submit"
Register = "register"
RegisterSubmit = "register.submit"
ForgotPassword = "forgot_password"
ForgotPasswordSubmit = "forgot_password.submit"
Logout = "logout"
VerifyEmail = "verify_email"
ResetPassword = "reset_password"
ResetPasswordSubmit = "reset_password.submit"
Search = "search"
Task = "task"
TaskSubmit = "task.submit"
Cache = "cache"
CacheSubmit = "cache.submit"
Files = "files"
FilesSubmit = "files.submit"
AdminTasks = "admin:tasks"
)
func AdminEntityList(entityTypeName string) string {
return fmt.Sprintf("admin:%s_list", entityTypeName)
}
func AdminEntityAdd(entityTypeName string) string {
return fmt.Sprintf("admin:%s_add", entityTypeName)
}
func AdminEntityEdit(entityTypeName string) string {
return fmt.Sprintf("admin:%s_edit", entityTypeName)
}
func AdminEntityDelete(entityTypeName string) string {
return fmt.Sprintf("admin:%s_delete", entityTypeName)
}
func AdminEntityAddSubmit(entityTypeName string) string {
return fmt.Sprintf("admin:%s_add.submit", entityTypeName)
}
func AdminEntityEditSubmit(entityTypeName string) string {
return fmt.Sprintf("admin:%s_edit.submit", entityTypeName)
}
func AdminEntityDeleteSubmit(entityTypeName string) string {
return fmt.Sprintf("admin:%s_delete.submit", entityTypeName)
}

218
internal/services/auth.go Normal file
View file

@ -0,0 +1,218 @@
package services
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/ent/passwordtoken"
"github.com/camzawacki/personal-site/ent/user"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/session"
"github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
)
const (
// authSessionName stores the name of the session which contains authentication data
authSessionName = "ua"
// authSessionKeyUserID stores the key used to store the user ID in the session
authSessionKeyUserID = "user_id"
// authSessionKeyAuthenticated stores the key used to store the authentication status in the session
authSessionKeyAuthenticated = "authenticated"
)
// NotAuthenticatedError is an error returned when a user is not authenticated
type NotAuthenticatedError struct{}
// Error implements the error interface.
func (e NotAuthenticatedError) Error() string {
return "user not authenticated"
}
// InvalidPasswordTokenError is an error returned when an invalid token is provided
type InvalidPasswordTokenError struct{}
// Error implements the error interface.
func (e InvalidPasswordTokenError) Error() string {
return "invalid password token"
}
// AuthClient is the client that handles authentication requests
type AuthClient struct {
config *config.Config
orm *ent.Client
}
// NewAuthClient creates a new authentication client
func NewAuthClient(cfg *config.Config, orm *ent.Client) *AuthClient {
return &AuthClient{
config: cfg,
orm: orm,
}
}
// Login logs in a user of a given ID
func (c *AuthClient) Login(ctx echo.Context, userID int) error {
sess, err := session.Get(ctx, authSessionName)
if err != nil {
return err
}
sess.Values[authSessionKeyUserID] = userID
sess.Values[authSessionKeyAuthenticated] = true
return sess.Save(ctx.Request(), ctx.Response())
}
// Logout logs the requesting user out
func (c *AuthClient) Logout(ctx echo.Context) error {
sess, err := session.Get(ctx, authSessionName)
if err != nil {
return err
}
sess.Values[authSessionKeyAuthenticated] = false
return sess.Save(ctx.Request(), ctx.Response())
}
// GetAuthenticatedUserID returns the authenticated user's ID, if the user is logged in
func (c *AuthClient) GetAuthenticatedUserID(ctx echo.Context) (int, error) {
sess, err := session.Get(ctx, authSessionName)
if err != nil {
return 0, err
}
if sess.Values[authSessionKeyAuthenticated] == true {
return sess.Values[authSessionKeyUserID].(int), nil
}
return 0, NotAuthenticatedError{}
}
// GetAuthenticatedUser returns the authenticated user if the user is logged in
func (c *AuthClient) GetAuthenticatedUser(ctx echo.Context) (*ent.User, error) {
if userID, err := c.GetAuthenticatedUserID(ctx); err == nil {
return c.orm.User.Query().
Where(user.ID(userID)).
Only(ctx.Request().Context())
}
return nil, NotAuthenticatedError{}
}
// CheckPassword check if a given password matches a given hash
func (c *AuthClient) CheckPassword(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
// GeneratePasswordResetToken generates a password reset token for a given user.
// For security purposes, the token itself is not stored in the database but rather
// a hash of the token, exactly how passwords are handled. This method returns both
// the generated token and the token entity which only contains the hash.
func (c *AuthClient) GeneratePasswordResetToken(ctx echo.Context, userID int) (string, *ent.PasswordToken, error) {
// Generate the token, which is what will go in the URL, but not the database
token, err := c.RandomToken(c.config.App.PasswordToken.Length)
if err != nil {
return "", nil, err
}
// Create and save the password reset token
pt, err := c.orm.PasswordToken.
Create().
SetToken(token).
SetUserID(userID).
Save(ctx.Request().Context())
return token, pt, err
}
// GetValidPasswordToken returns a valid, non-expired password token entity for a given user, token ID and token.
// Since the actual token is not stored in the database for security purposes, if a matching password token entity is
// found a hash of the provided token is compared with the hash stored in the database in order to validate.
func (c *AuthClient) GetValidPasswordToken(ctx echo.Context, userID, tokenID int, token string) (*ent.PasswordToken, error) {
// Ensure expired tokens are never returned
expiration := time.Now().Add(-c.config.App.PasswordToken.Expiration)
// Query to find a password token entity that matches the given user and token ID
pt, err := c.orm.PasswordToken.
Query().
Where(passwordtoken.ID(tokenID)).
Where(passwordtoken.HasUserWith(user.ID(userID))).
Where(passwordtoken.CreatedAtGTE(expiration)).
Only(ctx.Request().Context())
switch err.(type) {
case *ent.NotFoundError:
case nil:
// Check the token for a hash match
if err := c.CheckPassword(token, pt.Token); err == nil {
return pt, nil
}
default:
if !context.IsCanceledError(err) {
return nil, err
}
}
return nil, InvalidPasswordTokenError{}
}
// DeletePasswordTokens deletes all password tokens in the database for a belonging to a given user.
// This should be called after a successful password reset.
func (c *AuthClient) DeletePasswordTokens(ctx echo.Context, userID int) error {
_, err := c.orm.PasswordToken.
Delete().
Where(passwordtoken.HasUserWith(user.ID(userID))).
Exec(ctx.Request().Context())
return err
}
// RandomToken generates a random token string of a given length
func (c *AuthClient) RandomToken(length int) (string, error) {
b := make([]byte, (length/2)+1)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
return token[:length], nil
}
// GenerateEmailVerificationToken generates an email verification token for a given email address using JWT which
// is set to expire based on the duration stored in configuration
func (c *AuthClient) GenerateEmailVerificationToken(email string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"email": email,
"exp": time.Now().Add(c.config.App.EmailVerificationTokenExpiration).Unix(),
})
return token.SignedString([]byte(c.config.App.EncryptionKey))
}
// ValidateEmailVerificationToken validates an email verification token and returns the associated email address if
// the token is valid and has not expired
func (c *AuthClient) ValidateEmailVerificationToken(token string) (string, error) {
t, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(c.config.App.EncryptionKey), nil
})
if err != nil {
return "", err
}
if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid {
return claims["email"].(string), nil
}
return "", errors.New("invalid or expired token")
}

View file

@ -0,0 +1,147 @@
package services
import (
"context"
"errors"
"testing"
"time"
"github.com/camzawacki/personal-site/ent/passwordtoken"
"github.com/camzawacki/personal-site/ent/user"
"golang.org/x/crypto/bcrypt"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)
func TestAuthClient_Auth(t *testing.T) {
assertNoAuth := func() {
_, err := c.Auth.GetAuthenticatedUserID(ctx)
assert.True(t, errors.Is(err, NotAuthenticatedError{}))
_, err = c.Auth.GetAuthenticatedUser(ctx)
assert.True(t, errors.Is(err, NotAuthenticatedError{}))
}
assertNoAuth()
err := c.Auth.Login(ctx, usr.ID)
require.NoError(t, err)
uid, err := c.Auth.GetAuthenticatedUserID(ctx)
require.NoError(t, err)
assert.Equal(t, usr.ID, uid)
u, err := c.Auth.GetAuthenticatedUser(ctx)
require.NoError(t, err)
assert.Equal(t, u.ID, usr.ID)
err = c.Auth.Logout(ctx)
require.NoError(t, err)
assertNoAuth()
}
func TestAuthClient_CheckPassword(t *testing.T) {
pw := "testcheckpassword"
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
require.NoError(t, err)
assert.NotEqual(t, hash, pw)
err = c.Auth.CheckPassword(pw, string(hash))
assert.NoError(t, err)
}
func TestAuthClient_GeneratePasswordResetToken(t *testing.T) {
token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
require.NoError(t, err)
assert.Len(t, token, c.Config.App.PasswordToken.Length)
assert.NoError(t, c.Auth.CheckPassword(token, pt.Token))
}
func TestAuthClient_GetValidPasswordToken(t *testing.T) {
// Check that a fake token is not valid
_, err := c.Auth.GetValidPasswordToken(ctx, usr.ID, 1, "faketoken")
assert.Error(t, err)
// Generate a valid token and check that it is returned
token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
require.NoError(t, err)
pt2, err := c.Auth.GetValidPasswordToken(ctx, usr.ID, pt.ID, token)
require.NoError(t, err)
assert.Equal(t, pt.ID, pt2.ID)
// Expire the token by pushing the date far enough back
count, err := c.ORM.PasswordToken.
Update().
SetCreatedAt(time.Now().Add(-(c.Config.App.PasswordToken.Expiration + time.Hour))).
Where(passwordtoken.ID(pt.ID)).
Save(context.Background())
require.NoError(t, err)
require.Equal(t, 1, count)
// Expired tokens should not be valid
_, err = c.Auth.GetValidPasswordToken(ctx, usr.ID, pt.ID, token)
assert.Error(t, err)
}
func TestAuthClient_DeletePasswordTokens(t *testing.T) {
// Create three tokens for the user
for i := 0; i < 3; i++ {
_, _, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
require.NoError(t, err)
}
// Delete all tokens for the user
err := c.Auth.DeletePasswordTokens(ctx, usr.ID)
require.NoError(t, err)
// Check that no tokens remain
count, err := c.ORM.PasswordToken.
Query().
Where(passwordtoken.HasUserWith(user.ID(usr.ID))).
Count(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestAuthClient_RandomToken(t *testing.T) {
length := c.Config.App.PasswordToken.Length
a, err := c.Auth.RandomToken(length)
require.NoError(t, err)
b, err := c.Auth.RandomToken(length)
require.NoError(t, err)
assert.Len(t, a, length)
assert.Len(t, b, length)
assert.NotEqual(t, a, b)
}
func TestAuthClient_EmailVerificationToken(t *testing.T) {
t.Run("valid token", func(t *testing.T) {
email := "test@localhost.com"
token, err := c.Auth.GenerateEmailVerificationToken(email)
require.NoError(t, err)
tokenEmail, err := c.Auth.ValidateEmailVerificationToken(token)
require.NoError(t, err)
assert.Equal(t, email, tokenEmail)
})
t.Run("invalid token", func(t *testing.T) {
badToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAbG9jYWxob3N0LmNvbSIsImV4cCI6MTkxNzg2NDAwMH0.ScJCpfEEzlilKfRs_aVouzwPNKI28M3AIm-hyImQHUQ"
_, err := c.Auth.ValidateEmailVerificationToken(badToken)
assert.Error(t, err)
})
t.Run("expired token", func(t *testing.T) {
c.Config.App.EmailVerificationTokenExpiration = -time.Hour
email := "test@localhost.com"
token, err := c.Auth.GenerateEmailVerificationToken(email)
require.NoError(t, err)
_, err = c.Auth.ValidateEmailVerificationToken(token)
assert.Error(t, err)
c.Config.App.EmailVerificationTokenExpiration = time.Hour * 12
})
}

351
internal/services/cache.go Normal file
View file

@ -0,0 +1,351 @@
package services
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/maypok86/otter"
)
// ErrCacheMiss indicates that the requested key does not exist in the cache
var ErrCacheMiss = errors.New("cache miss")
type (
// CacheStore provides an interface for cache storage
CacheStore interface {
// get attempts to get a cached value
get(context.Context, *CacheGetOp) (any, error)
// set attempts to set an entry in the cache
set(context.Context, *CacheSetOp) error
// flush removes a given key and/or tags from the cache
flush(context.Context, *CacheFlushOp) error
// close shuts down the cache storage
close()
}
// CacheClient is the client that allows you to interact with the cache
CacheClient struct {
// store holds the Cache storage
store CacheStore
}
// CacheSetOp handles chaining a set operation
CacheSetOp struct {
client *CacheClient
key string
group string
data any
expiration time.Duration
tags []string
}
// CacheGetOp handles chaining a get operation
CacheGetOp struct {
client *CacheClient
key string
group string
}
// CacheFlushOp handles chaining a flush operation
CacheFlushOp struct {
client *CacheClient
key string
group string
tags []string
}
// inMemoryCacheStore is a cache store implementation in memory
inMemoryCacheStore struct {
store *otter.CacheWithVariableTTL[string, any]
tagIndex *tagIndex
}
// tagIndex maintains an index to support cache tags for in-memory cache stores.
// There is a performance and memory impact to using cache tags since set and get operations using tags will require
// locking, and we need to keep track of this index in order to keep everything in sync.
// If using something like Redis for caching, you can leverage sets to store the index.
// Cache tags can be useful and convenient, so you should decide if your app benefits enough from this.
// As it stands here, there is no limiting how much memory this will consume and it will track all keys
// and tags added and removed from the cache. You could store these in the cache itself but allowing these to
// be evicted poses challenges.
tagIndex struct {
sync.Mutex
tags map[string]map[string]struct{} // tag->keys
keys map[string]map[string]struct{} // key->tags
}
)
// NewCacheClient creates a new cache client
func NewCacheClient(store CacheStore) *CacheClient {
return &CacheClient{store: store}
}
// Close closes the connection to the cache
func (c *CacheClient) Close() {
c.store.close()
}
// Set creates a cache set operation
func (c *CacheClient) Set() *CacheSetOp {
return &CacheSetOp{
client: c,
}
}
// Get creates a cache get operation
func (c *CacheClient) Get() *CacheGetOp {
return &CacheGetOp{
client: c,
}
}
// Flush creates a cache flush operation
func (c *CacheClient) Flush() *CacheFlushOp {
return &CacheFlushOp{
client: c,
}
}
// cacheKey formats a cache key with an optional group
func (c *CacheClient) cacheKey(group, key string) string {
if group != "" {
return fmt.Sprintf("%s::%s", group, key)
}
return key
}
// Key sets the cache key
func (c *CacheSetOp) Key(key string) *CacheSetOp {
c.key = key
return c
}
// Group sets the cache group
func (c *CacheSetOp) Group(group string) *CacheSetOp {
c.group = group
return c
}
// Data sets the data to cache
func (c *CacheSetOp) Data(data any) *CacheSetOp {
c.data = data
return c
}
// Expiration sets the expiration duration of the cached data
func (c *CacheSetOp) Expiration(expiration time.Duration) *CacheSetOp {
c.expiration = expiration
return c
}
// Tags sets the cache tags
func (c *CacheSetOp) Tags(tags ...string) *CacheSetOp {
c.tags = tags
return c
}
// Save saves the data in the cache
func (c *CacheSetOp) Save(ctx context.Context) error {
switch {
case c.key == "":
return errors.New("no cache key specified")
case c.data == nil:
return errors.New("no cache data specified")
case c.expiration == 0:
return errors.New("no cache expiration specified")
}
return c.client.store.set(ctx, c)
}
// Key sets the cache key
func (c *CacheGetOp) Key(key string) *CacheGetOp {
c.key = key
return c
}
// Group sets the cache group
func (c *CacheGetOp) Group(group string) *CacheGetOp {
c.group = group
return c
}
// Fetch fetches the data from the cache
func (c *CacheGetOp) Fetch(ctx context.Context) (any, error) {
if c.key == "" {
return nil, errors.New("no cache key specified")
}
return c.client.store.get(ctx, c)
}
// Key sets the cache key
func (c *CacheFlushOp) Key(key string) *CacheFlushOp {
c.key = key
return c
}
// Group sets the cache group
func (c *CacheFlushOp) Group(group string) *CacheFlushOp {
c.group = group
return c
}
// Tags sets the cache tags
func (c *CacheFlushOp) Tags(tags ...string) *CacheFlushOp {
c.tags = tags
return c
}
// Execute flushes the data from the cache
func (c *CacheFlushOp) Execute(ctx context.Context) error {
return c.client.store.flush(ctx, c)
}
// newInMemoryCache creates a new in-memory CacheStore
func newInMemoryCache(capacity int) (CacheStore, error) {
s := &inMemoryCacheStore{
tagIndex: newTagIndex(),
}
store, err := otter.MustBuilder[string, any](capacity).
WithVariableTTL().
DeletionListener(func(key string, value any, cause otter.DeletionCause) {
s.tagIndex.purgeKeys(key)
}).
Build()
if err != nil {
return nil, err
}
s.store = &store
return s, nil
}
func (s *inMemoryCacheStore) get(_ context.Context, op *CacheGetOp) (any, error) {
v, exists := s.store.Get(op.client.cacheKey(op.group, op.key))
if !exists {
return nil, ErrCacheMiss
}
return v, nil
}
func (s *inMemoryCacheStore) set(_ context.Context, op *CacheSetOp) error {
key := op.client.cacheKey(op.group, op.key)
added := s.store.Set(
key,
op.data,
op.expiration,
)
if len(op.tags) > 0 {
s.tagIndex.setTags(key, op.tags...)
}
if !added {
return errors.New("cache set failed")
}
return nil
}
func (s *inMemoryCacheStore) flush(_ context.Context, op *CacheFlushOp) error {
keys := make([]string, 0)
if key := op.client.cacheKey(op.group, op.key); key != "" {
keys = append(keys, key)
}
if len(op.tags) > 0 {
keys = append(keys, s.tagIndex.purgeTags(op.tags...)...)
}
for _, key := range keys {
s.store.Delete(key)
}
s.tagIndex.purgeKeys(keys...)
return nil
}
func (s *inMemoryCacheStore) close() {
s.store.Close()
}
func newTagIndex() *tagIndex {
return &tagIndex{
tags: make(map[string]map[string]struct{}),
keys: make(map[string]map[string]struct{}),
}
}
func (i *tagIndex) setTags(key string, tags ...string) {
i.Lock()
defer i.Unlock()
if _, exists := i.keys[key]; !exists {
i.keys[key] = make(map[string]struct{})
}
for _, tag := range tags {
if _, exists := i.tags[tag]; !exists {
i.tags[tag] = make(map[string]struct{})
}
i.tags[tag][key] = struct{}{}
i.keys[key][tag] = struct{}{}
}
}
func (i *tagIndex) purgeTags(tags ...string) []string {
i.Lock()
defer i.Unlock()
keys := make([]string, 0)
for _, tag := range tags {
if tagKeys, exists := i.tags[tag]; exists {
delete(i.tags, tag)
for key := range tagKeys {
delete(i.keys[key], tag)
if len(i.keys[key]) == 0 {
delete(i.keys, key)
}
keys = append(keys, key)
}
}
}
return keys
}
func (i *tagIndex) purgeKeys(keys ...string) {
i.Lock()
defer i.Unlock()
for _, key := range keys {
if keyTags, exists := i.keys[key]; exists {
delete(i.keys, key)
for tag := range keyTags {
delete(i.tags[tag], key)
if len(i.tags[tag]) == 0 {
delete(i.tags, tag)
}
}
}
}
}

View file

@ -0,0 +1,105 @@
package services
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCacheClient(t *testing.T) {
type cacheTest struct {
Value string
}
// Cache some data
data := cacheTest{Value: "abcdef"}
group := "testgroup"
key := "testkey"
err := c.Cache.
Set().
Group(group).
Key(key).
Data(data).
Expiration(500 * time.Millisecond).
Save(context.Background())
require.NoError(t, err)
// Get the data
fromCache, err := c.Cache.
Get().
Group(group).
Key(key).
Fetch(context.Background())
require.NoError(t, err)
cast, ok := fromCache.(cacheTest)
require.True(t, ok)
assert.Equal(t, data, cast)
// The same key with the wrong group should fail
_, err = c.Cache.
Get().
Key(key).
Fetch(context.Background())
assert.Equal(t, ErrCacheMiss, err)
// Flush the data
err = c.Cache.
Flush().
Group(group).
Key(key).
Execute(context.Background())
require.NoError(t, err)
// The data should be gone
assertFlushed := func(key string) {
// The data should be gone
_, err = c.Cache.
Get().
Group(group).
Key(key).
Fetch(context.Background())
assert.Equal(t, ErrCacheMiss, err)
}
assertFlushed(key)
// Set with tags
key = "testkey2"
err = c.Cache.
Set().
Group(group).
Key(key).
Data(data).
Tags("tag1", "tag2").
Expiration(time.Hour).
Save(context.Background())
require.NoError(t, err)
// Check the tag index
index := c.Cache.store.(*inMemoryCacheStore).tagIndex
gk := c.Cache.cacheKey(group, key)
_, exists := index.tags["tag1"][gk]
assert.True(t, exists)
_, exists = index.tags["tag2"][gk]
assert.True(t, exists)
_, exists = index.keys[gk]["tag1"]
assert.True(t, exists)
_, exists = index.keys[gk]["tag2"]
assert.True(t, exists)
// Flush one of tags
err = c.Cache.
Flush().
Tags("tag1").
Execute(context.Background())
require.NoError(t, err)
// The data should be gone
assertFlushed(key)
// The index should be empty
assert.Empty(t, index.tags)
assert.Empty(t, index.keys)
}

View file

@ -0,0 +1,246 @@
package services
import (
"context"
"database/sql"
"fmt"
"log/slog"
"math/rand"
"os"
"strings"
entsql "entgo.io/ent/dialect/sql"
"github.com/labstack/echo/v4"
_ "github.com/mattn/go-sqlite3"
"github.com/mikestefanello/backlite"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/log"
"github.com/spf13/afero"
// Required by ent.
_ "github.com/camzawacki/personal-site/ent/runtime"
)
// Container contains all services used by the application and provides an easy way to handle dependency
// injection including within tests.
type Container struct {
// Validator stores a validator
Validator *Validator
// Web stores the web framework.
Web *echo.Echo
// Config stores the application configuration.
Config *config.Config
// Cache contains the cache client.
Cache *CacheClient
// Database stores the connection to the database.
Database *sql.DB
// Files stores the file system.
Files afero.Fs
// ORM stores a client to the ORM.
ORM *ent.Client
// Mail stores an email sending client.
Mail *MailClient
// Auth stores an authentication client.
Auth *AuthClient
// Tasks stores the task client.
Tasks *backlite.Client
}
// NewContainer creates and initializes a new Container.
func NewContainer() *Container {
c := new(Container)
c.initConfig()
c.initValidator()
c.initWeb()
c.initCache()
c.initDatabase()
c.initFiles()
c.initORM()
c.initAuth()
c.initMail()
c.initTasks()
return c
}
// Shutdown gracefully shuts the Container down and disconnects all connections.
func (c *Container) Shutdown() error {
// Shutdown the web server.
webCtx, webCancel := context.WithTimeout(context.Background(), c.Config.HTTP.ShutdownTimeout)
defer webCancel()
if err := c.Web.Shutdown(webCtx); err != nil {
return err
}
// Shutdown the task runner.
taskCtx, taskCancel := context.WithTimeout(context.Background(), c.Config.Tasks.ShutdownTimeout)
defer taskCancel()
c.Tasks.Stop(taskCtx)
// Shutdown the ORM.
if err := c.ORM.Close(); err != nil {
return err
}
// Shutdown the database.
if err := c.Database.Close(); err != nil {
return err
}
// Shutdown the cache.
c.Cache.Close()
return nil
}
// initConfig initializes configuration.
func (c *Container) initConfig() {
cfg, err := config.GetConfig()
if err != nil {
panic(fmt.Sprintf("failed to load config: %v", err))
}
c.Config = &cfg
// Configure logging.
switch cfg.App.Environment {
case config.EnvProduction:
slog.SetLogLoggerLevel(slog.LevelInfo)
default:
slog.SetLogLoggerLevel(slog.LevelDebug)
}
}
// initValidator initializes the validator.
func (c *Container) initValidator() {
c.Validator = NewValidator()
}
// initWeb initializes the web framework.
func (c *Container) initWeb() {
c.Web = echo.New()
c.Web.HideBanner = true
c.Web.Validator = c.Validator
}
// initCache initializes the cache.
func (c *Container) initCache() {
store, err := newInMemoryCache(c.Config.Cache.Capacity)
if err != nil {
panic(err)
}
c.Cache = NewCacheClient(store)
}
// initDatabase initializes the database.
func (c *Container) initDatabase() {
var err error
var connection string
switch c.Config.App.Environment {
case config.EnvTest:
// TODO: Drop/recreate the DB, if this isn't in memory?
connection = c.Config.Database.TestConnection
default:
connection = c.Config.Database.Connection
}
c.Database, err = openDB(c.Config.Database.Driver, connection)
if err != nil {
panic(err)
}
}
// initFiles initializes the file system.
func (c *Container) initFiles() {
// Use in-memory storage for tests.
if c.Config.App.Environment == config.EnvTest {
c.Files = afero.NewMemMapFs()
return
}
fs := afero.NewOsFs()
if err := fs.MkdirAll(c.Config.Files.Directory, 0755); err != nil {
panic(err)
}
c.Files = afero.NewBasePathFs(fs, c.Config.Files.Directory)
}
// initORM initializes the ORM.
func (c *Container) initORM() {
drv := entsql.OpenDB(c.Config.Database.Driver, c.Database)
c.ORM = ent.NewClient(ent.Driver(drv))
// Run the auto migration tool.
if err := c.ORM.Schema.Create(context.Background()); err != nil {
panic(err)
}
}
// initAuth initializes the authentication client.
func (c *Container) initAuth() {
c.Auth = NewAuthClient(c.Config, c.ORM)
}
// initMail initialize the mail client.
func (c *Container) initMail() {
var err error
c.Mail, err = NewMailClient(c.Config)
if err != nil {
panic(fmt.Sprintf("failed to create mail client: %v", err))
}
}
// initTasks initializes the task client.
func (c *Container) initTasks() {
var err error
// You could use a separate database for tasks, if you'd like, but using one
// makes transaction support easier.
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
DB: c.Database,
Logger: log.Default(),
NumWorkers: c.Config.Tasks.Goroutines,
ReleaseAfter: c.Config.Tasks.ReleaseAfter,
CleanupInterval: c.Config.Tasks.CleanupInterval,
})
if err != nil {
panic(fmt.Sprintf("failed to create task client: %v", err))
}
if err = c.Tasks.Install(); err != nil {
panic(fmt.Sprintf("failed to install task schema: %v", err))
}
}
// openDB opens a database connection.
func openDB(driver, connection string) (*sql.DB, error) {
if driver == "sqlite3" {
// Helper to automatically create the directories that the specified sqlite file
// should reside in, if one.
d := strings.Split(connection, "/")
if len(d) > 1 {
dirpath := strings.Join(d[:len(d)-1], "/")
if err := os.MkdirAll(dirpath, 0755); err != nil {
return nil, err
}
}
// Check if a random value is required, which is often used for in-memory test databases.
if strings.Contains(connection, "$RAND") {
connection = strings.Replace(connection, "$RAND", fmt.Sprint(rand.Int()), 1)
}
}
return sql.Open(driver, connection)
}

View file

@ -0,0 +1,20 @@
package services
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewContainer(t *testing.T) {
assert.NotNil(t, c.Web)
assert.NotNil(t, c.Config)
assert.NotNil(t, c.Validator)
assert.NotNil(t, c.Cache)
assert.NotNil(t, c.Database)
assert.NotNil(t, c.Files)
assert.NotNil(t, c.ORM)
assert.NotNil(t, c.Mail)
assert.NotNil(t, c.Auth)
assert.NotNil(t, c.Tasks)
}

127
internal/services/mail.go Normal file
View file

@ -0,0 +1,127 @@
package services
import (
"bytes"
"errors"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/internal/log"
"maragu.dev/gomponents"
"github.com/labstack/echo/v4"
)
type (
// MailClient provides a client for sending email
// This is purposely not completed because there are many different methods and services
// for sending email, many of which are very different. Choose what works best for you
// and populate the methods below. For now, emails will just be logged.
MailClient struct {
// config stores application configuration.
config *config.Config
}
// mail represents an email to be sent.
mail struct {
client *MailClient
from string
to string
subject string
body string
component gomponents.Node
}
)
// NewMailClient creates a new MailClient.
func NewMailClient(cfg *config.Config) (*MailClient, error) {
return &MailClient{
config: cfg,
}, nil
}
// Compose creates a new email.
func (m *MailClient) Compose() *mail {
return &mail{
client: m,
from: m.config.Mail.FromAddress,
}
}
// skipSend determines if mail sending should be skipped.
func (m *MailClient) skipSend() bool {
return m.config.App.Environment != config.EnvProduction
}
// send attempts to send the email.
func (m *MailClient) send(email *mail, ctx echo.Context) error {
switch {
case email.to == "":
return errors.New("email cannot be sent without a to address")
case email.body == "" && email.component == nil:
return errors.New("email cannot be sent without a body or component to render")
}
// Check if a component was supplied.
if email.component != nil {
// Render the component and use as the body.
// TODO pool the buffers?
buf := bytes.NewBuffer(nil)
if err := email.component.Render(buf); err != nil {
return err
}
email.body = buf.String()
}
// Check if mail sending should be skipped.
if m.skipSend() {
log.Ctx(ctx).Debug("skipping email delivery",
"to", email.to,
)
return nil
}
// TODO: Finish based on your mail sender of choice or stop logging below!
log.Ctx(ctx).Info("sending email",
"to", email.to,
"subject", email.subject,
"body", email.body,
)
return nil
}
// From sets the email from address.
func (m *mail) From(from string) *mail {
m.from = from
return m
}
// To sets the email address this email will be sent to.
func (m *mail) To(to string) *mail {
m.to = to
return m
}
// Subject sets the subject line of the email.
func (m *mail) Subject(subject string) *mail {
m.subject = subject
return m
}
// Body sets the body of the email.
// This is not required and will be ignored if a component is set via Component().
func (m *mail) Body(body string) *mail {
m.body = body
return m
}
// Component sets a renderable component to use as the body of the email.
func (m *mail) Component(component gomponents.Node) *mail {
m.component = component
return m
}
// Send attempts to send the email.
func (m *mail) Send(ctx echo.Context) error {
return m.client.send(m, ctx)
}

View file

@ -0,0 +1,3 @@
package services
// Fill this in once you implement your mail client

View file

@ -0,0 +1,46 @@
package services
import (
"os"
"testing"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/labstack/echo/v4"
)
var (
c *Container
ctx echo.Context
usr *ent.User
)
func TestMain(m *testing.M) {
// Set the environment to test
config.SwitchEnvironment(config.EnvTest)
// Create a new container
c = NewContainer()
// Create a web context
ctx, _ = tests.NewContext(c.Web, "/")
tests.InitSession(ctx)
// Create a test user
var err error
if usr, err = tests.CreateUser(c.ORM); err != nil {
panic(err)
}
// Run tests
exitVal := m.Run()
// Shutdown the container
if err = c.Shutdown(); err != nil {
panic(err)
}
os.Exit(exitVal)
}

View file

@ -0,0 +1,26 @@
package services
import (
"github.com/go-playground/validator/v10"
)
// Validator provides validation mainly validating structs within the web context
type Validator struct {
// validator stores the underlying validator
validator *validator.Validate
}
// NewValidator creats a new Validator
func NewValidator() *Validator {
return &Validator{
validator: validator.New(),
}
}
// Validate validates a struct
func (v *Validator) Validate(i any) error {
if err := v.validator.Struct(i); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,19 @@
package services
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidator(t *testing.T) {
type example struct {
Value string `validate:"required"`
}
e := example{}
err := c.Validator.Validate(e)
assert.Error(t, err)
e.Value = "a"
err = c.Validator.Validate(e)
assert.NoError(t, err)
}

View file

@ -0,0 +1,27 @@
package session
import (
"errors"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/context"
)
// ErrStoreNotFound indicates that the session store was not present in the context
var ErrStoreNotFound = errors.New("session store not found")
// Get returns a session
func Get(ctx echo.Context, name string) (*sessions.Session, error) {
s := ctx.Get(context.SessionKey)
if s == nil {
return nil, ErrStoreNotFound
}
store := s.(sessions.Store)
return store.Get(ctx.Request(), name)
}
// Store sets the session storage in the context
func Store(ctx echo.Context, store sessions.Store) {
ctx.Set(context.SessionKey, store)
}

View file

@ -0,0 +1,23 @@
package session
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func TestGetStore(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
ctx := e.NewContext(req, httptest.NewRecorder())
_, err := Get(ctx, "test")
assert.Equal(t, ErrStoreNotFound, err)
Store(ctx, sessions.NewCookieStore([]byte("secret")))
_, err = Get(ctx, "test")
assert.NoError(t, err)
}

53
internal/tasks/example.go Normal file
View file

@ -0,0 +1,53 @@
package tasks
import (
"context"
"time"
"github.com/mikestefanello/backlite"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/log"
"github.com/camzawacki/personal-site/internal/services"
)
// ExampleTask is an example implementation of backlite.Task.
// This represents the task that can be queued for execution via the task client and should contain everything
// that your queue processor needs to process the task.
type ExampleTask struct {
Message string
}
// Config satisfies the backlite.Task interface by providing configuration for the queue that these items will be
// placed into for execution.
func (t ExampleTask) Config() backlite.QueueConfig {
return backlite.QueueConfig{
Name: "ExampleTask",
MaxAttempts: 3,
Timeout: 5 * time.Second,
Backoff: 10 * time.Second,
Retention: &backlite.Retention{
Duration: 24 * time.Hour,
OnlyFailed: false,
Data: &backlite.RetainData{
OnlyFailed: false,
},
},
}
}
// NewExampleTaskQueue provides a Queue that can process ExampleTask tasks.
// The service container is provided so the subscriber can have access to the app dependencies.
// All queues must be registered in the Register() function.
// Whenever an ExampleTask is added to the task client, it will be queued and eventually sent here for execution.
func NewExampleTaskQueue(c *services.Container) backlite.Queue {
return backlite.NewQueue[ExampleTask](func(ctx context.Context, task ExampleTask) error {
log.Default().Info("Example task received",
"message", task.Message,
)
log.Default().Info("This can access the container for dependencies",
"echo", c.Web.Reverse(routenames.Home),
)
return nil
})
}

View file

@ -0,0 +1,10 @@
package tasks
import (
"github.com/camzawacki/personal-site/internal/services"
)
// Register registers all task queues with the task client.
func Register(c *services.Container) {
c.Tasks.Register(NewExampleTaskQueue(c))
}

74
internal/tests/tests.go Normal file
View file

@ -0,0 +1,74 @@
package tests
import (
"context"
"fmt"
"math/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/session"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
)
// NewContext creates a new Echo context for tests using an HTTP test request and response recorder
func NewContext(e *echo.Echo, url string) (echo.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, url, strings.NewReader(""))
rec := httptest.NewRecorder()
return e.NewContext(req, rec), rec
}
// InitSession initializes a session for a given Echo context
func InitSession(ctx echo.Context) {
session.Store(ctx, sessions.NewCookieStore([]byte("secret")))
}
// ExecuteMiddleware executes a middleware function on a given Echo context
func ExecuteMiddleware(ctx echo.Context, mw echo.MiddlewareFunc) error {
handler := mw(func(c echo.Context) error {
return nil
})
return handler(ctx)
}
// ExecuteHandler executes a handler with an optional stack of middleware
func ExecuteHandler(ctx echo.Context, handler echo.HandlerFunc, mw ...echo.MiddlewareFunc) error {
return ExecuteMiddleware(ctx, func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
run := handler
for _, w := range mw {
run = w(run)
}
return run(ctx)
}
})
}
// AssertHTTPErrorCode asserts an HTTP status code on a given Echo HTTP error
func AssertHTTPErrorCode(t *testing.T, err error, code int) {
httpError, ok := err.(*echo.HTTPError)
require.True(t, ok)
assert.Equal(t, code, httpError.Code)
}
// CreateUser creates a random user entity
func CreateUser(orm *ent.Client) (*ent.User, error) {
seed := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), rand.Intn(1000000))
return orm.User.
Create().
SetEmail(fmt.Sprintf("testuser-%s@localhost.localhost", seed)).
SetPassword("password").
SetName(fmt.Sprintf("Test User %s", seed)).
Save(context.Background())
}

69
internal/ui/cache/cache.go vendored Normal file
View file

@ -0,0 +1,69 @@
package cache
import (
"bytes"
"sync"
"github.com/camzawacki/personal-site/internal/log"
"maragu.dev/gomponents"
)
var (
// cache stores a cache of assembled components by key.
cache = make(map[string]gomponents.Node)
// mu handles concurrent access to the cache.
mu sync.RWMutex
)
// Set sets a given renderable node in the cache with a given key.
// You should only cache nodes that are entirely static.
// This will panic if the node fails to render.
//
// To optimize performance, the node will be rendered and converted to a Raw component so the assembly and rendering
// of the entire, nested node only has to execute once. This can eliminate countless function calls and string building.
// It's worth noting that this performance optimization is, in most cases, entirely unnecessary, but it's easy to do
// and realize some performance gains. In my very limited testing, gomponents actually outperformed Go templates in
// many areas; but not all. The results were still very close and my limited testing is in no way definitive.
//
// In most applications, these slight differences in nanoseconds and bytes allocated will almost never matter or even
// be noticeable, but it's good to be aware of them; and it's fun to address them. In looking at the example layouts
// provided, I noticed that a lot of nested function calls and string building was happening on every single page load
// just to re-render static HTML such as the navbar and the search form/modal. Benchmarks quickly revealed that caching
// those high-level nodes made a significant difference in speed and memory allocations. Going further, I thought that
// with the entire node cached, you still have to render the entire nested structure each time it's used, so that is why
// this will render them upfront, then cache. If my few examples have a handful of static nodes, I assume most full
// applications will have many, so maybe this is useful.
func Set(key string, node gomponents.Node) {
buf := bytes.NewBuffer(nil)
if err := node.Render(buf); err != nil {
log.Default().Error("failed to cache ui node",
"error", err,
"key", key,
)
return
}
mu.Lock()
defer mu.Unlock()
cache[key] = gomponents.Raw(buf.String())
}
// Get returns the node cached under the provided key, if one exists.
func Get(key string) gomponents.Node {
mu.RLock()
defer mu.RUnlock()
return cache[key]
}
// SetIfNotExists will return the cached Node for the key, if it exists, otherwise it will use the provided callback
// function to generate the node and cache it.
func SetIfNotExists(key string, gen func() gomponents.Node) gomponents.Node {
if n := Get(key); n != nil {
return n
}
n := gen()
Set(key, n)
return n
}

57
internal/ui/cache/cache_test.go vendored Normal file
View file

@ -0,0 +1,57 @@
package cache
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func TestCache_GetSet(t *testing.T) {
key := "test"
assert.Nil(t, Get(key))
node := Div(Text("hello"))
Set(key, node)
got := Get(key)
require.NotNil(t, got)
// Check it was converted to a Raw component.
_, ok := got.(NodeFunc)
require.True(t, ok)
// Both nodes should render the same string.
buf1, buf2 := bytes.NewBuffer(nil), bytes.NewBuffer(nil)
require.NoError(t, node.Render(buf1))
require.NoError(t, got.Render(buf2))
assert.Equal(t, buf1.String(), buf2.String())
}
func TestCache_SetIfNotExists(t *testing.T) {
key := "test2"
called := 0
callback := func() Node {
called++
return Div(Text("hello"))
}
assertRender := func(n Node) {
buf := bytes.NewBuffer(nil)
require.NoError(t, n.Render(buf))
assert.Equal(t, `<div>hello</div>`, buf.String())
}
got := SetIfNotExists(key, callback)
assert.Equal(t, 1, called)
require.NotNil(t, got)
assertRender(got)
got = SetIfNotExists(key, callback)
assert.Equal(t, 1, called)
require.NotNil(t, got)
assertRender(got)
}

View file

@ -0,0 +1,66 @@
package components
import (
"github.com/camzawacki/personal-site/internal/msg"
"github.com/camzawacki/personal-site/internal/ui"
"github.com/camzawacki/personal-site/internal/ui/icons"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func FlashMessages(r *ui.Request) Node {
var g Group
var color Color
for _, typ := range []msg.Type{
msg.TypeSuccess,
msg.TypeInfo,
msg.TypeWarning,
msg.TypeError,
} {
for _, str := range msg.Get(r.Context, typ) {
switch typ {
case msg.TypeSuccess:
color = ColorSuccess
case msg.TypeInfo:
color = ColorInfo
case msg.TypeWarning:
color = ColorWarning
case msg.TypeError:
color = ColorError
}
g = append(g, Alert(color, str))
}
}
return g
}
func Alert(color Color, text string) Node {
var class string
switch color {
case ColorSuccess:
class = "alert-success"
case ColorInfo:
class = "alert-info"
case ColorWarning:
class = "alert-warning"
case ColorError:
class = "alert-error"
}
return Div(
Role("alert"),
Class("alert mb-2 "+class),
Attr("x-data", "{show: true}"),
Attr("x-show", "show"),
Span(
Attr("@click", "show = false"),
Class("cursor-pointer"),
icons.XCircle(),
),
Span(Text(text)),
)
}

View file

@ -0,0 +1,121 @@
package components
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type (
CardParams struct {
Title string
Body Group
Footer Group
Color Color
Size Size
}
Stat struct {
Title string
Value string
Description string
Icon Node
}
)
func Badge(color Color, text string) Node {
var class string
switch color {
case ColorSuccess:
class = "badge-success"
case ColorWarning:
class = "badge-warning"
}
return Div(
Class("badge "+class),
Text(text),
)
}
func Divider(text string) Node {
return Div(
Class("divider"),
Text(text),
)
}
func Card(params CardParams) Node {
var colorClass, sizeClass string
switch params.Color {
case ColorSuccess:
colorClass = "bg-success text-success-content"
case ColorPrimary:
colorClass = "bg-primary text-primary-content"
case ColorAccent:
colorClass = "bg-accent text-accent-content"
case ColorNeutral:
colorClass = "bg-neutral text-neutral-content"
case ColorWarning:
colorClass = "bg-warning text-warning-content"
case ColorInfo:
colorClass = "bg-info text-info-content"
}
switch params.Size {
case SizeSmall:
sizeClass = "card-sm"
case SizeMedium:
sizeClass = "card-md"
case SizeLarge:
sizeClass = "card-lg"
}
return Div(
Class("cards mb-2 "+colorClass+" "+sizeClass),
Div(
Class("card-body"),
If(len(params.Title) > 0, Span(
Class("card-title"),
Text(params.Title),
)),
params.Body,
If(params.Footer != nil, Div(
Class("card-actions justify-end"),
params.Footer,
)),
),
)
}
func Stats(stats ...Stat) Node {
g := make(Group, 0, len(stats))
for _, stat := range stats {
g = append(g, Div(
Class("stat"),
Iff(stat.Icon != nil, func() Node {
return Div(
Class("stat-figure text-secondary"),
stat.Icon,
)
}),
Div(
Class("stat-title"),
Text(stat.Title),
),
Div(
Class("stat-value"),
Text(stat.Value),
),
Div(
Class("stat-desc"),
Text(stat.Description),
),
))
}
return Div(
Class("stats shadow"),
g,
)
}

View file

@ -0,0 +1,268 @@
package components
import (
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type (
InputFieldParams struct {
Form form.Form
FormField string
Name string
InputType string
Label string
Value string
Placeholder string
Help string
}
FileFieldParams struct {
Name string
Label string
Help string
}
OptionsParams struct {
Form form.Form
FormField string
Name string
Label string
Value string
Options []Choice
Help string
}
Choice struct {
Value string
Label string
}
TextareaFieldParams struct {
Form form.Form
FormField string
Name string
Label string
Value string
Help string
}
CheckboxParams struct {
Form form.Form
FormField string
Name string
Label string
Checked bool
}
)
func ControlGroup(controls ...Node) Node {
return Div(
Class("mt-2 flex gap-2"),
Group(controls),
)
}
func TextareaField(el TextareaFieldParams) Node {
return Fieldset(
el.Label,
Textarea(
Class("textarea h-24 w-2/3 "+formFieldStatusClass(el.Form, el.FormField)),
ID(el.Name),
Name(el.Name),
Text(el.Value),
),
Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
func Radios(el OptionsParams) Node {
buttons := make(Group, len(el.Options))
for i, opt := range el.Options {
id := "radio-" + el.Name + "-" + opt.Value
buttons[i] = Div(
Class("mb-2"),
Input(
ID(id),
Type("radio"),
Name(el.Name),
Value(opt.Value),
Class("radio mr-1 "+formFieldStatusClass(el.Form, el.FormField)),
If(el.Value == opt.Value, Checked()),
),
Label(
Text(opt.Label),
For(id),
),
)
}
return Fieldset(
el.Label,
buttons,
formFieldErrors(el.Form, el.FormField),
)
}
func SelectList(el OptionsParams) Node {
buttons := make(Group, len(el.Options))
for i, opt := range el.Options {
buttons[i] = Option(
Text(opt.Label),
Value(opt.Value),
If(opt.Value == el.Value, Attr("selected")),
)
}
return Fieldset(
el.Label,
Select(
Class("select "+formFieldStatusClass(el.Form, el.FormField)),
Name(el.Name),
buttons,
),
Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
func Checkbox(el CheckboxParams) Node {
return Div(
Label(
Class("label"),
Input(
Class("checkbox"),
Type("checkbox"),
Name(el.Name),
If(el.Checked, Checked()),
Value("true"),
),
Text(" "+el.Label),
),
formFieldErrors(el.Form, el.FormField),
)
}
func InputField(el InputFieldParams) Node {
return Fieldset(
el.Label,
Input(
ID(el.Name),
Name(el.Name),
Type(el.InputType),
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
Value(el.Value),
If(el.Placeholder != "", Placeholder(el.Placeholder)),
),
Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
func Help(text string) Node {
return If(len(text) > 0, Div(
Class("label"),
Text(text),
))
}
func Fieldset(label string, els ...Node) Node {
return FieldSet(
Class("fieldset"),
If(len(label) > 0, Legend(
Class("fieldset-legend"),
Text(label),
)),
Group(els),
)
}
func FileField(el FileFieldParams) Node {
return Fieldset(
el.Label,
Input(
Type("file"),
Class("file-input"),
Name(el.Name),
),
Help(el.Help),
)
}
func formFieldStatusClass(fm form.Form, formField string) string {
switch {
case fm == nil:
return ""
case !fm.IsSubmitted():
return ""
case fm.FieldHasErrors(formField):
return "input-error"
default:
return "input-success"
}
}
func formFieldErrors(fm form.Form, field string) Node {
if fm == nil {
return nil
}
errs := fm.GetFieldErrors(field)
if len(errs) == 0 {
return nil
}
g := make(Group, len(errs))
for i, err := range errs {
g[i] = Div(
Class("text-error"),
Text(err),
)
}
return g
}
func CSRF(r *ui.Request) Node {
return Input(
Type("hidden"),
Name("csrf"),
Value(r.CSRF),
)
}
func FormButton(color Color, label string) Node {
return Button(
Class("btn "+buttonColor(color)),
Text(label),
)
}
func ButtonLink(color Color, href, label string) Node {
return A(
Href(href),
Class("btn "+buttonColor(color)),
Text(label),
)
}
func buttonColor(color Color) string {
// Only colors being used are included so unused styles are not compiled.
switch color {
case ColorPrimary:
return "btn-primary"
case ColorInfo:
return "btn-info"
case ColorAccent:
return "btn-accent"
case ColorError:
return "btn-error"
case ColorLink:
return "btn-link"
default:
return ""
}
}

View file

@ -0,0 +1,35 @@
package components
import (
"strings"
"github.com/camzawacki/personal-site/internal/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func JS() Node {
return Group{
Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js"), Defer()),
Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()),
}
}
func CSS() Node {
return Link(
Href(ui.StaticFile("main.css")),
Rel("stylesheet"),
Type("text/css"),
)
}
func Metatags(r *ui.Request) Node {
return Group{
Meta(Charset("utf-8")),
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
Link(Rel("icon"), Href(ui.StaticFile("favicon.png"))),
TitleEl(Text(r.Config.App.Name), If(r.Title != "", Text(" | "+r.Title))),
If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))),
If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))),
}
}

View file

@ -0,0 +1,39 @@
package components
import (
"fmt"
"github.com/camzawacki/personal-site/internal/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func HtmxListeners(r *ui.Request) Node {
const htmxErr = `
document.body.addEventListener('htmx:beforeSwap', function(evt) {
if (evt.detail.xhr.status >= 400){
evt.detail.shouldSwap = true;
evt.detail.target = htmx.find("body");
}
});
`
const htmxCSRF = `
document.body.addEventListener('htmx:configRequest', function(evt) {
if (evt.detail.verb !== "get") {
evt.detail.parameters['csrf'] = '%s';
}
})
`
return Group{
Script(Raw(htmxErr)),
Iff(len(r.CSRF) > 0, func() Node {
return Script(Raw(fmt.Sprintf(htmxCSRF, r.CSRF)))
}),
}
}
func HxBoost() Node {
return Attr("hx-boost", "true")
}

View file

@ -0,0 +1,91 @@
package components
import (
"fmt"
"github.com/camzawacki/personal-site/internal/pager"
"github.com/camzawacki/personal-site/internal/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
)
func NavLink(r *ui.Request, title, routeName string, disabled bool, routeParams ...any) Node {
href := r.Path(routeName, routeParams...)
var link Node
if disabled {
link = Span(
Class("text-xl text-base-content/40"),
Text(title),
)
} else {
link = A(
Class("text-xl hover:underline cursor-pointer"),
Href(href),
Text(title),
)
}
return link
}
func MenuLink(r *ui.Request, icon Node, title, routeName string, routeParams ...any) Node {
href := r.Path(routeName, routeParams...)
return Li(
Class("ml-2"),
A(
Href(href),
icon,
Text(title),
Classes{
"menu-active": href == r.CurrentPath,
"p-2": true,
},
),
)
}
func Pager(page int, path string, hasNext bool, hxTarget string) Node {
href := func(page int) string {
return fmt.Sprintf("%s?%s=%d",
path,
pager.QueryKey,
page,
)
}
return Div(
Class("join"),
A(
Class("join-item btn"),
Text("«"),
If(page <= 1, Disabled()),
Href(href(page-1)),
Iff(len(hxTarget) > 0, func() Node {
return Group{
Attr("hx-get", href(page-1)),
Attr("hx-swap", "outerHTML"),
Attr("hx-target", hxTarget),
}
}),
),
Button(
Class("join-item btn"),
Textf("Page %d", page),
),
A(
Class("join-item btn"),
Text("»"),
If(!hasNext, Disabled()),
Href(href(page+1)),
Iff(len(hxTarget) > 0, func() Node {
return Group{
Attr("hx-get", href(page+1)),
Attr("hx-swap", "outerHTML"),
Attr("hx-target", hxTarget),
}
}),
),
)
}

View file

@ -0,0 +1,27 @@
package components
type (
Color int
Size int
)
const (
ColorNone Color = iota
ColorNeutral
ColorPrimary
ColorSecondary
ColorAccent
ColorInfo
ColorSuccess
ColorWarning
ColorError
ColorLink
)
const (
SizeExtraSmall Size = iota
SizeSmall
SizeMedium
SizeLarge
SizeExtraLarge
)

View file

@ -0,0 +1,38 @@
package components
import (
"fmt"
"math/rand"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Tab struct {
Title, Body string
}
func Tabs(tabs []Tab) Node {
g := make(Group, 0, len(tabs)*2)
id := fmt.Sprintf("tabs-%d", rand.Int())
for i, tab := range tabs {
g = append(g,
Input(
Type("radio"),
Name(id),
Class("tab"),
Aria("label", tab.Title),
If(i == 0, Checked()),
),
Div(
Class("tab-content bg-base-100 border-base-300 p-6"),
Raw(tab.Body),
))
}
return Div(
Class("tabs tabs-lift"),
g,
)
}

View file

@ -0,0 +1,22 @@
package emails
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func ConfirmEmailAddress(ctx echo.Context, username, token string) Node {
url := ui.NewRequest(ctx).
Url(routenames.VerifyEmail, token)
return Group{
Strong(Textf("Hello %s,", username)),
Br(),
P(Text("Please click on the following link to confirm your email address:")),
Br(),
A(Href(url), Text(url)),
}
}

View file

@ -0,0 +1,124 @@
package forms
import (
"net/http"
"net/url"
"entgo.io/ent/schema/field"
"github.com/camzawacki/personal-site/ent/admin"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func AdminEntity(r *ui.Request, entityType admin.EntityType, values url.Values) Node {
// TODO inline validation?
isNew := values == nil
nodes := make(Group, 0)
getValue := func(name string) string {
// Values in the submitted form take precedence.
if value := r.Context.FormValue(name); value != "" {
return value
}
// Fallback to the entity's values, if being edited.
if values != nil && len(values[name]) > 0 {
return values[name][0]
}
return ""
}
// Attempt to add form elements for all editable entity fields.
for _, f := range entityType.GetSchema() {
// TODO cardinality?
if !isNew && f.Immutable {
continue
}
switch f.Type {
case field.TypeString:
p := InputFieldParams{
Name: f.Name,
InputType: "text",
Label: admin.FieldLabel(f.Name),
Value: getValue(f.Name),
}
if f.Sensitive {
p.InputType = "password"
if !isNew {
p.Placeholder = "*****"
p.Help = "SENSITIVE: This field will only be updated if a value is provided."
}
}
nodes = append(nodes, InputField(p))
case field.TypeTime:
nodes = append(nodes, InputField(InputFieldParams{
Name: f.Name,
InputType: "datetime-local",
Label: admin.FieldLabel(f.Name),
Value: getValue(f.Name),
}))
case field.TypeInt, field.TypeInt8, field.TypeInt16, field.TypeInt32, field.TypeInt64,
field.TypeUint, field.TypeUint8, field.TypeUint16, field.TypeUint32, field.TypeUint64,
field.TypeFloat32, field.TypeFloat64:
nodes = append(nodes, InputField(InputFieldParams{
Name: f.Name,
InputType: "number",
Label: admin.FieldLabel(f.Name),
Value: getValue(f.Name),
}))
case field.TypeBool:
nodes = append(nodes, Checkbox(CheckboxParams{
Name: f.Name,
Label: admin.FieldLabel(f.Name),
Checked: getValue(f.Name) == "true",
}))
case field.TypeEnum:
options := make([]Choice, 0, len(f.Enums)+1)
if f.Optional {
options = append(options, Choice{
Label: "-",
Value: "",
})
}
for _, enum := range f.Enums {
options = append(options, Choice{
Label: enum,
Value: enum,
})
}
nodes = append(nodes, SelectList(OptionsParams{
Name: f.Name,
Label: admin.FieldLabel(f.Name),
Value: getValue(f.Name),
Options: options,
}))
default:
nodes = append(nodes, P(Textf("%s not supported", f.Name)))
}
}
return Form(
Method(http.MethodPost),
nodes,
ControlGroup(
FormButton(ColorPrimary, "Submit"),
ButtonLink(
ColorNone,
r.Path(routenames.AdminEntityList(entityType.GetName())),
"Cancel",
),
),
CSRF(r),
)
}

View file

@ -0,0 +1,30 @@
package forms
import (
"net/http"
"github.com/camzawacki/personal-site/ent/admin"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func AdminEntityDelete(r *ui.Request, entityType admin.EntityType) Node {
return Form(
Method(http.MethodPost),
P(
Textf("Are you sure you want to delete this %s?", entityType.GetName()),
),
ControlGroup(
FormButton(ColorError, "Delete"),
ButtonLink(
ColorNone,
r.Path(routenames.AdminEntityList(entityType.GetName())),
"Cancel",
),
),
CSRF(r),
)
}

View file

@ -0,0 +1,54 @@
package forms
import (
"net/http"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Cache struct {
CurrentValue string
Value string `form:"value"`
form.Submission
}
func (f *Cache) Render(r *ui.Request) Node {
return Form(
ID("cache"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.CacheSubmit)),
Card(CardParams{
Title: "Test the cache",
Body: Group{
Span(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")),
Span(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
},
Color: ColorInfo,
Size: SizeMedium,
}),
Label(
For("value"),
Class("value"),
Text("Value in cache: "),
),
If(f.CurrentValue != "", Badge(ColorSuccess, f.CurrentValue)),
If(f.CurrentValue == "", Badge(ColorWarning, "empty")),
InputField(InputFieldParams{
Form: f,
FormField: "Value",
Name: "value",
InputType: "text",
Label: "Value",
Value: f.Value,
}),
ControlGroup(
FormButton(ColorPrimary, "Update cache"),
),
CSRF(r),
)
}

View file

@ -0,0 +1,58 @@
package forms
import (
"net/http"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Contact struct {
Email string `form:"email" validate:"required,email"`
Department string `form:"department" validate:"required,oneof=sales marketing hr"`
Message string `form:"message" validate:"required"`
form.Submission
}
func (f *Contact) Render(r *ui.Request) Node {
return Form(
ID("contact"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.ContactSubmit)),
InputField(InputFieldParams{
Form: f,
FormField: "Email",
Name: "email",
InputType: "email",
Label: "Email address",
Value: f.Email,
}),
Radios(OptionsParams{
Form: f,
FormField: "Department",
Name: "department",
Label: "Department",
Value: f.Department,
Options: []Choice{
{Value: "sales", Label: "Sales"},
{Value: "marketing", Label: "Marketing"},
{Value: "hr", Label: "HR"},
},
}),
TextareaField(TextareaFieldParams{
Form: f,
FormField: "Message",
Name: "message",
Label: "Message",
Value: f.Message,
}),
ControlGroup(
FormButton(ColorPrimary, "Submit"),
),
CSRF(r),
)
}

31
internal/ui/forms/file.go Normal file
View file

@ -0,0 +1,31 @@
package forms
import (
"net/http"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type File struct{}
func (f File) Render(r *ui.Request) Node {
return Form(
ID("files"),
Method(http.MethodPost),
Action(r.Path(routenames.FilesSubmit)),
EncType("multipart/form-data"),
FileField(FileFieldParams{
Name: "file",
Label: "Test file",
Help: "Pick a file to upload.",
}),
ControlGroup(
FormButton(ColorPrimary, "Upload"),
),
CSRF(r),
)
}

View file

@ -0,0 +1,39 @@
package forms
import (
"net/http"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type ForgotPassword struct {
Email string `form:"email" validate:"required,email"`
form.Submission
}
func (f *ForgotPassword) Render(r *ui.Request) Node {
return Form(
ID("forgot-password"),
Method(http.MethodPost),
HxBoost(),
Action(r.Path(routenames.ForgotPasswordSubmit)),
InputField(InputFieldParams{
Form: f,
FormField: "Email",
Name: "email",
InputType: "email",
Label: "Email address",
Value: f.Email,
}),
ControlGroup(
FormButton(ColorPrimary, "Reset password"),
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
),
CSRF(r),
)
}

View file

@ -0,0 +1,64 @@
package forms
import (
"net/http"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Login struct {
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"`
form.Submission
}
func (f *Login) Render(r *ui.Request) Node {
return Form(
ID("login"),
Method(http.MethodPost),
HxBoost(),
Action(r.Path(routenames.LoginSubmit)),
FlashMessages(r),
InputField(InputFieldParams{
Form: f,
FormField: "Email",
Name: "email",
InputType: "email",
Label: "Email address",
Value: f.Email,
}),
InputField(InputFieldParams{
Form: f,
FormField: "Password",
Name: "password",
InputType: "password",
Label: "Password",
Placeholder: "******",
}),
Div(
Class("text-right text-primary mt-2"),
A(
Href(r.Path(routenames.ForgotPassword)),
Text("Forgot password?"),
),
),
ControlGroup(
FormButton(ColorPrimary, "Login"),
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
),
CSRF(r),
Div(
Class("text-center text-base-content/50 mt-4"),
Text("Don't have an account? "),
A(
Href(r.Path(routenames.Register)),
Text("Register"),
),
),
)
}

View file

@ -0,0 +1,74 @@
package forms
import (
"net/http"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Register struct {
Name string `form:"name" validate:"required"`
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
form.Submission
}
func (f *Register) Render(r *ui.Request) Node {
return Form(
ID("register"),
Method(http.MethodPost),
HxBoost(),
Action(r.Path(routenames.RegisterSubmit)),
InputField(InputFieldParams{
Form: f,
FormField: "Name",
Name: "name",
InputType: "text",
Label: "Name",
Value: f.Name,
}),
InputField(InputFieldParams{
Form: f,
FormField: "Email",
Name: "email",
InputType: "email",
Label: "Email address",
Value: f.Email,
}),
InputField(InputFieldParams{
Form: f,
FormField: "Password",
Name: "password",
InputType: "password",
Label: "Password",
Placeholder: "******",
}),
InputField(InputFieldParams{
Form: f,
FormField: "ConfirmPassword",
Name: "password-confirm",
InputType: "password",
Label: "Confirm password",
Placeholder: "******",
}),
ControlGroup(
FormButton(ColorPrimary, "Register"),
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
),
CSRF(r),
Div(
Class("text-center text-base-content/50 mt-4"),
Text("Already have an account? "),
A(
Href(r.Path(routenames.Login)),
Text("Login"),
),
),
)
}

View file

@ -0,0 +1,46 @@
package forms
import (
"net/http"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type ResetPassword struct {
Password string `form:"password" validate:"required"`
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
form.Submission
}
func (f *ResetPassword) Render(r *ui.Request) Node {
return Form(
ID("reset-password"),
Method(http.MethodPost),
HxBoost(),
Action(r.CurrentPath),
InputField(InputFieldParams{
Form: f,
FormField: "Password",
Name: "password",
InputType: "password",
Label: "Password",
Placeholder: "******",
}),
InputField(InputFieldParams{
Form: f,
FormField: "PasswordConfirm",
Name: "password-confirm",
InputType: "password",
Label: "Confirm password",
Placeholder: "******",
}),
ControlGroup(
FormButton(ColorPrimary, "Update password"),
),
CSRF(r),
)
}

49
internal/ui/forms/task.go Normal file
View file

@ -0,0 +1,49 @@
package forms
import (
"fmt"
"net/http"
"github.com/camzawacki/personal-site/internal/form"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type Task struct {
Delay int `form:"delay" validate:"gte=0"`
Message string `form:"message" validate:"required"`
form.Submission
}
func (f *Task) Render(r *ui.Request) Node {
return Form(
ID("task"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.TaskSubmit)),
FlashMessages(r),
InputField(InputFieldParams{
Form: f,
FormField: "Delay",
Name: "delay",
InputType: "number",
Label: "Delay (in seconds)",
Help: "How long to wait until the task is executed",
Value: fmt.Sprint(f.Delay),
}),
TextareaField(TextareaFieldParams{
Form: f,
FormField: "Message",
Name: "message",
Label: "Message",
Value: f.Message,
Help: "The message the task will output to the log",
}),
ControlGroup(
FormButton(ColorPrimary, "Add task to queue"),
),
CSRF(r),
)
}

208
internal/ui/icons/icons.go Normal file
View file

@ -0,0 +1,208 @@
package icons
import (
"fmt"
"github.com/camzawacki/personal-site/internal/ui/cache"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func CircleStack() Node {
return icon("CircleStack",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"),
),
)
}
func Eyes() Node {
return icon("Eyes",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"),
),
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"),
),
)
}
func UserCircle() Node {
return icon("UserCircle",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"),
),
)
}
func Globe() Node {
return icon("Globe",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"),
),
)
}
func Home() Node {
return icon("Home",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"),
),
)
}
func Info() Node {
return icon("Info",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"),
),
)
}
func Mail() Node {
return icon("Mail",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"),
),
)
}
func Archive() Node {
return icon("Archive",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z"),
),
)
}
func PencilSquare() Node {
return icon("PencilSquare",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"),
),
)
}
func Document() Node {
return icon("Document",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"),
),
)
}
func Exit() Node {
return icon("Exit",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15"),
),
)
}
func Enter() Node {
return icon("Enter",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75"),
),
)
}
func UserPlus() Node {
return icon("UserPlus",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"),
),
)
}
func QuestionCircle() Node {
return icon("QuestionCircle",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"),
),
)
}
func XCircle() Node {
return icon("XCircle",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"),
),
)
}
func MagnifyingGlass() Node {
return icon("MagnifyingGlass",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"),
),
)
}
func LockClosed() Node {
return icon("LockClosed",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"),
),
)
}
func Star() Node {
return icon("Star",
El("path",
Attr("stroke-linecap", "round"),
Attr("stroke-linejoin", "round"),
Attr("d", "M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"),
),
)
}
func icon(id string, els ...Node) Node {
return cache.SetIfNotExists(fmt.Sprintf("icon.%s", id), func() Node {
return SVG(
Attr("xmlns", "http://www.w3.org/2000/svg"),
Attr("fill", "none"),
Attr("viewBox", "0 0 24 24"),
Attr("stroke-width", "1.5"),
Attr("stroke", "currentColor"),
Class("w-5 h-5"),
Group(els),
)
})
}

View file

@ -0,0 +1,40 @@
package layouts
import (
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Auth(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
Data("theme", "dark"),
Head(
Metatags(r),
CSS(),
JS(),
),
Body(
Div(
Class("hero flex items-center justify-center min-h-screen"),
Div(
Class("flex-col hero-content"),
Div(
Class("card shadow-md bg-base-200 w-96"),
Div(
Class("card-body"),
If(len(r.Title) > 0, H1(Class("text-2xl font-bold"), Text(r.Title))),
FlashMessages(r),
content,
),
),
),
),
HtmxListeners(r),
),
),
)
}

View file

@ -0,0 +1,42 @@
package layouts
import (
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Primary(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
Data("theme", "light"),
Head(
Metatags(r),
CSS(),
JS(),
),
Body(
Nav(
Class("navbar bg-base-100 border-b border-gray-200 p-5 justify-center"),
Div(
Class("flex items-center"),
NavLink(r, "Cam Zalewaki", routenames.Home, false),
Span(Class("divider divider-horizontal")),
NavLink(r, "Writing", routenames.About, true),
Span(Class("divider divider-horizontal")),
NavLink(r, "Projects", routenames.About, true),
Span(Class("divider divider-horizontal")),
NavLink(r, "Misc", routenames.About, true),
Span(Class("divider divider-horizontal")),
NavLink(r, "About", routenames.About, true),
),
),
content,
HtmxListeners(r),
),
),
)
}

View file

@ -0,0 +1,22 @@
package models
import (
"fmt"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type File struct {
Name string
Size int64
Modified string
}
func (f *File) Render() Node {
return Tr(
Td(Text(f.Name)),
Td(Text(fmt.Sprint(f.Size))),
Td(Text(f.Modified)),
)
}

View file

@ -0,0 +1,67 @@
package models
import (
"fmt"
"github.com/camzawacki/personal-site/internal/pager"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type (
Posts struct {
Posts []Post
Pager pager.Pager
}
Post struct {
ID int
Title, Body string
}
)
func (p *Posts) Render(path string) Node {
g := make(Group, len(p.Posts))
for i, post := range p.Posts {
g[i] = post.Render()
}
return Div(
ID("posts"),
Ul(
Class("list bg-base-100 rounded-box shadow-md not-prose"),
g,
),
Div(Class("mb-4")),
Pager(p.Pager.Page, path, !p.Pager.IsEnd(), "#posts"),
)
}
func (p *Post) Render() Node {
return Li(
Class("list-row"),
Div(
Class("text-4xl font-thin opacity-30 tabular-nums"),
Text(fmt.Sprintf("%02d", p.ID)),
),
Div(
Img(
Class("size-10 rounded-box"),
Src(ui.StaticFile("gopher.png")),
Alt("Gopher"),
),
),
Div(
Class("list-col-grow"),
Div(
Text(p.Title),
),
Div(
Class("text-xs font-semibold opacity-60"),
Text(p.Body),
),
),
)
}

View file

@ -0,0 +1,21 @@
package models
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type SearchResult struct {
Title string
URL string
}
func (s *SearchResult) Render() Node {
return Li(
Class("list-row"),
A(
Href(s.URL),
Text(s.Title),
),
)
}

View file

@ -0,0 +1,61 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/ui"
"github.com/camzawacki/personal-site/internal/ui/cache"
. "github.com/camzawacki/personal-site/internal/ui/components"
"github.com/camzawacki/personal-site/internal/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func About(ctx echo.Context) error {
r := ui.NewRequest(ctx)
r.Title = "About"
r.Metatags.Description = "Learn a little about what's included in Pagoda."
// The tabs are static, so we can render and cache them.
tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node {
return Group{
H2(Text("Frontend")),
P(Text("The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.")),
Tabs(
[]Tab{
{
Title: "HTMX",
Body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit <a href=\"https://htmx.org/\">htmx.org</a> to learn more.",
},
{
Title: "Alpine.js",
Body: "Drop-in, Vue-like functionality written directly in your markup. Visit <a href=\"https://alpinejs.dev/\">alpinejs.dev</a> to learn more.",
},
{
Title: "DaisyUI",
Body: "DaisyUI is the Tailwind CSS plugin you will love! It provides useful component class names to help you write less code and build faster. No JavaScript requirements. Visit <a href=\"https://daisyui.com/\">daisyui.com</a> to learn more.",
},
},
),
H2(Text("Backend")),
P(Text("The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.")),
Tabs(
[]Tab{
{
Title: "Echo",
Body: "High performance, extensible, minimalist Go web framework. Visit <a href=\"https://echo.labstack.com/\">echo.labstack.com</a> to learn more.",
},
{
Title: "Ent",
Body: "Simple, yet powerful ORM for modeling and querying data. Visit <a href=\"https://entgo.io/\">entgo.io</a> to learn more.",
},
{
Title: "Gomponents",
Body: "HTML components written in pure Go. They render to HTML 5, and make it easy for you to build reusable components. Visit <a href=\"https://gomponents.com/\">gomponents.com</a> to learn more.",
},
},
),
}
})
return r.Render(layouts.Primary, tabs)
}

View file

@ -0,0 +1,115 @@
package pages
import (
"fmt"
"net/url"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/ent/admin"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func AdminEntityDelete(ctx echo.Context, entityType admin.EntityType) error {
r := ui.NewRequest(ctx)
r.Title = fmt.Sprintf("Delete %s", entityType.GetName())
return r.Render(
layouts.Primary,
forms.AdminEntityDelete(r, entityType),
)
}
func AdminEntityInput(ctx echo.Context, entityType admin.EntityType, values url.Values) error {
r := ui.NewRequest(ctx)
if values == nil {
r.Title = fmt.Sprintf("Add %s", entityType.GetName())
} else {
r.Title = fmt.Sprintf("Edit %s", entityType.GetName())
}
return r.Render(
layouts.Primary,
forms.AdminEntity(r, entityType, values),
)
}
func AdminEntityList(
ctx echo.Context,
entityType admin.EntityType,
entityList *admin.EntityList,
) error {
r := ui.NewRequest(ctx)
r.Title = entityType.GetName()
genHeader := func() Node {
g := make(Group, 0, len(entityList.Columns)+2)
g = append(g, Th(Text("ID")))
for _, h := range entityList.Columns {
g = append(g, Th(Text(h)))
}
g = append(g, Th())
return g
}
genRow := func(row admin.EntityValues) Node {
g := make(Group, 0, len(row.Values)+3)
g = append(g, Th(Text(fmt.Sprint(row.ID))))
for _, h := range row.Values {
g = append(g, Td(Text(h)))
}
g = append(g,
Td(
ButtonLink(
ColorInfo,
r.Path(routenames.AdminEntityEdit(entityType.GetName()), row.ID),
"Edit",
),
Span(Class("mr-2")),
ButtonLink(
ColorError,
r.Path(routenames.AdminEntityDelete(entityType.GetName()), row.ID),
"Delete",
),
),
)
return g
}
genRows := func() Node {
g := make(Group, 0, len(entityList.Entities))
for _, row := range entityList.Entities {
g = append(g, Tr(genRow(row)))
}
return g
}
return r.Render(layouts.Primary, Group{
Div(
Class("form-control mb-2"),
ButtonLink(
ColorAccent,
r.Path(routenames.AdminEntityAdd(entityType.GetName())),
fmt.Sprintf("Add %s", entityType.GetName()),
),
),
Table(
Class("table table-zebra mb-2"),
THead(
Tr(genHeader()),
),
TBody(genRows()),
),
Pager(
entityList.Page,
r.Path(routenames.AdminEntityAdd(entityType.GetName())),
entityList.HasNextPage,
"",
),
})
}

46
internal/ui/pages/auth.go Normal file
View file

@ -0,0 +1,46 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/ui"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Login(ctx echo.Context, form *forms.Login) error {
r := ui.NewRequest(ctx)
r.Title = "Login"
return r.Render(layouts.Auth, form.Render(r))
}
func Register(ctx echo.Context, form *forms.Register) error {
r := ui.NewRequest(ctx)
r.Title = "Register"
return r.Render(layouts.Auth, form.Render(r))
}
func ForgotPassword(ctx echo.Context, form *forms.ForgotPassword) error {
r := ui.NewRequest(ctx)
r.Title = "Forgot password"
g := Group{
Div(
Class("content"),
P(Text("Enter your email address and we'll email you a link that allows you to reset your password.")),
),
form.Render(r),
}
return r.Render(layouts.Auth, g)
}
func ResetPassword(ctx echo.Context, form *forms.ResetPassword) error {
r := ui.NewRequest(ctx)
r.Title = "Reset your password"
return r.Render(layouts.Auth, form.Render(r))
}

View file

@ -0,0 +1,15 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/ui"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/layouts"
)
func UpdateCache(ctx echo.Context, form *forms.Cache) error {
r := ui.NewRequest(ctx)
r.Title = "Set a cache entry"
return r.Render(layouts.Primary, form.Render(r))
}

View file

@ -0,0 +1,46 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func ContactUs(ctx echo.Context, form *forms.Contact) error {
r := ui.NewRequest(ctx)
r.Title = "Contact us"
r.Metatags.Description = "Get in touch with us."
g := Group{
Iff(r.Htmx.Target != "contact", func() Node {
return Card(CardParams{
Title: "Card component",
Body: Group{
Span(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")),
Span(Text("Only the form below will update async upon submission.")),
},
Color: ColorWarning,
Size: SizeMedium,
})
}),
Iff(form.IsDone(), func() Node {
return Card(CardParams{
Title: "Thank you!",
Body: Group{
Span(Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled.")),
},
Color: ColorSuccess,
Size: SizeLarge,
})
}),
Iff(!form.IsDone(), func() Node {
return form.Render(r)
}),
}
return r.Render(layouts.Primary, g)
}

View file

@ -0,0 +1,38 @@
package pages
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
"github.com/camzawacki/personal-site/internal/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Error(ctx echo.Context, code int) error {
r := ui.NewRequest(ctx)
r.Title = http.StatusText(code)
var body Node
switch code {
case http.StatusInternalServerError:
body = Text("Please try again.")
case http.StatusForbidden, http.StatusUnauthorized:
body = Text("You are not authorized to view the requested page.")
case http.StatusNotFound:
body = Group{
Text("Click "),
A(
Href(r.Path(routenames.Home)),
Text("here"),
),
Text(" to go return home."),
}
default:
body = Text("Something went wrong.")
}
return r.Render(layouts.Primary, P(body))
}

53
internal/ui/pages/file.go Normal file
View file

@ -0,0 +1,53 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/layouts"
"github.com/camzawacki/personal-site/internal/ui/models"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func UploadFile(ctx echo.Context, files []*models.File) error {
r := ui.NewRequest(ctx)
r.Title = "Upload a file"
fileList := make(Group, len(files))
for i, file := range files {
fileList[i] = file.Render()
}
n := Group{
P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")),
Divider(""),
forms.File{}.Render(r),
Divider(""),
H3(
Class("title"),
Text("Uploaded files"),
),
Card(CardParams{
Body: Group{Text("Below are all files in the configured upload directory.")},
Color: ColorWarning,
Size: SizeMedium,
}),
Table(
Class("table"),
THead(
Tr(
Th(Text("Filename")),
Th(Text("Size")),
Th(Text("Modified on")),
),
),
TBody(
fileList,
),
),
}
return r.Render(layouts.Primary, n)
}

63
internal/ui/pages/home.go Normal file
View file

@ -0,0 +1,63 @@
package pages
import (
"github.com/labstack/echo/v4"
// "github.com/camzawacki/personal-site/internal/routenames"
"github.com/camzawacki/personal-site/internal/ui"
// . "github.com/camzawacki/personal-site/internal/ui/components"
// "github.com/camzawacki/personal-site/internal/ui/icons"
"github.com/camzawacki/personal-site/internal/ui/layouts"
"github.com/camzawacki/personal-site/internal/ui/models"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Home(ctx echo.Context, posts *models.Posts) error {
r := ui.NewRequest(ctx)
r.Metatags.Description = "This is my homepage."
r.Metatags.Keywords = []string{"Software", "Coding", "Projects", "Homepage"}
img := Div(
Class("w-full h-full flex justify-center"),
Div(
Class("bg-blue-100 size-92 object-contain overflow-hidden rounded-4xl"),
Img(
Src(ui.StaticFile("me2.webp")),
),
),
)
// tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node {
banner := Div(
Class("w-full py-4 bg-red-100 text-center text-lg"),
Text("This website is currently under construction. For an older version, see "),
A(
Class("underline"),
Href("https://camzawacki.com"),
Text("camzawacki.com"),
),
)
education := Div(
Class("prose-xl"),
H2(Text("Education")),
Ul(Class("list-disc pl-3"),
Li(Text("PhD Electrical Engineering")),
Li(Text("MS Robotics")),
Li(Text("BS Mechanical Engineering & Computer Science")),
),
)
content := Div(
Class("flex flex-col p-5 mx-10 gap-2"),
img,
Div(Class("w-full divider")),
banner,
Div(
Class("mx-auto w-160"),
education,
),
)
return r.Render(layouts.Primary, content)
}

View file

@ -0,0 +1,20 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/ui"
"github.com/camzawacki/personal-site/internal/ui/layouts"
"github.com/camzawacki/personal-site/internal/ui/models"
. "maragu.dev/gomponents"
)
func SearchResults(ctx echo.Context, results []*models.SearchResult) error {
r := ui.NewRequest(ctx)
g := make(Group, len(results))
for i, result := range results {
g[i] = result.Render()
}
return r.Render(layouts.Primary, g)
}

41
internal/ui/pages/task.go Normal file
View file

@ -0,0 +1,41 @@
package pages
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/internal/ui"
. "github.com/camzawacki/personal-site/internal/ui/components"
"github.com/camzawacki/personal-site/internal/ui/forms"
"github.com/camzawacki/personal-site/internal/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func AddTask(ctx echo.Context, form *forms.Task) error {
r := ui.NewRequest(ctx)
r.Title = "Create a task"
r.Metatags.Description = "Test creating a task to see how it works."
g := Group{
Iff(r.Htmx.Target != "task", func() Node {
return Group{
P(Raw("Submitting this form will create an <i>ExampleTask</i> in the task queue. After the specified delay, the message will be logged by the queue processor.")),
P(Raw("See <i>pkg/tasks</i> and the README for more information.")),
}
}),
form.Render(r),
Iff(r.Htmx.Target != "task", func() Node {
var text string
if r.IsAdmin {
text = "View all queued tasks by clicking on the Tasks link in the sidebar."
} else {
text = "Log in as an admin in order to access the task and queue monitoring UI."
}
return Group{
Div(Class("mt-5")),
Alert(ColorWarning, text),
}
}),
}
return r.Render(layouts.Primary, g)
}

114
internal/ui/request.go Normal file
View file

@ -0,0 +1,114 @@
package ui
import (
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/htmx"
"maragu.dev/gomponents"
)
type (
// Request encapsulates information about the incoming request in order to provide your ui with important and
// useful information needed for rendering.
Request struct {
// Title stores the title of the page.
Title string
// Context stores the request context.
Context echo.Context
// CurrentPath stores the path of the current request.
CurrentPath string
// IsHome stores whether the requested page is the home page.
IsHome bool
// IsAuth stores whether the user is authenticated.
IsAuth bool
// IsAdmin stores whether the user is an admin.
IsAdmin bool
// AuthUser stores the authenticated user.
AuthUser *ent.User
// Metatags stores metatag values.
Metatags struct {
// Description stores the description metatag value.
Description string
// Keywords stores the keywords metatag values.
Keywords []string
}
// CSRF stores the CSRF token for the given request.
// This will only be populated if the CSRF middleware is in effect for the given request.
// If this is populated, all forms must include this value otherwise the requests will be rejected.
CSRF string
// Htmx stores information provided by HTMX about this request.
Htmx *htmx.Request
// Config stores the application configuration.
// This will only be populated if the Config middleware is installed in the router.
Config *config.Config
}
// LayoutFunc is a callback function intended to render your page node within a given layout.
// This is handled as a callback to automatically support HTMX requests so that you can respond
// with only the page content and not the entire layout.
// See Request.Render().
LayoutFunc func(*Request, gomponents.Node) gomponents.Node
)
// NewRequest generates a new Request using the Echo context of a given HTTP request.
func NewRequest(ctx echo.Context) *Request {
p := &Request{
Context: ctx,
CurrentPath: ctx.Request().URL.Path,
Htmx: htmx.GetRequest(ctx),
}
p.IsHome = p.CurrentPath == "/"
if csrf := ctx.Get(context.CSRFKey); csrf != nil {
p.CSRF = csrf.(string)
}
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
p.IsAuth = true
p.AuthUser = u.(*ent.User)
p.IsAdmin = p.AuthUser.Admin
}
if cfg := ctx.Get(context.ConfigKey); cfg != nil {
p.Config = cfg.(*config.Config)
}
return p
}
// Path generates a URL path for a given route name and optional route parameters.
// This will only work if you've supplied names for each of your routes. It's optional to use and helps avoids
// having duplicate, hard-coded paths and parameters all over your application.
func (r *Request) Path(routeName string, routeParams ...any) string {
return r.Context.Echo().Reverse(routeName, routeParams...)
}
// Url generates an absolute URL for a given route name and optional route parameters.
func (r *Request) Url(routeName string, routeParams ...any) string {
return r.Config.App.Host + r.Path(routeName, routeParams...)
}
// Render renders a given node, optionally within a given layout based on the HTMX request headers.
// If the request is being made by HTMX and is not boosted, this will automatically only render the node without
// the layout, to support partial rendering.
func (r *Request) Render(layout LayoutFunc, node gomponents.Node) error {
if r.Htmx.Enabled && !r.Htmx.Boosted {
return node.Render(r.Context.Response().Writer)
}
return layout(r, node).Render(r.Context.Response().Writer)
}

View file

@ -0,0 +1,93 @@
package ui
import (
"testing"
"github.com/labstack/echo/v4"
"github.com/camzawacki/personal-site/config"
"github.com/camzawacki/personal-site/ent"
"github.com/camzawacki/personal-site/internal/context"
"github.com/camzawacki/personal-site/internal/htmx"
"github.com/camzawacki/personal-site/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"maragu.dev/gomponents"
"maragu.dev/gomponents/html"
)
func TestNewRequest(t *testing.T) {
e := echo.New()
ctx, _ := tests.NewContext(e, "/")
r := NewRequest(ctx)
assert.Same(t, ctx, r.Context)
assert.Equal(t, "/", r.CurrentPath)
assert.True(t, r.IsHome)
assert.False(t, r.IsAuth)
assert.Nil(t, r.AuthUser)
assert.Empty(t, r.CSRF)
assert.Nil(t, r.Config)
assert.Same(t, htmx.GetRequest(ctx), r.Htmx)
ctx, _ = tests.NewContext(e, "/abc")
usr := &ent.User{
ID: 1,
}
ctx.Set(context.AuthenticatedUserKey, usr)
ctx.Set(context.CSRFKey, "12345")
ctx.Set(context.ConfigKey, &config.Config{
App: config.AppConfig{
Name: "testing",
},
})
r = NewRequest(ctx)
assert.Equal(t, "/abc", r.CurrentPath)
assert.False(t, r.IsHome)
assert.True(t, r.IsAuth)
assert.Equal(t, usr, r.AuthUser)
assert.Equal(t, "12345", r.CSRF)
assert.Equal(t, "testing", r.Config.App.Name)
}
func TestRequest_UrlPath(t *testing.T) {
e := echo.New()
e.GET("/abc/:id", func(c echo.Context) error { return nil }).Name = "test"
ctx, _ := tests.NewContext(e, "/")
r := NewRequest(ctx)
r.Config = &config.Config{
App: config.AppConfig{
Host: "http://localhost",
},
}
assert.Equal(t, "http://localhost/abc/123", r.Url("test", 123))
assert.Equal(t, "/abc/123", r.Path("test", 123))
}
func TestRequest_Render(t *testing.T) {
e := echo.New()
layout := func(r *Request, n gomponents.Node) gomponents.Node {
return html.Div(html.Class("test"), n)
}
node := html.P(gomponents.Text("hello"))
t.Run("no htmx", func(t *testing.T) {
ctx, rec := tests.NewContext(e, "/")
r := NewRequest(ctx)
r.Htmx = &htmx.Request{}
err := r.Render(layout, node)
require.NoError(t, err)
assert.Equal(t, `<div class="test"><p>hello</p></div>`, rec.Body.String())
})
t.Run("htmx", func(t *testing.T) {
ctx, rec := tests.NewContext(e, "/")
r := NewRequest(ctx)
r.Htmx = &htmx.Request{
Enabled: true,
Boosted: false,
}
err := r.Render(layout, node)
require.NoError(t, err)
assert.Equal(t, `<p>hello</p>`, rec.Body.String())
})
}

21
internal/ui/ui.go Normal file
View file

@ -0,0 +1,21 @@
package ui
import (
"fmt"
"time"
)
var (
// cacheBuster stores the current time as a cache buster for static files.
cacheBuster = fmt.Sprint(time.Now().Unix())
)
// PublicFile generates a relative URL to a public file.
func PublicFile(filepath string) string {
return fmt.Sprintf("/%s/%s", "files", filepath)
}
// StaticFile generates a relative URL to a static file including a cache-buster query parameter.
func StaticFile(filepath string) string {
return fmt.Sprintf("/%s/%s?v=%s", "static", filepath, cacheBuster)
}

Some files were not shown because too many files have changed in this diff Show more