Added a basic homepage
This commit is contained in:
parent
d40640a648
commit
12fd3c04ca
113 changed files with 414 additions and 506 deletions
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue