Added a basic homepage
This commit is contained in:
parent
d40640a648
commit
12fd3c04ca
113 changed files with 414 additions and 506 deletions
62
internal/context/context.go
Normal file
62
internal/context/context.go
Normal 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
|
||||
}
|
||||
47
internal/context/context_test.go
Normal file
47
internal/context/context_test.go
Normal 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
55
internal/form/form.go
Normal 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)
|
||||
}
|
||||
67
internal/form/form_test.go
Normal file
67
internal/form/form_test.go
Normal 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
105
internal/form/submission.go
Normal 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)
|
||||
}
|
||||
}
|
||||
57
internal/form/submission_test.go
Normal file
57
internal/form/submission_test.go
Normal 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
198
internal/handlers/admin.go
Normal 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
380
internal/handlers/auth.go
Normal 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()
|
||||
}
|
||||
76
internal/handlers/cache.go
Normal file
76
internal/handlers/cache.go
Normal 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)
|
||||
}
|
||||
62
internal/handlers/contact.go
Normal file
62
internal/handlers/contact.go
Normal 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)
|
||||
}
|
||||
43
internal/handlers/error.go
Normal file
43
internal/handlers/error.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
80
internal/handlers/files.go
Normal file
80
internal/handlers/files.go
Normal 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)
|
||||
}
|
||||
36
internal/handlers/handlers.go
Normal file
36
internal/handlers/handlers.go
Normal 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))
|
||||
}
|
||||
29
internal/handlers/handlers_test.go
Normal file
29
internal/handlers/handlers_test.go
Normal 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)
|
||||
}
|
||||
55
internal/handlers/pages.go
Normal file
55
internal/handlers/pages.go
Normal 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)
|
||||
}
|
||||
24
internal/handlers/pages_test.go
Normal file
24
internal/handlers/pages_test.go
Normal 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())
|
||||
}
|
||||
93
internal/handlers/router.go
Normal file
93
internal/handlers/router.go
Normal 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
|
||||
}
|
||||
138
internal/handlers/router_test.go
Normal file
138
internal/handlers/router_test.go
Normal 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
|
||||
}
|
||||
44
internal/handlers/search.go
Normal file
44
internal/handlers/search.go
Normal 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
71
internal/handlers/task.go
Normal 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
97
internal/htmx/htmx.go
Normal 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
|
||||
}
|
||||
}
|
||||
62
internal/htmx/htmx_test.go
Normal file
62
internal/htmx/htmx_test.go
Normal 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
27
internal/log/log.go
Normal 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
21
internal/log/log_test.go
Normal 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
120
internal/middleware/auth.go
Normal 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)
|
||||
}
|
||||
}
|
||||
145
internal/middleware/auth_test.go
Normal file
145
internal/middleware/auth_test.go
Normal 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)
|
||||
}
|
||||
22
internal/middleware/cache.go
Normal file
22
internal/middleware/cache.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
internal/middleware/cache_test.go
Normal file
18
internal/middleware/cache_test.go
Normal 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"))
|
||||
}
|
||||
17
internal/middleware/config.go
Normal file
17
internal/middleware/config.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
internal/middleware/config_test.go
Normal file
22
internal/middleware/config_test.go
Normal 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)
|
||||
}
|
||||
43
internal/middleware/entity.go
Normal file
43
internal/middleware/entity.go
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
internal/middleware/entity_test.go
Normal file
23
internal/middleware/entity_test.go
Normal 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)
|
||||
}
|
||||
73
internal/middleware/log.go
Normal file
73
internal/middleware/log.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
109
internal/middleware/log_test.go
Normal file
109
internal/middleware/log_test.go
Normal 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)
|
||||
}
|
||||
40
internal/middleware/middleware_test.go
Normal file
40
internal/middleware/middleware_test.go
Normal 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)
|
||||
}
|
||||
19
internal/middleware/session.go
Normal file
19
internal/middleware/session.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
internal/middleware/session_test.go
Normal file
24
internal/middleware/session_test.go
Normal 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
97
internal/msg/msg.go
Normal 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
46
internal/msg/msg_test.go
Normal 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
81
internal/pager/pager.go
Normal 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
|
||||
}
|
||||
74
internal/pager/pager_test.go
Normal file
74
internal/pager/pager_test.go
Normal 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())
|
||||
}
|
||||
91
internal/redirect/redirect.go
Normal file
91
internal/redirect/redirect.go
Normal 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)
|
||||
}
|
||||
}
|
||||
77
internal/redirect/redirect_test.go
Normal file
77
internal/redirect/redirect_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
58
internal/routenames/names.go
Normal file
58
internal/routenames/names.go
Normal 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
218
internal/services/auth.go
Normal 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")
|
||||
}
|
||||
147
internal/services/auth_test.go
Normal file
147
internal/services/auth_test.go
Normal 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
351
internal/services/cache.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
internal/services/cache_test.go
Normal file
105
internal/services/cache_test.go
Normal 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)
|
||||
}
|
||||
246
internal/services/container.go
Normal file
246
internal/services/container.go
Normal 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)
|
||||
}
|
||||
20
internal/services/container_test.go
Normal file
20
internal/services/container_test.go
Normal 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
127
internal/services/mail.go
Normal 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)
|
||||
}
|
||||
3
internal/services/mail_test.go
Normal file
3
internal/services/mail_test.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package services
|
||||
|
||||
// Fill this in once you implement your mail client
|
||||
46
internal/services/services_test.go
Normal file
46
internal/services/services_test.go
Normal 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)
|
||||
}
|
||||
26
internal/services/validator.go
Normal file
26
internal/services/validator.go
Normal 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
|
||||
}
|
||||
19
internal/services/validator_test.go
Normal file
19
internal/services/validator_test.go
Normal 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)
|
||||
}
|
||||
27
internal/session/session.go
Normal file
27
internal/session/session.go
Normal 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)
|
||||
}
|
||||
23
internal/session/session_test.go
Normal file
23
internal/session/session_test.go
Normal 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
53
internal/tasks/example.go
Normal 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
|
||||
})
|
||||
}
|
||||
10
internal/tasks/register.go
Normal file
10
internal/tasks/register.go
Normal 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
74
internal/tests/tests.go
Normal 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
69
internal/ui/cache/cache.go
vendored
Normal 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
57
internal/ui/cache/cache_test.go
vendored
Normal 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)
|
||||
}
|
||||
66
internal/ui/components/alerts.go
Normal file
66
internal/ui/components/alerts.go
Normal 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)),
|
||||
)
|
||||
}
|
||||
121
internal/ui/components/data.go
Normal file
121
internal/ui/components/data.go
Normal 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,
|
||||
)
|
||||
}
|
||||
268
internal/ui/components/form.go
Normal file
268
internal/ui/components/form.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
35
internal/ui/components/head.go
Normal file
35
internal/ui/components/head.go
Normal 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, ", ")))),
|
||||
}
|
||||
}
|
||||
39
internal/ui/components/htmx.go
Normal file
39
internal/ui/components/htmx.go
Normal 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")
|
||||
}
|
||||
91
internal/ui/components/nav.go
Normal file
91
internal/ui/components/nav.go
Normal 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),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
27
internal/ui/components/styles.go
Normal file
27
internal/ui/components/styles.go
Normal 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
|
||||
)
|
||||
38
internal/ui/components/tabs.go
Normal file
38
internal/ui/components/tabs.go
Normal 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,
|
||||
)
|
||||
}
|
||||
22
internal/ui/emails/auth.go
Normal file
22
internal/ui/emails/auth.go
Normal 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)),
|
||||
}
|
||||
}
|
||||
124
internal/ui/forms/admin_entity.go
Normal file
124
internal/ui/forms/admin_entity.go
Normal 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),
|
||||
)
|
||||
}
|
||||
30
internal/ui/forms/admin_entity_delete.go
Normal file
30
internal/ui/forms/admin_entity_delete.go
Normal 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),
|
||||
)
|
||||
}
|
||||
54
internal/ui/forms/cache.go
Normal file
54
internal/ui/forms/cache.go
Normal 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),
|
||||
)
|
||||
}
|
||||
58
internal/ui/forms/contact.go
Normal file
58
internal/ui/forms/contact.go
Normal 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
31
internal/ui/forms/file.go
Normal 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),
|
||||
)
|
||||
}
|
||||
39
internal/ui/forms/forgot_password.go
Normal file
39
internal/ui/forms/forgot_password.go
Normal 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),
|
||||
)
|
||||
}
|
||||
64
internal/ui/forms/login.go
Normal file
64
internal/ui/forms/login.go
Normal 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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
74
internal/ui/forms/register.go
Normal file
74
internal/ui/forms/register.go
Normal 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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
46
internal/ui/forms/reset_password.go
Normal file
46
internal/ui/forms/reset_password.go
Normal 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
49
internal/ui/forms/task.go
Normal 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
208
internal/ui/icons/icons.go
Normal 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),
|
||||
)
|
||||
})
|
||||
}
|
||||
40
internal/ui/layouts/auth.go
Normal file
40
internal/ui/layouts/auth.go
Normal 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
42
internal/ui/layouts/primary.go
Normal file
42
internal/ui/layouts/primary.go
Normal 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
22
internal/ui/models/file.go
Normal file
22
internal/ui/models/file.go
Normal 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)),
|
||||
)
|
||||
}
|
||||
67
internal/ui/models/post.go
Normal file
67
internal/ui/models/post.go
Normal 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
21
internal/ui/models/search_result.go
Normal file
21
internal/ui/models/search_result.go
Normal 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),
|
||||
),
|
||||
)
|
||||
}
|
||||
61
internal/ui/pages/about.go
Normal file
61
internal/ui/pages/about.go
Normal 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)
|
||||
}
|
||||
115
internal/ui/pages/admin_entity.go
Normal file
115
internal/ui/pages/admin_entity.go
Normal 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
46
internal/ui/pages/auth.go
Normal 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))
|
||||
}
|
||||
15
internal/ui/pages/cache.go
Normal file
15
internal/ui/pages/cache.go
Normal 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))
|
||||
}
|
||||
46
internal/ui/pages/contact.go
Normal file
46
internal/ui/pages/contact.go
Normal 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)
|
||||
}
|
||||
38
internal/ui/pages/error.go
Normal file
38
internal/ui/pages/error.go
Normal 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
53
internal/ui/pages/file.go
Normal 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
63
internal/ui/pages/home.go
Normal 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)
|
||||
}
|
||||
20
internal/ui/pages/search.go
Normal file
20
internal/ui/pages/search.go
Normal 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
41
internal/ui/pages/task.go
Normal 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
114
internal/ui/request.go
Normal 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)
|
||||
}
|
||||
93
internal/ui/request_test.go
Normal file
93
internal/ui/request_test.go
Normal 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
21
internal/ui/ui.go
Normal 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
Loading…
Add table
Add a link
Reference in a new issue