aboutsummaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
authorAlexander Neonxp Kiryukhin <i@neonxp.ru>2024-10-12 02:52:22 +0300
committerAlexander Neonxp Kiryukhin <i@neonxp.ru>2024-10-12 02:53:52 +0300
commitd05ea66f4bbcf0cc5c8908f3435c68de1b070fa1 (patch)
tree7c7a769206646f2b81a0eda0680f0be5033a4197 /pkg
Начальная версияv0.0.1
Diffstat (limited to 'pkg')
-rw-r--r--pkg/config/config.go27
-rw-r--r--pkg/db/config.go5
-rw-r--r--pkg/db/db.go18
-rw-r--r--pkg/handler/error.go47
-rw-r--r--pkg/handler/handler.go15
-rw-r--r--pkg/handler/user/errors.go30
-rw-r--r--pkg/handler/user/handler.go24
-rw-r--r--pkg/handler/user/login.go53
-rw-r--r--pkg/handler/user/logout.go18
-rw-r--r--pkg/handler/user/profile.go16
-rw-r--r--pkg/handler/user/register.go45
-rw-r--r--pkg/middleware/context.go21
-rw-r--r--pkg/middleware/session/store.go232
-rw-r--r--pkg/middleware/user.go28
-rw-r--r--pkg/model/user.go26
-rw-r--r--pkg/service/crud/crud.go87
-rw-r--r--pkg/service/user/login.go31
-rw-r--r--pkg/service/user/register.go85
-rw-r--r--pkg/service/user/user.go12
-rw-r--r--pkg/tpl/error.templ10
-rw-r--r--pkg/tpl/error_templ.go86
-rw-r--r--pkg/tpl/error_templ.txt3
-rw-r--r--pkg/tpl/layout.templ49
-rw-r--r--pkg/tpl/layout_templ.go79
-rw-r--r--pkg/tpl/layout_templ.txt6
-rw-r--r--pkg/tpl/login.templ60
-rw-r--r--pkg/tpl/login_templ.go114
-rw-r--r--pkg/tpl/login_templ.txt6
-rw-r--r--pkg/tpl/profile.templ17
-rw-r--r--pkg/tpl/profile_templ.go86
-rw-r--r--pkg/tpl/profile_templ.txt3
-rw-r--r--pkg/tpl/register.templ63
-rw-r--r--pkg/tpl/register_templ.go114
-rw-r--r--pkg/tpl/register_templ.txt6
-rw-r--r--pkg/utils/htmx.go11
-rw-r--r--pkg/utils/user.go41
36 files changed, 1574 insertions, 0 deletions
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..6a9d30c
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,27 @@
+package config
+
+import (
+ "os"
+
+ "go.neonxp.ru/framework/pkg/db"
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ Debug bool `yaml:"debug"`
+ DB *db.Config `yaml:"db"`
+ Listen string `yaml:"listen"`
+ Host string `yaml:"host"`
+ Admins map[string]string `yaml:"admins"`
+ Keypairs []string `yaml:"keys"`
+}
+
+func New(file string) (*Config, error) {
+ cfg := new(Config)
+ fp, err := os.Open(file)
+ if err != nil {
+ return nil, err
+ }
+
+ return cfg, yaml.NewDecoder(fp).Decode(cfg)
+}
diff --git a/pkg/db/config.go b/pkg/db/config.go
new file mode 100644
index 0000000..4df38f0
--- /dev/null
+++ b/pkg/db/config.go
@@ -0,0 +1,5 @@
+package db
+
+type Config struct {
+ DSN string `yaml:"dsn"`
+}
diff --git a/pkg/db/db.go b/pkg/db/db.go
new file mode 100644
index 0000000..8b7c47c
--- /dev/null
+++ b/pkg/db/db.go
@@ -0,0 +1,18 @@
+package db
+
+import (
+ "database/sql"
+
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect/pgdialect"
+ "github.com/uptrace/bun/driver/pgdriver"
+)
+
+// dsn := "postgres://postgres:@localhost:5432/test?sslmode=disable"
+// dsn := "unix://user:pass@dbname/var/run/postgresql/.s.PGSQL.5432"
+
+func New(config *Config) *bun.DB {
+ sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(config.DSN)))
+
+ return bun.NewDB(sqldb, pgdialect.New())
+}
diff --git a/pkg/handler/error.go b/pkg/handler/error.go
new file mode 100644
index 0000000..eac791d
--- /dev/null
+++ b/pkg/handler/error.go
@@ -0,0 +1,47 @@
+package handler
+
+import (
+ "log"
+ "net/http"
+
+ "github.com/labstack/echo/v4"
+ "go.neonxp.ru/framework/pkg/tpl"
+)
+
+func ErrorHandler(err error, c echo.Context) {
+ if c.Response().Committed {
+ return
+ }
+
+ he, ok := err.(*echo.HTTPError)
+ if ok {
+ if he.Internal != nil {
+ if herr, ok := he.Internal.(*echo.HTTPError); ok {
+ he = herr
+ }
+ }
+ } else {
+ he = &echo.HTTPError{
+ Code: http.StatusInternalServerError,
+ Message: http.StatusText(http.StatusInternalServerError),
+ }
+ }
+ code := he.Code
+ message, ok := he.Message.(string)
+ if !ok {
+ message = "Неизвестная ошибка"
+ }
+
+ if c.Request().Method == http.MethodHead {
+ err = c.NoContent(he.Code)
+ } else {
+ c.Response().WriteHeader(code)
+ if err := tpl.ErrorPage(code, message).
+ Render(c.Request().Context(), c.Response()); err != nil {
+ log.Println(err)
+ }
+ }
+ if err != nil {
+ log.Println(err)
+ }
+}
diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go
new file mode 100644
index 0000000..15eb42d
--- /dev/null
+++ b/pkg/handler/handler.go
@@ -0,0 +1,15 @@
+package handler
+
+import "github.com/labstack/echo/v4"
+
+type Handler interface {
+ Register(g *echo.Group)
+}
+
+type Router map[string]Handler
+
+func (r Router) Register(e *echo.Echo) {
+ for groupName, handlers := range r {
+ handlers.Register(e.Group(groupName))
+ }
+}
diff --git a/pkg/handler/user/errors.go b/pkg/handler/user/errors.go
new file mode 100644
index 0000000..ab8fc4a
--- /dev/null
+++ b/pkg/handler/user/errors.go
@@ -0,0 +1,30 @@
+package user
+
+import (
+ "errors"
+
+ "go.neonxp.ru/framework/pkg/service/user"
+)
+
+var UserErrors = Errors{
+ user.ErrInvalidUserOrPassword: "Неверный email или пароль",
+ user.ErrPasswordTooShort: "Пароль слишком короткий",
+ user.ErrPasswordTooWeak: "Пароль слишком простой",
+ user.ErrUserAlreadyExist: "Пользователь уже существует",
+ user.ErrUsernameToShort: "Имя пользователя слишком короткое",
+ // user.ErrEmailEmpty: "Электропочта не указана",
+ // user.ErrPasswordEmpty: "Пароль не указан",
+ // user.ErrNameEmpty: "Имя пользователя не указано",
+}
+
+type Errors map[error]string
+
+func (e Errors) Get(err error) string {
+ for target, msg := range e {
+ if errors.Is(err, target) {
+ return msg
+ }
+ }
+
+ return ""
+}
diff --git a/pkg/handler/user/handler.go b/pkg/handler/user/handler.go
new file mode 100644
index 0000000..6bc8bff
--- /dev/null
+++ b/pkg/handler/user/handler.go
@@ -0,0 +1,24 @@
+package user
+
+import (
+ "github.com/labstack/echo/v4"
+ "go.neonxp.ru/framework/pkg/service/user"
+)
+
+type Handler struct {
+ user *user.Service
+}
+
+// NewHandler returns new Handler.
+func NewHandler(u *user.Service) *Handler {
+ return &Handler{user: u}
+}
+
+func (h *Handler) Register(g *echo.Group) {
+ g.GET("/login", h.LoginForm)
+ g.POST("/login", h.LoginForm)
+ g.GET("/register", h.RegisterForm)
+ g.POST("/register", h.RegisterForm)
+ g.GET("/profile", h.Profile)
+ g.POST("/logout", h.Logout)
+}
diff --git a/pkg/handler/user/login.go b/pkg/handler/user/login.go
new file mode 100644
index 0000000..b3fbdbf
--- /dev/null
+++ b/pkg/handler/user/login.go
@@ -0,0 +1,53 @@
+package user
+
+import (
+ "net/http"
+
+ "github.com/labstack/echo/v4"
+
+ "go.neonxp.ru/framework/pkg/tpl"
+ "go.neonxp.ru/framework/pkg/utils"
+)
+
+const oneyear = 86400 * 365
+
+func (h *Handler) LoginForm(c echo.Context) error {
+ form := &tpl.LoginForm{}
+ if err := c.Bind(form); err != nil {
+ return err
+ }
+
+ if c.Request().Method == http.MethodPost {
+ err := h.doLogin(c, form)
+ if err == nil {
+ if utils.IsHTMX(c) {
+ utils.HTMXRedirect(c, "/")
+ return c.NoContent(http.StatusNoContent)
+ }
+
+ return c.Redirect(http.StatusFound, "/")
+ }
+
+ form.Message = UserErrors.Get(err)
+ if form.Message == "" {
+ return err
+ }
+ }
+
+ return tpl.Login(form).
+ Render(c.Request().Context(), c.Response())
+}
+
+func (h *Handler) doLogin(c echo.Context, form *tpl.LoginForm) error {
+ u, err := h.user.Login(c.Request().Context(), form)
+ if err != nil {
+ return err
+ }
+
+ maxage := 0
+ if form.Remember == "on" {
+ maxage = oneyear
+ }
+
+ return utils.SetUser(c, u, maxage)
+}
diff --git a/pkg/handler/user/logout.go b/pkg/handler/user/logout.go
new file mode 100644
index 0000000..9b7ea5f
--- /dev/null
+++ b/pkg/handler/user/logout.go
@@ -0,0 +1,18 @@
+package user
+
+import (
+ "net/http"
+
+ "github.com/labstack/echo/v4"
+ "go.neonxp.ru/framework/pkg/utils"
+)
+
+func (*Handler) Logout(c echo.Context) error {
+ if err := utils.SetUser(c, nil, -1); err != nil {
+ return err
+ }
+
+ utils.HTMXRedirect(c, "/")
+
+ return c.NoContent(http.StatusNoContent)
+}
diff --git a/pkg/handler/user/profile.go b/pkg/handler/user/profile.go
new file mode 100644
index 0000000..7181ca7
--- /dev/null
+++ b/pkg/handler/user/profile.go
@@ -0,0 +1,16 @@
+package user
+
+import (
+ "github.com/labstack/echo/v4"
+ "go.neonxp.ru/framework/pkg/tpl"
+ "go.neonxp.ru/framework/pkg/utils"
+)
+
+func (*Handler) Profile(c echo.Context) error {
+ u := utils.GetUserCtx(c.Request().Context())
+ if u == nil {
+ return echo.ErrForbidden
+ }
+
+ return tpl.Profile(u).Render(c.Request().Context(), c.Response())
+}
diff --git a/pkg/handler/user/register.go b/pkg/handler/user/register.go
new file mode 100644
index 0000000..2bd810a
--- /dev/null
+++ b/pkg/handler/user/register.go
@@ -0,0 +1,45 @@
+package user
+
+import (
+ "net/http"
+
+ "github.com/labstack/echo/v4"
+ "go.neonxp.ru/framework/pkg/tpl"
+ "go.neonxp.ru/framework/pkg/utils"
+)
+
+func (h *Handler) RegisterForm(c echo.Context) error {
+ form := &tpl.RegisterForm{}
+ if err := c.Bind(form); err != nil {
+ return err
+ }
+
+ if c.Request().Method == http.MethodPost {
+ err := h.doRegister(c, form)
+ if err == nil {
+ if utils.IsHTMX(c) {
+ utils.HTMXRedirect(c, "/")
+ return c.NoContent(http.StatusNoContent)
+ }
+
+ return c.Redirect(http.StatusFound, "/")
+ }
+
+ form.Message = UserErrors.Get(err)
+ if form.Message == "" {
+ return err
+ }
+ }
+
+ return tpl.Register(form).
+ Render(c.Request().Context(), c.Response())
+}
+
+func (h *Handler) doRegister(c echo.Context, form *tpl.RegisterForm) error {
+ u, err := h.user.Register(c.Request().Context(), form)
+ if err != nil {
+ return err
+ }
+
+ return utils.SetUser(c, u, oneyear)
+}
diff --git a/pkg/middleware/context.go b/pkg/middleware/context.go
new file mode 100644
index 0000000..f9c4425
--- /dev/null
+++ b/pkg/middleware/context.go
@@ -0,0 +1,21 @@
+package middleware
+
+import (
+ "context"
+
+ "github.com/labstack/echo/v4"
+)
+
+type ContextKey string
+
+func Context(key ContextKey, value any) echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ ctx := context.WithValue(c.Request().Context(), key, value)
+ r := c.Request().WithContext(ctx)
+ c.SetRequest(r)
+
+ return next(c)
+ }
+ }
+}
diff --git a/pkg/middleware/session/store.go b/pkg/middleware/session/store.go
new file mode 100644
index 0000000..78172d7
--- /dev/null
+++ b/pkg/middleware/session/store.go
@@ -0,0 +1,232 @@
+package session
+
+import (
+ "context"
+ "encoding/base32"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gorilla/securecookie"
+ "github.com/gorilla/sessions"
+ "github.com/uptrace/bun"
+)
+
+const (
+ sessionIDLen = 32
+ defaultTableName = "sessions"
+ defaultMaxAge = 60 * 60 * 24 * 30 // 30 days
+ defaultPath = "/"
+)
+
+// Options for bunstore.
+type Options struct {
+ TableName string
+ SkipCreateTable bool
+}
+
+// Store represent a bunstore.
+type Store struct {
+ db *bun.DB
+ opts Options
+ Codecs []securecookie.Codec
+ SessionOpts *sessions.Options
+}
+
+type Model struct {
+ bun.BaseModel `bun:"table:sessions,alias:s"`
+
+ ID string `bun:",pk,unique"`
+ Data string
+ CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ ExpiresAt time.Time
+}
+
+type KeyPairs []string
+
+func (k KeyPairs) ToKeys() [][]byte {
+ b := make([][]byte, 0, len(k))
+ for _, kk := range k {
+ b = append(b, []byte(kk))
+ }
+
+ return b
+}
+
+// New creates a new bunstore session.
+func New(db *bun.DB, keyPairs KeyPairs) (*Store, error) {
+ return NewOptions(db, Options{}, keyPairs)
+}
+
+// NewOptions creates a new bunstore session with options.
+func NewOptions(db *bun.DB, opts Options, keyPairs KeyPairs) (*Store, error) {
+ st := &Store{
+ db: db,
+ opts: opts,
+ Codecs: securecookie.CodecsFromPairs(keyPairs.ToKeys()...),
+ SessionOpts: &sessions.Options{
+ Path: defaultPath,
+ MaxAge: defaultMaxAge,
+ },
+ }
+
+ return st, nil
+}
+
+// Get returns a session for the given name after adding it to the registry.
+func (st *Store) Get(r *http.Request, name string) (*sessions.Session, error) {
+ return sessions.GetRegistry(r).Get(st, name)
+}
+
+// New creates a session with name without adding it to the registry.
+func (st *Store) New(r *http.Request, name string) (*sessions.Session, error) {
+ session := sessions.NewSession(st, name)
+ opts := *st.SessionOpts
+ session.Options = &opts
+ session.IsNew = true
+
+ st.MaxAge(st.SessionOpts.MaxAge)
+
+ // try fetch from db if there is a cookie
+ s := st.getSessionFromCookie(r, session.Name())
+ if s != nil {
+ if err := securecookie.DecodeMulti(session.Name(), s.Data, &session.Values, st.Codecs...); err != nil {
+ //nolint:nilerr
+ return session, nil
+ }
+
+ session.ID = s.ID
+ session.IsNew = false
+ }
+
+ return session, nil
+}
+
+// Save session and set cookie header.
+func (st *Store) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+ s := st.getSessionFromCookie(r, session.Name())
+
+ // delete if max age is < 0
+ if session.Options.MaxAge < 0 {
+ if s != nil {
+ if _, err := st.db.NewDelete().Model(&Model{ID: session.ID}).WherePK("id").Exec(r.Context()); err != nil {
+ return err
+ }
+ }
+
+ http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
+
+ return nil
+ }
+
+ data, err := securecookie.EncodeMulti(session.Name(), session.Values, st.Codecs...)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now()
+ expire := now.Add(time.Second * time.Duration(session.Options.MaxAge))
+
+ if s == nil {
+ // generate random session ID key suitable for storage in the db
+ session.ID = strings.TrimRight(
+ base32.StdEncoding.EncodeToString(
+ securecookie.GenerateRandomKey(sessionIDLen)), "=")
+ s = &Model{
+ ID: session.ID,
+ Data: data,
+ ExpiresAt: expire,
+ }
+
+ if _, err := st.db.NewInsert().Model(s).Exec(r.Context()); err != nil {
+ return err
+ }
+ } else {
+ s.Data = data
+ s.ExpiresAt = expire
+
+ if _, err := st.db.NewUpdate().Model(s).WherePK("id").Column("data", "expires_at").Exec(r.Context()); err != nil {
+ return err
+ }
+ }
+
+ // set session id cookie
+ id, err := securecookie.EncodeMulti(session.Name(), s.ID, st.Codecs...)
+ if err != nil {
+ return err
+ }
+
+ http.SetCookie(w, sessions.NewCookie(session.Name(), id, session.Options))
+
+ return nil
+}
+
+// getSessionFromCookie looks for an existing bunSession from a session ID stored inside a cookie.
+func (st *Store) getSessionFromCookie(r *http.Request, name string) *Model {
+ if cookie, err := r.Cookie(name); err == nil {
+ sessionID := ""
+ if err := securecookie.DecodeMulti(name, cookie.Value, &sessionID, st.Codecs...); err != nil {
+ return nil
+ }
+
+ s := &Model{}
+ if err := st.db.NewSelect().
+ Model(s).
+ Where("id = ? AND expires_at > ?", sessionID, time.Now()).
+ Scan(r.Context()); err != nil {
+ return nil
+ }
+
+ return s
+ }
+
+ return nil
+}
+
+// MaxAge sets the maximum age for the store and the underlying cookie
+// implementation. Individual sessions can be deleted by setting
+// Options.MaxAge = -1 for that session.
+func (st *Store) MaxAge(age int) {
+ st.SessionOpts.MaxAge = age
+ for _, codec := range st.Codecs {
+ if sc, ok := codec.(*securecookie.SecureCookie); ok {
+ sc.MaxAge(age)
+ }
+ }
+}
+
+// MaxLength restricts the maximum length of new sessions to l.
+// If l is 0 there is no limit to the size of a session, use with caution.
+// The default is 4096 (default for securecookie).
+func (st *Store) MaxLength(l int) {
+ for _, c := range st.Codecs {
+ if codec, ok := c.(*securecookie.SecureCookie); ok {
+ codec.MaxLength(l)
+ }
+ }
+}
+
+// Cleanup deletes expired sessions.
+func (st *Store) Cleanup() {
+ _, err := st.db.NewDelete().Model(&Model{}).Where("expires_at <= ?", time.Now()).Exec(context.Background())
+ if err != nil {
+ slog.Default().With("error", err).Error("cleanup")
+ }
+}
+
+// PeriodicCleanup runs Cleanup every interval. Close quit channel to stop.
+func (st *Store) PeriodicCleanup(interval time.Duration, quit <-chan struct{}) {
+ t := time.NewTicker(interval)
+ defer t.Stop()
+
+ for {
+ select {
+ case <-t.C:
+ st.Cleanup()
+ case <-quit:
+ return
+ }
+ }
+}
diff --git a/pkg/middleware/user.go b/pkg/middleware/user.go
new file mode 100644
index 0000000..6271e43
--- /dev/null
+++ b/pkg/middleware/user.go
@@ -0,0 +1,28 @@
+package middleware
+
+import (
+ "context"
+
+ "github.com/labstack/echo-contrib/session"
+ "github.com/labstack/echo/v4"
+)
+
+func User() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ sess, err := session.Get("user", c)
+ if err != nil {
+ return err
+ }
+
+ u := sess.Values["user"]
+ c.Set("user", u)
+
+ ctx := context.WithValue(c.Request().Context(), ContextKey("user"), u)
+ r := c.Request().WithContext(ctx)
+ c.SetRequest(r)
+
+ return next(c)
+ }
+ }
+}
diff --git a/pkg/model/user.go b/pkg/model/user.go
new file mode 100644
index 0000000..ec524a8
--- /dev/null
+++ b/pkg/model/user.go
@@ -0,0 +1,26 @@
+package model
+
+import (
+ "encoding/gob"
+ "encoding/json"
+ "time"
+
+ "github.com/uptrace/bun"
+)
+
+//nolint:gochecknoinits
+func init() {
+ gob.Register(User{})
+}
+
+type User struct {
+ bun.BaseModel `bun:"table:users,alias:u"`
+
+ ID int64 `bun:",pk,autoincrement"`
+ Email string `bun:",notnull,unique"`
+ Username string `bun:",notnull,unique"`
+ Password string `bun:",notnull"`
+ Meta json.RawMessage `bun:",type:jsonb"`
+ CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ DeletedAt time.Time `bun:",soft_delete,nullzero"`
+}
diff --git a/pkg/service/crud/crud.go b/pkg/service/crud/crud.go
new file mode 100644
index 0000000..799eacb
--- /dev/null
+++ b/pkg/service/crud/crud.go
@@ -0,0 +1,87 @@
+package crud
+
+import (
+ "context"
+ "errors"
+
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/driver/pgdriver"
+)
+
+var ErrRecordAlreadyExists = errors.New("record already exists")
+
+type Service[T any] struct {
+ db *bun.DB
+}
+
+func NewService[T any](db *bun.DB) *Service[T] {
+ return &Service[T]{
+ db: db,
+ }
+}
+
+func (s *Service[T]) Insert(ctx context.Context, model *T) error {
+ if _, err := s.db.NewInsert().Model(model).Returning("*").Exec(ctx); err != nil {
+ pqErr := pgdriver.Error{}
+ if errors.As(err, &pqErr) {
+ if pqErr.Field('C') == "23505" {
+ return ErrRecordAlreadyExists
+ }
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+func (s *Service[T]) UpdatePk(ctx context.Context, model *T, columns []string) error {
+ if _, err := s.db.NewUpdate().Model(model).WherePK(columns...).Column(columns...).Exec(ctx); err != nil {
+ pqErr := pgdriver.Error{}
+ if errors.As(err, &pqErr) {
+ if pqErr.Field('C') == "23505" {
+ return ErrRecordAlreadyExists
+ }
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+//nolint:revive
+func (s *Service[T]) Update(ctx context.Context, model *T, columns []string, query string, args ...any) error {
+ if _, err := s.db.NewUpdate().Model(model).Where(query, args...).Column(columns...).Exec(ctx); err != nil {
+ pqErr := pgdriver.Error{}
+ if errors.As(err, &pqErr) {
+ if pqErr.Field('C') == "23505" {
+ return ErrRecordAlreadyExists
+ }
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+func (s *Service[T]) FindOne(ctx context.Context, query string, args ...any) (*T, error) {
+ m := new(T)
+
+ return m, s.db.NewSelect().Model(m).Where(query, args...).Scan(ctx, m)
+}
+
+//nolint:revive
+func (s *Service[T]) Find(ctx context.Context, query string, args ...any) ([]T, int, error) {
+ m := make([]T, 0)
+ c, err := s.db.NewSelect().Model(m).Where(query, args...).ScanAndCount(ctx, &m)
+
+ return m, c, err
+}
+
+func (s *Service[T]) Delete(ctx context.Context, query string, args ...any) error {
+ _, err := s.db.NewDelete().Model((*T)(nil)).Where(query, args...).Exec(ctx)
+
+ return err
+}
diff --git a/pkg/service/user/login.go b/pkg/service/user/login.go
new file mode 100644
index 0000000..d1f5c65
--- /dev/null
+++ b/pkg/service/user/login.go
@@ -0,0 +1,31 @@
+package user
+
+import (
+ "context"
+ "errors"
+
+ normalizer "github.com/dimuska139/go-email-normalizer/v3"
+ "golang.org/x/crypto/bcrypt"
+
+ "go.neonxp.ru/framework/pkg/model"
+ "go.neonxp.ru/framework/pkg/tpl"
+)
+
+var ErrInvalidUserOrPassword = errors.New("invalid_user_or_password")
+
+func (s *Service) Login(ctx context.Context, form *tpl.LoginForm) (*model.User, error) {
+ n := normalizer.NewNormalizer()
+ u := &model.User{
+ Email: n.Normalize(form.Email),
+ }
+
+ if err := s.db.NewSelect().Model(u).Where("email = ?", u.Email).Scan(ctx, u); err != nil {
+ return nil, errors.Join(err, ErrInvalidUserOrPassword)
+ }
+
+ if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(form.Password)); err != nil {
+ return nil, errors.Join(err, ErrInvalidUserOrPassword)
+ }
+
+ return u, nil
+}
diff --git a/pkg/service/user/register.go b/pkg/service/user/register.go
new file mode 100644
index 0000000..69f19a2
--- /dev/null
+++ b/pkg/service/user/register.go
@@ -0,0 +1,85 @@
+package user
+
+import (
+ "context"
+ "errors"
+ "regexp"
+
+ normalizer "github.com/dimuska139/go-email-normalizer/v3"
+ "github.com/uptrace/bun/driver/pgdriver"
+ "golang.org/x/crypto/bcrypt"
+
+ "go.neonxp.ru/framework/pkg/model"
+ "go.neonxp.ru/framework/pkg/tpl"
+)
+
+var (
+ ErrUsernameToShort = errors.New("username_to_short")
+ ErrUserAlreadyExist = errors.New("user_already_exists")
+ ErrPasswordTooWeak = errors.New("password_too_weak")
+ ErrPasswordTooShort = errors.New("password_too_short")
+)
+
+const (
+ minUsernameLen = 3
+ minPasswordLen = 8
+)
+
+func (s *Service) Register(ctx context.Context, form *tpl.RegisterForm) (*model.User, error) {
+ if len(form.Username) < minUsernameLen {
+ return nil, ErrUsernameToShort
+ }
+
+ if err := checkPasswordLever(form.Password); err != nil {
+ return nil, err
+ }
+
+ password, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, err
+ }
+
+ n := normalizer.NewNormalizer()
+ u := &model.User{
+ Username: form.Username,
+ Email: n.Normalize(form.Email),
+ Password: string(password),
+ }
+
+ if _, err := s.db.NewInsert().Model(u).Returning("*").Exec(ctx); err != nil {
+ pqErr := pgdriver.Error{}
+ if errors.As(err, &pqErr) {
+ if pqErr.Field('C') == "23505" {
+ return nil, ErrUserAlreadyExist
+ }
+ }
+
+ return nil, err
+ }
+
+ return u, nil
+}
+
+func checkPasswordLever(ps string) error {
+ if len(ps) < minPasswordLen {
+ return ErrPasswordTooShort
+ }
+
+ lowerCase := `[a-z]{1}`
+ upperCase := `[A-Z]{1}`
+ symbol := `[0-9!@#~$%^&*()+|_]{1}`
+
+ if b, err := regexp.MatchString(lowerCase, ps); !b || err != nil {
+ return ErrPasswordTooWeak
+ }
+
+ if b, err := regexp.MatchString(upperCase, ps); !b || err != nil {
+ return ErrPasswordTooWeak
+ }
+
+ if b, err := regexp.MatchString(symbol, ps); !b || err != nil {
+ return ErrPasswordTooWeak
+ }
+
+ return nil
+}
diff --git a/pkg/service/user/user.go b/pkg/service/user/user.go
new file mode 100644
index 0000000..aa27eee
--- /dev/null
+++ b/pkg/service/user/user.go
@@ -0,0 +1,12 @@
+package user
+
+import "github.com/uptrace/bun"
+
+type Service struct {
+ db *bun.DB
+}
+
+// NewService returns new Service.
+func NewService(db *bun.DB) *Service {
+ return &Service{db: db}
+}
diff --git a/pkg/tpl/error.templ b/pkg/tpl/error.templ
new file mode 100644
index 0000000..0e0dcfa
--- /dev/null
+++ b/pkg/tpl/error.templ
@@ -0,0 +1,10 @@
+package tpl
+
+import "strconv"
+
+templ ErrorPage(code int, message string) {
+ @Layout() {
+ <h1>Ошибка { strconv.Itoa(code) }!</h1>
+ <p>{ message }</p>
+ }
+}
diff --git a/pkg/tpl/error_templ.go b/pkg/tpl/error_templ.go
new file mode 100644
index 0000000..04b4921
--- /dev/null
+++ b/pkg/tpl/error_templ.go
@@ -0,0 +1,86 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package tpl
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "strconv"
+
+func ErrorPage(code int, message string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(code))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/error.templ`, Line: 7, Col: 39}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/error.templ`, Line: 8, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/pkg/tpl/error_templ.txt b/pkg/tpl/error_templ.txt
new file mode 100644
index 0000000..5d1d217
--- /dev/null
+++ b/pkg/tpl/error_templ.txt
@@ -0,0 +1,3 @@
+<h1>Ошибка
+!</h1><p>
+</p>
diff --git a/pkg/tpl/layout.templ b/pkg/tpl/layout.templ
new file mode 100644
index 0000000..4993b97
--- /dev/null
+++ b/pkg/tpl/layout.templ
@@ -0,0 +1,49 @@
+package tpl
+
+import "go.neonxp.ru/framework/pkg/utils"
+
+templ Layout() {
+ {{ user := utils.GetUserCtx(ctx) }}
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
+ <meta name="color-scheme" content="light dark"/>
+ <link rel="stylesheet" href="/css/pico/pico.blue.min.css"/>
+ <link rel="stylesheet" href="/css/style.css"/>
+ <link rel="stylesheet" href="/css/fork-awesome.min.css"/>
+ <title>App</title>
+ </head>
+ <body>
+ <main class="container">
+ <nav>
+ <ul>
+ <li><a href="/"><strong>App</strong></a></li>
+ <li><span aria-busy="true" id="loader" class="htmx-indicator">Загрузка...</span></li>
+ </ul>
+ <ul hx-boost="true" hx-indicator="#loader">
+ if user == nil {
+ <li><a href="/user/login">Вход</a></li>
+ <li><a href="/user/register">Регистрация</a></li>
+ } else {
+ <li><a href="/user/profile">{ user.Username }</a></li>
+ <li><a href="#" hx-post="/user/logout">Выход</a></li>
+ }
+ </ul>
+ </nav>
+ { children... }
+ </main>
+ </body>
+ <footer>
+ <main class="container">
+ <nav>
+ <ul>
+ <li>Сделал <a href="https://neonxp.ru/">NeonXP</a> в 2024 году.</li>
+ </ul>
+ </nav>
+ </main>
+ </footer>
+ <script src="/js/htmx.min.js"></script>
+ </html>
+}
diff --git a/pkg/tpl/layout_templ.go b/pkg/tpl/layout_templ.go
new file mode 100644
index 0000000..1edca5a
--- /dev/null
+++ b/pkg/tpl/layout_templ.go
@@ -0,0 +1,79 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package tpl
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "go.neonxp.ru/framework/pkg/utils"
+
+func Layout() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ user := utils.GetUserCtx(ctx)
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if user == nil {
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/layout.templ`, Line: 30, Col: 50}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 6)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/pkg/tpl/layout_templ.txt b/pkg/tpl/layout_templ.txt
new file mode 100644
index 0000000..c6fb6f2
--- /dev/null
+++ b/pkg/tpl/layout_templ.txt
@@ -0,0 +1,6 @@
+<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"color-scheme\" content=\"light dark\"><link rel=\"stylesheet\" href=\"/css/pico/pico.blue.min.css\"><link rel=\"stylesheet\" href=\"/css/style.css\"><link rel=\"stylesheet\" href=\"/css/fork-awesome.min.css\"><title>App</title></head><body><main class=\"container\"><nav><ul><li><a href=\"/\"><strong>App</strong></a></li><li><span aria-busy=\"true\" id=\"loader\" class=\"htmx-indicator\">Загрузка...</span></li></ul><ul hx-boost=\"true\" hx-indicator=\"#loader\">
+<li><a href=\"/user/login\">Вход</a></li><li><a href=\"/user/register\">Регистрация</a></li>
+<li><a href=\"/user/profile\">
+</a></li><li><a href=\"#\" hx-post=\"/user/logout\">Выход</a></li>
+</ul></nav>
+</main></body><footer><main class=\"container\"><nav><ul><li>Сделал <a href=\"https://neonxp.ru/\">NeonXP</a> в 2024 году.</li></ul></nav></main></footer><script src=\"/js/htmx.min.js\"></script></html>
diff --git a/pkg/tpl/login.templ b/pkg/tpl/login.templ
new file mode 100644
index 0000000..1f28e4d
--- /dev/null
+++ b/pkg/tpl/login.templ
@@ -0,0 +1,60 @@
+package tpl
+
+templ Login(form *LoginForm) {
+ @Layout() {
+ <article class="grid">
+ <div>
+ <hgroup>
+ <h1>Вход</h1>
+ <p>Вход в систему</p>
+ </hgroup>
+ <form
+ method="post"
+ hx-post="/user/login"
+ hx-target="form"
+ hx-select="form"
+ hx-indicator="#loader"
+ >
+ if form.Message != "" {
+ <article>
+ <header>Ошибка</header>
+ { form.Message }
+ </article>
+ }
+ <input
+ type="email"
+ name="email"
+ placeholder="Электропочта"
+ aria-label="Электропочта"
+ autocomplete="email"
+ value={ form.Email }
+ required
+ />
+ <input
+ type="password"
+ name="password"
+ placeholder="Пароль"
+ aria-label="Пароль"
+ autocomplete="current-password"
+ required
+ />
+ <fieldset>
+ <label for="remember">
+ <input type="checkbox" role="switch" id="remember" name="remember" checked={ form.Remember }/>
+ Запомнить
+ </label>
+ </fieldset>
+ <button type="submit" class="contrast">Вход</button>
+ </form>
+ </div>
+ <div></div>
+ </article>
+ }
+}
+
+type LoginForm struct {
+ Message string
+ Email string `form:"email"`
+ Password string `form:"password"`
+ Remember string `form:"remember"`
+}
diff --git a/pkg/tpl/login_templ.go b/pkg/tpl/login_templ.go
new file mode 100644
index 0000000..8c02424
--- /dev/null
+++ b/pkg/tpl/login_templ.go
@@ -0,0 +1,114 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package tpl
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func Login(form *LoginForm) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if form.Message != "" {
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Message)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/login.templ`, Line: 21, Col: 21}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/login.templ`, Line: 30, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Remember)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/login.templ`, Line: 43, Col: 97}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 6)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+type LoginForm struct {
+ Message string
+ Email string `form:"email"`
+ Password string `form:"password"`
+ Remember string `form:"remember"`
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/pkg/tpl/login_templ.txt b/pkg/tpl/login_templ.txt
new file mode 100644
index 0000000..af8cbb3
--- /dev/null
+++ b/pkg/tpl/login_templ.txt
@@ -0,0 +1,6 @@
+<article class=\"grid\"><div><hgroup><h1>Вход</h1><p>Вход в систему</p></hgroup><form method=\"post\" hx-post=\"/user/login\" hx-target=\"form\" hx-select=\"form\" hx-indicator=\"#loader\">
+<article><header>Ошибка</header>
+</article>
+<input type=\"email\" name=\"email\" placeholder=\"Электропочта\" aria-label=\"Электропочта\" autocomplete=\"email\" value=\"
+\" required> <input type=\"password\" name=\"password\" placeholder=\"Пароль\" aria-label=\"Пароль\" autocomplete=\"current-password\" required><fieldset><label for=\"remember\"><input type=\"checkbox\" role=\"switch\" id=\"remember\" name=\"remember\" checked=\"
+\"> Запомнить</label></fieldset><button type=\"submit\" class=\"contrast\">Вход</button></form></div><div></div></article>
diff --git a/pkg/tpl/profile.templ b/pkg/tpl/profile.templ
new file mode 100644
index 0000000..61a9221
--- /dev/null
+++ b/pkg/tpl/profile.templ
@@ -0,0 +1,17 @@
+package tpl
+
+import "go.neonxp.ru/framework/pkg/model"
+
+templ Profile(user *model.User) {
+ @Layout() {
+ <article class="grid">
+ <div>
+ <hgroup>
+ <h1>{ user.Username }</h1>
+ <p>{ user.Email }</p>
+ </hgroup>
+ </div>
+ <div></div>
+ </article>
+ }
+}
diff --git a/pkg/tpl/profile_templ.go b/pkg/tpl/profile_templ.go
new file mode 100644
index 0000000..e51ab9a
--- /dev/null
+++ b/pkg/tpl/profile_templ.go
@@ -0,0 +1,86 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package tpl
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "go.neonxp.ru/framework/pkg/model"
+
+func Profile(user *model.User) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/profile.templ`, Line: 10, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/profile.templ`, Line: 11, Col: 20}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/pkg/tpl/profile_templ.txt b/pkg/tpl/profile_templ.txt
new file mode 100644
index 0000000..5fa238c
--- /dev/null
+++ b/pkg/tpl/profile_templ.txt
@@ -0,0 +1,3 @@
+<article class=\"grid\"><div><hgroup><h1>
+</h1><p>
+</p></hgroup></div><div></div></article>
diff --git a/pkg/tpl/register.templ b/pkg/tpl/register.templ
new file mode 100644
index 0000000..c7c7210
--- /dev/null
+++ b/pkg/tpl/register.templ
@@ -0,0 +1,63 @@
+package tpl
+
+templ Register(form *RegisterForm) {
+ @Layout() {
+ <article class="grid">
+ <div>
+ <hgroup>
+ <h1>Регистрация</h1>
+ <p>Регистрация в системе</p>
+ </hgroup>
+ <form
+ method="post"
+ hx-post="/user/register"
+ hx-target="form"
+ hx-select="form"
+ hx-indicator="#loader"
+ >
+ if form.Message != "" {
+ <article>
+ <header>Ошибка</header>
+ { form.Message }
+ </article>
+ }
+ <input
+ type="text"
+ name="username"
+ placeholder="Отображаемое имя"
+ aria-label="Отображаемое имя"
+ autocomplete="username"
+ value={ form.Username }
+ required
+ />
+ <input
+ type="email"
+ name="email"
+ placeholder="Электропочта"
+ aria-label="Электропочта"
+ autocomplete="email"
+ value={ form.Email }
+ required
+ />
+ <input
+ type="password"
+ name="password"
+ placeholder="Пароль"
+ aria-label="Пароль"
+ autocomplete="current-password"
+ required
+ />
+ <button type="submit" class="contrast">Регистрация</button>
+ </form>
+ </div>
+ <div></div>
+ </article>
+ }
+}
+
+type RegisterForm struct {
+ Message string
+ Username string `form:"username"`
+ Email string `form:"email"`
+ Password string `form:"password"`
+}
diff --git a/pkg/tpl/register_templ.go b/pkg/tpl/register_templ.go
new file mode 100644
index 0000000..ffab2de
--- /dev/null
+++ b/pkg/tpl/register_templ.go
@@ -0,0 +1,114 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.778
+package tpl
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func Register(form *RegisterForm) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if form.Message != "" {
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Message)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/register.templ`, Line: 21, Col: 21}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(form.Username)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/register.templ`, Line: 30, Col: 27}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/tpl/register.templ`, Line: 39, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 6)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+ templ_7745c5c3_Err = Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
+
+type RegisterForm struct {
+ Message string
+ Username string `form:"username"`
+ Email string `form:"email"`
+ Password string `form:"password"`
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/pkg/tpl/register_templ.txt b/pkg/tpl/register_templ.txt
new file mode 100644
index 0000000..c404586
--- /dev/null
+++ b/pkg/tpl/register_templ.txt
@@ -0,0 +1,6 @@
+<article class=\"grid\"><div><hgroup><h1>Регистрация</h1><p>Регистрация в системе</p></hgroup><form method=\"post\" hx-post=\"/user/register\" hx-target=\"form\" hx-select=\"form\" hx-indicator=\"#loader\">
+<article><header>Ошибка</header>
+</article>
+<input type=\"text\" name=\"username\" placeholder=\"Отображаемое имя\" aria-label=\"Отображаемое имя\" autocomplete=\"username\" value=\"
+\" required> <input type=\"email\" name=\"email\" placeholder=\"Электропочта\" aria-label=\"Электропочта\" autocomplete=\"email\" value=\"
+\" required> <input type=\"password\" name=\"password\" placeholder=\"Пароль\" aria-label=\"Пароль\" autocomplete=\"current-password\" required> <button type=\"submit\" class=\"contrast\">Регистрация</button></form></div><div></div></article>
diff --git a/pkg/utils/htmx.go b/pkg/utils/htmx.go
new file mode 100644
index 0000000..c4120f3
--- /dev/null
+++ b/pkg/utils/htmx.go
@@ -0,0 +1,11 @@
+package utils
+
+import "github.com/labstack/echo/v4"
+
+func IsHTMX(c echo.Context) bool {
+ return c.Request().Header.Get("HX-Request") == "true"
+}
+
+func HTMXRedirect(c echo.Context, location string) {
+ c.Response().Header().Set("HX-Redirect", location)
+}
diff --git a/pkg/utils/user.go b/pkg/utils/user.go
new file mode 100644
index 0000000..4006b3c
--- /dev/null
+++ b/pkg/utils/user.go
@@ -0,0 +1,41 @@
+package utils
+
+import (
+ "context"
+
+ "github.com/gorilla/sessions"
+ "github.com/labstack/echo-contrib/session"
+ "github.com/labstack/echo/v4"
+ "go.neonxp.ru/framework/pkg/middleware"
+ "go.neonxp.ru/framework/pkg/model"
+)
+
+func GetUserCtx(ctx context.Context) *model.User {
+ u := ctx.Value(middleware.ContextKey("user"))
+ if u == nil {
+ return nil
+ }
+
+ if u, ok := u.(model.User); ok {
+ return &u
+ }
+
+ return nil
+}
+
+func SetUser(c echo.Context, u *model.User, maxage int) error {
+ sess, err := session.Get("user", c)
+ if err != nil {
+ return err
+ }
+
+ sess.Values["user"] = u
+ sess.Options = &sessions.Options{
+ Path: "/",
+ MaxAge: maxage,
+ HttpOnly: true,
+ Secure: true,
+ }
+
+ return sess.Save(c.Request(), c.Response())
+}