Added a basic homepage

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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