From 60027e4ab8793b2fb2707135679d2d8665048963 Mon Sep 17 00:00:00 2001
From: Thomas Miceli <tho.miceli@gmail.com>
Date: Tue, 3 Dec 2024 02:18:04 +0100
Subject: [PATCH] wip

---
 internal/web/admin.go                     | 236 --------
 internal/web/context/context.go           | 140 +++++
 internal/web/context/store.go             |  28 +
 internal/web/handler/admin.go             | 238 ++++++++
 internal/web/{ => handler}/auth.go        | 394 +++++++-------
 internal/web/{ => handler}/gist.go        | 466 ++++++++--------
 internal/web/{ => handler}/git_http.go    |  94 ++--
 internal/web/{ => handler}/healthcheck.go |   8 +-
 internal/web/handler/settings.go          | 227 ++++++++
 internal/web/handler/util.go              |  79 +++
 internal/web/server.go                    | 626 ----------------------
 internal/web/server/middlewares.go        | 175 ++++++
 internal/web/server/renderer.go           | 216 ++++++++
 internal/web/server/router.go             |   1 +
 internal/web/server/server.go             | 319 +++++++++++
 internal/web/server/util.go               |  33 ++
 internal/web/settings.go                  | 227 --------
 internal/web/util.go                      | 248 ---------
 18 files changed, 1938 insertions(+), 1817 deletions(-)
 delete mode 100644 internal/web/admin.go
 create mode 100644 internal/web/context/context.go
 create mode 100644 internal/web/context/store.go
 create mode 100644 internal/web/handler/admin.go
 rename internal/web/{ => handler}/auth.go (58%)
 rename internal/web/{ => handler}/gist.go (53%)
 rename internal/web/{ => handler}/git_http.go (74%)
 rename internal/web/{ => handler}/healthcheck.go (76%)
 create mode 100644 internal/web/handler/settings.go
 create mode 100644 internal/web/handler/util.go
 delete mode 100644 internal/web/server.go
 create mode 100644 internal/web/server/middlewares.go
 create mode 100644 internal/web/server/renderer.go
 create mode 100644 internal/web/server/router.go
 create mode 100644 internal/web/server/server.go
 create mode 100644 internal/web/server/util.go
 delete mode 100644 internal/web/settings.go
 delete mode 100644 internal/web/util.go

