From 94bb72eae47b99df24ce44f0a5dfb5937395fb9f Mon Sep 17 00:00:00 2001 From: xmdhs Date: Sun, 3 Sep 2023 23:19:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/testserver/main.go | 6 +++ db/cache/fastcache.go | 4 ++ db/ent/migrate/schema.go | 9 +++- db/ent/runtime/runtime.go | 3 +- db/ent/schema/userprofile.go | 2 +- go.mod | 2 +- go.sum | 12 ++++++ handle/yggdrasil/authenticate.go | 20 +++++++-- model/yggdrasil/model.go | 2 +- server/route/middleware.go | 2 +- server/route/route.go | 6 +-- service/user.go | 71 ++++++++++++++++--------------- service/yggdrasil/authenticate.go | 55 +++++++++++++++++++++--- service/yggdrasil/yggdrasil.go | 4 +- utils/argon2id.go | 2 +- utils/tx.go | 55 +++++++++++++----------- 16 files changed, 174 insertions(+), 81 deletions(-) diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go index 5a39cee..f58e735 100644 --- a/cmd/testserver/main.go +++ b/cmd/testserver/main.go @@ -25,6 +25,12 @@ func main() { Node: 0, Epoch: 1693645718534, Debug: true, + Cache: struct { + Type string + Ram int + }{ + Ram: 1000 * 1000 * 50, + }, } s, c, err := server.InitializeRoute(ctx, config) if err != nil { diff --git a/db/cache/fastcache.go b/db/cache/fastcache.go index 045beae..9dff1b7 100644 --- a/db/cache/fastcache.go +++ b/db/cache/fastcache.go @@ -40,6 +40,10 @@ func (f *FastCache) Del(k []byte) error { func (f *FastCache) Get(k []byte) ([]byte, error) { b := f.c.GetBig(nil, k) + if b == nil { + return nil, nil + } + me := ttlCache{} err := binary.Unmarshal(b, &me) if err != nil { diff --git a/db/ent/migrate/schema.go b/db/ent/migrate/schema.go index 1f49aba..3a9116d 100644 --- a/db/ent/migrate/schema.go +++ b/db/ent/migrate/schema.go @@ -67,11 +67,18 @@ var ( OnDelete: schema.SetNull, }, }, + Indexes: []*schema.Index{ + { + Name: "user_email", + Unique: true, + Columns: []*schema.Column{UsersColumns[1]}, + }, + }, } // UserProfilesColumns holds the columns for the "user_profiles" table. UserProfilesColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt, Increment: true}, - {Name: "name", Type: field.TypeString}, + {Name: "name", Type: field.TypeString, Unique: true}, {Name: "uuid", Type: field.TypeString}, {Name: "user_profile", Type: field.TypeInt, Unique: true}, } diff --git a/db/ent/runtime/runtime.go b/db/ent/runtime/runtime.go index 788cc38..eff54c3 100644 --- a/db/ent/runtime/runtime.go +++ b/db/ent/runtime/runtime.go @@ -5,6 +5,5 @@ package runtime // The schema-stitching logic is generated in github.com/xmdhs/authlib-skin/db/ent/runtime.go const ( - Version = "v0.12.3" // Version of ent codegen. - Sum = "h1:N5lO2EOrHpCH5HYfiMOCHYbo+oh5M8GjT0/cx5x6xkk=" // Sum of ent codegen. + Version = "(devel)" // Version of ent codegen. ) diff --git a/db/ent/schema/userprofile.go b/db/ent/schema/userprofile.go index a90577e..e5c8f5e 100644 --- a/db/ent/schema/userprofile.go +++ b/db/ent/schema/userprofile.go @@ -15,7 +15,7 @@ type UserProfile struct { // Fields of the UserProfile. func (UserProfile) Fields() []ent.Field { return []ent.Field{ - field.String("name"), + field.String("name").Unique(), field.String("uuid"), } } diff --git a/go.mod b/go.mod index d70af21..ade10b0 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/bwmarrin/snowflake v0.3.0 github.com/go-playground/validator/v10 v10.15.3 github.com/go-sql-driver/mysql v1.7.1 + github.com/golang-jwt/jwt/v5 v5.0.0 github.com/google/uuid v1.3.1 github.com/google/wire v0.5.0 github.com/julienschmidt/httprouter v1.3.0 @@ -24,7 +25,6 @@ require ( github.com/go-openapi/inflect v0.19.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-cmp v0.5.6 // indirect github.com/hashicorp/hcl/v2 v2.13.0 // indirect diff --git a/go.sum b/go.sum index 877838d..9fc7b38 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc= github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -66,14 +68,22 @@ github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3v github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -107,6 +117,8 @@ golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.8.1-0.20230428195545-5283a0178901 h1:0wxTF6pSjIIhNt7mo9GvjDfzyCOiWhmICgtO/Ah948s= +golang.org/x/tools v0.8.1-0.20230428195545-5283a0178901/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/handle/yggdrasil/authenticate.go b/handle/yggdrasil/authenticate.go index 98ce5e6..c52622b 100644 --- a/handle/yggdrasil/authenticate.go +++ b/handle/yggdrasil/authenticate.go @@ -1,10 +1,13 @@ package yggdrasil import ( + "encoding/json" + "errors" "net/http" "github.com/julienschmidt/httprouter" "github.com/xmdhs/authlib-skin/model/yggdrasil" + yggdrasilS "github.com/xmdhs/authlib-skin/service/yggdrasil" "github.com/xmdhs/authlib-skin/utils" ) @@ -13,11 +16,22 @@ func (y *Yggdrasil) Authenticate() httprouter.Handle { cxt := r.Context() a, err := utils.DeCodeBody[yggdrasil.Authenticate](r.Body, y.validate) if err != nil { - y.logger.InfoContext(cxt, err.Error()) + y.logger.DebugContext(cxt, err.Error()) handleYgError(cxt, w, yggdrasil.Error{ErrorMessage: err.Error()}, 400) return } - - _ = a + t, err := y.yggdrasilService.Authenticate(cxt, a) + if err != nil { + if errors.Is(err, yggdrasilS.ErrPassWord) || errors.Is(err, yggdrasilS.ErrRate) { + y.logger.DebugContext(cxt, err.Error()) + handleYgError(cxt, w, yggdrasil.Error{ErrorMessage: "Invalid credentials. Invalid username or password.", Error: "ForbiddenOperationException"}, 403) + return + } + y.logger.WarnContext(cxt, err.Error()) + handleYgError(cxt, w, yggdrasil.Error{ErrorMessage: err.Error()}, 500) + return + } + b, _ := json.Marshal(t) + w.Write(b) } } diff --git a/model/yggdrasil/model.go b/model/yggdrasil/model.go index 43dabb8..65aa753 100644 --- a/model/yggdrasil/model.go +++ b/model/yggdrasil/model.go @@ -22,7 +22,7 @@ type Token struct { AvailableProfiles []TokenProfile `json:"availableProfiles"` ClientToken string `json:"clientToken"` SelectedProfile TokenProfile `json:"selectedProfile"` - User TokenUser `json:"user"` + User TokenUser `json:"user,omitempty"` } type TokenProfile struct { diff --git a/server/route/middleware.go b/server/route/middleware.go index e2a9d3f..e067a3c 100644 --- a/server/route/middleware.go +++ b/server/route/middleware.go @@ -6,7 +6,7 @@ import ( "github.com/julienschmidt/httprouter" ) -func warpCtJSON(handle httprouter.Handle) httprouter.Handle { +func warpHJSON(handle httprouter.Handle) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { w.Header().Set("Content-Type", "application/json; charset=utf-8") handle(w, r, p) diff --git a/server/route/route.go b/server/route/route.go index 95a12ee..b9136bf 100644 --- a/server/route/route.go +++ b/server/route/route.go @@ -10,7 +10,7 @@ import ( func NewRoute(yggService *yggdrasil.Yggdrasil, handel *handle.Handel) (*httprouter.Router, error) { r := httprouter.New() - err := newYggdrasil(r) + err := newYggdrasil(r, *yggService) if err != nil { return nil, fmt.Errorf("NewRoute: %w", err) } @@ -21,8 +21,8 @@ func NewRoute(yggService *yggdrasil.Yggdrasil, handel *handle.Handel) (*httprout return r, nil } -func newYggdrasil(r *httprouter.Router) error { - r.POST("/api/authserver/authenticate", nil) +func newYggdrasil(r *httprouter.Router, handelY yggdrasil.Yggdrasil) error { + r.POST("/api/authserver/authenticate", warpHJSON(handelY.Authenticate())) return nil } diff --git a/service/user.go b/service/user.go index 06a9055..ee26b01 100644 --- a/service/user.go +++ b/service/user.go @@ -3,7 +3,6 @@ package service import ( "context" "crypto/md5" - "database/sql" "encoding/hex" "errors" "fmt" @@ -11,7 +10,9 @@ import ( "time" "github.com/google/uuid" + "github.com/xmdhs/authlib-skin/db/ent" "github.com/xmdhs/authlib-skin/db/ent/user" + "github.com/xmdhs/authlib-skin/db/ent/userprofile" "github.com/xmdhs/authlib-skin/model" "github.com/xmdhs/authlib-skin/utils" ) @@ -22,46 +23,48 @@ var ( ) func (w *WebService) Reg(ctx context.Context, u model.User) error { - tx, err := w.client.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) - if err != nil { - return fmt.Errorf("Reg: %w", err) - } - defer tx.Rollback() - count, err := tx.User.Query().Where(user.EmailEQ(u.Email)).ForUpdate().Count(ctx) - if err != nil { - return fmt.Errorf("Reg: %w", err) - } - if count != 0 { - return fmt.Errorf("Reg: %w", ErrExistUser) - } - p, s := utils.Argon2ID(u.Password) - - du, err := w.client.User.Create(). - SetEmail(u.Email). - SetPassword(p). - SetSalt(s). - SetRegTime(time.Now().Unix()). - SetState(0).Save(ctx) - if err != nil { - return fmt.Errorf("Reg: %w", err) - } - var userUuid string if w.config.OfflineUUID { userUuid = uuidGen(u.Name) } else { userUuid = strings.ReplaceAll(uuid.New().String(), "-", "") } + p, s := utils.Argon2ID(u.Password) - _, err = w.client.UserProfile.Create(). - SetUser(du). - SetName(u.Name). - SetUUID(userUuid). - Save(ctx) - if err != nil { - return fmt.Errorf("Reg: %w", err) - } - err = tx.Commit() + err := utils.WithTx(ctx, w.client, func(tx *ent.Tx) error { + count, err := tx.User.Query().Where(user.EmailEQ(u.Email)).ForUpdate().Count(ctx) + if err != nil { + return err + } + if count != 0 { + return ErrExistUser + } + nameCount, err := tx.UserProfile.Query().Where(userprofile.NameEQ(u.Name)).ForUpdate().Count(ctx) + if err != nil { + return err + } + if nameCount != 0 { + return ErrExitsName + } + du, err := tx.User.Create(). + SetEmail(u.Email). + SetPassword(p). + SetSalt(s). + SetRegTime(time.Now().Unix()). + SetState(0).Save(ctx) + if err != nil { + return err + } + _, err = tx.UserProfile.Create(). + SetUser(du). + SetName(u.Name). + SetUUID(userUuid). + Save(ctx) + if err != nil { + return err + } + return nil + }) if err != nil { return fmt.Errorf("Reg: %w", err) } diff --git a/service/yggdrasil/authenticate.go b/service/yggdrasil/authenticate.go index a8c8373..76a67b0 100644 --- a/service/yggdrasil/authenticate.go +++ b/service/yggdrasil/authenticate.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "errors" "fmt" + "strconv" "strings" "time" @@ -37,13 +38,13 @@ func (y *Yggdrasil) Authenticate(cxt context.Context, auth yggdrasil.Authenticat } } b := make([]byte, 8) - binary.BigEndian.PutUint64(b, uint64(time.Now().Add(10*time.Second).Unix())) + binary.BigEndian.PutUint64(b, uint64(time.Now().Add(5*time.Second).Unix())) err = y.cache.Put(key, b, time.Now().Add(20*time.Second)) if err != nil { return yggdrasil.Token{}, fmt.Errorf("Authenticate: %w", err) } - u, err := y.client.User.Query().Where(user.EmailEQ(auth.Username)).WithProfile().WithToken().First(cxt) + u, err := y.client.User.Query().Where(user.EmailEQ(auth.Username)).WithProfile().First(cxt) if err != nil { var nf *ent.NotFoundError if errors.As(err, &nf) { @@ -59,15 +60,59 @@ func (y *Yggdrasil) Authenticate(cxt context.Context, auth yggdrasil.Authenticat clientToken = strings.ReplaceAll(uuid.New().String(), "-", "") } + var utoken *ent.UserToken + err = utils.WithTx(cxt, y.client, func(tx *ent.Tx) error { + utoken, err = tx.User.QueryToken(u).ForUpdate().First(cxt) + if err != nil { + var nf *ent.NotFoundError + if !errors.As(err, &nf) { + return err + } + } + if utoken == nil { + ut, err := tx.UserToken.Create().SetTokenID(1).Save(cxt) + if err != nil { + return err + } + err = tx.User.UpdateOne(u).SetToken(ut).Exec(cxt) + if err != nil { + return err + } + utoken = ut + } + return nil + }) + if err != nil { + return yggdrasil.Token{}, fmt.Errorf("Authenticate: %w", err) + } + claims := model.TokenClaims{ - Tid: u.Edges.Profile.UUID, + Tid: strconv.FormatUint(utoken.TokenID, 10), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * 24 * time.Hour)), Issuer: "authlib-skin", Subject: u.Edges.Profile.UUID, + IssuedAt: jwt.NewNumericDate(time.Now()), }, } - _ = claims + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + jwts, err := token.SignedString([]byte(y.config.JwtKey)) + if err != nil { + return yggdrasil.Token{}, fmt.Errorf("Authenticate: %w", err) + } + p := yggdrasil.TokenProfile{ + ID: u.Edges.Profile.UUID, + Name: u.Edges.Profile.Name, + } - return yggdrasil.Token{}, nil + return yggdrasil.Token{ + AccessToken: jwts, + AvailableProfiles: []yggdrasil.TokenProfile{p}, + ClientToken: clientToken, + SelectedProfile: p, + User: yggdrasil.TokenUser{ + ID: u.Edges.Profile.UUID, + Properties: []any{}, + }, + }, nil } diff --git a/service/yggdrasil/yggdrasil.go b/service/yggdrasil/yggdrasil.go index bac2d4a..8d4798d 100644 --- a/service/yggdrasil/yggdrasil.go +++ b/service/yggdrasil/yggdrasil.go @@ -9,13 +9,13 @@ import ( type Yggdrasil struct { client *ent.Client cache cache.Cache - c config.Config + config config.Config } func NewYggdrasil(client *ent.Client, cache cache.Cache, c config.Config) *Yggdrasil { return &Yggdrasil{ client: client, cache: cache, - c: c, + config: c, } } diff --git a/utils/argon2id.go b/utils/argon2id.go index 9ee3292..3de40cd 100644 --- a/utils/argon2id.go +++ b/utils/argon2id.go @@ -19,7 +19,7 @@ func Argon2ID(pass string) (password string, salt string) { } func Argon2Compare(pass, hashPass string, salt string) bool { - s, err := base64.StdEncoding.DecodeString(hashPass) + s, err := base64.StdEncoding.DecodeString(salt) if err != nil { return false } diff --git a/utils/tx.go b/utils/tx.go index 9359906..d42edb6 100644 --- a/utils/tx.go +++ b/utils/tx.go @@ -1,28 +1,31 @@ package utils -// func WithTx(ctx context.Context, opts *sql.TxOptions, q mysql.Querier, db *sql.DB, f func(mysql.Querier) error) error { -// w, ok := q.(interface { -// WithTx(tx *sql.Tx) *mysql.Queries -// }) -// var tx *sql.Tx -// if ok { -// var err error -// tx, err = db.BeginTx(ctx, opts) -// if err != nil { -// return fmt.Errorf("WithTx: %w", err) -// } -// defer tx.Rollback() -// q = w.WithTx(tx) -// } -// err := f(q) -// if err != nil { -// return fmt.Errorf("WithTx: %w", err) -// } -// if tx != nil { -// err := tx.Commit() -// if err != nil { -// return fmt.Errorf("WithTx: %w", err) -// } -// } -// return nil -// } +import ( + "context" + "fmt" + + "github.com/xmdhs/authlib-skin/db/ent" +) + +func WithTx(ctx context.Context, client *ent.Client, fn func(tx *ent.Tx) error) error { + tx, err := client.Tx(ctx) + if err != nil { + return err + } + defer func() { + if v := recover(); v != nil { + tx.Rollback() + panic(v) + } + }() + if err := fn(tx); err != nil { + if rerr := tx.Rollback(); rerr != nil { + err = fmt.Errorf("%w: rolling back transaction: %v", err, rerr) + } + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing transaction: %w", err) + } + return nil +}