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(``, baseHttpUrl+"/"+userName+"/"+gistName+".js")) + ctx.SetData("httpCopyUrl", baseHttpUrl+"/"+userName+"/"+gistName) + ctx.SetData("currentUrl", template.URL(ctx.Request().URL.Path)) + ctx.SetData("embedScript", fmt.Sprintf(``, 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(``)) + ctx.SetData("csrfHtml", template.HTML(``)) +} + +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(``)) -} - -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()) -}