Вход
+Вход в систему
+ + +From d05ea66f4bbcf0cc5c8908f3435c68de1b070fa1 Mon Sep 17 00:00:00 2001
From: Alexander Neonxp Kiryukhin
Date: Sat, 12 Oct 2024 02:52:22 +0300
Subject: Начальная версия
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pkg/config/config.go | 27 +++++
pkg/db/config.go | 5 +
pkg/db/db.go | 18 ++++
pkg/handler/error.go | 47 ++++++++
pkg/handler/handler.go | 15 +++
pkg/handler/user/errors.go | 30 ++++++
pkg/handler/user/handler.go | 24 +++++
pkg/handler/user/login.go | 53 +++++++++
pkg/handler/user/logout.go | 18 ++++
pkg/handler/user/profile.go | 16 +++
pkg/handler/user/register.go | 45 ++++++++
pkg/middleware/context.go | 21 ++++
pkg/middleware/session/store.go | 232 ++++++++++++++++++++++++++++++++++++++++
pkg/middleware/user.go | 28 +++++
pkg/model/user.go | 26 +++++
pkg/service/crud/crud.go | 87 +++++++++++++++
pkg/service/user/login.go | 31 ++++++
pkg/service/user/register.go | 85 +++++++++++++++
pkg/service/user/user.go | 12 +++
pkg/tpl/error.templ | 10 ++
pkg/tpl/error_templ.go | 86 +++++++++++++++
pkg/tpl/error_templ.txt | 3 +
pkg/tpl/layout.templ | 49 +++++++++
pkg/tpl/layout_templ.go | 79 ++++++++++++++
pkg/tpl/layout_templ.txt | 6 ++
pkg/tpl/login.templ | 60 +++++++++++
pkg/tpl/login_templ.go | 114 ++++++++++++++++++++
pkg/tpl/login_templ.txt | 6 ++
pkg/tpl/profile.templ | 17 +++
pkg/tpl/profile_templ.go | 86 +++++++++++++++
pkg/tpl/profile_templ.txt | 3 +
pkg/tpl/register.templ | 63 +++++++++++
pkg/tpl/register_templ.go | 114 ++++++++++++++++++++
pkg/tpl/register_templ.txt | 6 ++
pkg/utils/htmx.go | 11 ++
pkg/utils/user.go | 41 +++++++
36 files changed, 1574 insertions(+)
create mode 100644 pkg/config/config.go
create mode 100644 pkg/db/config.go
create mode 100644 pkg/db/db.go
create mode 100644 pkg/handler/error.go
create mode 100644 pkg/handler/handler.go
create mode 100644 pkg/handler/user/errors.go
create mode 100644 pkg/handler/user/handler.go
create mode 100644 pkg/handler/user/login.go
create mode 100644 pkg/handler/user/logout.go
create mode 100644 pkg/handler/user/profile.go
create mode 100644 pkg/handler/user/register.go
create mode 100644 pkg/middleware/context.go
create mode 100644 pkg/middleware/session/store.go
create mode 100644 pkg/middleware/user.go
create mode 100644 pkg/model/user.go
create mode 100644 pkg/service/crud/crud.go
create mode 100644 pkg/service/user/login.go
create mode 100644 pkg/service/user/register.go
create mode 100644 pkg/service/user/user.go
create mode 100644 pkg/tpl/error.templ
create mode 100644 pkg/tpl/error_templ.go
create mode 100644 pkg/tpl/error_templ.txt
create mode 100644 pkg/tpl/layout.templ
create mode 100644 pkg/tpl/layout_templ.go
create mode 100644 pkg/tpl/layout_templ.txt
create mode 100644 pkg/tpl/login.templ
create mode 100644 pkg/tpl/login_templ.go
create mode 100644 pkg/tpl/login_templ.txt
create mode 100644 pkg/tpl/profile.templ
create mode 100644 pkg/tpl/profile_templ.go
create mode 100644 pkg/tpl/profile_templ.txt
create mode 100644 pkg/tpl/register.templ
create mode 100644 pkg/tpl/register_templ.go
create mode 100644 pkg/tpl/register_templ.txt
create mode 100644 pkg/utils/htmx.go
create mode 100644 pkg/utils/user.go
(limited to 'pkg')
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() {
+ Ошибка { strconv.Itoa(code) }!
+
{ message }
+ } +} 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 @@ ++
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) }} + + + + + + + + + +Вход в систему
+ + +Вход в систему
{ user.Email }
+ ++
Регистрация в системе
+ + +Регистрация в системе