diff --git a/internal/web/admin.go b/internal/web/admin.go
deleted file mode 100644
index c49d778..0000000
--- a/internal/web/admin.go
+++ /dev/null
@@ -1,236 +0,0 @@
-package web
-
-import (
-	"github.com/labstack/echo/v4"
-	"github.com/thomiceli/opengist/internal/actions"
-	"github.com/thomiceli/opengist/internal/config"
-	"github.com/thomiceli/opengist/internal/db"
-	"github.com/thomiceli/opengist/internal/git"
-	"runtime"
-	"strconv"
-	"time"
-)
-
-func adminIndex(ctx echo.Context) error {
-	setData(ctx, "htmlTitle", trH(ctx, "admin.admin_panel"))
-	setData(ctx, "adminHeaderPage", "index")
-
-	setData(ctx, "opengistVersion", config.OpengistVersion)
-	setData(ctx, "goVersion", runtime.Version())
-	gitVersion, err := git.GetGitVersion()
-	if err != nil {
-		return errorRes(500, "Cannot get git version", err)
-	}
-	setData(ctx, "gitVersion", gitVersion)
-
-	countUsers, err := db.CountAll(&db.User{})
-	if err != nil {
-		return errorRes(500, "Cannot count users", err)
-	}
-	setData(ctx, "countUsers", countUsers)
-
-	countGists, err := db.CountAll(&db.Gist{})
-	if err != nil {
-		return errorRes(500, "Cannot count gists", err)
-	}
-	setData(ctx, "countGists", countGists)
-
-	countKeys, err := db.CountAll(&db.SSHKey{})
-	if err != nil {
-		return errorRes(500, "Cannot count SSH keys", err)
-	}
-	setData(ctx, "countKeys", countKeys)
-
-	setData(ctx, "syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS))
-	setData(ctx, "syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB))
-	setData(ctx, "gitGcRepos", actions.IsRunning(actions.GitGcRepos))
-	setData(ctx, "syncGistPreviews", actions.IsRunning(actions.SyncGistPreviews))
-	setData(ctx, "resetHooks", actions.IsRunning(actions.ResetHooks))
-	setData(ctx, "indexGists", actions.IsRunning(actions.IndexGists))
-	return html(ctx, "admin_index.html")
-}
-
-func adminUsers(ctx echo.Context) error {
-	setData(ctx, "htmlTitle", trH(ctx, "admin.users")+" - "+trH(ctx, "admin.admin_panel"))
-	setData(ctx, "adminHeaderPage", "users")
-	pageInt := getPage(ctx)
-
-	var data []*db.User
-	var err error
-	if data, err = db.GetAllUsers(pageInt - 1); err != nil {
-		return errorRes(500, "Cannot get users", err)
-	}
-
-	if err = paginate(ctx, data, pageInt, 10, "data", "admin-panel/users", 1); err != nil {
-		return errorRes(404, tr(ctx, "error.page-not-found"), nil)
-	}
-
-	return html(ctx, "admin_users.html")
-}
-
-func adminGists(ctx echo.Context) error {
-	setData(ctx, "htmlTitle", trH(ctx, "admin.gists")+" - "+trH(ctx, "admin.admin_panel"))
-	setData(ctx, "adminHeaderPage", "gists")
-	pageInt := getPage(ctx)
-
-	var data []*db.Gist
-	var err error
-	if data, err = db.GetAllGists(pageInt - 1); err != nil {
-		return errorRes(500, "Cannot get gists", err)
-	}
-
-	if err = paginate(ctx, data, pageInt, 10, "data", "admin-panel/gists", 1); err != nil {
-		return errorRes(404, tr(ctx, "error.page-not-found"), nil)
-	}
-
-	return html(ctx, "admin_gists.html")
-}
-
-func adminUserDelete(ctx echo.Context) error {
-	userId, _ := strconv.ParseUint(ctx.Param("user"), 10, 64)
-	user, err := db.GetUserById(uint(userId))
-	if err != nil {
-		return errorRes(500, "Cannot retrieve user", err)
-	}
-
-	if err := user.Delete(); err != nil {
-		return errorRes(500, "Cannot delete this user", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.admin.user-deleted"), "success")
-	return redirect(ctx, "/admin-panel/users")
-}
-
-func adminGistDelete(ctx echo.Context) error {
-	gist, err := db.GetGistByID(ctx.Param("gist"))
-	if err != nil {
-		return errorRes(500, "Cannot retrieve gist", err)
-	}
-
-	if err = gist.DeleteRepository(); err != nil {
-		return errorRes(500, "Cannot delete the repository", err)
-	}
-
-	if err = gist.Delete(); err != nil {
-		return errorRes(500, "Cannot delete this gist", err)
-	}
-
-	gist.RemoveFromIndex()
-
-	addFlash(ctx, tr(ctx, "flash.admin.gist-deleted"), "success")
-	return redirect(ctx, "/admin-panel/gists")
-}
-
-func adminSyncReposFromFS(ctx echo.Context) error {
-	addFlash(ctx, tr(ctx, "flash.admin.sync-fs"), "success")
-	go actions.Run(actions.SyncReposFromFS)
-	return redirect(ctx, "/admin-panel")
-}
-
-func adminSyncReposFromDB(ctx echo.Context) error {
-	addFlash(ctx, tr(ctx, "flash.admin.sync-db"), "success")
-	go actions.Run(actions.SyncReposFromDB)
-	return redirect(ctx, "/admin-panel")
-}
-
-func adminGcRepos(ctx echo.Context) error {
-	addFlash(ctx, tr(ctx, "flash.admin.git-gc"), "success")
-	go actions.Run(actions.GitGcRepos)
-	return redirect(ctx, "/admin-panel")
-}
-
-func adminSyncGistPreviews(ctx echo.Context) error {
-	addFlash(ctx, tr(ctx, "flash.admin.sync-previews"), "success")
-	go actions.Run(actions.SyncGistPreviews)
-	return redirect(ctx, "/admin-panel")
-}
-
-func adminResetHooks(ctx echo.Context) error {
-	addFlash(ctx, tr(ctx, "flash.admin.reset-hooks"), "success")
-	go actions.Run(actions.ResetHooks)
-	return redirect(ctx, "/admin-panel")
-}
-
-func adminIndexGists(ctx echo.Context) error {
-	addFlash(ctx, tr(ctx, "flash.admin.index-gists"), "success")
-	go actions.Run(actions.IndexGists)
-	return redirect(ctx, "/admin-panel")
-}
-
-func adminConfig(ctx echo.Context) error {
-	setData(ctx, "htmlTitle", trH(ctx, "admin.configuration")+" - "+trH(ctx, "admin.admin_panel"))
-	setData(ctx, "adminHeaderPage", "config")
-
-	setData(ctx, "dbtype", db.DatabaseInfo.Type.String())
-	setData(ctx, "dbname", db.DatabaseInfo.Database)
-
-	return html(ctx, "admin_config.html")
-}
-
-func adminSetConfig(ctx echo.Context) error {
-	key := ctx.FormValue("key")
-	value := ctx.FormValue("value")
-
-	if err := db.UpdateSetting(key, value); err != nil {
-		return errorRes(500, "Cannot set setting", err)
-	}
-
-	return ctx.JSON(200, map[string]interface{}{
-		"success": true,
-	})
-}
-
-func adminInvitations(ctx echo.Context) error {
-	setData(ctx, "htmlTitle", trH(ctx, "admin.invitations")+" - "+trH(ctx, "admin.admin_panel"))
-	setData(ctx, "adminHeaderPage", "invitations")
-
-	var invitations []*db.Invitation
-	var err error
-	if invitations, err = db.GetAllInvitations(); err != nil {
-		return errorRes(500, "Cannot get invites", err)
-	}
-
-	setData(ctx, "invitations", invitations)
-	return html(ctx, "admin_invitations.html")
-}
-
-func adminInvitationsCreate(ctx echo.Context) error {
-	code := ctx.FormValue("code")
-	nbMax, err := strconv.ParseUint(ctx.FormValue("nbMax"), 10, 64)
-	if err != nil {
-		nbMax = 10
-	}
-
-	expiresAtUnix, err := strconv.ParseInt(ctx.FormValue("expiredAtUnix"), 10, 64)
-	if err != nil {
-		expiresAtUnix = time.Now().Unix() + 604800 // 1 week
-	}
-
-	invitation := &db.Invitation{
-		Code:      code,
-		ExpiresAt: expiresAtUnix,
-		NbMax:     uint(nbMax),
-	}
-
-	if err := invitation.Create(); err != nil {
-		return errorRes(500, "Cannot create invitation", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.admin.invitation-created"), "success")
-	return redirect(ctx, "/admin-panel/invitations")
-}
-
-func adminInvitationsDelete(ctx echo.Context) error {
-	id, _ := strconv.ParseUint(ctx.Param("id"), 10, 64)
-	invitation, err := db.GetInvitationByID(uint(id))
-	if err != nil {
-		return errorRes(500, "Cannot retrieve invitation", err)
-	}
-
-	if err := invitation.Delete(); err != nil {
-		return errorRes(500, "Cannot delete this invitation", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.admin.invitation-deleted"), "success")
-	return redirect(ctx, "/admin-panel/invitations")
-}
diff --git a/internal/web/context/context.go b/internal/web/context/context.go
new file mode 100644
index 0000000..9c3559c
--- /dev/null
+++ b/internal/web/context/context.go
@@ -0,0 +1,140 @@
+package context
+
+import (
+	"github.com/gorilla/sessions"
+	"github.com/labstack/echo/v4"
+	"github.com/rs/zerolog/log"
+	"github.com/thomiceli/opengist/internal/config"
+	"github.com/thomiceli/opengist/internal/db"
+	"github.com/thomiceli/opengist/internal/i18n"
+	"html/template"
+	"net/http"
+)
+
+type OGContext struct {
+	echo.Context
+
+	data echo.Map
+
+	store *Store
+	User  *db.User
+}
+
+func NewContext(c echo.Context) *OGContext {
+	return &OGContext{
+		Context: c,
+		data:    make(echo.Map),
+	}
+}
+
+func (ctx *OGContext) SetData(key string, value any) {
+	ctx.data[key] = value
+}
+
+func (ctx *OGContext) GetData(key string) any {
+	return ctx.data[key]
+}
+
+func (ctx *OGContext) DataMap() echo.Map {
+	return ctx.data
+}
+
+func (ctx *OGContext) ErrorRes(code int, message string, err error) error {
+	if code >= 500 {
+		var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
+		skipLogger.Error().Err(err).Msg(message)
+	}
+
+	return &echo.HTTPError{Code: code, Message: message, Internal: err}
+}
+
+func (ctx *OGContext) JsonErrorRes(code int, message string, err error) error {
+	if code >= 500 {
+		var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
+		skipLogger.Error().Err(err).Msg(message)
+	}
+
+	return &echo.HTTPError{Code: code, Message: message, Internal: err}
+}
+
+func (ctx *OGContext) RedirectTo(location string) error {
+	return ctx.Context.Redirect(302, config.C.ExternalUrl+location)
+}
+
+func (ctx *OGContext) HTML_(template string) error {
+	return ctx.HtmlWithCode(200, template)
+}
+
+func (ctx *OGContext) HtmlWithCode(code int, template string) error {
+	ctx.setErrorFlashes()
+	return ctx.Render(code, template, ctx.DataMap())
+}
+
+func (ctx *OGContext) JSON_(data any) error {
+	return ctx.JsonWithCode(200, data)
+}
+
+func (ctx *OGContext) JsonWithCode(code int, data any) error {
+	return ctx.JSON(code, data)
+}
+
+func (ctx *OGContext) PlainText(code int, message string) error {
+	return ctx.String(code, message)
+}
+
+func (ctx *OGContext) NotFound(message string) error {
+	return ctx.ErrorRes(404, message, nil)
+}
+
+func (ctx *OGContext) setErrorFlashes() {
+	sess, _ := ctx.store.flashStore.Get(ctx.Request(), "flash")
+
+	ctx.SetData("flashErrors", sess.Flashes("error"))
+	ctx.SetData("flashSuccess", sess.Flashes("success"))
+	ctx.SetData("flashWarnings", sess.Flashes("warning"))
+
+	_ = sess.Save(ctx.Request(), ctx.Response())
+}
+
+func (ctx *OGContext) GetSession() *sessions.Session {
+	sess, _ := ctx.store.UserStore.Get(ctx.Request(), "session")
+	return sess
+}
+
+func (ctx *OGContext) SaveSession(sess *sessions.Session) {
+	_ = sess.Save(ctx.Request(), ctx.Response())
+}
+
+func (ctx *OGContext) DeleteSession() {
+	sess := ctx.GetSession()
+	sess.Options.MaxAge = -1
+	ctx.SaveSession(sess)
+}
+
+func (ctx *OGContext) AddFlash(flashMessage string, flashType string) {
+	sess, _ := ctx.store.flashStore.Get(ctx.Request(), "flash")
+	sess.AddFlash(flashMessage, flashType)
+	_ = sess.Save(ctx.Request(), ctx.Response())
+}
+
+func (ctx *OGContext) getUserLogged() *db.User {
+	user := ctx.GetData("userLogged")
+	if user != nil {
+		return user.(*db.User)
+	}
+	return nil
+}
+
+func (ctx *OGContext) DeleteCsrfCookie() {
+	ctx.SetCookie(&http.Cookie{Name: "_csrf", Path: "/", MaxAge: -1})
+}
+
+func (ctx *OGContext) TrH(key string, args ...any) template.HTML {
+	l := ctx.GetData("locale").(*i18n.Locale)
+	return l.Tr(key, args...)
+}
+
+func (ctx *OGContext) Tr(key string, args ...any) string {
+	l := ctx.GetData("locale").(*i18n.Locale)
+	return l.String(key, args...)
+}
diff --git a/internal/web/context/store.go b/internal/web/context/store.go
new file mode 100644
index 0000000..01a0ef0
--- /dev/null
+++ b/internal/web/context/store.go
@@ -0,0 +1,28 @@
+package context
+
+import (
+	"github.com/gorilla/sessions"
+	"github.com/markbates/goth/gothic"
+	"github.com/thomiceli/opengist/internal/config"
+	"github.com/thomiceli/opengist/internal/utils"
+	"path/filepath"
+)
+
+type Store struct {
+	sessionsPath string
+
+	flashStore *sessions.CookieStore
+	UserStore  *sessions.FilesystemStore
+}
+
+func NewStore(sessionsPath string) *Store {
+	return &Store{sessionsPath: sessionsPath}
+}
+
+func (s *Store) setupSessionStore() {
+	s.flashStore = sessions.NewCookieStore([]byte("opengist"))
+	encryptKey, _ := utils.GenerateSecretKey(filepath.Join(s.sessionsPath, "session-encrypt.key"))
+	s.UserStore = sessions.NewFilesystemStore(s.sessionsPath, config.SecretKey, encryptKey)
+	s.UserStore.MaxLength(10 * 1024)
+	gothic.Store = s.UserStore
+}
diff --git a/internal/web/handler/admin.go b/internal/web/handler/admin.go
new file mode 100644
index 0000000..30b2991
--- /dev/null
+++ b/internal/web/handler/admin.go
@@ -0,0 +1,238 @@
+package handler
+
+import (
+	"github.com/thomiceli/opengist/internal/actions"
+	"github.com/thomiceli/opengist/internal/config"
+	"github.com/thomiceli/opengist/internal/db"
+	"github.com/thomiceli/opengist/internal/git"
+	"github.com/thomiceli/opengist/internal/web/context"
+	"runtime"
+	"strconv"
+	"time"
+)
+
+func AdminIndex(ctx *context.OGContext) error {
+	ctx.SetData("htmlTitle", ctx.TrH("admin.admin_panel"))
+	ctx.SetData("adminHeaderPage", "index")
+
+	ctx.SetData("opengistVersion", config.OpengistVersion)
+	ctx.SetData("goVersion", runtime.Version())
+	gitVersion, err := git.GetGitVersion()
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot get git version", err)
+	}
+	ctx.SetData("gitVersion", gitVersion)
+
+	countUsers, err := db.CountAll(&db.User{})
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot count users", err)
+	}
+	ctx.SetData("countUsers", countUsers)
+
+	countGists, err := db.CountAll(&db.Gist{})
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot count gists", err)
+	}
+	ctx.SetData("countGists", countGists)
+
+	countKeys, err := db.CountAll(&db.SSHKey{})
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot count SSH keys", err)
+	}
+	ctx.SetData("countKeys", countKeys)
+
+	ctx.SetData("syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS))
+	ctx.SetData("syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB))
+	ctx.SetData("gitGcRepos", actions.IsRunning(actions.GitGcRepos))
+	ctx.SetData("syncGistPreviews", actions.IsRunning(actions.SyncGistPreviews))
+	ctx.SetData("resetHooks", actions.IsRunning(actions.ResetHooks))
+	ctx.SetData("indexGists", actions.IsRunning(actions.IndexGists))
+	return ctx.HTML_("admin_index.html")
+}
+
+func AdminUsers(ctx *context.OGContext) error {
+	ctx.SetData("htmlTitle", ctx.TrH("admin.users")+" - "+ctx.TrH("admin.admin_panel"))
+	ctx.SetData("adminHeaderPage", "users")
+	ctx.SetData("loadStartTime", time.Now())
+
+	pageInt := getPage(ctx)
+
+	var data []*db.User
+	var err error
+	if data, err = db.GetAllUsers(pageInt - 1); err != nil {
+		return ctx.ErrorRes(500, "Cannot get users", err)
+	}
+
+	if err = paginate(ctx, data, pageInt, 10, "data", "admin-panel/users", 1); err != nil {
+		return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
+	}
+
+	return ctx.HTML_("admin_users.html")
+}
+
+func AdminGists(ctx *context.OGContext) error {
+	ctx.SetData("htmlTitle", ctx.TrH("admin.gists")+" - "+ctx.TrH("admin.admin_panel"))
+	ctx.SetData("adminHeaderPage", "gists")
+	pageInt := getPage(ctx)
+
+	var data []*db.Gist
+	var err error
+	if data, err = db.GetAllGists(pageInt - 1); err != nil {
+		return ctx.ErrorRes(500, "Cannot get gists", err)
+	}
+
+	if err = paginate(ctx, data, pageInt, 10, "data", "admin-panel/gists", 1); err != nil {
+		return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
+	}
+
+	return ctx.HTML_("admin_gists.html")
+}
+
+func AdminUserDelete(ctx *context.OGContext) error {
+	userId, _ := strconv.ParseUint(ctx.Param("user"), 10, 64)
+	user, err := db.GetUserById(uint(userId))
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot retrieve user", err)
+	}
+
+	if err := user.Delete(); err != nil {
+		return ctx.ErrorRes(500, "Cannot delete this user", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success")
+	return ctx.RedirectTo("/admin-panel/users")
+}
+
+func AdminGistDelete(ctx *context.OGContext) error {
+	gist, err := db.GetGistByID(ctx.Param("gist"))
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot retrieve gist", err)
+	}
+
+	if err = gist.DeleteRepository(); err != nil {
+		return ctx.ErrorRes(500, "Cannot delete the repository", err)
+	}
+
+	if err = gist.Delete(); err != nil {
+		return ctx.ErrorRes(500, "Cannot delete this gist", err)
+	}
+
+	gist.RemoveFromIndex()
+
+	ctx.AddFlash(ctx.Tr("flash.admin.gist-deleted"), "success")
+	return ctx.RedirectTo("/admin-panel/gists")
+}
+
+func AdminSyncReposFromFS(ctx *context.OGContext) error {
+	ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success")
+	go actions.Run(actions.SyncReposFromFS)
+	return ctx.RedirectTo("/admin-panel")
+}
+
+func AdminSyncReposFromDB(ctx *context.OGContext) error {
+	ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success")
+	go actions.Run(actions.SyncReposFromDB)
+	return ctx.RedirectTo("/admin-panel")
+}
+
+func AdminGcRepos(ctx *context.OGContext) error {
+	ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success")
+	go actions.Run(actions.GitGcRepos)
+	return ctx.RedirectTo("/admin-panel")
+}
+
+func AdminSyncGistPreviews(ctx *context.OGContext) error {
+	ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success")
+	go actions.Run(actions.SyncGistPreviews)
+	return ctx.RedirectTo("/admin-panel")
+}
+
+func AdminResetHooks(ctx *context.OGContext) error {
+	ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success")
+	go actions.Run(actions.ResetHooks)
+	return ctx.RedirectTo("/admin-panel")
+}
+
+func AdminIndexGists(ctx *context.OGContext) error {
+	ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success")
+	go actions.Run(actions.IndexGists)
+	return ctx.RedirectTo("/admin-panel")
+}
+
+func AdminConfig(ctx *context.OGContext) error {
+	ctx.SetData("htmlTitle", ctx.TrH("admin.configuration")+" - "+ctx.TrH("admin.admin_panel"))
+	ctx.SetData("adminHeaderPage", "config")
+
+	ctx.SetData("dbtype", db.DatabaseInfo.Type.String())
+	ctx.SetData("dbname", db.DatabaseInfo.Database)
+
+	return ctx.HTML_("admin_config.html")
+}
+
+func AdminSetConfig(ctx *context.OGContext) error {
+	key := ctx.FormValue("key")
+	value := ctx.FormValue("value")
+
+	if err := db.UpdateSetting(key, value); err != nil {
+		return ctx.ErrorRes(500, "Cannot set setting", err)
+	}
+
+	return ctx.JSON(200, map[string]interface{}{
+		"success": true,
+	})
+}
+
+func AdminInvitations(ctx *context.OGContext) error {
+	ctx.SetData("htmlTitle", ctx.TrH("admin.invitations")+" - "+ctx.TrH("admin.admin_panel"))
+	ctx.SetData("adminHeaderPage", "invitations")
+
+	var invitations []*db.Invitation
+	var err error
+	if invitations, err = db.GetAllInvitations(); err != nil {
+		return ctx.ErrorRes(500, "Cannot get invites", err)
+	}
+
+	ctx.SetData("invitations", invitations)
+	return ctx.HTML_("admin_invitations.html")
+}
+
+func AdminInvitationsCreate(ctx *context.OGContext) error {
+	code := ctx.FormValue("code")
+	nbMax, err := strconv.ParseUint(ctx.FormValue("nbMax"), 10, 64)
+	if err != nil {
+		nbMax = 10
+	}
+
+	expiresAtUnix, err := strconv.ParseInt(ctx.FormValue("expiredAtUnix"), 10, 64)
+	if err != nil {
+		expiresAtUnix = time.Now().Unix() + 604800 // 1 week
+	}
+
+	invitation := &db.Invitation{
+		Code:      code,
+		ExpiresAt: expiresAtUnix,
+		NbMax:     uint(nbMax),
+	}
+
+	if err := invitation.Create(); err != nil {
+		return ctx.ErrorRes(500, "Cannot create invitation", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.admin.invitation-created"), "success")
+	return ctx.RedirectTo("/admin-panel/invitations")
+}
+
+func AdminInvitationsDelete(ctx *context.OGContext) error {
+	id, _ := strconv.ParseUint(ctx.Param("id"), 10, 64)
+	invitation, err := db.GetInvitationByID(uint(id))
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot retrieve invitation", err)
+	}
+
+	if err := invitation.Delete(); err != nil {
+		return ctx.ErrorRes(500, "Cannot delete this invitation", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.admin.invitation-deleted"), "success")
+	return ctx.RedirectTo("/admin-panel/invitations")
+}
diff --git a/internal/web/auth.go b/internal/web/handler/auth.go
similarity index 58%
rename from internal/web/auth.go
rename to internal/web/handler/auth.go
index 39d3d28..735b687 100644
--- a/internal/web/auth.go
+++ b/internal/web/handler/auth.go
@@ -1,13 +1,12 @@
-package web
+package handler
 
 import (
 	"bytes"
-	"context"
+	gocontext "context"
 	"crypto/md5"
 	gojson "encoding/json"
 	"errors"
 	"fmt"
-	"github.com/labstack/echo/v4"
 	"github.com/markbates/goth"
 	"github.com/markbates/goth/gothic"
 	"github.com/markbates/goth/providers/gitea"
@@ -21,6 +20,7 @@ import (
 	"github.com/thomiceli/opengist/internal/db"
 	"github.com/thomiceli/opengist/internal/i18n"
 	"github.com/thomiceli/opengist/internal/utils"
+	"github.com/thomiceli/opengist/internal/web/context"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 	"gorm.io/gorm"
@@ -37,115 +37,115 @@ const (
 	OpenIDConnect  = "openid-connect"
 )
 
-func register(ctx echo.Context) error {
-	disableSignup := getData(ctx, "DisableSignup")
-	disableForm := getData(ctx, "DisableLoginForm")
+func register(ctx *context.OGContext) error {
+	disableSignup := ctx.GetData("DisableSignup")
+	disableForm := ctx.GetData("DisableLoginForm")
 
 	code := ctx.QueryParam("code")
 	if code != "" {
 		if invitation, err := db.GetInvitationByCode(code); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
-			return errorRes(500, "Cannot check for invitation code", err)
+			return ctx.ErrorRes(500, "Cannot check for invitation code", err)
 		} else if invitation != nil && invitation.IsUsable() {
 			disableSignup = false
 		}
 	}
 
-	setData(ctx, "title", trH(ctx, "auth.new-account"))
-	setData(ctx, "htmlTitle", trH(ctx, "auth.new-account"))
-	setData(ctx, "disableForm", disableForm)
-	setData(ctx, "disableSignup", disableSignup)
-	setData(ctx, "isLoginPage", false)
-	return html(ctx, "auth_form.html")
+	ctx.SetData("title", ctx.TrH("auth.new-account"))
+	ctx.SetData("htmlTitle", ctx.TrH("auth.new-account"))
+	ctx.SetData("disableForm", disableForm)
+	ctx.SetData("disableSignup", disableSignup)
+	ctx.SetData("isLoginPage", false)
+	return ctx.HTML_("auth_form.html")
 }
 
-func processRegister(ctx echo.Context) error {
-	disableSignup := getData(ctx, "DisableSignup")
+func processRegister(ctx *context.OGContext) error {
+	disableSignup := ctx.GetData("DisableSignup")
 
 	code := ctx.QueryParam("code")
 	invitation, err := db.GetInvitationByCode(code)
 	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
-		return errorRes(500, "Cannot check for invitation code", err)
+		return ctx.ErrorRes(500, "Cannot check for invitation code", err)
 	} else if invitation.ID != 0 && invitation.IsUsable() {
 		disableSignup = false
 	}
 
 	if disableSignup == true {
-		return errorRes(403, tr(ctx, "error.signup-disabled"), nil)
+		return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled"), nil)
 	}
 
-	if getData(ctx, "DisableLoginForm") == true {
-		return errorRes(403, tr(ctx, "error.signup-disabled-form"), nil)
+	if ctx.GetData("DisableLoginForm") == true {
+		return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled-form"), nil)
 	}
 
-	setData(ctx, "title", trH(ctx, "auth.new-account"))
-	setData(ctx, "htmlTitle", trH(ctx, "auth.new-account"))
+	ctx.SetData("title", ctx.TrH("auth.new-account"))
+	ctx.SetData("htmlTitle", ctx.TrH("auth.new-account"))
 
-	sess := getSession(ctx)
+	sess := ctx.GetSession()
 
 	dto := new(db.UserDTO)
 	if err := ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
 	}
 
 	if err := ctx.Validate(dto); err != nil {
-		addFlash(ctx, utils.ValidationMessages(&err, getData(ctx, "locale").(*i18n.Locale)), "error")
-		return html(ctx, "auth_form.html")
+		ctx.AddFlash(utils.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
+		return ctx.HTML_("auth_form.html")
 	}
 
 	if exists, err := db.UserExists(dto.Username); err != nil || exists {
-		addFlash(ctx, tr(ctx, "flash.auth.username-exists"), "error")
-		return html(ctx, "auth_form.html")
+		ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
+		return ctx.HTML_("auth_form.html")
 	}
 
 	user := dto.ToUser()
 
 	password, err := utils.Argon2id.Hash(user.Password)
 	if err != nil {
-		return errorRes(500, "Cannot hash password", err)
+		return ctx.ErrorRes(500, "Cannot hash password", err)
 	}
 	user.Password = password
 
 	if err = user.Create(); err != nil {
-		return errorRes(500, "Cannot create user", err)
+		return ctx.ErrorRes(500, "Cannot create user", err)
 	}
 
 	if user.ID == 1 {
 		if err = user.SetAdmin(); err != nil {
-			return errorRes(500, "Cannot set user admin", err)
+			return ctx.ErrorRes(500, "Cannot set user admin", err)
 		}
 	}
 
 	if invitation.ID != 0 {
 		if err := invitation.Use(); err != nil {
-			return errorRes(500, "Cannot use invitation", err)
+			return ctx.ErrorRes(500, "Cannot use invitation", err)
 		}
 	}
 
 	sess.Values["user"] = user.ID
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	return redirect(ctx, "/")
+	return ctx.RedirectTo("/")
 }
 
-func login(ctx echo.Context) error {
-	setData(ctx, "title", trH(ctx, "auth.login"))
-	setData(ctx, "htmlTitle", trH(ctx, "auth.login"))
-	setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
-	setData(ctx, "isLoginPage", true)
-	return html(ctx, "auth_form.html")
+func login(ctx *context.OGContext) error {
+	ctx.SetData("title", ctx.TrH("auth.login"))
+	ctx.SetData("htmlTitle", ctx.TrH("auth.login"))
+	ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
+	ctx.SetData("isLoginPage", true)
+	return ctx.HTML_("auth_form.html")
 }
 
-func processLogin(ctx echo.Context) error {
-	if getData(ctx, "DisableLoginForm") == true {
-		return errorRes(403, tr(ctx, "error.login-disabled-form"), nil)
+func processLogin(ctx *context.OGContext) error {
+	if ctx.GetData("DisableLoginForm") == true {
+		return ctx.ErrorRes(403, ctx.Tr("error.login-disabled-form"), nil)
 	}
 
 	var err error
-	sess := getSession(ctx)
+	sess := ctx.GetSession()
 
 	dto := &db.UserDTO{}
 	if err = ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
 	}
 	password := dto.Password
 
@@ -153,86 +153,86 @@ func processLogin(ctx echo.Context) error {
 
 	if user, err = db.GetUserByUsername(dto.Username); err != nil {
 		if !errors.Is(err, gorm.ErrRecordNotFound) {
-			return errorRes(500, "Cannot get user", err)
+			return ctx.ErrorRes(500, "Cannot get user", err)
 		}
 		log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
-		addFlash(ctx, tr(ctx, "flash.auth.invalid-credentials"), "error")
-		return redirect(ctx, "/login")
+		ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
+		return ctx.RedirectTo("/login")
 	}
 
 	if ok, err := utils.Argon2id.Verify(password, user.Password); !ok {
 		if err != nil {
-			return errorRes(500, "Cannot check for password", err)
+			return ctx.ErrorRes(500, "Cannot check for password", err)
 		}
 		log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
-		addFlash(ctx, tr(ctx, "flash.auth.invalid-credentials"), "error")
-		return redirect(ctx, "/login")
+		ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
+		return ctx.RedirectTo("/login")
 	}
 
 	// handle MFA
 	var hasWebauthn, hasTotp bool
 	if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
-		return errorRes(500, "Cannot check for user MFA", err)
+		return ctx.ErrorRes(500, "Cannot check for user MFA", err)
 	}
 	if hasWebauthn || hasTotp {
 		sess.Values["mfaID"] = user.ID
 		sess.Options.MaxAge = 5 * 60 // 5 minutes
-		saveSession(sess, ctx)
-		return redirect(ctx, "/mfa")
+		ctx.SaveSession(sess)
+		return ctx.RedirectTo("/mfa")
 	}
 
 	sess.Values["user"] = user.ID
 	sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
-	saveSession(sess, ctx)
-	deleteCsrfCookie(ctx)
+	ctx.SaveSession(sess)
+	ctx.DeleteCsrfCookie()
 
-	return redirect(ctx, "/")
+	return ctx.RedirectTo("/")
 }
 
-func mfa(ctx echo.Context) error {
+func mfa(ctx *context.OGContext) error {
 	var err error
 
-	user := db.User{ID: getSession(ctx).Values["mfaID"].(uint)}
+	user := db.User{ID: ctx.GetSession().Values["mfaID"].(uint)}
 
 	var hasWebauthn, hasTotp bool
 	if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
-		return errorRes(500, "Cannot check for user MFA", err)
+		return ctx.ErrorRes(500, "Cannot check for user MFA", err)
 	}
 
-	setData(ctx, "hasWebauthn", hasWebauthn)
-	setData(ctx, "hasTotp", hasTotp)
+	ctx.SetData("hasWebauthn", hasWebauthn)
+	ctx.SetData("hasTotp", hasTotp)
 
-	return html(ctx, "mfa.html")
+	return ctx.HTML_("mfa.html")
 }
 
-func oauthCallback(ctx echo.Context) error {
+func oauthCallback(ctx *context.OGContext) error {
 	user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
 	if err != nil {
-		return errorRes(400, tr(ctx, "error.complete-oauth-login", err.Error()), err)
+		return ctx.ErrorRes(400, ctx.Tr("error.complete-oauth-login", err.Error()), err)
 	}
 
-	currUser := getUserLogged(ctx)
+	currUser := ctx.User
 	if currUser != nil {
 		// if user is logged in, link account to user and update its avatar URL
 		updateUserProviderInfo(currUser, user.Provider, user)
 
 		if err = currUser.Update(); err != nil {
-			return errorRes(500, "Cannot update user "+cases.Title(language.English).String(user.Provider)+" id", err)
+			return ctx.ErrorRes(500, "Cannot update user "+cases.Title(language.English).String(user.Provider)+" id", err)
 		}
 
-		addFlash(ctx, tr(ctx, "flash.auth.account-linked-oauth", cases.Title(language.English).String(user.Provider)), "success")
-		return redirect(ctx, "/settings")
+		ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", cases.Title(language.English).String(user.Provider)), "success")
+		return ctx.RedirectTo("/settings")
 	}
 
 	// if user is not in database, create it
 	userDB, err := db.GetUserByProvider(user.UserID, user.Provider)
 	if err != nil {
-		if getData(ctx, "DisableSignup") == true {
-			return errorRes(403, tr(ctx, "error.signup-disabled"), nil)
+		if ctx.GetData("DisableSignup") == true {
+			return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled"), nil)
 		}
 
 		if !errors.Is(err, gorm.ErrRecordNotFound) {
-			return errorRes(500, "Cannot get user", err)
+			return ctx.ErrorRes(500, "Cannot get user", err)
 		}
 
 		if user.NickName == "" {
@@ -250,16 +250,16 @@ func oauthCallback(ctx echo.Context) error {
 
 		if err = userDB.Create(); err != nil {
 			if db.IsUniqueConstraintViolation(err) {
-				addFlash(ctx, tr(ctx, "flash.auth.username-exists"), "error")
-				return redirect(ctx, "/login")
+				ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
+				return ctx.RedirectTo("/login")
 			}
 
-			return errorRes(500, "Cannot create user", err)
+			return ctx.ErrorRes(500, "Cannot create user", err)
 		}
 
 		if userDB.ID == 1 {
 			if err = userDB.SetAdmin(); err != nil {
-				return errorRes(500, "Cannot set user admin", err)
+				return ctx.ErrorRes(500, "Cannot set user admin", err)
 			}
 		}
 
@@ -280,7 +280,7 @@ func oauthCallback(ctx echo.Context) error {
 
 			body, err := io.ReadAll(resp.Body)
 			if err != nil {
-				addFlash(ctx, tr(ctx, "flash.auth.user-sshkeys-not-retrievable"), "error")
+				ctx.AddFlash(ctx.Tr("flash.auth.user-sshkeys-not-retrievable"), "error")
 				log.Error().Err(err).Msg("Could not get user keys")
 			}
 
@@ -296,22 +296,22 @@ func oauthCallback(ctx echo.Context) error {
 				}
 
 				if err = sshKey.Create(); err != nil {
-					addFlash(ctx, tr(ctx, "flash.auth.user-sshkeys-not-created"), "error")
+					ctx.AddFlash(ctx.Tr("flash.auth.user-sshkeys-not-created"), "error")
 					log.Error().Err(err).Msg("Could not create ssh key")
 				}
 			}
 		}
 	}
 
-	sess := getSession(ctx)
+	sess := ctx.GetSession()
 	sess.Values["user"] = userDB.ID
-	saveSession(sess, ctx)
-	deleteCsrfCookie(ctx)
+	ctx.SaveSession(sess)
+	ctx.DeleteCsrfCookie()
 
-	return redirect(ctx, "/")
+	return ctx.RedirectTo("/")
 }
 
-func oauth(ctx echo.Context) error {
+func oauth(ctx *context.OGContext) error {
 	provider := ctx.Param("provider")
 
 	httpProtocol := "http"
@@ -385,26 +385,26 @@ func oauth(ctx echo.Context) error {
 		)
 
 		if err != nil {
-			return errorRes(500, "Cannot create OIDC provider", err)
+			return ctx.ErrorRes(500, "Cannot create OIDC provider", err)
 		}
 
 		goth.UseProviders(oidcProvider)
 	}
 
-	ctxValue := context.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, provider)
+	ctxValue := gocontext.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, provider)
 	ctx.SetRequest(ctx.Request().WithContext(ctxValue))
 	if provider != GitHubProvider && provider != GitLabProvider && provider != GiteaProvider && provider != OpenIDConnect {
-		return errorRes(400, tr(ctx, "error.oauth-unsupported"), nil)
+		return ctx.ErrorRes(400, ctx.Tr("error.oauth-unsupported"), nil)
 	}
 
 	gothic.BeginAuthHandler(ctx.Response(), ctx.Request())
 	return nil
 }
 
-func oauthUnlink(ctx echo.Context) error {
+func oauthUnlink(ctx *context.OGContext) error {
 	provider := ctx.Param("provider")
 
-	currUser := getUserLogged(ctx)
+	currUser := ctx.User
 	// Map each provider to a function that checks the relevant ID in currUser
 	providerIDCheckMap := map[string]func() bool{
 		GitHubProvider: func() bool { return currUser.GithubID != "" },
@@ -415,43 +415,43 @@ func oauthUnlink(ctx echo.Context) error {
 
 	if checkFunc, exists := providerIDCheckMap[provider]; exists && checkFunc() {
 		if err := currUser.DeleteProviderID(provider); err != nil {
-			return errorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(provider), err)
+			return ctx.ErrorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(provider), err)
 		}
 
-		addFlash(ctx, tr(ctx, "flash.auth.account-unlinked-oauth", cases.Title(language.English).String(provider)), "success")
-		return redirect(ctx, "/settings")
+		ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", cases.Title(language.English).String(provider)), "success")
+		return ctx.RedirectTo("/settings")
 	}
 
-	return redirect(ctx, "/settings")
+	return ctx.RedirectTo("/settings")
 }
 
-func beginWebAuthnBinding(ctx echo.Context) error {
-	credsCreation, jsonWaSession, err := webauthn.BeginBinding(getUserLogged(ctx))
+func beginWebAuthnBinding(ctx *context.OGContext) error {
+	credsCreation, jsonWaSession, err := webauthn.BeginBinding(ctx.User)
 	if err != nil {
-		return errorRes(500, "Cannot begin WebAuthn registration", err)
+		return ctx.ErrorRes(500, "Cannot begin WebAuthn registration", err)
 	}
 
-	sess := getSession(ctx)
+	sess := ctx.GetSession()
 	sess.Values["webauthn_registration_session"] = jsonWaSession
 	sess.Options.MaxAge = 5 * 60 // 5 minutes
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
 	return ctx.JSON(200, credsCreation)
 }
 
-func finishWebAuthnBinding(ctx echo.Context) error {
-	sess := getSession(ctx)
+func finishWebAuthnBinding(ctx *context.OGContext) error {
+	sess := ctx.GetSession()
 	jsonWaSession, ok := sess.Values["webauthn_registration_session"].([]byte)
 	if !ok {
-		return jsonErrorRes(401, "Cannot get WebAuthn registration session", nil)
+		return ctx.JsonErrorRes(401, "Cannot get WebAuthn registration session", nil)
 	}
 
-	user := getUserLogged(ctx)
+	user := ctx.User
 
 	// extract passkey name from request
 	body, err := io.ReadAll(ctx.Request().Body)
 	if err != nil {
-		return jsonErrorRes(400, "Failed to read request body", err)
+		return ctx.JsonErrorRes(400, "Failed to read request body", err)
 	}
 	ctx.Request().Body.Close()
 	ctx.Request().Body = io.NopCloser(bytes.NewBuffer(body))
@@ -460,7 +460,7 @@ func finishWebAuthnBinding(ctx echo.Context) error {
 	_ = gojson.Unmarshal(body, &dto)
 
 	if err = ctx.Validate(dto); err != nil {
-		return jsonErrorRes(400, "Invalid request", err)
+		return ctx.JsonErrorRes(400, "Invalid request", err)
 	}
 	passkeyName := dto.PasskeyName
 	if passkeyName == "" {
@@ -469,91 +469,91 @@ func finishWebAuthnBinding(ctx echo.Context) error {
 
 	waCredential, err := webauthn.FinishBinding(user, jsonWaSession, ctx.Request())
 	if err != nil {
-		return jsonErrorRes(403, "Failed binding attempt for passkey", err)
+		return ctx.JsonErrorRes(403, "Failed binding attempt for passkey", err)
 	}
 
 	if _, err = db.CreateFromCrendential(user.ID, passkeyName, waCredential); err != nil {
-		return jsonErrorRes(500, "Cannot create WebAuthn credential on database", err)
+		return ctx.JsonErrorRes(500, "Cannot create WebAuthn credential on database", err)
 	}
 
 	delete(sess.Values, "webauthn_registration_session")
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	addFlash(ctx, tr(ctx, "flash.auth.passkey-registred", passkeyName), "success")
-	return json(ctx, []string{"OK"})
+	ctx.AddFlash(ctx.Tr("flash.auth.passkey-registred", passkeyName), "success")
+	return ctx.JSON_([]string{"OK"})
 }
 
-func beginWebAuthnLogin(ctx echo.Context) error {
+func beginWebAuthnLogin(ctx *context.OGContext) error {
 	credsCreation, jsonWaSession, err := webauthn.BeginDiscoverableLogin()
 	if err != nil {
-		return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
+		return ctx.JsonErrorRes(401, "Cannot begin WebAuthn login", err)
 	}
 
-	sess := getSession(ctx)
+	sess := ctx.GetSession()
 	sess.Values["webauthn_login_session"] = jsonWaSession
 	sess.Options.MaxAge = 5 * 60 // 5 minutes
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	return json(ctx, credsCreation)
+	return ctx.JSON_(credsCreation)
 }
 
-func finishWebAuthnLogin(ctx echo.Context) error {
-	sess := getSession(ctx)
+func finishWebAuthnLogin(ctx *context.OGContext) error {
+	sess := ctx.GetSession()
 	sessionData, ok := sess.Values["webauthn_login_session"].([]byte)
 	if !ok {
-		return jsonErrorRes(401, "Cannot get WebAuthn login session", nil)
+		return ctx.JsonErrorRes(401, "Cannot get WebAuthn login session", nil)
 	}
 
 	userID, err := webauthn.FinishDiscoverableLogin(sessionData, ctx.Request())
 	if err != nil {
-		return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
+		return ctx.JsonErrorRes(403, "Failed authentication attempt for passkey", err)
 	}
 
 	sess.Values["user"] = userID
 	sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
 
 	delete(sess.Values, "webauthn_login_session")
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	return json(ctx, []string{"OK"})
+	return ctx.JSON_([]string{"OK"})
 }
 
-func beginWebAuthnAssertion(ctx echo.Context) error {
-	sess := getSession(ctx)
+func beginWebAuthnAssertion(ctx *context.OGContext) error {
+	sess := ctx.GetSession()
 
 	ogUser, err := db.GetUserById(sess.Values["mfaID"].(uint))
 	if err != nil {
-		return jsonErrorRes(500, "Cannot get user", err)
+		return ctx.JsonErrorRes(500, "Cannot get user", err)
 	}
 
 	credsCreation, jsonWaSession, err := webauthn.BeginLogin(ogUser)
 	if err != nil {
-		return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
+		return ctx.JsonErrorRes(401, "Cannot begin WebAuthn login", err)
 	}
 
 	sess.Values["webauthn_assertion_session"] = jsonWaSession
 	sess.Options.MaxAge = 5 * 60 // 5 minutes
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	return json(ctx, credsCreation)
+	return ctx.JSON_(credsCreation)
 }
 
-func finishWebAuthnAssertion(ctx echo.Context) error {
-	sess := getSession(ctx)
+func finishWebAuthnAssertion(ctx *context.OGContext) error {
+	sess := ctx.GetSession()
 	sessionData, ok := sess.Values["webauthn_assertion_session"].([]byte)
 	if !ok {
-		return jsonErrorRes(401, "Cannot get WebAuthn assertion session", nil)
+		return ctx.JsonErrorRes(401, "Cannot get WebAuthn assertion session", nil)
 	}
 
 	userId := sess.Values["mfaID"].(uint)
 
 	ogUser, err := db.GetUserById(userId)
 	if err != nil {
-		return jsonErrorRes(500, "Cannot get user", err)
+		return ctx.JsonErrorRes(500, "Cannot get user", err)
 	}
 
 	if err = webauthn.FinishLogin(ogUser, sessionData, ctx.Request()); err != nil {
-		return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
+		return ctx.JsonErrorRes(403, "Failed authentication attempt for passkey", err)
 	}
 
 	sess.Values["user"] = userId
@@ -561,184 +561,184 @@ func finishWebAuthnAssertion(ctx echo.Context) error {
 
 	delete(sess.Values, "webauthn_assertion_session")
 	delete(sess.Values, "mfaID")
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	return json(ctx, []string{"OK"})
+	return ctx.JSON_([]string{"OK"})
 }
 
-func beginTotp(ctx echo.Context) error {
-	user := getUserLogged(ctx)
+func beginTotp(ctx *context.OGContext) error {
+	user := ctx.User
 
 	if _, hasTotp, err := user.HasMFA(); err != nil {
-		return errorRes(500, "Cannot check for user MFA", err)
+		return ctx.ErrorRes(500, "Cannot check for user MFA", err)
 	} else if hasTotp {
-		addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error")
-		return redirect(ctx, "/settings")
+		ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
+		return ctx.RedirectTo("/settings")
 	}
 
-	ogUrl, err := url.Parse(getData(ctx, "baseHttpUrl").(string))
+	ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string))
 	if err != nil {
-		return errorRes(500, "Cannot parse base URL", err)
+		return ctx.ErrorRes(500, "Cannot parse base URL", err)
 	}
 
-	sess := getSession(ctx)
+	sess := ctx.GetSession()
 	generatedSecret, _ := sess.Values["generatedSecret"].([]byte)
 
-	totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(getUserLogged(ctx).Username, ogUrl.Hostname(), generatedSecret)
+	totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(ctx.User.Username, ogUrl.Hostname(), generatedSecret)
 	if err != nil {
-		return errorRes(500, "Cannot generate TOTP QR code", err)
+		return ctx.ErrorRes(500, "Cannot generate TOTP QR code", err)
 	}
 	sess.Values["totpSecret"] = totpSecret
 	sess.Values["generatedSecret"] = generatedSecret
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	setData(ctx, "totpSecret", totpSecret)
-	setData(ctx, "totpQrcode", qrcode)
+	ctx.SetData("totpSecret", totpSecret)
+	ctx.SetData("totpQrcode", qrcode)
 
-	return html(ctx, "totp.html")
+	return ctx.HTML_("totp.html")
 
 }
 
-func finishTotp(ctx echo.Context) error {
-	user := getUserLogged(ctx)
+func finishTotp(ctx *context.OGContext) error {
+	user := ctx.User
 
 	if _, hasTotp, err := user.HasMFA(); err != nil {
-		return errorRes(500, "Cannot check for user MFA", err)
+		return ctx.ErrorRes(500, "Cannot check for user MFA", err)
 	} else if hasTotp {
-		addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error")
-		return redirect(ctx, "/settings")
+		ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
+		return ctx.RedirectTo("/settings")
 	}
 
 	dto := &db.TOTPDTO{}
 	if err := ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
 	}
 
 	if err := ctx.Validate(dto); err != nil {
-		addFlash(ctx, "Invalid secret", "error")
-		return redirect(ctx, "/settings/totp/generate")
+		ctx.AddFlash("Invalid secret", "error")
+		return ctx.RedirectTo("/settings/totp/generate")
 	}
 
-	sess := getSession(ctx)
+	sess := ctx.GetSession()
 	secret, ok := sess.Values["totpSecret"].(string)
 	if !ok {
-		return errorRes(500, "Cannot get TOTP secret from session", nil)
+		return ctx.ErrorRes(500, "Cannot get TOTP secret from session", nil)
 	}
 
 	if !totp.Validate(dto.Code, secret) {
-		addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
+		ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error")
 
-		return redirect(ctx, "/settings/totp/generate")
+		return ctx.RedirectTo("/settings/totp/generate")
 	}
 
 	userTotp := &db.TOTP{
-		UserID: getUserLogged(ctx).ID,
+		UserID: ctx.User.ID,
 	}
 	if err := userTotp.StoreSecret(secret); err != nil {
-		return errorRes(500, "Cannot store TOTP secret", err)
+		return ctx.ErrorRes(500, "Cannot store TOTP secret", err)
 	}
 
 	if err := userTotp.Create(); err != nil {
-		return errorRes(500, "Cannot create TOTP", err)
+		return ctx.ErrorRes(500, "Cannot create TOTP", err)
 	}
 
-	addFlash(ctx, "TOTP successfully enabled", "success")
+	ctx.AddFlash("TOTP successfully enabled", "success")
 	codes, err := userTotp.GenerateRecoveryCodes()
 	if err != nil {
-		return errorRes(500, "Cannot generate recovery codes", err)
+		return ctx.ErrorRes(500, "Cannot generate recovery codes", err)
 	}
 
 	delete(sess.Values, "totpSecret")
 	delete(sess.Values, "generatedSecret")
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	setData(ctx, "recoveryCodes", codes)
-	return html(ctx, "totp.html")
+	ctx.SetData("recoveryCodes", codes)
+	return ctx.HTML_("totp.html")
 }
 
-func assertTotp(ctx echo.Context) error {
+func assertTotp(ctx *context.OGContext) error {
 	var err error
 	dto := &db.TOTPDTO{}
 	if err := ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
 	}
 
 	if err := ctx.Validate(dto); err != nil {
-		addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
-		return redirect(ctx, "/mfa")
+		ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error")
+		return ctx.RedirectTo("/mfa")
 	}
 
-	sess := getSession(ctx)
+	sess := ctx.GetSession()
 	userId := sess.Values["mfaID"].(uint)
 	var userTotp *db.TOTP
 	if userTotp, err = db.GetTOTPByUserID(userId); err != nil {
-		return errorRes(500, "Cannot get TOTP by UID", err)
+		return ctx.ErrorRes(500, "Cannot get TOTP by UID", err)
 	}
 
 	redirectUrl := "/"
 
 	var validCode, validRecoveryCode bool
 	if validCode, err = userTotp.ValidateCode(dto.Code); err != nil {
-		return errorRes(500, "Cannot validate TOTP code", err)
+		return ctx.ErrorRes(500, "Cannot validate TOTP code", err)
 	}
 	if !validCode {
 		validRecoveryCode, err = userTotp.ValidateRecoveryCode(dto.Code)
 		if err != nil {
-			return errorRes(500, "Cannot validate TOTP code", err)
+			return ctx.ErrorRes(500, "Cannot validate TOTP code", err)
 		}
 
 		if !validRecoveryCode {
-			addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
-			return redirect(ctx, "/mfa")
+			ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error")
+			return ctx.RedirectTo("/mfa")
 		}
 
-		addFlash(ctx, tr(ctx, "auth.totp.code-used", dto.Code), "warning")
+		ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning")
 		redirectUrl = "/settings"
 	}
 
 	sess.Values["user"] = userId
 	sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
 	delete(sess.Values, "mfaID")
-	saveSession(sess, ctx)
+	ctx.SaveSession(sess)
 
-	return redirect(ctx, redirectUrl)
+	return ctx.RedirectTo(redirectUrl)
 }
 
-func disableTotp(ctx echo.Context) error {
-	user := getUserLogged(ctx)
+func disableTotp(ctx *context.OGContext) error {
+	user := ctx.User
 	userTotp, err := db.GetTOTPByUserID(user.ID)
 	if err != nil {
-		return errorRes(500, "Cannot get TOTP by UID", err)
+		return ctx.ErrorRes(500, "Cannot get TOTP by UID", err)
 	}
 
 	if err = userTotp.Delete(); err != nil {
-		return errorRes(500, "Cannot delete TOTP", err)
+		return ctx.ErrorRes(500, "Cannot delete TOTP", err)
 	}
 
-	addFlash(ctx, tr(ctx, "auth.totp.disabled"), "success")
-	return redirect(ctx, "/settings")
+	ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success")
+	return ctx.RedirectTo("/settings")
 }
 
-func regenerateTotpRecoveryCodes(ctx echo.Context) error {
-	user := getUserLogged(ctx)
+func regenerateTotpRecoveryCodes(ctx *context.OGContext) error {
+	user := ctx.User
 	userTotp, err := db.GetTOTPByUserID(user.ID)
 	if err != nil {
-		return errorRes(500, "Cannot get TOTP by UID", err)
+		return ctx.ErrorRes(500, "Cannot get TOTP by UID", err)
 	}
 
 	codes, err := userTotp.GenerateRecoveryCodes()
 	if err != nil {
-		return errorRes(500, "Cannot generate recovery codes", err)
+		return ctx.ErrorRes(500, "Cannot generate recovery codes", err)
 	}
 
-	setData(ctx, "recoveryCodes", codes)
-	return html(ctx, "totp.html")
+	ctx.SetData("recoveryCodes", codes)
+	return ctx.HTML_("totp.html")
 }
 
-func logout(ctx echo.Context) error {
-	deleteSession(ctx)
-	deleteCsrfCookie(ctx)
-	return redirect(ctx, "/all")
+func logout(ctx *context.OGContext) error {
+	ctx.DeleteSession()
+	ctx.DeleteCsrfCookie()
+	return ctx.RedirectTo("/all")
 }
 
 func urlJoin(base string, elem ...string) string {
@@ -803,13 +803,13 @@ func getAvatarUrlFromProvider(provider string, identifier string) string {
 }
 
 type ContextAuthInfo struct {
-	context echo.Context
+	context *context.OGContext
 }
 
 func (auth ContextAuthInfo) RequireLogin() (bool, error) {
-	return getData(auth.context, "RequireLogin") == true, nil
+	return auth.context.GetData("RequireLogin") == true, nil
 }
 
 func (auth ContextAuthInfo) AllowGistsWithoutLogin() (bool, error) {
-	return getData(auth.context, "AllowGistsWithoutLogin") == true, nil
+	return auth.context.GetData("AllowGistsWithoutLogin") == true, nil
 }
diff --git a/internal/web/gist.go b/internal/web/handler/gist.go
similarity index 53%
rename from internal/web/gist.go
rename to internal/web/handler/gist.go
index f7d3564..8ec6334 100644
--- a/internal/web/gist.go
+++ b/internal/web/handler/gist.go
@@ -1,4 +1,4 @@
-package web
+package handler
 
 import (
 	"archive/zip"
@@ -7,6 +7,8 @@ import (
 	gojson "encoding/json"
 	"errors"
 	"fmt"
+	"github.com/thomiceli/opengist/internal/web/context"
+	"github.com/thomiceli/opengist/internal/web/server"
 	"html/template"
 	"net/url"
 	"path/filepath"
@@ -29,37 +31,37 @@ import (
 	"gorm.io/gorm"
 )
 
-func gistInit(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		currUser := getUserLogged(ctx)
+func GistInit(next context.Handler) context.Handler {
+	return func(ctx *context.OGContext) error {
+		currUser := ctx.User
 
 		userName := ctx.Param("user")
 		gistName := ctx.Param("gistname")
 
 		switch filepath.Ext(gistName) {
 		case ".js":
-			setData(ctx, "gistpage", "js")
+			ctx.SetData("gistpage", "js")
 			gistName = strings.TrimSuffix(gistName, ".js")
 		case ".json":
-			setData(ctx, "gistpage", "json")
+			ctx.SetData("gistpage", "json")
 			gistName = strings.TrimSuffix(gistName, ".json")
 		case ".git":
-			setData(ctx, "gistpage", "git")
+			ctx.SetData("gistpage", "git")
 			gistName = strings.TrimSuffix(gistName, ".git")
 		}
 
 		gist, err := db.GetGist(userName, gistName)
 		if err != nil {
-			return notFound("Gist not found")
+			return ctx.NotFound("Gist not found")
 		}
 
 		if gist.Private == db.PrivateVisibility {
 			if currUser == nil || currUser.ID != gist.UserID {
-				return notFound("Gist not found")
+				return ctx.NotFound("Gist not found")
 			}
 		}
 
-		setData(ctx, "gist", gist)
+		ctx.SetData("gist", gist)
 
 		if config.C.SshGit {
 			var sshDomain string
@@ -71,93 +73,93 @@ func gistInit(next echo.HandlerFunc) echo.HandlerFunc {
 			}
 
 			if config.C.SshPort == "22" {
-				setData(ctx, "sshCloneUrl", sshDomain+":"+userName+"/"+gistName+".git")
+				ctx.SetData("sshCloneUrl", sshDomain+":"+userName+"/"+gistName+".git")
 			} else {
-				setData(ctx, "sshCloneUrl", "ssh://"+sshDomain+":"+config.C.SshPort+"/"+userName+"/"+gistName+".git")
+				ctx.SetData("sshCloneUrl", "ssh://"+sshDomain+":"+config.C.SshPort+"/"+userName+"/"+gistName+".git")
 			}
 		}
 
-		baseHttpUrl := getData(ctx, "baseHttpUrl").(string)
+		baseHttpUrl := ctx.GetData("baseHttpUrl").(string)
 
 		if config.C.HttpGit {
-			setData(ctx, "httpCloneUrl", baseHttpUrl+"/"+userName+"/"+gistName+".git")
+			ctx.SetData("httpCloneUrl", baseHttpUrl+"/"+userName+"/"+gistName+".git")
 		}
 
-		setData(ctx, "httpCopyUrl", baseHttpUrl+"/"+userName+"/"+gistName)
-		setData(ctx, "currentUrl", template.URL(ctx.Request().URL.Path))
-		setData(ctx, "embedScript", fmt.Sprintf(`<script src="%s"></script>`, baseHttpUrl+"/"+userName+"/"+gistName+".js"))
+		ctx.SetData("httpCopyUrl", baseHttpUrl+"/"+userName+"/"+gistName)
+		ctx.SetData("currentUrl", template.URL(ctx.Request().URL.Path))
+		ctx.SetData("embedScript", fmt.Sprintf(`<script src="%s"></script>`, baseHttpUrl+"/"+userName+"/"+gistName+".js"))
 
 		nbCommits, err := gist.NbCommits()
 		if err != nil {
-			return errorRes(500, "Error fetching number of commits", err)
+			return ctx.ErrorRes(500, "Error fetching number of commits", err)
 		}
-		setData(ctx, "nbCommits", nbCommits)
+		ctx.SetData("nbCommits", nbCommits)
 
 		if currUser != nil {
 			hasLiked, err := currUser.HasLiked(gist)
 			if err != nil {
-				return errorRes(500, "Cannot get user like status", err)
+				return ctx.ErrorRes(500, "Cannot get user like status", err)
 			}
-			setData(ctx, "hasLiked", hasLiked)
+			ctx.SetData("hasLiked", hasLiked)
 		}
 
 		if gist.Private > 0 {
-			setData(ctx, "NoIndex", true)
+			ctx.SetData("NoIndex", true)
 		}
 
 		return next(ctx)
 	}
 }
 
-// gistSoftInit try to load a gist (same as gistInit) but does not return a 404 if the gist is not found
+// GistSoftInit try to load a gist (same as gistInit) but does not return a 404 if the gist is not found
 // useful for git clients using HTTP to obfuscate the existence of a private gist
-func gistSoftInit(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
+func GistSoftInit(next echo.HandlerFunc) context.Handler {
+	return func(ctx *context.OGContext) error {
 		userName := ctx.Param("user")
 		gistName := ctx.Param("gistname")
 
 		gistName = strings.TrimSuffix(gistName, ".git")
 
 		gist, _ := db.GetGist(userName, gistName)
-		setData(ctx, "gist", gist)
+		ctx.SetData("gist", gist)
 
 		return next(ctx)
 	}
 }
 
-// gistNewPushInit has the same behavior as gistSoftInit but create a new gist empty instead
-func gistNewPushSoftInit(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(c echo.Context) error {
-		setData(c, "gist", new(db.Gist))
-		return next(c)
+// GistNewPushSoftInit has the same behavior as gistSoftInit but create a new gist empty instead
+func GistNewPushSoftInit(next context.Handler) context.Handler {
+	return func(ctx *context.OGContext) error {
+		ctx.SetData("gist", new(db.Gist))
+		return next(ctx)
 	}
 }
 
-func allGists(ctx echo.Context) error {
+func AllGists(ctx *context.OGContext) error {
 	var err error
 	var urlPage string
 
 	fromUserStr := ctx.Param("user")
-	userLogged := getUserLogged(ctx)
+	userLogged := ctx.User
 	pageInt := getPage(ctx)
 
 	sort := "created"
-	sortText := trH(ctx, "gist.list.sort-by-created")
+	sortText := ctx.TrH("gist.list.sort-by-created")
 	order := "desc"
-	orderText := trH(ctx, "gist.list.order-by-desc")
+	orderText := ctx.TrH("gist.list.order-by-desc")
 
 	if ctx.QueryParam("sort") == "updated" {
 		sort = "updated"
-		sortText = trH(ctx, "gist.list.sort-by-updated")
+		sortText = ctx.TrH("gist.list.sort-by-updated")
 	}
 
 	if ctx.QueryParam("order") == "asc" {
 		order = "asc"
-		orderText = trH(ctx, "gist.list.order-by-asc")
+		orderText = ctx.TrH("gist.list.order-by-asc")
 	}
 
-	setData(ctx, "sort", sortText)
-	setData(ctx, "order", orderText)
+	ctx.SetData("sort", sortText)
+	ctx.SetData("order", orderText)
 
 	var gists []*db.Gist
 	var currentUserId uint
@@ -170,15 +172,15 @@ func allGists(ctx echo.Context) error {
 	if fromUserStr == "" {
 		urlctx := ctx.Request().URL.Path
 		if strings.HasSuffix(urlctx, "search") {
-			setData(ctx, "htmlTitle", trH(ctx, "gist.list.search-results"))
-			setData(ctx, "mode", "search")
-			setData(ctx, "searchQuery", ctx.QueryParam("q"))
-			setData(ctx, "searchQueryUrl", template.URL("&q="+ctx.QueryParam("q")))
+			ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
+			ctx.SetData("mode", "search")
+			ctx.SetData("searchQuery", ctx.QueryParam("q"))
+			ctx.SetData("searchQueryUrl", template.URL("&q="+ctx.QueryParam("q")))
 			urlPage = "search"
 			gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order)
 		} else if strings.HasSuffix(urlctx, "all") {
-			setData(ctx, "htmlTitle", trH(ctx, "gist.list.all"))
-			setData(ctx, "mode", "all")
+			ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
+			ctx.SetData("mode", "all")
 			urlPage = "all"
 			gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
 		}
@@ -188,12 +190,12 @@ func allGists(ctx echo.Context) error {
 
 		liked, err = regexp.MatchString(`/[^/]*/liked`, ctx.Request().URL.Path)
 		if err != nil {
-			return errorRes(500, "Error matching regexp", err)
+			return ctx.ErrorRes(500, "Error matching regexp", err)
 		}
 
 		forked, err = regexp.MatchString(`/[^/]*/forked`, ctx.Request().URL.Path)
 		if err != nil {
-			return errorRes(500, "Error matching regexp", err)
+			return ctx.ErrorRes(500, "Error matching regexp", err)
 		}
 
 		var fromUser *db.User
@@ -201,44 +203,44 @@ func allGists(ctx echo.Context) error {
 		fromUser, err = db.GetUserByUsername(fromUserStr)
 		if err != nil {
 			if errors.Is(err, gorm.ErrRecordNotFound) {
-				return notFound("User not found")
+				return ctx.NotFound("User not found")
 			}
-			return errorRes(500, "Error fetching user", err)
+			return ctx.ErrorRes(500, "Error fetching user", err)
 		}
-		setData(ctx, "fromUser", fromUser)
+		ctx.SetData("fromUser", fromUser)
 
 		if countFromUser, err := db.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil {
-			return errorRes(500, "Error counting gists", err)
+			return ctx.ErrorRes(500, "Error counting gists", err)
 		} else {
-			setData(ctx, "countFromUser", countFromUser)
+			ctx.SetData("countFromUser", countFromUser)
 		}
 
 		if countLiked, err := db.CountAllGistsLikedByUser(fromUser.ID, currentUserId); err != nil {
-			return errorRes(500, "Error counting liked gists", err)
+			return ctx.ErrorRes(500, "Error counting liked gists", err)
 		} else {
-			setData(ctx, "countLiked", countLiked)
+			ctx.SetData("countLiked", countLiked)
 		}
 
 		if countForked, err := db.CountAllGistsForkedByUser(fromUser.ID, currentUserId); err != nil {
-			return errorRes(500, "Error counting forked gists", err)
+			return ctx.ErrorRes(500, "Error counting forked gists", err)
 		} else {
-			setData(ctx, "countForked", countForked)
+			ctx.SetData("countForked", countForked)
 		}
 
 		if liked {
 			urlPage = fromUserStr + "/liked"
-			setData(ctx, "htmlTitle", trH(ctx, "gist.list.all-liked-by", fromUserStr))
-			setData(ctx, "mode", "liked")
+			ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-liked-by", fromUserStr))
+			ctx.SetData("mode", "liked")
 			gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
 		} else if forked {
 			urlPage = fromUserStr + "/forked"
-			setData(ctx, "htmlTitle", trH(ctx, "gist.list.all-forked-by", fromUserStr))
-			setData(ctx, "mode", "forked")
+			ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-forked-by", fromUserStr))
+			ctx.SetData("mode", "forked")
 			gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
 		} else {
 			urlPage = fromUserStr
-			setData(ctx, "htmlTitle", trH(ctx, "gist.list.all-from", fromUserStr))
-			setData(ctx, "mode", "fromUser")
+			ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-from", fromUserStr))
+			ctx.SetData("mode", "fromUser")
 			gists, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
 		}
 	}
@@ -253,25 +255,25 @@ func allGists(ctx echo.Context) error {
 	}
 
 	if err != nil {
-		return errorRes(500, "Error fetching gists", err)
+		return ctx.ErrorRes(500, "Error fetching gists", err)
 	}
 
 	if err = paginate(ctx, renderedGists, pageInt, 10, "gists", fromUserStr, 2, "&sort="+sort+"&order="+order); err != nil {
-		return errorRes(404, tr(ctx, "error.page-not-found"), nil)
+		return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
 	}
 
-	setData(ctx, "urlPage", urlPage)
-	return html(ctx, "all.html")
+	ctx.SetData("urlPage", urlPage)
+	return ctx.HTML_("all.html")
 }
 
-func search(ctx echo.Context) error {
+func Search(ctx *context.OGContext) error {
 	var err error
 
-	content, meta := parseSearchQueryStr(ctx.QueryParam("q"))
+	content, meta := ParseSearchQueryStr(ctx.QueryParam("q"))
 	pageInt := getPage(ctx)
 
 	var currentUserId uint
-	userLogged := getUserLogged(ctx)
+	userLogged := ctx.User
 	if userLogged != nil {
 		currentUserId = userLogged.ID
 	} else {
@@ -281,7 +283,7 @@ func search(ctx echo.Context) error {
 	var visibleGistsIds []uint
 	visibleGistsIds, err = db.GetAllGistsVisibleByUser(currentUserId)
 	if err != nil {
-		return errorRes(500, "Error fetching gists", err)
+		return ctx.ErrorRes(500, "Error fetching gists", err)
 	}
 
 	gistsIds, nbHits, langs, err := index.SearchGists(content, index.SearchGistMetadata{
@@ -292,12 +294,12 @@ func search(ctx echo.Context) error {
 		Language:  meta["language"],
 	}, visibleGistsIds, pageInt)
 	if err != nil {
-		return errorRes(500, "Error searching gists", err)
+		return ctx.ErrorRes(500, "Error searching gists", err)
 	}
 
 	gists, err := db.GetAllGistsByIds(gistsIds)
 	if err != nil {
-		return errorRes(500, "Error fetching gists", err)
+		return ctx.ErrorRes(500, "Error fetching gists", err)
 	}
 
 	renderedGists := make([]*render.RenderedGist, 0, len(gists))
@@ -310,31 +312,31 @@ func search(ctx echo.Context) error {
 	}
 
 	if pageInt > 1 && len(renderedGists) != 0 {
-		setData(ctx, "prevPage", pageInt-1)
+		ctx.SetData("prevPage", pageInt-1)
 	}
 	if 10*pageInt < int(nbHits) {
-		setData(ctx, "nextPage", pageInt+1)
+		ctx.SetData("nextPage", pageInt+1)
 	}
-	setData(ctx, "prevLabel", trH(ctx, "pagination.previous"))
-	setData(ctx, "nextLabel", trH(ctx, "pagination.next"))
-	setData(ctx, "urlPage", "search")
-	setData(ctx, "urlParams", template.URL("&q="+ctx.QueryParam("q")))
-	setData(ctx, "htmlTitle", trH(ctx, "gist.list.search-results"))
-	setData(ctx, "nbHits", nbHits)
-	setData(ctx, "gists", renderedGists)
-	setData(ctx, "langs", langs)
-	setData(ctx, "searchQuery", ctx.QueryParam("q"))
-	return html(ctx, "search.html")
+	ctx.SetData("prevLabel", ctx.TrH("pagination.previous"))
+	ctx.SetData("nextLabel", ctx.TrH("pagination.next"))
+	ctx.SetData("urlPage", "search")
+	ctx.SetData("urlParams", template.URL("&q="+ctx.QueryParam("q")))
+	ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
+	ctx.SetData("nbHits", nbHits)
+	ctx.SetData("gists", renderedGists)
+	ctx.SetData("langs", langs)
+	ctx.SetData("searchQuery", ctx.QueryParam("q"))
+	return ctx.HTML_("search.html")
 }
 
-func gistIndex(ctx echo.Context) error {
-	if getData(ctx, "gistpage") == "js" {
-		return gistJs(ctx)
-	} else if getData(ctx, "gistpage") == "json" {
-		return gistJson(ctx)
+func GistIndex(ctx *context.OGContext) error {
+	if ctx.GetData("gistpage") == "js" {
+		return GistJs(ctx)
+	} else if ctx.GetData("gistpage") == "json" {
+		return GistJson(ctx)
 	}
 
-	gist := getData(ctx, "gist").(*db.Gist)
+	gist := ctx.GetData("gist").(*db.Gist)
 	revision := ctx.Param("revision")
 
 	if revision == "" {
@@ -343,46 +345,46 @@ func gistIndex(ctx echo.Context) error {
 
 	files, err := gist.Files(revision, true)
 	if _, ok := err.(*git.RevisionNotFoundError); ok {
-		return notFound("Revision not found")
+		return ctx.NotFound("Revision not found")
 	} else if err != nil {
-		return errorRes(500, "Error fetching files", err)
+		return ctx.ErrorRes(500, "Error fetching files", err)
 	}
 
 	renderedFiles := render.HighlightFiles(files)
 
-	setData(ctx, "page", "code")
-	setData(ctx, "commit", revision)
-	setData(ctx, "files", renderedFiles)
-	setData(ctx, "revision", revision)
-	setData(ctx, "htmlTitle", gist.Title)
-	return html(ctx, "gist.html")
+	ctx.SetData("page", "code")
+	ctx.SetData("commit", revision)
+	ctx.SetData("files", renderedFiles)
+	ctx.SetData("revision", revision)
+	ctx.SetData("htmlTitle", gist.Title)
+	return ctx.HTML_("gist.html")
 }
 
-func gistJson(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func GistJson(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 	files, err := gist.Files("HEAD", true)
 	if err != nil {
-		return errorRes(500, "Error fetching files", err)
+		return ctx.ErrorRes(500, "Error fetching files", err)
 	}
 
 	renderedFiles := render.HighlightFiles(files)
-	setData(ctx, "files", renderedFiles)
+	ctx.SetData("files", renderedFiles)
 
 	htmlbuf := bytes.Buffer{}
 	w := bufio.NewWriter(&htmlbuf)
-	if err = ctx.Echo().Renderer.Render(w, "gist_embed.html", dataMap(ctx), ctx); err != nil {
+	if err = ctx.Echo().Renderer.Render(w, "gist_embed.html", ctx.DataMap(), ctx); err != nil {
 		return err
 	}
 	_ = w.Flush()
 
-	jsUrl, err := url.JoinPath(getData(ctx, "baseHttpUrl").(string), gist.User.Username, gist.Identifier()+".js")
+	jsUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), gist.User.Username, gist.Identifier()+".js")
 	if err != nil {
-		return errorRes(500, "Error joining js url", err)
+		return ctx.ErrorRes(500, "Error joining js url", err)
 	}
 
-	cssUrl, err := url.JoinPath(getData(ctx, "baseHttpUrl").(string), manifestEntries["embed.css"].File)
+	cssUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), server.ManifestEntries["embed.css"].File)
 	if err != nil {
-		return errorRes(500, "Error joining css url", err)
+		return ctx.ErrorRes(500, "Error joining css url", err)
 	}
 
 	return ctx.JSON(200, map[string]interface{}{
@@ -403,42 +405,42 @@ func gistJson(ctx echo.Context) error {
 	})
 }
 
-func gistJs(ctx echo.Context) error {
+func GistJs(ctx *context.OGContext) error {
 	if _, exists := ctx.QueryParams()["dark"]; exists {
-		setData(ctx, "dark", "dark")
+		ctx.SetData("dark", "dark")
 	}
 
-	gist := getData(ctx, "gist").(*db.Gist)
+	gist := ctx.GetData("gist").(*db.Gist)
 	files, err := gist.Files("HEAD", true)
 	if err != nil {
-		return errorRes(500, "Error fetching files", err)
+		return ctx.ErrorRes(500, "Error fetching files", err)
 	}
 
 	renderedFiles := render.HighlightFiles(files)
-	setData(ctx, "files", renderedFiles)
+	ctx.SetData("files", renderedFiles)
 
 	htmlbuf := bytes.Buffer{}
 	w := bufio.NewWriter(&htmlbuf)
-	if err = ctx.Echo().Renderer.Render(w, "gist_embed.html", dataMap(ctx), ctx); err != nil {
+	if err = ctx.Echo().Renderer.Render(w, "gist_embed.html", ctx.DataMap(), ctx); err != nil {
 		return err
 	}
 	_ = w.Flush()
 
-	cssUrl, err := url.JoinPath(getData(ctx, "baseHttpUrl").(string), manifestEntries["embed.css"].File)
+	cssUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), server.ManifestEntries["embed.css"].File)
 	if err != nil {
-		return errorRes(500, "Error joining css url", err)
+		return ctx.ErrorRes(500, "Error joining css url", err)
 	}
 
 	js, err := escapeJavaScriptContent(htmlbuf.String(), cssUrl)
 	if err != nil {
-		return errorRes(500, "Error escaping JavaScript content", err)
+		return ctx.ErrorRes(500, "Error escaping JavaScript content", err)
 	}
 	ctx.Response().Header().Set("Content-Type", "application/javascript")
-	return plainText(ctx, 200, js)
+	return ctx.PlainText(200, js)
 }
 
-func revisions(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func Revisions(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 	userName := gist.User.Username
 	gistName := gist.Identifier()
 
@@ -446,11 +448,11 @@ func revisions(ctx echo.Context) error {
 
 	commits, err := gist.Log((pageInt - 1) * 10)
 	if err != nil {
-		return errorRes(500, "Error fetching commits log", err)
+		return ctx.ErrorRes(500, "Error fetching commits log", err)
 	}
 
 	if err := paginate(ctx, commits, pageInt, 10, "commits", userName+"/"+gistName+"/revisions", 2); err != nil {
-		return errorRes(404, tr(ctx, "error.page-not-found"), nil)
+		return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
 	}
 
 	emailsSet := map[string]struct{}{}
@@ -463,23 +465,23 @@ func revisions(ctx echo.Context) error {
 
 	emailsUsers, err := db.GetUsersFromEmails(emailsSet)
 	if err != nil {
-		return errorRes(500, "Error fetching users emails", err)
+		return ctx.ErrorRes(500, "Error fetching users emails", err)
 	}
 
-	setData(ctx, "page", "revisions")
-	setData(ctx, "revision", "HEAD")
-	setData(ctx, "emails", emailsUsers)
-	setData(ctx, "htmlTitle", trH(ctx, "gist.revision-of", gist.Title))
+	ctx.SetData("page", "revisions")
+	ctx.SetData("revision", "HEAD")
+	ctx.SetData("emails", emailsUsers)
+	ctx.SetData("htmlTitle", ctx.TrH("gist.revision-of", gist.Title))
 
-	return html(ctx, "revisions.html")
+	return ctx.HTML_("revisions.html")
 }
 
-func create(ctx echo.Context) error {
-	setData(ctx, "htmlTitle", trH(ctx, "gist.new.create-a-new-gist"))
-	return html(ctx, "create.html")
+func Create(ctx *context.OGContext) error {
+	ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist"))
+	return ctx.HTML_("create.html")
 }
 
-func processCreate(ctx echo.Context) error {
+func ProcessCreate(ctx *context.OGContext) error {
 	isCreate := false
 	if ctx.Request().URL.Path == "/" {
 		isCreate = true
@@ -487,21 +489,21 @@ func processCreate(ctx echo.Context) error {
 
 	err := ctx.Request().ParseForm()
 	if err != nil {
-		return errorRes(400, tr(ctx, "error.bad-request"), err)
+		return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), err)
 	}
 
 	dto := new(db.GistDTO)
 	var gist *db.Gist
 
 	if isCreate {
-		setData(ctx, "htmlTitle", trH(ctx, "gist.new.create-a-new-gist"))
+		ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist"))
 	} else {
-		gist = getData(ctx, "gist").(*db.Gist)
-		setData(ctx, "htmlTitle", trH(ctx, "gist.edit.edit-gist", gist.Title))
+		gist = ctx.GetData("gist").(*db.Gist)
+		ctx.SetData("htmlTitle", ctx.TrH("gist.edit.edit-gist", gist.Title))
 	}
 
 	if err := ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
 	}
 
 	dto.Files = make([]db.FileDTO, 0)
@@ -517,7 +519,7 @@ func processCreate(ctx echo.Context) error {
 
 		escapedValue, err := url.QueryUnescape(content)
 		if err != nil {
-			return errorRes(400, tr(ctx, "error.invalid-character-unescaped"), err)
+			return ctx.ErrorRes(400, ctx.Tr("error.invalid-character-unescaped"), err)
 		}
 
 		dto.Files = append(dto.Files, db.FileDTO{
@@ -528,16 +530,16 @@ func processCreate(ctx echo.Context) error {
 
 	err = ctx.Validate(dto)
 	if err != nil {
-		addFlash(ctx, utils.ValidationMessages(&err, getData(ctx, "locale").(*i18n.Locale)), "error")
+		ctx.AddFlash(utils.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
 		if isCreate {
-			return html(ctx, "create.html")
+			return ctx.HTML_("create.html")
 		} else {
 			files, err := gist.Files("HEAD", false)
 			if err != nil {
-				return errorRes(500, "Error fetching files", err)
+				return ctx.ErrorRes(500, "Error fetching files", err)
 			}
-			setData(ctx, "files", files)
-			return html(ctx, "edit.html")
+			ctx.SetData("files", files)
+			return ctx.HTML_("edit.html")
 		}
 	}
 
@@ -547,13 +549,13 @@ func processCreate(ctx echo.Context) error {
 		gist = dto.ToExistingGist(gist)
 	}
 
-	user := getUserLogged(ctx)
+	user := ctx.User
 	gist.NbFiles = len(dto.Files)
 
 	if isCreate {
 		uuidGist, err := uuid.NewRandom()
 		if err != nil {
-			return errorRes(500, "Error creating an UUID", err)
+			return ctx.ErrorRes(500, "Error creating an UUID", err)
 		}
 		gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
 
@@ -581,104 +583,104 @@ func processCreate(ctx echo.Context) error {
 	}
 
 	if err = gist.InitRepository(); err != nil {
-		return errorRes(500, "Error creating the repository", err)
+		return ctx.ErrorRes(500, "Error creating the repository", err)
 	}
 
 	if err = gist.AddAndCommitFiles(&dto.Files); err != nil {
-		return errorRes(500, "Error adding and committing files", err)
+		return ctx.ErrorRes(500, "Error adding and committing files", err)
 	}
 
 	if isCreate {
 		if err = gist.Create(); err != nil {
-			return errorRes(500, "Error creating the gist", err)
+			return ctx.ErrorRes(500, "Error creating the gist", err)
 		}
 	} else {
 		if err = gist.Update(); err != nil {
-			return errorRes(500, "Error updating the gist", err)
+			return ctx.ErrorRes(500, "Error updating the gist", err)
 		}
 	}
 
 	gist.AddInIndex()
 
-	return redirect(ctx, "/"+user.Username+"/"+gist.Identifier())
+	return ctx.RedirectTo("/" + user.Username + "/" + gist.Identifier())
 }
 
-func editVisibility(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func EditVisibility(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 
 	dto := new(db.VisibilityDTO)
 	if err := ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
 	}
 
 	gist.Private = dto.Private
 	if err := gist.UpdateNoTimestamps(); err != nil {
-		return errorRes(500, "Error updating this gist", err)
+		return ctx.ErrorRes(500, "Error updating this gist", err)
 	}
 
-	addFlash(ctx, tr(ctx, "flash.gist.visibility-changed"), "success")
-	return redirect(ctx, "/"+gist.User.Username+"/"+gist.Identifier())
+	ctx.AddFlash(ctx.Tr("flash.gist.visibility-changed"), "success")
+	return ctx.RedirectTo("/" + gist.User.Username + "/" + gist.Identifier())
 }
 
-func deleteGist(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func DeleteGist(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 
 	if err := gist.Delete(); err != nil {
-		return errorRes(500, "Error deleting this gist", err)
+		return ctx.ErrorRes(500, "Error deleting this gist", err)
 	}
 	gist.RemoveFromIndex()
 
-	addFlash(ctx, tr(ctx, "flash.gist.deleted"), "success")
-	return redirect(ctx, "/")
+	ctx.AddFlash(ctx.Tr("flash.gist.deleted"), "success")
+	return ctx.RedirectTo("/")
 }
 
-func like(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
-	currentUser := getUserLogged(ctx)
+func Like(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
+	currentUser := ctx.User
 
 	hasLiked, err := currentUser.HasLiked(gist)
 	if err != nil {
-		return errorRes(500, "Error checking if user has liked a gist", err)
+		return ctx.ErrorRes(500, "Error checking if user has liked a gist", err)
 	}
 
 	if hasLiked {
-		err = gist.RemoveUserLike(getUserLogged(ctx))
+		err = gist.RemoveUserLike(ctx.User)
 	} else {
-		err = gist.AppendUserLike(getUserLogged(ctx))
+		err = gist.AppendUserLike(ctx.User)
 	}
 
 	if err != nil {
-		return errorRes(500, "Error liking/dislking this gist", err)
+		return ctx.ErrorRes(500, "Error liking/dislking this gist", err)
 	}
 
 	redirectTo := "/" + gist.User.Username + "/" + gist.Identifier()
 	if r := ctx.QueryParam("redirecturl"); r != "" {
 		redirectTo = r
 	}
-	return redirect(ctx, redirectTo)
+	return ctx.RedirectTo(redirectTo)
 }
 
-func fork(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
-	currentUser := getUserLogged(ctx)
+func Fork(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
+	currentUser := ctx.User
 
 	alreadyForked, err := gist.GetForkParent(currentUser)
 	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
-		return errorRes(500, "Error checking if gist is already forked", err)
+		return ctx.ErrorRes(500, "Error checking if gist is already forked", err)
 	}
 
 	if gist.User.ID == currentUser.ID {
-		addFlash(ctx, tr(ctx, "flash.gist.fork-own-gist"), "error")
-		return redirect(ctx, "/"+gist.User.Username+"/"+gist.Identifier())
+		ctx.AddFlash(ctx.Tr("flash.gist.fork-own-gist"), "error")
+		return ctx.RedirectTo("/" + gist.User.Username + "/" + gist.Identifier())
 	}
 
 	if alreadyForked.ID != 0 {
-		return redirect(ctx, "/"+alreadyForked.User.Username+"/"+alreadyForked.Identifier())
+		return ctx.RedirectTo("/" + alreadyForked.User.Username + "/" + alreadyForked.Identifier())
 	}
 
 	uuidGist, err := uuid.NewRandom()
 	if err != nil {
-		return errorRes(500, "Error creating an UUID", err)
+		return ctx.ErrorRes(500, "Error creating an UUID", err)
 	}
 
 	newGist := &db.Gist{
@@ -694,44 +696,44 @@ func fork(ctx echo.Context) error {
 	}
 
 	if err = newGist.CreateForked(); err != nil {
-		return errorRes(500, "Error forking the gist in database", err)
+		return ctx.ErrorRes(500, "Error forking the gist in database", err)
 	}
 
 	if err = gist.ForkClone(currentUser.Username, newGist.Uuid); err != nil {
-		return errorRes(500, "Error cloning the repository while forking", err)
+		return ctx.ErrorRes(500, "Error cloning the repository while forking", err)
 	}
 	if err = gist.IncrementForkCount(); err != nil {
-		return errorRes(500, "Error incrementing the fork count", err)
+		return ctx.ErrorRes(500, "Error incrementing the fork count", err)
 	}
 
-	addFlash(ctx, tr(ctx, "flash.gist.forked"), "success")
+	ctx.AddFlash(ctx.Tr("flash.gist.forked"), "success")
 
-	return redirect(ctx, "/"+currentUser.Username+"/"+newGist.Identifier())
+	return ctx.RedirectTo("/" + currentUser.Username + "/" + newGist.Identifier())
 }
 
-func rawFile(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func RawFile(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 	file, err := gist.File(ctx.Param("revision"), ctx.Param("file"), false)
 	if err != nil {
-		return errorRes(500, "Error getting file content", err)
+		return ctx.ErrorRes(500, "Error getting file content", err)
 	}
 
 	if file == nil {
-		return notFound("File not found")
+		return ctx.NotFound("File not found")
 	}
 
-	return plainText(ctx, 200, file.Content)
+	return ctx.PlainText(200, file.Content)
 }
 
-func downloadFile(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func DownloadFile(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 	file, err := gist.File(ctx.Param("revision"), ctx.Param("file"), false)
 	if err != nil {
-		return errorRes(500, "Error getting file content", err)
+		return ctx.ErrorRes(500, "Error getting file content", err)
 	}
 
 	if file == nil {
-		return notFound("File not found")
+		return ctx.NotFound("File not found")
 	}
 
 	ctx.Response().Header().Set("Content-Type", "text/plain")
@@ -739,36 +741,36 @@ func downloadFile(ctx echo.Context) error {
 	ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
 	_, err = ctx.Response().Write([]byte(file.Content))
 	if err != nil {
-		return errorRes(500, "Error downloading the file", err)
+		return ctx.ErrorRes(500, "Error downloading the file", err)
 	}
 
 	return nil
 }
 
-func edit(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func Edit(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 
 	files, err := gist.Files("HEAD", false)
 	if err != nil {
-		return errorRes(500, "Error fetching files from repository", err)
+		return ctx.ErrorRes(500, "Error fetching files from repository", err)
 	}
 
-	setData(ctx, "files", files)
-	setData(ctx, "htmlTitle", trH(ctx, "gist.edit.edit-gist", gist.Title))
+	ctx.SetData("files", files)
+	ctx.SetData("htmlTitle", ctx.TrH("gist.edit.edit-gist", gist.Title))
 
-	return html(ctx, "edit.html")
+	return ctx.HTML_("edit.html")
 }
 
-func downloadZip(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func DownloadZip(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 	revision := ctx.Param("revision")
 
 	files, err := gist.Files(revision, false)
 	if err != nil {
-		return errorRes(500, "Error fetching files from repository", err)
+		return ctx.ErrorRes(500, "Error fetching files from repository", err)
 	}
 	if len(files) == 0 {
-		return notFound("No files found in this revision")
+		return ctx.NotFound("No files found in this revision")
 	}
 
 	zipFile := new(bytes.Buffer)
@@ -782,16 +784,16 @@ func downloadZip(ctx echo.Context) error {
 		}
 		f, err := zipWriter.CreateHeader(fh)
 		if err != nil {
-			return errorRes(500, "Error adding a file the to the zip archive", err)
+			return ctx.ErrorRes(500, "Error adding a file the to the zip archive", err)
 		}
 		_, err = f.Write([]byte(file.Content))
 		if err != nil {
-			return errorRes(500, "Error adding file content the to the zip archive", err)
+			return ctx.ErrorRes(500, "Error adding file content the to the zip archive", err)
 		}
 	}
 	err = zipWriter.Close()
 	if err != nil {
-		return errorRes(500, "Error closing the zip archive", err)
+		return ctx.ErrorRes(500, "Error closing the zip archive", err)
 	}
 
 	ctx.Response().Header().Set("Content-Type", "application/zip")
@@ -799,35 +801,35 @@ func downloadZip(ctx echo.Context) error {
 	ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(zipFile.Bytes())))
 	_, err = ctx.Response().Write(zipFile.Bytes())
 	if err != nil {
-		return errorRes(500, "Error writing the zip archive", err)
+		return ctx.ErrorRes(500, "Error writing the zip archive", err)
 	}
 	return nil
 }
 
-func likes(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func Likes(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 
 	pageInt := getPage(ctx)
 
 	likers, err := gist.GetUsersLikes(pageInt - 1)
 	if err != nil {
-		return errorRes(500, "Error getting users who liked this gist", err)
+		return ctx.ErrorRes(500, "Error getting users who liked this gist", err)
 	}
 
 	if err = paginate(ctx, likers, pageInt, 30, "likers", gist.User.Username+"/"+gist.Identifier()+"/likes", 1); err != nil {
-		return errorRes(404, tr(ctx, "error.page-not-found"), nil)
+		return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
 	}
 
-	setData(ctx, "htmlTitle", trH(ctx, "gist.likes.for", gist.Title))
-	setData(ctx, "revision", "HEAD")
-	return html(ctx, "likes.html")
+	ctx.SetData("htmlTitle", ctx.TrH("gist.likes.for", gist.Title))
+	ctx.SetData("revision", "HEAD")
+	return ctx.HTML_("likes.html")
 }
 
-func forks(ctx echo.Context) error {
-	gist := getData(ctx, "gist").(*db.Gist)
+func Forks(ctx *context.OGContext) error {
+	gist := ctx.GetData("gist").(*db.Gist)
 	pageInt := getPage(ctx)
 
-	currentUser := getUserLogged(ctx)
+	currentUser := ctx.User
 	var fromUserID uint = 0
 	if currentUser != nil {
 		fromUserID = currentUser.ID
@@ -835,63 +837,63 @@ func forks(ctx echo.Context) error {
 
 	forks, err := gist.GetForks(fromUserID, pageInt-1)
 	if err != nil {
-		return errorRes(500, "Error getting users who liked this gist", err)
+		return ctx.ErrorRes(500, "Error getting users who liked this gist", err)
 	}
 
 	if err = paginate(ctx, forks, pageInt, 30, "forks", gist.User.Username+"/"+gist.Identifier()+"/forks", 2); err != nil {
-		return errorRes(404, tr(ctx, "error.page-not-found"), nil)
+		return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
 	}
 
-	setData(ctx, "htmlTitle", trH(ctx, "gist.forks.for", gist.Title))
-	setData(ctx, "revision", "HEAD")
-	return html(ctx, "forks.html")
+	ctx.SetData("htmlTitle", ctx.TrH("gist.forks.for", gist.Title))
+	ctx.SetData("revision", "HEAD")
+	return ctx.HTML_("forks.html")
 }
 
-func checkbox(ctx echo.Context) error {
+func Checkbox(ctx *context.OGContext) error {
 	filename := ctx.FormValue("file")
 	checkboxNb := ctx.FormValue("checkbox")
 
 	i, err := strconv.Atoi(checkboxNb)
 	if err != nil {
-		return errorRes(400, tr(ctx, "error.invalid-number"), nil)
+		return ctx.ErrorRes(400, ctx.Tr("error.invalid-number"), nil)
 	}
 
-	gist := getData(ctx, "gist").(*db.Gist)
+	gist := ctx.GetData("gist").(*db.Gist)
 	file, err := gist.File("HEAD", filename, false)
 	if err != nil {
-		return errorRes(500, "Error getting file content", err)
+		return ctx.ErrorRes(500, "Error getting file content", err)
 	} else if file == nil {
-		return notFound("File not found")
+		return ctx.NotFound("File not found")
 	}
 
 	markdown, err := render.Checkbox(file.Content, i)
 	if err != nil {
-		return errorRes(500, "Error checking checkbox", err)
+		return ctx.ErrorRes(500, "Error checking checkbox", err)
 	}
 
 	if err = gist.AddAndCommitFile(&db.FileDTO{
 		Filename: filename,
 		Content:  markdown,
 	}); err != nil {
-		return errorRes(500, "Error adding and committing files", err)
+		return ctx.ErrorRes(500, "Error adding and committing files", err)
 	}
 
 	if err = gist.UpdatePreviewAndCount(true); err != nil {
-		return errorRes(500, "Error updating the gist", err)
+		return ctx.ErrorRes(500, "Error updating the gist", err)
 	}
 
-	return plainText(ctx, 200, "ok")
+	return ctx.PlainText(200, "ok")
 }
 
-func preview(ctx echo.Context) error {
+func Preview(ctx *context.OGContext) error {
 	content := ctx.FormValue("content")
 
 	previewStr, err := render.MarkdownString(content)
 	if err != nil {
-		return errorRes(500, "Error rendering markdown", err)
+		return ctx.ErrorRes(500, "Error rendering markdown", err)
 	}
 
-	return plainText(ctx, 200, previewStr)
+	return ctx.PlainText(200, previewStr)
 }
 
 func escapeJavaScriptContent(htmlContent, cssUrl string) (string, error) {
diff --git a/internal/web/git_http.go b/internal/web/handler/git_http.go
similarity index 74%
rename from internal/web/git_http.go
rename to internal/web/handler/git_http.go
index a0341ff..278620d 100644
--- a/internal/web/git_http.go
+++ b/internal/web/handler/git_http.go
@@ -1,4 +1,4 @@
-package web
+package handler
 
 import (
 	"bytes"
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"github.com/thomiceli/opengist/internal/utils"
+	"github.com/thomiceli/opengist/internal/web/context"
 	"net/http"
 	"os"
 	"os/exec"
@@ -17,7 +18,6 @@ import (
 	"time"
 
 	"github.com/google/uuid"
-	"github.com/labstack/echo/v4"
 	"github.com/rs/zerolog/log"
 	"github.com/thomiceli/opengist/internal/auth"
 	"github.com/thomiceli/opengist/internal/db"
@@ -29,7 +29,7 @@ import (
 var routes = []struct {
 	gitUrl  string
 	method  string
-	handler func(ctx echo.Context) error
+	handler func(ctx *context.OGContext) error
 }{
 	{"(.*?)/git-upload-pack$", "POST", uploadPack},
 	{"(.*?)/git-receive-pack$", "POST", receivePack},
@@ -44,7 +44,7 @@ var routes = []struct {
 	{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$", "GET", idxFile},
 }
 
-func gitHttp(ctx echo.Context) error {
+func gitHttp(ctx *context.OGContext) error {
 	for _, route := range routes {
 		matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path)
 		if ctx.Request().Method == route.method && matched {
@@ -52,7 +52,7 @@ func gitHttp(ctx echo.Context) error {
 				continue
 			}
 
-			gist := getData(ctx, "gist").(*db.Gist)
+			gist := ctx.GetData("gist").(*db.Gist)
 
 			isInit := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs")
 			isInitReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack")
@@ -65,11 +65,11 @@ func gitHttp(ctx echo.Context) error {
 			if _, err := os.Stat(repositoryPath); os.IsNotExist(err) {
 				if err != nil {
 					log.Info().Err(err).Msg("Repository directory does not exist")
-					return errorRes(404, "Repository directory does not exist", err)
+					return ctx.ErrorRes(404, "Repository directory does not exist", err)
 				}
 			}
 
-			setData(ctx, "repositoryPath", repositoryPath)
+			ctx.SetData("repositoryPath", repositoryPath)
 
 			allow, err := auth.ShouldAllowUnauthenticatedGistAccess(ContextAuthInfo{ctx}, true)
 			if err != nil {
@@ -102,7 +102,7 @@ func gitHttp(ctx echo.Context) error {
 
 			if !isInit && !isInitReceive {
 				if gist.ID == 0 {
-					return plainText(ctx, 404, "Check your credentials or make sure you have access to the Gist")
+					return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
 				}
 
 				var userToCheckPermissions *db.User
@@ -114,27 +114,27 @@ func gitHttp(ctx echo.Context) error {
 
 				if ok, err := utils.Argon2id.Verify(authPassword, userToCheckPermissions.Password); !ok {
 					if err != nil {
-						return errorRes(500, "Cannot verify password", err)
+						return ctx.ErrorRes(500, "Cannot verify password", err)
 					}
 					log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
-					return plainText(ctx, 404, "Check your credentials or make sure you have access to the Gist")
+					return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
 				}
 			} else {
 				var user *db.User
 				if user, err = db.GetUserByUsername(authUsername); err != nil {
 					if !errors.Is(err, gorm.ErrRecordNotFound) {
-						return errorRes(500, "Cannot get user", err)
+						return ctx.ErrorRes(500, "Cannot get user", err)
 					}
 					log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
-					return errorRes(401, "Invalid credentials", nil)
+					return ctx.ErrorRes(401, "Invalid credentials", nil)
 				}
 
 				if ok, err := utils.Argon2id.Verify(authPassword, user.Password); !ok {
 					if err != nil {
-						return errorRes(500, "Cannot check for password", err)
+						return ctx.ErrorRes(500, "Cannot check for password", err)
 					}
 					log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
-					return errorRes(401, "Invalid credentials", nil)
+					return ctx.ErrorRes(401, "Invalid credentials", nil)
 				}
 
 				if isInit {
@@ -143,56 +143,56 @@ func gitHttp(ctx echo.Context) error {
 					gist.User = *user
 					uuidGist, err := uuid.NewRandom()
 					if err != nil {
-						return errorRes(500, "Error creating an UUID", err)
+						return ctx.ErrorRes(500, "Error creating an UUID", err)
 					}
 					gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
 					gist.Title = "gist:" + gist.Uuid
 
 					if err = gist.InitRepository(); err != nil {
-						return errorRes(500, "Cannot init repository in the file system", err)
+						return ctx.ErrorRes(500, "Cannot init repository in the file system", err)
 					}
 
 					if err = gist.Create(); err != nil {
-						return errorRes(500, "Cannot init repository in database", err)
+						return ctx.ErrorRes(500, "Cannot init repository in database", err)
 					}
 
 					if err := memdb.InsertGistInit(user.ID, gist); err != nil {
-						return errorRes(500, "Cannot save the URL for the new Gist", err)
+						return ctx.ErrorRes(500, "Cannot save the URL for the new Gist", err)
 					}
 
-					setData(ctx, "gist", gist)
+					ctx.SetData("gist", gist)
 				} else {
 					gistFromMemdb, err := memdb.GetGistInitAndDelete(user.ID)
 					if err != nil {
-						return errorRes(500, "Cannot get the gist link from the in memory database", err)
+						return ctx.ErrorRes(500, "Cannot get the gist link from the in memory database", err)
 					}
 
 					gist := gistFromMemdb.Gist
-					setData(ctx, "gist", gist)
-					setData(ctx, "repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
+					ctx.SetData("gist", gist)
+					ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
 				}
 			}
 
 			return route.handler(ctx)
 		}
 	}
-	return notFound("Gist not found")
+	return ctx.NotFound("Gist not found")
 }
 
-func uploadPack(ctx echo.Context) error {
+func uploadPack(ctx *context.OGContext) error {
 	return pack(ctx, "upload-pack")
 }
 
-func receivePack(ctx echo.Context) error {
+func receivePack(ctx *context.OGContext) error {
 	return pack(ctx, "receive-pack")
 }
 
-func pack(ctx echo.Context, serviceType string) error {
+func pack(ctx *context.OGContext, serviceType string) error {
 	noCacheHeaders(ctx)
 	defer ctx.Request().Body.Close()
 
 	if ctx.Request().Header.Get("Content-Type") != "application/x-git-"+serviceType+"-request" {
-		return errorRes(401, "Git client unsupported", nil)
+		return ctx.ErrorRes(401, "Git client unsupported", nil)
 	}
 	ctx.Response().Header().Set("Content-Type", "application/x-git-"+serviceType+"-result")
 
@@ -202,12 +202,12 @@ func pack(ctx echo.Context, serviceType string) error {
 	if ctx.Request().Header.Get("Content-Encoding") == "gzip" {
 		reqBody, err = gzip.NewReader(reqBody)
 		if err != nil {
-			return errorRes(500, "Cannot create gzip reader", err)
+			return ctx.ErrorRes(500, "Cannot create gzip reader", err)
 		}
 	}
 
-	repositoryPath := getData(ctx, "repositoryPath").(string)
-	gist := getData(ctx, "gist").(*db.Gist)
+	repositoryPath := ctx.GetData("repositoryPath").(string)
+	gist := ctx.GetData("gist").(*db.Gist)
 
 	var stderr bytes.Buffer
 	cmd := exec.Command("git", serviceType, "--stateless-rpc", repositoryPath)
@@ -220,17 +220,17 @@ func pack(ctx echo.Context, serviceType string) error {
 	cmd.Env = append(cmd.Env, "OPENGIST_REPOSITORY_ID="+strconv.Itoa(int(gist.ID)))
 
 	if err = cmd.Run(); err != nil {
-		return errorRes(500, "Cannot run git "+serviceType+" ; "+stderr.String(), err)
+		return ctx.ErrorRes(500, "Cannot run git "+serviceType+" ; "+stderr.String(), err)
 	}
 
 	return nil
 }
 
-func infoRefs(ctx echo.Context) error {
+func infoRefs(ctx *context.OGContext) error {
 	noCacheHeaders(ctx)
 	var service string
 
-	gist := getData(ctx, "gist").(*db.Gist)
+	gist := ctx.GetData("gist").(*db.Gist)
 
 	serviceType := ctx.QueryParam("service")
 	if strings.HasPrefix(serviceType, "git-") {
@@ -239,14 +239,14 @@ func infoRefs(ctx echo.Context) error {
 
 	if service != "upload-pack" && service != "receive-pack" {
 		if err := gist.UpdateServerInfo(); err != nil {
-			return errorRes(500, "Cannot update server info", err)
+			return ctx.ErrorRes(500, "Cannot update server info", err)
 		}
 		return sendFile(ctx, "text/plain; charset=utf-8")
 	}
 
 	refs, err := gist.RPC(service)
 	if err != nil {
-		return errorRes(500, "Cannot run git "+service, err)
+		return ctx.ErrorRes(500, "Cannot run git "+service, err)
 	}
 
 	ctx.Response().Header().Set("Content-Type", "application/x-git-"+service+"-advertisement")
@@ -258,38 +258,38 @@ func infoRefs(ctx echo.Context) error {
 	return nil
 }
 
-func textFile(ctx echo.Context) error {
+func textFile(ctx *context.OGContext) error {
 	noCacheHeaders(ctx)
 	return sendFile(ctx, "text/plain")
 }
 
-func infoPacks(ctx echo.Context) error {
+func infoPacks(ctx *context.OGContext) error {
 	cacheHeadersForever(ctx)
 	return sendFile(ctx, "text/plain; charset=utf-8")
 }
 
-func looseObject(ctx echo.Context) error {
+func looseObject(ctx *context.OGContext) error {
 	cacheHeadersForever(ctx)
 	return sendFile(ctx, "application/x-git-loose-object")
 }
 
-func packFile(ctx echo.Context) error {
+func packFile(ctx *context.OGContext) error {
 	cacheHeadersForever(ctx)
 	return sendFile(ctx, "application/x-git-packed-objects")
 }
 
-func idxFile(ctx echo.Context) error {
+func idxFile(ctx *context.OGContext) error {
 	cacheHeadersForever(ctx)
 	return sendFile(ctx, "application/x-git-packed-objects-toc")
 }
 
-func noCacheHeaders(ctx echo.Context) {
+func noCacheHeaders(ctx *context.OGContext) {
 	ctx.Response().Header().Set("Expires", "Thu, 01 Jan 1970 00:00:00 UTC")
 	ctx.Response().Header().Set("Pragma", "no-cache")
 	ctx.Response().Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
 }
 
-func cacheHeadersForever(ctx echo.Context) {
+func cacheHeadersForever(ctx *context.OGContext) {
 	now := time.Now().Unix()
 	expires := now + 31536000
 	ctx.Response().Header().Set("Date", fmt.Sprintf("%d", now))
@@ -297,9 +297,9 @@ func cacheHeadersForever(ctx echo.Context) {
 	ctx.Response().Header().Set("Cache-Control", "public, max-age=31536000")
 }
 
-func basicAuth(ctx echo.Context) error {
+func basicAuth(ctx *context.OGContext) error {
 	ctx.Response().Header().Set("WWW-Authenticate", `Basic realm="."`)
-	return plainText(ctx, 401, "Requires authentication")
+	return ctx.PlainText(401, "Requires authentication")
 }
 
 func basicAuthDecode(encoded string) (string, string, error) {
@@ -312,12 +312,12 @@ func basicAuthDecode(encoded string) (string, string, error) {
 	return auth[0], auth[1], nil
 }
 
-func sendFile(ctx echo.Context, contentType string) error {
+func sendFile(ctx *context.OGContext, contentType string) error {
 	gitFile := "/" + strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/")
-	gitFile = path.Join(getData(ctx, "repositoryPath").(string), gitFile)
+	gitFile = path.Join(ctx.GetData("repositoryPath").(string), gitFile)
 	fi, err := os.Stat(gitFile)
 	if os.IsNotExist(err) {
-		return errorRes(404, "File not found", nil)
+		return ctx.ErrorRes(404, "File not found", nil)
 	}
 	ctx.Response().Header().Set("Content-Type", contentType)
 	ctx.Response().Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
diff --git a/internal/web/healthcheck.go b/internal/web/handler/healthcheck.go
similarity index 76%
rename from internal/web/healthcheck.go
rename to internal/web/handler/healthcheck.go
index 6927d28..0ae6953 100644
--- a/internal/web/healthcheck.go
+++ b/internal/web/handler/healthcheck.go
@@ -1,12 +1,12 @@
-package web
+package handler
 
 import (
-	"github.com/labstack/echo/v4"
 	"github.com/thomiceli/opengist/internal/db"
+	"github.com/thomiceli/opengist/internal/web/context"
 	"time"
 )
 
-func healthcheck(ctx echo.Context) error {
+func healthcheck(ctx *context.OGContext) error {
 	// Check database connection
 	dbOk := "ok"
 	httpStatus := 200
@@ -26,6 +26,6 @@ func healthcheck(ctx echo.Context) error {
 
 // metrics is a dummy handler to satisfy the /metrics endpoint (for Prometheus, Openmetrics, etc.)
 // until we have a proper metrics endpoint
-func metrics(ctx echo.Context) error {
+func metrics(ctx *context.OGContext) error {
 	return ctx.String(200, "")
 }
diff --git a/internal/web/handler/settings.go b/internal/web/handler/settings.go
new file mode 100644
index 0000000..ea18541
--- /dev/null
+++ b/internal/web/handler/settings.go
@@ -0,0 +1,227 @@
+package handler
+
+import (
+	"crypto/md5"
+	"fmt"
+	"github.com/thomiceli/opengist/internal/config"
+	"github.com/thomiceli/opengist/internal/git"
+	"github.com/thomiceli/opengist/internal/i18n"
+	"github.com/thomiceli/opengist/internal/utils"
+	"github.com/thomiceli/opengist/internal/web/context"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/thomiceli/opengist/internal/db"
+	"golang.org/x/crypto/ssh"
+)
+
+func userSettings(ctx *context.OGContext) error {
+	user := ctx.User
+
+	keys, err := db.GetSSHKeysByUserID(user.ID)
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot get SSH keys", err)
+	}
+
+	passkeys, err := db.GetAllCredentialsForUser(user.ID)
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot get WebAuthn credentials", err)
+	}
+
+	_, hasTotp, err := user.HasMFA()
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot get MFA status", err)
+	}
+
+	ctx.SetData("email", user.Email)
+	ctx.SetData("sshKeys", keys)
+	ctx.SetData("passkeys", passkeys)
+	ctx.SetData("hasTotp", hasTotp)
+	ctx.SetData("hasPassword", user.Password != "")
+	ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
+	ctx.SetData("htmlTitle", ctx.TrH("settings"))
+	return ctx.HTML_("settings.html")
+}
+
+func emailProcess(ctx *context.OGContext) error {
+	user := ctx.User
+	email := ctx.FormValue("email")
+	var hash string
+
+	if email == "" {
+		// generate random md5 string
+		hash = fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String())))
+	} else {
+		hash = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))))
+	}
+
+	user.Email = strings.ToLower(email)
+	user.MD5Hash = hash
+
+	if err := user.Update(); err != nil {
+		return ctx.ErrorRes(500, "Cannot update email", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.user.email-updated"), "success")
+	return ctx.RedirectTo("/settings")
+}
+
+func accountDeleteProcess(ctx *context.OGContext) error {
+	user := ctx.User
+
+	if err := user.Delete(); err != nil {
+		return ctx.ErrorRes(500, "Cannot delete this user", err)
+	}
+
+	return ctx.RedirectTo("/all")
+}
+
+func sshKeysProcess(ctx *context.OGContext) error {
+	user := ctx.User
+
+	dto := new(db.SSHKeyDTO)
+	if err := ctx.Bind(dto); err != nil {
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
+	}
+
+	if err := ctx.Validate(dto); err != nil {
+		ctx.AddFlash(utils.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
+		return ctx.RedirectTo("/settings")
+	}
+	key := dto.ToSSHKey()
+
+	key.UserID = user.ID
+
+	pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
+	if err != nil {
+		ctx.AddFlash(ctx.Tr("flash.user.invalid-ssh-key"), "error")
+		return ctx.RedirectTo("/settings")
+	}
+	key.Content = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
+
+	if exists, err := db.SSHKeyDoesExists(key.Content); exists {
+		if err != nil {
+			return ctx.ErrorRes(500, "Cannot check if SSH key exists", err)
+		}
+		ctx.AddFlash(ctx.Tr("settings.ssh-key-exists"), "error")
+		return ctx.RedirectTo("/settings")
+	}
+
+	if err := key.Create(); err != nil {
+		return ctx.ErrorRes(500, "Cannot add SSH key", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success")
+	return ctx.RedirectTo("/settings")
+}
+
+func sshKeysDelete(ctx *context.OGContext) error {
+	user := ctx.User
+	keyId, err := strconv.Atoi(ctx.Param("id"))
+	if err != nil {
+		return ctx.RedirectTo("/settings")
+	}
+
+	key, err := db.GetSSHKeyByID(uint(keyId))
+
+	if err != nil || key.UserID != user.ID {
+		return ctx.RedirectTo("/settings")
+	}
+
+	if err := key.Delete(); err != nil {
+		return ctx.ErrorRes(500, "Cannot delete SSH key", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success")
+	return ctx.RedirectTo("/settings")
+}
+
+func passkeyDelete(ctx *context.OGContext) error {
+	user := ctx.User
+	keyId, err := strconv.Atoi(ctx.Param("id"))
+	if err != nil {
+		return ctx.RedirectTo("/settings")
+	}
+
+	passkey, err := db.GetCredentialByIDDB(uint(keyId))
+	if err != nil || passkey.UserID != user.ID {
+		return ctx.RedirectTo("/settings")
+	}
+
+	if err := passkey.Delete(); err != nil {
+		return ctx.ErrorRes(500, "Cannot delete passkey", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.auth.passkey-deleted"), "success")
+	return ctx.RedirectTo("/settings")
+}
+
+func passwordProcess(ctx *context.OGContext) error {
+	user := ctx.User
+
+	dto := new(db.UserDTO)
+	if err := ctx.Bind(dto); err != nil {
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
+	}
+	dto.Username = user.Username
+
+	if err := ctx.Validate(dto); err != nil {
+		ctx.AddFlash(utils.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
+		return ctx.HTML_("settings.html")
+	}
+
+	password, err := utils.Argon2id.Hash(dto.Password)
+	if err != nil {
+		return ctx.ErrorRes(500, "Cannot hash password", err)
+	}
+	user.Password = password
+
+	if err = user.Update(); err != nil {
+		return ctx.ErrorRes(500, "Cannot update password", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.user.password-updated"), "success")
+	return ctx.RedirectTo("/settings")
+}
+
+func usernameProcess(ctx *context.OGContext) error {
+	user := ctx.User
+
+	dto := new(db.UserDTO)
+	if err := ctx.Bind(dto); err != nil {
+		return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
+	}
+	dto.Password = user.Password
+
+	if err := ctx.Validate(dto); err != nil {
+		ctx.AddFlash(utils.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
+		return ctx.RedirectTo("/settings")
+	}
+
+	if exists, err := db.UserExists(dto.Username); err != nil || exists {
+		ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
+		return ctx.RedirectTo("/settings")
+	}
+
+	sourceDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(user.Username))
+	destinationDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(dto.Username))
+
+	if _, err := os.Stat(sourceDir); !os.IsNotExist(err) {
+		err := os.Rename(sourceDir, destinationDir)
+		if err != nil {
+			return ctx.ErrorRes(500, "Cannot rename user directory", err)
+		}
+	}
+
+	user.Username = dto.Username
+
+	if err := user.Update(); err != nil {
+		return ctx.ErrorRes(500, "Cannot update username", err)
+	}
+
+	ctx.AddFlash(ctx.Tr("flash.user.username-updated"), "success")
+	return ctx.RedirectTo("/settings")
+}
diff --git a/internal/web/handler/util.go b/internal/web/handler/util.go
new file mode 100644
index 0000000..62ea6ad
--- /dev/null
+++ b/internal/web/handler/util.go
@@ -0,0 +1,79 @@
+package handler
+
+import (
+	"errors"
+	"github.com/thomiceli/opengist/internal/web/context"
+	"html/template"
+	"strconv"
+	"strings"
+)
+
+func getPage(ctx *context.OGContext) int {
+	page := ctx.QueryParam("page")
+	if page == "" {
+		page = "1"
+	}
+	pageInt, err := strconv.Atoi(page)
+	if err != nil {
+		pageInt = 1
+	}
+	ctx.SetData("currPage", pageInt)
+
+	return pageInt
+}
+
+func paginate[T any](ctx *context.OGContext, data []*T, pageInt int, perPage int, templateDataName string, urlPage string, labels int, urlParams ...string) error {
+	lenData := len(data)
+	if lenData == 0 && pageInt != 1 {
+		return errors.New("page not found")
+	}
+
+	if lenData > perPage {
+		if lenData > 1 {
+			data = data[:lenData-1]
+		}
+		ctx.SetData("nextPage", pageInt+1)
+	}
+	if pageInt > 1 {
+		ctx.SetData("prevPage", pageInt-1)
+	}
+
+	if len(urlParams) > 0 {
+		ctx.SetData("urlParams", template.URL(urlParams[0]))
+	}
+
+	switch labels {
+	case 1:
+		ctx.SetData("prevLabel", ctx.TrH("pagination.previous"))
+		ctx.SetData("nextLabel", ctx.TrH("pagination.next"))
+	case 2:
+		ctx.SetData("prevLabel", ctx.TrH("pagination.newer"))
+		ctx.SetData("nextLabel", ctx.TrH("pagination.older"))
+	}
+
+	ctx.SetData("urlPage", urlPage)
+	ctx.SetData(templateDataName, data)
+	return nil
+}
+
+func ParseSearchQueryStr(query string) (string, map[string]string) {
+	words := strings.Fields(query)
+	metadata := make(map[string]string)
+	var contentBuilder strings.Builder
+
+	for _, word := range words {
+		if strings.Contains(word, ":") {
+			keyValue := strings.SplitN(word, ":", 2)
+			if len(keyValue) == 2 {
+				key := keyValue[0]
+				value := keyValue[1]
+				metadata[key] = value
+			}
+		} else {
+			contentBuilder.WriteString(word + " ")
+		}
+	}
+
+	content := strings.TrimSpace(contentBuilder.String())
+	return content, metadata
+}
diff --git a/internal/web/server.go b/internal/web/server.go
deleted file mode 100644
index f3d4850..0000000
--- a/internal/web/server.go
+++ /dev/null
@@ -1,626 +0,0 @@
-package web
-
-import (
-	"context"
-	gojson "encoding/json"
-	"errors"
-	"fmt"
-	htmlpkg "html"
-	"html/template"
-	"io"
-	"net/http"
-	"net/url"
-	"os"
-	"path"
-	"path/filepath"
-	"regexp"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/thomiceli/opengist/internal/index"
-	"github.com/thomiceli/opengist/internal/utils"
-	"github.com/thomiceli/opengist/templates"
-
-	"github.com/gorilla/sessions"
-	"github.com/labstack/echo/v4"
-	"github.com/labstack/echo/v4/middleware"
-	"github.com/markbates/goth/gothic"
-	"github.com/rs/zerolog/log"
-	"github.com/thomiceli/opengist/internal/auth"
-	"github.com/thomiceli/opengist/internal/config"
-	"github.com/thomiceli/opengist/internal/db"
-	"github.com/thomiceli/opengist/internal/git"
-	"github.com/thomiceli/opengist/internal/i18n"
-	"github.com/thomiceli/opengist/public"
-	"golang.org/x/text/language"
-)
-
-var (
-	dev        bool
-	flashStore *sessions.CookieStore     // session store for flash messages
-	userStore  *sessions.FilesystemStore // session store for user sessions
-	re         = regexp.MustCompile("[^a-z0-9]+")
-	fm         = template.FuncMap{
-		"split":     strings.Split,
-		"indexByte": strings.IndexByte,
-		"toInt": func(i string) int {
-			val, _ := strconv.Atoi(i)
-			return val
-		},
-		"inc": func(i int) int {
-			return i + 1
-		},
-		"splitGit": func(i string) []string {
-			return strings.FieldsFunc(i, func(r rune) bool {
-				return r == ',' || r == ' '
-			})
-		},
-		"lines": func(i string) []string {
-			return strings.Split(i, "\n")
-		},
-		"isMarkdown": func(i string) bool {
-			return strings.ToLower(filepath.Ext(i)) == ".md"
-		},
-		"isCsv": func(i string) bool {
-			return strings.ToLower(filepath.Ext(i)) == ".csv"
-		},
-		"isSvg": func(i string) bool {
-			return strings.ToLower(filepath.Ext(i)) == ".svg"
-		},
-		"csvFile": func(file *git.File) *git.CsvFile {
-			if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" {
-				return nil
-			}
-
-			csvFile, err := git.ParseCsv(file)
-			if err != nil {
-				return nil
-			}
-
-			return csvFile
-		},
-		"httpStatusText": http.StatusText,
-		"loadedTime": func(startTime time.Time) string {
-			return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
-		},
-		"slug": func(s string) string {
-			return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-")
-		},
-		"avatarUrl": func(user *db.User, noGravatar bool) string {
-			if user.AvatarURL != "" {
-				return user.AvatarURL
-			}
-
-			if user.MD5Hash != "" && !noGravatar {
-				return "https://www.gravatar.com/avatar/" + user.MD5Hash + "?d=identicon&s=200"
-			}
-
-			return defaultAvatar()
-		},
-		"asset":  asset,
-		"custom": customAsset,
-		"dev": func() bool {
-			return dev
-		},
-		"defaultAvatar": defaultAvatar,
-		"visibilityStr": func(visibility db.Visibility, lowercase bool) string {
-			s := "Public"
-			switch visibility {
-			case 1:
-				s = "Unlisted"
-			case 2:
-				s = "Private"
-			}
-
-			if lowercase {
-				return strings.ToLower(s)
-			}
-			return s
-		},
-		"unescape": htmlpkg.UnescapeString,
-		"join": func(s ...string) string {
-			return strings.Join(s, "")
-		},
-		"toStr": func(i interface{}) string {
-			return fmt.Sprint(i)
-		},
-		"safe": func(s string) template.HTML {
-			return template.HTML(s)
-		},
-		"dict": func(values ...interface{}) (map[string]interface{}, error) {
-			if len(values)%2 != 0 {
-				return nil, errors.New("invalid dict call")
-			}
-			dict := make(map[string]interface{})
-			for i := 0; i < len(values); i += 2 {
-				key, ok := values[i].(string)
-				if !ok {
-					return nil, errors.New("dict keys must be strings")
-				}
-				dict[key] = values[i+1]
-			}
-			return dict, nil
-		},
-		"addMetadataToSearchQuery": addMetadataToSearchQuery,
-		"indexEnabled":             index.Enabled,
-		"isUrl": func(s string) bool {
-			_, err := url.ParseRequestURI(s)
-			return err == nil
-		},
-	}
-)
-
-type Template struct {
-	templates *template.Template
-}
-
-func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error {
-	return t.templates.ExecuteTemplate(w, name, data)
-}
-
-type Server struct {
-	echo *echo.Echo
-	dev  bool
-}
-
-func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server {
-	dev = isDev
-	flashStore = sessions.NewCookieStore([]byte("opengist"))
-	encryptKey, _ := utils.GenerateSecretKey(filepath.Join(sessionsPath, "session-encrypt.key"))
-	userStore = sessions.NewFilesystemStore(sessionsPath, config.SecretKey, encryptKey)
-	userStore.MaxLength(10 * 1024)
-	gothic.Store = userStore
-
-	e := echo.New()
-	e.HideBanner = true
-	e.HidePort = true
-
-	if err := i18n.Locales.LoadAll(); err != nil {
-		log.Fatal().Err(err).Msg("Failed to load locales")
-	}
-
-	e.Use(dataInit)
-	e.Use(locale)
-	e.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
-		Getter: middleware.MethodFromForm("_method"),
-	}))
-	e.Pre(middleware.RemoveTrailingSlash())
-	e.Pre(middleware.CORS())
-	e.Pre(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
-		LogURI: true, LogStatus: true, LogMethod: true,
-		LogValuesFunc: func(ctx echo.Context, v middleware.RequestLoggerValues) error {
-			log.Info().Str("uri", v.URI).Int("status", v.Status).Str("method", v.Method).
-				Str("ip", ctx.RealIP()).TimeDiff("duration", time.Now(), v.StartTime).
-				Msg("HTTP")
-			return nil
-		},
-	}))
-	e.Use(middleware.Recover())
-	e.Use(middleware.Secure())
-
-	t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html"))
-	customPattern := filepath.Join(config.GetHomeDir(), "custom", "*.html")
-	matches, err := filepath.Glob(customPattern)
-	if err != nil {
-		log.Fatal().Err(err).Msg("Failed to check for custom templates")
-	}
-	if len(matches) > 0 {
-		t, err = t.ParseGlob(customPattern)
-		if err != nil {
-			log.Fatal().Err(err).Msg("Failed to parse custom templates")
-		}
-	}
-	e.Renderer = &Template{
-		templates: t,
-	}
-
-	e.HTTPErrorHandler = func(er error, ctx echo.Context) {
-		var httpErr *echo.HTTPError
-		if errors.As(er, &httpErr) {
-			acceptJson := strings.Contains(ctx.Request().Header.Get("Accept"), "application/json")
-			setData(ctx, "error", er)
-			if acceptJson {
-				if fatalErr := jsonWithCode(ctx, httpErr.Code, httpErr); fatalErr != nil {
-					log.Fatal().Err(fatalErr).Send()
-				}
-			} else {
-				if fatalErr := htmlWithCode(ctx, httpErr.Code, "error.html"); fatalErr != nil {
-					log.Fatal().Err(fatalErr).Send()
-				}
-			}
-		} else {
-			log.Fatal().Err(er).Send()
-		}
-	}
-
-	e.Use(sessionInit)
-
-	e.Validator = utils.NewValidator()
-
-	if !dev {
-		parseManifestEntries()
-	}
-
-	// Web based routes
-	g1 := e.Group("")
-	{
-		if !ignoreCsrf {
-			g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
-				TokenLookup:    "form:_csrf,header:X-CSRF-Token",
-				CookiePath:     "/",
-				CookieHTTPOnly: true,
-				CookieSameSite: http.SameSiteStrictMode,
-			}))
-			g1.Use(csrfInit)
-		}
-
-		g1.GET("/", create, logged)
-		g1.POST("/", processCreate, logged)
-		g1.POST("/preview", preview, logged)
-
-		g1.GET("/healthcheck", healthcheck)
-		g1.GET("/metrics", metrics)
-
-		g1.GET("/register", register)
-		g1.POST("/register", processRegister)
-		g1.GET("/login", login)
-		g1.POST("/login", processLogin)
-		g1.GET("/logout", logout)
-		g1.GET("/oauth/:provider", oauth)
-		g1.GET("/oauth/:provider/callback", oauthCallback)
-		g1.GET("/oauth/:provider/unlink", oauthUnlink, logged)
-		g1.POST("/webauthn/bind", beginWebAuthnBinding, logged)
-		g1.POST("/webauthn/bind/finish", finishWebAuthnBinding, logged)
-		g1.POST("/webauthn/login", beginWebAuthnLogin)
-		g1.POST("/webauthn/login/finish", finishWebAuthnLogin)
-		g1.POST("/webauthn/assertion", beginWebAuthnAssertion, inMFASession)
-		g1.POST("/webauthn/assertion/finish", finishWebAuthnAssertion, inMFASession)
-		g1.GET("/mfa", mfa, inMFASession)
-		g1.POST("/mfa/totp/assertion", assertTotp, inMFASession)
-
-		g1.GET("/settings", userSettings, logged)
-		g1.POST("/settings/email", emailProcess, logged)
-		g1.DELETE("/settings/account", accountDeleteProcess, logged)
-		g1.POST("/settings/ssh-keys", sshKeysProcess, logged)
-		g1.DELETE("/settings/ssh-keys/:id", sshKeysDelete, logged)
-		g1.DELETE("/settings/passkeys/:id", passkeyDelete, logged)
-		g1.PUT("/settings/password", passwordProcess, logged)
-		g1.PUT("/settings/username", usernameProcess, logged)
-		g1.GET("/settings/totp/generate", beginTotp, logged)
-		g1.POST("/settings/totp/generate", finishTotp, logged)
-		g1.DELETE("/settings/totp", disableTotp, logged)
-		g1.POST("/settings/totp/regenerate", regenerateTotpRecoveryCodes, logged)
-
-		g2 := g1.Group("/admin-panel")
-		{
-			g2.Use(adminPermission)
-			g2.GET("", adminIndex)
-			g2.GET("/users", adminUsers)
-			g2.POST("/users/:user/delete", adminUserDelete)
-			g2.GET("/gists", adminGists)
-			g2.POST("/gists/:gist/delete", adminGistDelete)
-			g2.GET("/invitations", adminInvitations)
-			g2.POST("/invitations", adminInvitationsCreate)
-			g2.POST("/invitations/:id/delete", adminInvitationsDelete)
-			g2.POST("/sync-fs", adminSyncReposFromFS)
-			g2.POST("/sync-db", adminSyncReposFromDB)
-			g2.POST("/gc-repos", adminGcRepos)
-			g2.POST("/sync-previews", adminSyncGistPreviews)
-			g2.POST("/reset-hooks", adminResetHooks)
-			g2.POST("/index-gists", adminIndexGists)
-			g2.GET("/configuration", adminConfig)
-			g2.PUT("/set-config", adminSetConfig)
-		}
-
-		if config.C.HttpGit {
-			e.Any("/init/*", gitHttp, gistNewPushSoftInit)
-		}
-
-		g1.GET("/all", allGists, checkRequireLogin)
-
-		if index.Enabled() {
-			g1.GET("/search", search, checkRequireLogin)
-		} else {
-			g1.GET("/search", allGists, checkRequireLogin)
-		}
-
-		g1.GET("/:user", allGists, checkRequireLogin)
-		g1.GET("/:user/liked", allGists, checkRequireLogin)
-		g1.GET("/:user/forked", allGists, checkRequireLogin)
-
-		g3 := g1.Group("/:user/:gistname")
-		{
-			g3.Use(makeCheckRequireLogin(true), gistInit)
-			g3.GET("", gistIndex)
-			g3.GET("/rev/:revision", gistIndex)
-			g3.GET("/revisions", revisions)
-			g3.GET("/archive/:revision", downloadZip)
-			g3.POST("/visibility", editVisibility, logged, writePermission)
-			g3.POST("/delete", deleteGist, logged, writePermission)
-			g3.GET("/raw/:revision/:file", rawFile)
-			g3.GET("/download/:revision/:file", downloadFile)
-			g3.GET("/edit", edit, logged, writePermission)
-			g3.POST("/edit", processCreate, logged, writePermission)
-			g3.POST("/like", like, logged)
-			g3.GET("/likes", likes, checkRequireLogin)
-			g3.POST("/fork", fork, logged)
-			g3.GET("/forks", forks, checkRequireLogin)
-			g3.PUT("/checkbox", checkbox, logged, writePermission)
-		}
-	}
-
-	customFs := os.DirFS(filepath.Join(config.GetHomeDir(), "custom"))
-	e.GET("/assets/*", func(ctx echo.Context) error {
-		if _, err := public.Files.Open(path.Join("assets", ctx.Param("*"))); !dev && err == nil {
-			ctx.Response().Header().Set("Cache-Control", "public, max-age=31536000")
-			ctx.Response().Header().Set("Expires", time.Now().AddDate(1, 0, 0).Format(http.TimeFormat))
-
-			return echo.WrapHandler(http.FileServer(http.FS(public.Files)))(ctx)
-		}
-
-		// if the custom file is an .html template, render it
-		if strings.HasSuffix(ctx.Param("*"), ".html") {
-			if err := html(ctx, ctx.Param("*")); err != nil {
-				return notFound("Page not found")
-			}
-			return nil
-		}
-
-		return echo.WrapHandler(http.StripPrefix("/assets/", http.FileServer(http.FS(customFs))))(ctx)
-	})
-
-	// Git HTTP routes
-	if config.C.HttpGit {
-		e.Any("/:user/:gistname/*", gitHttp, gistSoftInit)
-	}
-
-	e.Any("/*", noRouteFound)
-
-	return &Server{echo: e, dev: dev}
-}
-
-func (s *Server) Start() {
-	addr := config.C.HttpHost + ":" + config.C.HttpPort
-
-	log.Info().Msg("Starting HTTP server on http://" + addr)
-	if err := s.echo.Start(addr); err != nil && err != http.ErrServerClosed {
-		log.Fatal().Err(err).Msg("Failed to start HTTP server")
-	}
-}
-
-func (s *Server) Stop() {
-	if err := s.echo.Close(); err != nil {
-		log.Fatal().Err(err).Msg("Failed to stop HTTP server")
-	}
-}
-
-func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	s.echo.ServeHTTP(w, r)
-}
-
-func dataInit(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		ctxValue := context.WithValue(ctx.Request().Context(), dataKey, echo.Map{})
-		ctx.SetRequest(ctx.Request().WithContext(ctxValue))
-		setData(ctx, "loadStartTime", time.Now())
-
-		if err := loadSettings(ctx); err != nil {
-			return errorRes(500, "Cannot read settings from database", err)
-		}
-
-		setData(ctx, "c", config.C)
-
-		setData(ctx, "githubOauth", config.C.GithubClientKey != "" && config.C.GithubSecret != "")
-		setData(ctx, "gitlabOauth", config.C.GitlabClientKey != "" && config.C.GitlabSecret != "")
-		setData(ctx, "giteaOauth", config.C.GiteaClientKey != "" && config.C.GiteaSecret != "")
-		setData(ctx, "oidcOauth", config.C.OIDCClientKey != "" && config.C.OIDCSecret != "" && config.C.OIDCDiscoveryUrl != "")
-
-		httpProtocol := "http"
-		if ctx.Request().TLS != nil || ctx.Request().Header.Get("X-Forwarded-Proto") == "https" {
-			httpProtocol = "https"
-		}
-		setData(ctx, "httpProtocol", strings.ToUpper(httpProtocol))
-
-		var baseHttpUrl string
-		// if a custom external url is set, use it
-		if config.C.ExternalUrl != "" {
-			baseHttpUrl = config.C.ExternalUrl
-		} else {
-			baseHttpUrl = httpProtocol + "://" + ctx.Request().Host
-		}
-
-		setData(ctx, "baseHttpUrl", baseHttpUrl)
-
-		return next(ctx)
-	}
-}
-
-func locale(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		// Check URL arguments
-		lang := ctx.Request().URL.Query().Get("lang")
-		changeLang := lang != ""
-
-		// Then check cookies
-		if len(lang) == 0 {
-			cookie, _ := ctx.Request().Cookie("lang")
-			if cookie != nil {
-				lang = cookie.Value
-			}
-		}
-
-		// Check again in case someone changes the supported language list.
-		if lang != "" && !i18n.Locales.HasLocale(lang) {
-			lang = ""
-			changeLang = false
-		}
-
-		// 3.Then check from 'Accept-Language' header.
-		if len(lang) == 0 {
-			tags, _, _ := language.ParseAcceptLanguage(ctx.Request().Header.Get("Accept-Language"))
-			lang = i18n.Locales.MatchTag(tags)
-		}
-
-		if changeLang {
-			ctx.SetCookie(&http.Cookie{Name: "lang", Value: lang, Path: "/", MaxAge: 1<<31 - 1})
-		}
-
-		localeUsed, err := i18n.Locales.GetLocale(lang)
-		if err != nil {
-			return errorRes(500, "Cannot get locale", err)
-		}
-
-		setData(ctx, "localeName", localeUsed.Name)
-		setData(ctx, "locale", localeUsed)
-		setData(ctx, "allLocales", i18n.Locales.Locales)
-
-		return next(ctx)
-	}
-}
-
-func sessionInit(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		sess := getSession(ctx)
-		if sess.Values["user"] != nil {
-			var err error
-			var user *db.User
-
-			if user, err = db.GetUserById(sess.Values["user"].(uint)); err != nil {
-				sess.Values["user"] = nil
-				saveSession(sess, ctx)
-				setData(ctx, "userLogged", nil)
-				return redirect(ctx, "/all")
-			}
-			if user != nil {
-				setData(ctx, "userLogged", user)
-			}
-			return next(ctx)
-		}
-
-		setData(ctx, "userLogged", nil)
-		return next(ctx)
-	}
-}
-
-func csrfInit(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		setCsrfHtmlForm(ctx)
-		return next(ctx)
-	}
-}
-
-func writePermission(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		gist := getData(ctx, "gist")
-		user := getUserLogged(ctx)
-		if !gist.(*db.Gist).CanWrite(user) {
-			return redirect(ctx, "/"+gist.(*db.Gist).User.Username+"/"+gist.(*db.Gist).Identifier())
-		}
-		return next(ctx)
-	}
-}
-
-func adminPermission(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		user := getUserLogged(ctx)
-		if user == nil || !user.IsAdmin {
-			return notFound("User not found")
-		}
-		return next(ctx)
-	}
-}
-
-func logged(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		user := getUserLogged(ctx)
-		if user != nil {
-			return next(ctx)
-		}
-		return redirect(ctx, "/all")
-	}
-}
-
-func inMFASession(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(ctx echo.Context) error {
-		sess := getSession(ctx)
-		_, ok := sess.Values["mfaID"].(uint)
-		if !ok {
-			return errorRes(400, tr(ctx, "error.not-in-mfa-session"), nil)
-		}
-		return next(ctx)
-	}
-}
-
-func makeCheckRequireLogin(isSingleGistAccess bool) echo.MiddlewareFunc {
-	return func(next echo.HandlerFunc) echo.HandlerFunc {
-		return func(ctx echo.Context) error {
-			if user := getUserLogged(ctx); user != nil {
-				return next(ctx)
-			}
-
-			allow, err := auth.ShouldAllowUnauthenticatedGistAccess(ContextAuthInfo{ctx}, isSingleGistAccess)
-			if err != nil {
-				log.Fatal().Err(err).Msg("Failed to check if unauthenticated access is allowed")
-			}
-
-			if !allow {
-				addFlash(ctx, tr(ctx, "flash.auth.must-be-logged-in"), "error")
-				return redirect(ctx, "/login")
-			}
-			return next(ctx)
-		}
-	}
-}
-
-func checkRequireLogin(next echo.HandlerFunc) echo.HandlerFunc {
-	return makeCheckRequireLogin(false)(next)
-}
-
-func noRouteFound(echo.Context) error {
-	return notFound("Page not found")
-}
-
-// ---
-
-type Asset struct {
-	File string `json:"file"`
-}
-
-var manifestEntries map[string]Asset
-
-func parseManifestEntries() {
-	file, err := public.Files.Open("manifest.json")
-	if err != nil {
-		log.Fatal().Err(err).Msg("Failed to open manifest.json")
-	}
-	byteValue, err := io.ReadAll(file)
-	if err != nil {
-		log.Fatal().Err(err).Msg("Failed to read manifest.json")
-	}
-	if err = gojson.Unmarshal(byteValue, &manifestEntries); err != nil {
-		log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json")
-	}
-}
-
-func defaultAvatar() string {
-	if dev {
-		return "http://localhost:16157/default.png"
-	}
-	return config.C.ExternalUrl + "/" + manifestEntries["default.png"].File
-}
-
-func asset(file string) string {
-	if dev {
-		return "http://localhost:16157/" + file
-	}
-	return config.C.ExternalUrl + "/" + manifestEntries[file].File
-}
-
-func customAsset(file string) string {
-	assetpath, err := url.JoinPath("/", "assets", file)
-	if err != nil {
-		log.Error().Err(err).Msgf("Failed to join path for custom file %s", file)
-	}
-	return config.C.ExternalUrl + assetpath
-}
diff --git a/internal/web/server/middlewares.go b/internal/web/server/middlewares.go
new file mode 100644
index 0000000..4544447
--- /dev/null
+++ b/internal/web/server/middlewares.go
@@ -0,0 +1,175 @@
+package server
+
+import (
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/echo/v4/middleware"
+	"github.com/rs/zerolog/log"
+	"github.com/thomiceli/opengist/internal/config"
+	"github.com/thomiceli/opengist/internal/db"
+	"github.com/thomiceli/opengist/internal/i18n"
+	"github.com/thomiceli/opengist/internal/web/context"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+	"net/http"
+	"strings"
+	"time"
+)
+
+func (s *Server) useCustomContext() {
+	s.echo.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			cc := context.NewContext(c)
+			return next(cc)
+		}
+	})
+}
+
+func (s *Server) RegisterMiddlewares(e *echo.Echo) {
+	e.Use(Middleware(dataInit).ToEcho())
+	e.Use(Middleware(locale).ToEcho())
+
+	e.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
+		Getter: middleware.MethodFromForm("_method"),
+	}))
+	e.Pre(middleware.RemoveTrailingSlash())
+	e.Pre(middleware.CORS())
+	e.Pre(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
+		LogURI: true, LogStatus: true, LogMethod: true,
+		LogValuesFunc: func(ctx echo.Context, v middleware.RequestLoggerValues) error {
+			log.Info().Str("uri", v.URI).Int("status", v.Status).Str("method", v.Method).
+				Str("ip", ctx.RealIP()).TimeDiff("duration", time.Now(), v.StartTime).
+				Msg("HTTP")
+			return nil
+		},
+	}))
+	e.Use(middleware.Recover())
+	e.Use(middleware.Secure())
+
+	e.Use(Middleware(sessionInit).ToEcho())
+}
+
+func dataInit(next Handler) Handler {
+	return func(ctx *context.OGContext) error {
+		ctx.SetData("loadStartTime", time.Now())
+
+		if err := loadSettings(ctx); err != nil {
+			return ctx.ErrorRes(500, "Cannot load settings", err)
+		}
+
+		ctx.SetData("c", config.C)
+
+		ctx.SetData("githubOauth", config.C.GithubClientKey != "" && config.C.GithubSecret != "")
+		ctx.SetData("gitlabOauth", config.C.GitlabClientKey != "" && config.C.GitlabSecret != "")
+		ctx.SetData("giteaOauth", config.C.GiteaClientKey != "" && config.C.GiteaSecret != "")
+		ctx.SetData("oidcOauth", config.C.OIDCClientKey != "" && config.C.OIDCSecret != "" && config.C.OIDCDiscoveryUrl != "")
+
+		httpProtocol := "http"
+		if ctx.Request().TLS != nil || ctx.Request().Header.Get("X-Forwarded-Proto") == "https" {
+			httpProtocol = "https"
+		}
+		ctx.SetData("httpProtocol", strings.ToUpper(httpProtocol))
+
+		var baseHttpUrl string
+		// if a custom external url is set, use it
+		if config.C.ExternalUrl != "" {
+			baseHttpUrl = config.C.ExternalUrl
+		} else {
+			baseHttpUrl = httpProtocol + "://" + ctx.Request().Host
+		}
+
+		ctx.SetData("baseHttpUrl", baseHttpUrl)
+
+		return next(ctx)
+	}
+}
+
+func locale(next Handler) Handler {
+	return func(ctx *context.OGContext) error {
+		// Check URL arguments
+		lang := ctx.Request().URL.Query().Get("lang")
+		changeLang := lang != ""
+
+		// Then check cookies
+		if len(lang) == 0 {
+			cookie, _ := ctx.Request().Cookie("lang")
+			if cookie != nil {
+				lang = cookie.Value
+			}
+		}
+
+		// Check again in case someone changes the supported language list.
+		if lang != "" && !i18n.Locales.HasLocale(lang) {
+			lang = ""
+			changeLang = false
+		}
+
+		// 3.Then check from 'Accept-Language' header.
+		if len(lang) == 0 {
+			tags, _, _ := language.ParseAcceptLanguage(ctx.Request().Header.Get("Accept-Language"))
+			lang = i18n.Locales.MatchTag(tags)
+		}
+
+		if changeLang {
+			ctx.SetCookie(&http.Cookie{Name: "lang", Value: lang, Path: "/", MaxAge: 1<<31 - 1})
+		}
+
+		localeUsed, err := i18n.Locales.GetLocale(lang)
+		if err != nil {
+			return ctx.ErrorRes(500, "Cannot get locale", err)
+		}
+
+		ctx.SetData("localeName", localeUsed.Name)
+		ctx.SetData("locale", localeUsed)
+		ctx.SetData("allLocales", i18n.Locales.Locales)
+
+		return next(ctx)
+	}
+}
+
+func sessionInit(next Handler) Handler {
+	return func(ctx *context.OGContext) error {
+		sess := ctx.GetSession()
+		if sess.Values["user"] != nil {
+			var err error
+			var user *db.User
+
+			if user, err = db.GetUserById(sess.Values["user"].(uint)); err != nil {
+				sess.Values["user"] = nil
+				ctx.SaveSession(sess)
+				ctx.User = nil
+				ctx.SetData("userLogged", nil)
+				return ctx.RedirectTo("/all")
+			}
+			if user != nil {
+				ctx.User = user
+				ctx.SetData("userLogged", user)
+			}
+			return next(ctx)
+		}
+
+		ctx.User = nil
+		ctx.SetData("userLogged", nil)
+		return next(ctx)
+	}
+}
+
+func csrfInit(next Handler) Handler {
+	return func(ctx *context.OGContext) error {
+		setCsrfHtmlForm(ctx)
+		return next(ctx)
+	}
+}
+
+func loadSettings(ctx *context.OGContext) error {
+	settings, err := db.GetSettings()
+	if err != nil {
+		return err
+	}
+
+	for key, value := range settings {
+		s := strings.ReplaceAll(key, "-", " ")
+		s = cases.Title(language.English).String(s)
+		ctx.SetData(strings.ReplaceAll(s, " ", ""), value == "1")
+	}
+	return nil
+}
diff --git a/internal/web/server/renderer.go b/internal/web/server/renderer.go
new file mode 100644
index 0000000..ad4cb6c
--- /dev/null
+++ b/internal/web/server/renderer.go
@@ -0,0 +1,216 @@
+package server
+
+import (
+	gojson "encoding/json"
+	"errors"
+	"fmt"
+	"github.com/rs/zerolog/log"
+	"github.com/thomiceli/opengist/internal/config"
+	"github.com/thomiceli/opengist/internal/db"
+	"github.com/thomiceli/opengist/internal/git"
+	"github.com/thomiceli/opengist/internal/index"
+	"github.com/thomiceli/opengist/internal/web/handler"
+	"github.com/thomiceli/opengist/public"
+	"github.com/thomiceli/opengist/templates"
+	htmlpkg "html"
+	"html/template"
+	"io"
+	"net/http"
+	"net/url"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+var re = regexp.MustCompile("[^a-z0-9]+")
+
+func (s *Server) setFuncMap() {
+	fm := template.FuncMap{
+		"split":     strings.Split,
+		"indexByte": strings.IndexByte,
+		"toInt": func(i string) int {
+			val, _ := strconv.Atoi(i)
+			return val
+		},
+		"inc": func(i int) int {
+			return i + 1
+		},
+		"splitGit": func(i string) []string {
+			return strings.FieldsFunc(i, func(r rune) bool {
+				return r == ',' || r == ' '
+			})
+		},
+		"lines": func(i string) []string {
+			return strings.Split(i, "\n")
+		},
+		"isMarkdown": func(i string) bool {
+			return strings.ToLower(filepath.Ext(i)) == ".md"
+		},
+		"isCsv": func(i string) bool {
+			return strings.ToLower(filepath.Ext(i)) == ".csv"
+		},
+		"isSvg": func(i string) bool {
+			return strings.ToLower(filepath.Ext(i)) == ".svg"
+		},
+		"csvFile": func(file *git.File) *git.CsvFile {
+			if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" {
+				return nil
+			}
+
+			csvFile, err := git.ParseCsv(file)
+			if err != nil {
+				return nil
+			}
+
+			return csvFile
+		},
+		"httpStatusText": http.StatusText,
+		"loadedTime": func(startTime time.Time) string {
+			return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
+		},
+		"slug": func(s string) string {
+			return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-")
+		},
+		"avatarUrl": func(user *db.User, noGravatar bool) string {
+			if user.AvatarURL != "" {
+				return user.AvatarURL
+			}
+
+			if user.MD5Hash != "" && !noGravatar {
+				return "https://www.gravatar.com/avatar/" + user.MD5Hash + "?d=identicon&s=200"
+			}
+
+			return defaultAvatar()
+		},
+		"asset":  asset,
+		"custom": customAsset,
+		"dev": func() bool {
+			return s.dev
+		},
+		"defaultAvatar": defaultAvatar,
+		"visibilityStr": func(visibility db.Visibility, lowercase bool) string {
+			s := "Public"
+			switch visibility {
+			case 1:
+				s = "Unlisted"
+			case 2:
+				s = "Private"
+			}
+
+			if lowercase {
+				return strings.ToLower(s)
+			}
+			return s
+		},
+		"unescape": htmlpkg.UnescapeString,
+		"join": func(s ...string) string {
+			return strings.Join(s, "")
+		},
+		"toStr": func(i interface{}) string {
+			return fmt.Sprint(i)
+		},
+		"safe": func(s string) template.HTML {
+			return template.HTML(s)
+		},
+		"dict": func(values ...interface{}) (map[string]interface{}, error) {
+			if len(values)%2 != 0 {
+				return nil, errors.New("invalid dict call")
+			}
+			dict := make(map[string]interface{})
+			for i := 0; i < len(values); i += 2 {
+				key, ok := values[i].(string)
+				if !ok {
+					return nil, errors.New("dict keys must be strings")
+				}
+				dict[key] = values[i+1]
+			}
+			return dict, nil
+		},
+		"addMetadataToSearchQuery": addMetadataToSearchQuery,
+		"indexEnabled":             index.Enabled,
+		"isUrl": func(s string) bool {
+			_, err := url.ParseRequestURI(s)
+			return err == nil
+		},
+	}
+
+	t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html"))
+	customPattern := filepath.Join(config.GetHomeDir(), "custom", "*.html")
+	matches, err := filepath.Glob(customPattern)
+	if err != nil {
+		log.Fatal().Err(err).Msg("Failed to check for custom templates")
+	}
+	if len(matches) > 0 {
+		t, err = t.ParseGlob(customPattern)
+		if err != nil {
+			log.Fatal().Err(err).Msg("Failed to parse custom templates")
+		}
+	}
+	s.echo.Renderer = &Template{
+		templates: t,
+	}
+}
+
+type Asset struct {
+	File string `json:"file"`
+}
+
+var ManifestEntries map[string]Asset
+
+func parseManifestEntries() {
+	file, err := public.Files.Open("manifest.json")
+	if err != nil {
+		log.Fatal().Err(err).Msg("Failed to open manifest.json")
+	}
+	byteValue, err := io.ReadAll(file)
+	if err != nil {
+		log.Fatal().Err(err).Msg("Failed to read manifest.json")
+	}
+	if err = gojson.Unmarshal(byteValue, &ManifestEntries); err != nil {
+		log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json")
+	}
+}
+
+var dev = true
+
+func defaultAvatar() string {
+	if dev {
+		return "http://localhost:16157/default.png"
+	}
+	return config.C.ExternalUrl + "/" + ManifestEntries["default.png"].File
+}
+
+func asset(file string) string {
+	if dev {
+		return "http://localhost:16157/" + file
+	}
+	return config.C.ExternalUrl + "/" + ManifestEntries[file].File
+}
+
+func customAsset(file string) string {
+	assetpath, err := url.JoinPath("/", "assets", file)
+	if err != nil {
+		log.Error().Err(err).Msgf("Failed to join path for custom file %s", file)
+	}
+	return config.C.ExternalUrl + assetpath
+}
+
+func addMetadataToSearchQuery(input, key, value string) string {
+	content, metadata := handler.ParseSearchQueryStr(input)
+
+	metadata[key] = value
+
+	var resultBuilder strings.Builder
+	resultBuilder.WriteString(content)
+
+	for k, v := range metadata {
+		resultBuilder.WriteString(" ")
+		resultBuilder.WriteString(k)
+		resultBuilder.WriteString(":")
+		resultBuilder.WriteString(v)
+	}
+
+	return strings.TrimSpace(resultBuilder.String())
+}
diff --git a/internal/web/server/router.go b/internal/web/server/router.go
new file mode 100644
index 0000000..abb4e43
--- /dev/null
+++ b/internal/web/server/router.go
@@ -0,0 +1 @@
+package server
diff --git a/internal/web/server/server.go b/internal/web/server/server.go
new file mode 100644
index 0000000..6066952
--- /dev/null
+++ b/internal/web/server/server.go
@@ -0,0 +1,319 @@
+package server
+
+import (
+	"errors"
+	"github.com/thomiceli/opengist/internal/web/context"
+	"github.com/thomiceli/opengist/internal/web/handler"
+	"html/template"
+	"io"
+	"net/http"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/thomiceli/opengist/internal/index"
+	"github.com/thomiceli/opengist/internal/utils"
+
+	"github.com/gorilla/sessions"
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/echo/v4/middleware"
+	"github.com/rs/zerolog/log"
+	"github.com/thomiceli/opengist/internal/auth"
+	"github.com/thomiceli/opengist/internal/config"
+	"github.com/thomiceli/opengist/internal/db"
+	"github.com/thomiceli/opengist/internal/i18n"
+	"github.com/thomiceli/opengist/public"
+)
+
+type Template struct {
+	templates *template.Template
+}
+
+func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error {
+	return t.templates.ExecuteTemplate(w, name, data)
+}
+
+type Server struct {
+	echo       *echo.Echo
+	flashStore *sessions.CookieStore     // session store for flash messages
+	UserStore  *sessions.FilesystemStore // session store for user sessions
+
+	dev          bool
+	sessionsPath string
+	ignoreCsrf   bool
+}
+
+func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server {
+	e := echo.New()
+	e.HideBanner = true
+	e.HidePort = true
+
+	s := &Server{echo: e, dev: isDev, sessionsPath: sessionsPath, ignoreCsrf: ignoreCsrf}
+
+	s.useCustomContext()
+
+	if err := i18n.Locales.LoadAll(); err != nil {
+		log.Fatal().Err(err).Msg("Failed to load locales")
+	}
+
+	s.RegisterMiddlewares(e)
+	s.setFuncMap()
+	s.setHTTPErrorHandler()
+
+	e.Validator = utils.NewValidator()
+
+	if !s.dev {
+		parseManifestEntries()
+	}
+
+	// Web based routes
+	g1 := e.Group("")
+	{
+		if !ignoreCsrf {
+			g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
+				TokenLookup:    "form:_csrf,header:X-CSRF-Token",
+				CookiePath:     "/",
+				CookieHTTPOnly: true,
+				CookieSameSite: http.SameSiteStrictMode,
+			}))
+			g1.Use(Middleware(csrfInit).ToEcho())
+		}
+
+		g1.GET("/", Handler(handler.Create).ToEcho(), logged)
+		g1.POST("/", Handler(handler.ProcessCreate).ToEcho(), logged)
+		g1.POST("/preview", preview, logged)
+
+		g1.GET("/healthcheck", healthcheck)
+		g1.GET("/metrics", metrics)
+
+		g1.GET("/register", register)
+		g1.POST("/register", processRegister)
+		g1.GET("/login", login)
+		g1.POST("/login", processLogin)
+		g1.GET("/logout", logout)
+		g1.GET("/oauth/:provider", oauth)
+		g1.GET("/oauth/:provider/callback", oauthCallback)
+		g1.GET("/oauth/:provider/unlink", oauthUnlink, logged)
+		g1.POST("/webauthn/bind", beginWebAuthnBinding, logged)
+		g1.POST("/webauthn/bind/finish", finishWebAuthnBinding, logged)
+		g1.POST("/webauthn/login", beginWebAuthnLogin)
+		g1.POST("/webauthn/login/finish", finishWebAuthnLogin)
+		g1.POST("/webauthn/assertion", beginWebAuthnAssertion, inMFASession)
+		g1.POST("/webauthn/assertion/finish", finishWebAuthnAssertion, inMFASession)
+		g1.GET("/mfa", mfa, inMFASession)
+		g1.POST("/mfa/totp/assertion", assertTotp, inMFASession)
+
+		g1.GET("/settings", userSettings, logged)
+		g1.POST("/settings/email", emailProcess, logged)
+		g1.DELETE("/settings/account", accountDeleteProcess, logged)
+		g1.POST("/settings/ssh-keys", sshKeysProcess, logged)
+		g1.DELETE("/settings/ssh-keys/:id", sshKeysDelete, logged)
+		g1.DELETE("/settings/passkeys/:id", passkeyDelete, logged)
+		g1.PUT("/settings/password", passwordProcess, logged)
+		g1.PUT("/settings/username", usernameProcess, logged)
+		g1.GET("/settings/totp/generate", beginTotp, logged)
+		g1.POST("/settings/totp/generate", finishTotp, logged)
+		g1.DELETE("/settings/totp", disableTotp, logged)
+		g1.POST("/settings/totp/regenerate", regenerateTotpRecoveryCodes, logged)
+
+		g2 := g1.Group("/admin-panel")
+		{
+			g2.Use(adminPermission)
+			g2.GET("", adminIndex)
+			g2.GET("/users", Handler(adminUsers).ToEcho())
+			g2.POST("/users/:user/delete", adminUserDelete)
+			g2.GET("/gists", adminGists)
+			g2.POST("/gists/:gist/delete", adminGistDelete)
+			g2.GET("/invitations", adminInvitations)
+			g2.POST("/invitations", adminInvitationsCreate)
+			g2.POST("/invitations/:id/delete", adminInvitationsDelete)
+			g2.POST("/sync-fs", adminSyncReposFromFS)
+			g2.POST("/sync-db", adminSyncReposFromDB)
+			g2.POST("/gc-repos", adminGcRepos)
+			g2.POST("/sync-previews", adminSyncGistPreviews)
+			g2.POST("/reset-hooks", adminResetHooks)
+			g2.POST("/index-gists", adminIndexGists)
+			g2.GET("/configuration", adminConfig)
+			g2.PUT("/set-config", adminSetConfig)
+		}
+
+		if config.C.HttpGit {
+			e.Any("/init/*", gitHttp, gistNewPushSoftInit)
+		}
+
+		g1.GET("/all", allGists, checkRequireLogin)
+
+		if index.Enabled() {
+			g1.GET("/search", search, checkRequireLogin)
+		} else {
+			g1.GET("/search", allGists, checkRequireLogin)
+		}
+
+		g1.GET("/:user", allGists, checkRequireLogin)
+		g1.GET("/:user/liked", allGists, checkRequireLogin)
+		g1.GET("/:user/forked", allGists, checkRequireLogin)
+
+		g3 := g1.Group("/:user/:gistname")
+		{
+			g3.Use(makeCheckRequireLogin(true), gistInit)
+			g3.GET("", gistIndex)
+			g3.GET("/rev/:revision", gistIndex)
+			g3.GET("/revisions", revisions)
+			g3.GET("/archive/:revision", downloadZip)
+			g3.POST("/visibility", editVisibility, logged, writePermission)
+			g3.POST("/delete", deleteGist, logged, writePermission)
+			g3.GET("/raw/:revision/:file", rawFile)
+			g3.GET("/download/:revision/:file", downloadFile)
+			g3.GET("/edit", edit, logged, writePermission)
+			g3.POST("/edit", processCreate, logged, writePermission)
+			g3.POST("/like", like, logged)
+			g3.GET("/likes", likes, checkRequireLogin)
+			g3.POST("/fork", fork, logged)
+			g3.GET("/forks", forks, checkRequireLogin)
+			g3.PUT("/checkbox", checkbox, logged, writePermission)
+		}
+	}
+
+	customFs := os.DirFS(filepath.Join(config.GetHomeDir(), "custom"))
+	e.GET("/assets/*", func(ctx echo.Context) error {
+		if _, err := public.Files.Open(path.Join("assets", ctx.Param("*"))); !dev && err == nil {
+			ctx.Response().Header().Set("Cache-Control", "public, max-age=31536000")
+			ctx.Response().Header().Set("Expires", time.Now().AddDate(1, 0, 0).Format(http.TimeFormat))
+
+			return echo.WrapHandler(http.FileServer(http.FS(public.Files)))(ctx)
+		}
+
+		// if the custom file is an .html template, render it
+		if strings.HasSuffix(ctx.Param("*"), ".html") {
+			if err := html(ctx, ctx.Param("*")); err != nil {
+				return notFound("Page not found")
+			}
+			return nil
+		}
+
+		return echo.WrapHandler(http.StripPrefix("/assets/", http.FileServer(http.FS(customFs))))(ctx)
+	})
+
+	// Git HTTP routes
+	if config.C.HttpGit {
+		e.Any("/:user/:gistname/*", gitHttp, gistSoftInit)
+	}
+
+	e.Any("/*", noRouteFound)
+
+	return s
+}
+
+func (s *Server) Start() {
+	addr := config.C.HttpHost + ":" + config.C.HttpPort
+
+	log.Info().Msg("Starting HTTP server on http://" + addr)
+	if err := s.echo.Start(addr); err != nil && err != http.ErrServerClosed {
+		log.Fatal().Err(err).Msg("Failed to start HTTP server")
+	}
+}
+
+func (s *Server) Stop() {
+	if err := s.echo.Close(); err != nil {
+		log.Fatal().Err(err).Msg("Failed to stop HTTP server")
+	}
+}
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	s.echo.ServeHTTP(w, r)
+}
+
+func writePermission(next echo.HandlerFunc) echo.HandlerFunc {
+	return func(ctx echo.Context) error {
+		gist := getData(ctx, "gist")
+		user := getUserLogged(ctx)
+		if !gist.(*db.Gist).CanWrite(user) {
+			return redirect(ctx, "/"+gist.(*db.Gist).User.Username+"/"+gist.(*db.Gist).Identifier())
+		}
+		return next(ctx)
+	}
+}
+
+func adminPermission(next echo.HandlerFunc) echo.HandlerFunc {
+	return func(ctx echo.Context) error {
+		user := getUserLogged(ctx)
+		if user == nil || !user.IsAdmin {
+			return notFound("User not found")
+		}
+		return next(ctx)
+	}
+}
+
+func logged(next echo.HandlerFunc) echo.HandlerFunc {
+	return func(ctx echo.Context) error {
+		user := getUserLogged(ctx)
+		if user != nil {
+			return next(ctx)
+		}
+		return redirect(ctx, "/all")
+	}
+}
+
+func inMFASession(next echo.HandlerFunc) echo.HandlerFunc {
+	return func(ctx echo.Context) error {
+		sess := getSession(ctx)
+		_, ok := sess.Values["mfaID"].(uint)
+		if !ok {
+			return errorRes(400, tr(ctx, "error.not-in-mfa-session"), nil)
+		}
+		return next(ctx)
+	}
+}
+
+func makeCheckRequireLogin(isSingleGistAccess bool) echo.MiddlewareFunc {
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(ctx echo.Context) error {
+			if user := getUserLogged(ctx); user != nil {
+				return next(ctx)
+			}
+
+			allow, err := auth.ShouldAllowUnauthenticatedGistAccess(ContextAuthInfo{ctx}, isSingleGistAccess)
+			if err != nil {
+				log.Fatal().Err(err).Msg("Failed to check if unauthenticated access is allowed")
+			}
+
+			if !allow {
+				addFlash(ctx, tr(ctx, "flash.auth.must-be-logged-in"), "error")
+				return redirect(ctx, "/login")
+			}
+			return next(ctx)
+		}
+	}
+}
+
+func checkRequireLogin(next echo.HandlerFunc) echo.HandlerFunc {
+	return makeCheckRequireLogin(false)(next)
+}
+
+func noRouteFound(echo.Context) error {
+	return notFound("Page not found")
+}
+
+func (s *Server) setHTTPErrorHandler() {
+	s.echo.HTTPErrorHandler = func(er error, c echo.Context) {
+		ctx := c.(*context.OGContext)
+		var httpErr *echo.HTTPError
+		if errors.As(er, &httpErr) {
+			acceptJson := strings.Contains(ctx.Request().Header.Get("Accept"), "application/json")
+			ctx.SetData("error", er)
+			if acceptJson {
+				if fatalErr := jsonWithCode(ctx, httpErr.Code, httpErr); fatalErr != nil {
+					log.Fatal().Err(fatalErr).Send()
+				}
+			} else {
+				if fatalErr := htmlWithCode(ctx, httpErr.Code, "error.html"); fatalErr != nil {
+					log.Fatal().Err(fatalErr).Send()
+				}
+			}
+		} else {
+			log.Fatal().Err(er).Send()
+		}
+	}
+}
diff --git a/internal/web/server/util.go b/internal/web/server/util.go
new file mode 100644
index 0000000..e9872e5
--- /dev/null
+++ b/internal/web/server/util.go
@@ -0,0 +1,33 @@
+package server
+
+import (
+	"github.com/labstack/echo/v4"
+	"github.com/thomiceli/opengist/internal/web/context"
+	"html/template"
+)
+
+func setCsrfHtmlForm(ctx *context.OGContext) {
+	var csrf string
+	if csrfToken, ok := ctx.Get("csrf").(string); ok {
+		csrf = csrfToken
+	}
+	ctx.SetData("csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
+	ctx.SetData("csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
+}
+
+type Handler func(ctx *context.OGContext) error
+type Middleware func(next Handler) Handler
+
+func (h Handler) ToEcho() echo.HandlerFunc {
+	return func(c echo.Context) error {
+		return h(c.(*context.OGContext))
+	}
+}
+
+func (m Middleware) ToEcho() echo.MiddlewareFunc {
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return m(func(c *context.OGContext) error {
+			return next(c)
+		}).ToEcho()
+	}
+}
diff --git a/internal/web/settings.go b/internal/web/settings.go
deleted file mode 100644
index f6b6324..0000000
--- a/internal/web/settings.go
+++ /dev/null
@@ -1,227 +0,0 @@
-package web
-
-import (
-	"crypto/md5"
-	"fmt"
-	"github.com/thomiceli/opengist/internal/config"
-	"github.com/thomiceli/opengist/internal/git"
-	"github.com/thomiceli/opengist/internal/i18n"
-	"github.com/thomiceli/opengist/internal/utils"
-	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/labstack/echo/v4"
-	"github.com/thomiceli/opengist/internal/db"
-	"golang.org/x/crypto/ssh"
-)
-
-func userSettings(ctx echo.Context) error {
-	user := getUserLogged(ctx)
-
-	keys, err := db.GetSSHKeysByUserID(user.ID)
-	if err != nil {
-		return errorRes(500, "Cannot get SSH keys", err)
-	}
-
-	passkeys, err := db.GetAllCredentialsForUser(user.ID)
-	if err != nil {
-		return errorRes(500, "Cannot get WebAuthn credentials", err)
-	}
-
-	_, hasTotp, err := user.HasMFA()
-	if err != nil {
-		return errorRes(500, "Cannot get MFA status", err)
-	}
-
-	setData(ctx, "email", user.Email)
-	setData(ctx, "sshKeys", keys)
-	setData(ctx, "passkeys", passkeys)
-	setData(ctx, "hasTotp", hasTotp)
-	setData(ctx, "hasPassword", user.Password != "")
-	setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
-	setData(ctx, "htmlTitle", trH(ctx, "settings"))
-	return html(ctx, "settings.html")
-}
-
-func emailProcess(ctx echo.Context) error {
-	user := getUserLogged(ctx)
-	email := ctx.FormValue("email")
-	var hash string
-
-	if email == "" {
-		// generate random md5 string
-		hash = fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String())))
-	} else {
-		hash = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))))
-	}
-
-	user.Email = strings.ToLower(email)
-	user.MD5Hash = hash
-
-	if err := user.Update(); err != nil {
-		return errorRes(500, "Cannot update email", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.user.email-updated"), "success")
-	return redirect(ctx, "/settings")
-}
-
-func accountDeleteProcess(ctx echo.Context) error {
-	user := getUserLogged(ctx)
-
-	if err := user.Delete(); err != nil {
-		return errorRes(500, "Cannot delete this user", err)
-	}
-
-	return redirect(ctx, "/all")
-}
-
-func sshKeysProcess(ctx echo.Context) error {
-	user := getUserLogged(ctx)
-
-	dto := new(db.SSHKeyDTO)
-	if err := ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
-	}
-
-	if err := ctx.Validate(dto); err != nil {
-		addFlash(ctx, utils.ValidationMessages(&err, getData(ctx, "locale").(*i18n.Locale)), "error")
-		return redirect(ctx, "/settings")
-	}
-	key := dto.ToSSHKey()
-
-	key.UserID = user.ID
-
-	pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
-	if err != nil {
-		addFlash(ctx, tr(ctx, "flash.user.invalid-ssh-key"), "error")
-		return redirect(ctx, "/settings")
-	}
-	key.Content = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
-
-	if exists, err := db.SSHKeyDoesExists(key.Content); exists {
-		if err != nil {
-			return errorRes(500, "Cannot check if SSH key exists", err)
-		}
-		addFlash(ctx, tr(ctx, "settings.ssh-key-exists"), "error")
-		return redirect(ctx, "/settings")
-	}
-
-	if err := key.Create(); err != nil {
-		return errorRes(500, "Cannot add SSH key", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.user.ssh-key-added"), "success")
-	return redirect(ctx, "/settings")
-}
-
-func sshKeysDelete(ctx echo.Context) error {
-	user := getUserLogged(ctx)
-	keyId, err := strconv.Atoi(ctx.Param("id"))
-	if err != nil {
-		return redirect(ctx, "/settings")
-	}
-
-	key, err := db.GetSSHKeyByID(uint(keyId))
-
-	if err != nil || key.UserID != user.ID {
-		return redirect(ctx, "/settings")
-	}
-
-	if err := key.Delete(); err != nil {
-		return errorRes(500, "Cannot delete SSH key", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.user.ssh-key-deleted"), "success")
-	return redirect(ctx, "/settings")
-}
-
-func passkeyDelete(ctx echo.Context) error {
-	user := getUserLogged(ctx)
-	keyId, err := strconv.Atoi(ctx.Param("id"))
-	if err != nil {
-		return redirect(ctx, "/settings")
-	}
-
-	passkey, err := db.GetCredentialByIDDB(uint(keyId))
-	if err != nil || passkey.UserID != user.ID {
-		return redirect(ctx, "/settings")
-	}
-
-	if err := passkey.Delete(); err != nil {
-		return errorRes(500, "Cannot delete passkey", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.auth.passkey-deleted"), "success")
-	return redirect(ctx, "/settings")
-}
-
-func passwordProcess(ctx echo.Context) error {
-	user := getUserLogged(ctx)
-
-	dto := new(db.UserDTO)
-	if err := ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
-	}
-	dto.Username = user.Username
-
-	if err := ctx.Validate(dto); err != nil {
-		addFlash(ctx, utils.ValidationMessages(&err, getData(ctx, "locale").(*i18n.Locale)), "error")
-		return html(ctx, "settings.html")
-	}
-
-	password, err := utils.Argon2id.Hash(dto.Password)
-	if err != nil {
-		return errorRes(500, "Cannot hash password", err)
-	}
-	user.Password = password
-
-	if err = user.Update(); err != nil {
-		return errorRes(500, "Cannot update password", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.user.password-updated"), "success")
-	return redirect(ctx, "/settings")
-}
-
-func usernameProcess(ctx echo.Context) error {
-	user := getUserLogged(ctx)
-
-	dto := new(db.UserDTO)
-	if err := ctx.Bind(dto); err != nil {
-		return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
-	}
-	dto.Password = user.Password
-
-	if err := ctx.Validate(dto); err != nil {
-		addFlash(ctx, utils.ValidationMessages(&err, getData(ctx, "locale").(*i18n.Locale)), "error")
-		return redirect(ctx, "/settings")
-	}
-
-	if exists, err := db.UserExists(dto.Username); err != nil || exists {
-		addFlash(ctx, tr(ctx, "flash.auth.username-exists"), "error")
-		return redirect(ctx, "/settings")
-	}
-
-	sourceDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(user.Username))
-	destinationDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(dto.Username))
-
-	if _, err := os.Stat(sourceDir); !os.IsNotExist(err) {
-		err := os.Rename(sourceDir, destinationDir)
-		if err != nil {
-			return errorRes(500, "Cannot rename user directory", err)
-		}
-	}
-
-	user.Username = dto.Username
-
-	if err := user.Update(); err != nil {
-		return errorRes(500, "Cannot update username", err)
-	}
-
-	addFlash(ctx, tr(ctx, "flash.user.username-updated"), "success")
-	return redirect(ctx, "/settings")
-}
diff --git a/internal/web/util.go b/internal/web/util.go
deleted file mode 100644
index d0adc7b..0000000
--- a/internal/web/util.go
+++ /dev/null
@@ -1,248 +0,0 @@
-package web
-
-import (
-	"context"
-	"errors"
-	"github.com/gorilla/sessions"
-	"github.com/labstack/echo/v4"
-	"github.com/rs/zerolog/log"
-	"github.com/thomiceli/opengist/internal/config"
-	"github.com/thomiceli/opengist/internal/db"
-	"github.com/thomiceli/opengist/internal/i18n"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-	"html/template"
-	"net/http"
-	"strconv"
-	"strings"
-)
-
-type dataTypeKey string
-
-const dataKey dataTypeKey = "data"
-
-func setData(ctx echo.Context, key string, value any) {
-	data := ctx.Request().Context().Value(dataKey).(echo.Map)
-	data[key] = value
-	ctxValue := context.WithValue(ctx.Request().Context(), dataKey, data)
-	ctx.SetRequest(ctx.Request().WithContext(ctxValue))
-}
-
-func getData(ctx echo.Context, key string) any {
-	data := ctx.Request().Context().Value(dataKey).(echo.Map)
-	return data[key]
-}
-
-func dataMap(ctx echo.Context) echo.Map {
-	return ctx.Request().Context().Value(dataKey).(echo.Map)
-}
-
-func html(ctx echo.Context, template string) error {
-	return htmlWithCode(ctx, 200, template)
-}
-
-func htmlWithCode(ctx echo.Context, code int, template string) error {
-	setErrorFlashes(ctx)
-	return ctx.Render(code, template, ctx.Request().Context().Value(dataKey))
-}
-
-func json(ctx echo.Context, data any) error {
-	return jsonWithCode(ctx, 200, data)
-}
-
-func jsonWithCode(ctx echo.Context, code int, data any) error {
-	return ctx.JSON(code, data)
-}
-
-func redirect(ctx echo.Context, location string) error {
-	return ctx.Redirect(302, config.C.ExternalUrl+location)
-}
-
-func plainText(ctx echo.Context, code int, message string) error {
-	return ctx.String(code, message)
-}
-
-func notFound(message string) error {
-	return errorRes(404, message, nil)
-}
-
-func errorRes(code int, message string, err error) error {
-	if code >= 500 {
-		var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
-		skipLogger.Error().Err(err).Msg(message)
-	}
-
-	return &echo.HTTPError{Code: code, Message: message, Internal: err}
-}
-
-func jsonErrorRes(code int, message string, err error) error {
-	if code >= 500 {
-		var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
-		skipLogger.Error().Err(err).Msg(message)
-	}
-
-	return &echo.HTTPError{Code: code, Message: message, Internal: err}
-}
-
-func getUserLogged(ctx echo.Context) *db.User {
-	user := getData(ctx, "userLogged")
-	if user != nil {
-		return user.(*db.User)
-	}
-	return nil
-}
-
-func setErrorFlashes(ctx echo.Context) {
-	sess, _ := flashStore.Get(ctx.Request(), "flash")
-
-	setData(ctx, "flashErrors", sess.Flashes("error"))
-	setData(ctx, "flashSuccess", sess.Flashes("success"))
-	setData(ctx, "flashWarnings", sess.Flashes("warning"))
-
-	_ = sess.Save(ctx.Request(), ctx.Response())
-}
-
-func addFlash(ctx echo.Context, flashMessage string, flashType string) {
-	sess, _ := flashStore.Get(ctx.Request(), "flash")
-	sess.AddFlash(flashMessage, flashType)
-	_ = sess.Save(ctx.Request(), ctx.Response())
-}
-
-func getSession(ctx echo.Context) *sessions.Session {
-	sess, _ := userStore.Get(ctx.Request(), "session")
-	return sess
-}
-
-func saveSession(sess *sessions.Session, ctx echo.Context) {
-	_ = sess.Save(ctx.Request(), ctx.Response())
-}
-
-func deleteSession(ctx echo.Context) {
-	sess := getSession(ctx)
-	sess.Options.MaxAge = -1
-	saveSession(sess, ctx)
-}
-
-func setCsrfHtmlForm(ctx echo.Context) {
-	var csrf string
-	if csrfToken, ok := ctx.Get("csrf").(string); ok {
-		csrf = csrfToken
-	}
-	setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
-}
-
-func deleteCsrfCookie(ctx echo.Context) {
-	ctx.SetCookie(&http.Cookie{Name: "_csrf", Path: "/", MaxAge: -1})
-}
-
-func loadSettings(ctx echo.Context) error {
-	settings, err := db.GetSettings()
-	if err != nil {
-		return err
-	}
-
-	for key, value := range settings {
-		s := strings.ReplaceAll(key, "-", " ")
-		s = cases.Title(language.English).String(s)
-		setData(ctx, strings.ReplaceAll(s, " ", ""), value == "1")
-	}
-	return nil
-}
-
-func getPage(ctx echo.Context) int {
-	page := ctx.QueryParam("page")
-	if page == "" {
-		page = "1"
-	}
-	pageInt, err := strconv.Atoi(page)
-	if err != nil {
-		pageInt = 1
-	}
-	setData(ctx, "currPage", pageInt)
-
-	return pageInt
-}
-
-func paginate[T any](ctx echo.Context, data []*T, pageInt int, perPage int, templateDataName string, urlPage string, labels int, urlParams ...string) error {
-	lenData := len(data)
-	if lenData == 0 && pageInt != 1 {
-		return errors.New("page not found")
-	}
-
-	if lenData > perPage {
-		if lenData > 1 {
-			data = data[:lenData-1]
-		}
-		setData(ctx, "nextPage", pageInt+1)
-	}
-	if pageInt > 1 {
-		setData(ctx, "prevPage", pageInt-1)
-	}
-
-	if len(urlParams) > 0 {
-		setData(ctx, "urlParams", template.URL(urlParams[0]))
-	}
-
-	switch labels {
-	case 1:
-		setData(ctx, "prevLabel", trH(ctx, "pagination.previous"))
-		setData(ctx, "nextLabel", trH(ctx, "pagination.next"))
-	case 2:
-		setData(ctx, "prevLabel", trH(ctx, "pagination.newer"))
-		setData(ctx, "nextLabel", trH(ctx, "pagination.older"))
-	}
-
-	setData(ctx, "urlPage", urlPage)
-	setData(ctx, templateDataName, data)
-	return nil
-}
-
-func trH(ctx echo.Context, key string, args ...any) template.HTML {
-	l := getData(ctx, "locale").(*i18n.Locale)
-	return l.Tr(key, args...)
-}
-
-func tr(ctx echo.Context, key string, args ...any) string {
-	l := getData(ctx, "locale").(*i18n.Locale)
-	return l.String(key, args...)
-}
-
-func parseSearchQueryStr(query string) (string, map[string]string) {
-	words := strings.Fields(query)
-	metadata := make(map[string]string)
-	var contentBuilder strings.Builder
-
-	for _, word := range words {
-		if strings.Contains(word, ":") {
-			keyValue := strings.SplitN(word, ":", 2)
-			if len(keyValue) == 2 {
-				key := keyValue[0]
-				value := keyValue[1]
-				metadata[key] = value
-			}
-		} else {
-			contentBuilder.WriteString(word + " ")
-		}
-	}
-
-	content := strings.TrimSpace(contentBuilder.String())
-	return content, metadata
-}
-
-func addMetadataToSearchQuery(input, key, value string) string {
-	content, metadata := parseSearchQueryStr(input)
-
-	metadata[key] = value
-
-	var resultBuilder strings.Builder
-	resultBuilder.WriteString(content)
-
-	for k, v := range metadata {
-		resultBuilder.WriteString(" ")
-		resultBuilder.WriteString(k)
-		resultBuilder.WriteString(":")
-		resultBuilder.WriteString(v)
-	}
-
-	return strings.TrimSpace(resultBuilder.String())
-}