feat: user_rework (todo -> GetAll)

This commit is contained in:
2025-06-11 12:30:22 +01:00
parent 6aa3870a5c
commit 189534031b
24 changed files with 647 additions and 142 deletions
-35
View File
@@ -1,35 +0,0 @@
# Server Configuration
export ADDR=":8080"
export EXTERNAL_URL="localhost:8080"
export FRONTEND_URL="http://localhost:5173"
export ENV="development"
export ALLOWED_ORIGINS="https://*,http://*" # TODO: Change to the actual allowed origins on production
# Database Configuration
export DB_ADDR="postgres://postgres:postgres@localhost:5432/lk_tmpl?sslmode=disable"
export DB_MAX_OPEN_CONNS="30"
export DB_MAX_IDLE_CONNS="30"
export DB_MAX_IDLE_TIME="15m"
# Redis Configuration
export REDIS_ADDR="localhost:6379"
export REDIS_PASSWORD=""
export REDIS_DB="0"
export REDIS_ENABLED="false"
# Authentication
export BASIC_AUTH_USERNAME="admin"
export BASIC_AUTH_PASSWORD="admin"
export AUTH_TOKEN_SECRET="your-secret-key-here"
export AUTH_TOKEN_EXP="24h"
export AUTH_TOKEN_ISS="lkapi"
export AUTH_TOKEN_AUD="lkapi"
# Email Configuration
export FROM_EMAIL="[email protected]"
export SENDGRID_API_KEY="your-sendgrid-api-key"
# Rate Limiter
export RATE_LIMITER_REQS_COUNT="20"
export RATE_LIMITER_TIME_FRAME="1m"
export RATE_LIMITER_ENABLED="true"
+1 -1
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+35 -17
View File
@@ -48,9 +48,10 @@ type config struct {
} }
type mailConfig struct { type mailConfig struct {
exp time.Duration inviteExp time.Duration
fromEmail string passwordRefreshExp time.Duration
sendGrid sendGridConfig fromEmail string
sendGrid sendGridConfig
} }
type authConfig struct { type authConfig struct {
@@ -64,10 +65,11 @@ type basicConfig struct {
} }
type tokenConfig struct { type tokenConfig struct {
secret string secret string
exp time.Duration exp time.Duration
iss string refreshExp time.Duration
aud string iss string
aud string
} }
type redisConfig struct { type redisConfig struct {
@@ -89,6 +91,8 @@ type dbConfig struct {
maxIdleTime time.Duration maxIdleTime time.Duration
} }
// This `mount` function is responsible for setting up the routing for your API
// using the Chi router. Here's a breakdown of what it does:
func (api *api) mount() http.Handler { func (api *api) mount() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
// *CORS // *CORS
@@ -135,29 +139,37 @@ func (api *api) mount() http.Handler {
r.Route("/users", func(r chi.Router) { r.Route("/users", func(r chi.Router) {
r.Put("/activate/{token}", api.activateUserHandler) r.Put("/activate/{token}", api.activateUserHandler)
//r.Put("/reset-password/{token}", api.resetPasswordHandler)
r.Route("/{id}", func(r chi.Router) { r.Route("/me", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware) r.Use(api.AuthTokenMiddleware)
r.Get("/", api.getUserHandler) r.Get("/", api.getUserHandler)
r.Patch("/", api.checkProfileOwnership(api.updateUserHandler)) r.Patch("/", api.checkProfileOwnership(api.updateUserHandler))
r.Patch("/password", api.checkProfileOwnership(api.updatePasswordHandler))
r.Put(("/follow"), api.followUserHandler)
r.Put(("/unfollow"), api.unfollowUserHandler)
})
r.Group(func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Get("/feed", api.getUserFeedHandler)
}) })
}) })
r.Route("/authentication", func(r chi.Router) { r.Route("/authentication", func(r chi.Router) {
r.Post("/user", api.registerUserHandler) r.Post("/register", api.registerUserHandler)
r.Post("/token", api.createTokenHandler) r.Post("/token", api.createTokenHandler)
r.Post("/forgot-password", api.forgotPasswordHandler)
r.Post("/refresh", api.refreshTokenHandler)
}) })
}) })
r.Route("/admin", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Route("/users", func(r chi.Router) {
r.Get("/", api.protectAdminRoutes("moderator", api.getUsersHandler))
r.Route("/{id}", func(r chi.Router) {
r.Get("/", api.protectAdminRoutes("moderator", api.getUserByIdHandler))
})
})
})
return r return r
} }
@@ -204,6 +216,12 @@ func (api *api) run(mux http.Handler) error {
return err return err
} }
if userStore, ok := api.store.Users.(*store.UserStore); ok {
if err := userStore.Close(); err != nil {
api.logger.Errorw("error closing userstore", err)
}
}
api.logger.Infow("server has been shutdown") api.logger.Infow("server has been shutdown")
return nil return nil
+88 -1
View File
@@ -27,6 +27,15 @@ type UserWithToken struct {
Token string `json:"token"` Token string `json:"token"`
} }
type ForgotPasswordPayload struct {
Email string `json:"email" validate:"required,email,max=255"`
}
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
}
// RegisterUser godoc // RegisterUser godoc
// //
// @Summary Register a new User // @Summary Register a new User
@@ -75,7 +84,7 @@ func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) {
hashedToken := hex.EncodeToString(hash[:]) hashedToken := hex.EncodeToString(hash[:])
// Store the user // Store the user
err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.exp) err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.inviteExp)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, store.ErrDuplicateEmail): case errors.Is(err, store.ErrDuplicateEmail):
@@ -201,3 +210,81 @@ func (api *api) createTokenHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
// ResetPasswordHandler godoc
//
// @Summary Forgot Password
// @Description Forgot Password
// @Tags authentication
// @Accept json
// @Produce json
// @Param payload body ForgotPasswordPayload true "User Credentials"
// @Success 201 {string} string "Token Created"
// @Failure 400 {object} error
// @Failure 401 {object} error
// @Failure 500 {object} error
// @Router /authentication/forgot-password [post]
func (api *api) forgotPasswordHandler(w http.ResponseWriter, r *http.Request) {
var payload ForgotPasswordPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
user, err := api.store.Users.GetByEmail(r.Context(), payload.Email)
if err != nil {
switch err {
case store.ErrNotFound:
api.badRequestError(w, r, err)
default:
api.internalServerError(w, r, err)
}
}
plainToken := uuid.New().String()
hash := sha256.Sum256([]byte(plainToken))
hashedToken := hex.EncodeToString(hash[:])
//*Store The Request
err = api.store.Users.PasswordResetRequest(r.Context(), user.ID, hashedToken, api.config.mail.passwordRefreshExp)
if err != nil {
api.internalServerError(w, r, err)
return
}
updateURL := fmt.Sprintf("%s/reset-password/%s", api.config.frontEndURL, plainToken)
isProdEnv := api.config.env == "production"
vars := struct {
Username string
ServiceName string
UpdateURL string
Email string
CurrentYear string
}{
Username: user.Username,
ServiceName: "LK_API_Temp",
UpdateURL: updateURL,
Email: user.Email,
CurrentYear: time.Now().Format("2006"),
}
status, err := api.mailer.Send(mailer.ForgotPasswordTemplate, user.Username, user.Email, vars, !isProdEnv)
if err != nil {
api.internalServerError(w, r, err)
return
}
api.logger.Infow("email sent", "status code", status)
if err := api.jsonResponse(w, http.StatusCreated, plainToken); err != nil {
api.internalServerError(w, r, err)
return
}
}
-2
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"log"
"net/http" "net/http"
"github.com/FernandoVideira/LK_API_Temp/internal/store" "github.com/FernandoVideira/LK_API_Temp/internal/store"
@@ -48,7 +47,6 @@ func (api *api) getUserFeedHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
user := getUserFromContext(r) user := getUserFromContext(r)
log.Println(user)
feed, err := api.store.Posts.GetUserFeed(ctx, user.ID, fq) feed, err := api.store.Posts.GetUserFeed(ctx, user.ID, fq)
if err != nil { if err != nil {
api.internalServerError(w, r, err) api.internalServerError(w, r, err)
+7 -3
View File
@@ -56,8 +56,9 @@ func main() {
}, },
env: env.GetString("ENV", "development"), env: env.GetString("ENV", "development"),
mail: mailConfig{ mail: mailConfig{
exp: time.Hour * 24 * 3, inviteExp: time.Hour * 24 * 3,
fromEmail: env.GetString("FROM_EMAIL", ""), passwordRefreshExp: time.Hour * 24 * 7,
fromEmail: env.GetString("FROM_EMAIL", ""),
sendGrid: sendGridConfig{ sendGrid: sendGridConfig{
apiKey: env.GetString("SENDGRID_API_KEY", ""), apiKey: env.GetString("SENDGRID_API_KEY", ""),
}, },
@@ -111,7 +112,10 @@ func main() {
cfg.rateLimiter.TimeFrame, cfg.rateLimiter.TimeFrame,
) )
store := store.NewStorage(db) store, err := store.NewStorage(db)
if err != nil {
logger.Fatal(err)
}
cacheStorage := cache.NewRedisStorage(redisDb) cacheStorage := cache.NewRedisStorage(redisDb)
mailer := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail) mailer := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail)
+26 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -104,17 +105,20 @@ func (api *api) postsContextMiddleware(next http.Handler) http.Handler {
api.badRequestError(w, r, err) api.badRequestError(w, r, err)
return return
} }
ctx := r.Context() ctx := r.Context()
post, err := api.store.Posts.GetByID(ctx, id) post, err := api.store.Posts.GetByID(ctx, id)
log.Println("post", post)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, store.ErrNotFound): case errors.Is(err, store.ErrNotFound):
api.notFoundError(w, r, err) api.notFoundError(w, r, err)
return
default: default:
api.internalServerError(w, r, err) api.internalServerError(w, r, err)
return
} }
return
} }
ctx = context.WithValue(ctx, postCtx, post) ctx = context.WithValue(ctx, postCtx, post)
@@ -161,6 +165,27 @@ func (api *api) checkProfileOwnership(next http.HandlerFunc) http.HandlerFunc {
}) })
} }
func (api *api) protectAdminRoutes(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
allowed, err := api.checkRolePrecedence(r.Context(), user, requiredRole)
if err != nil {
api.internalServerError(w, r, err)
return
}
if !allowed {
api.forbiddenError(w, r, fmt.Errorf("forbidden"))
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, roleContextKey, user.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (api *api) checkRolePrecedence(ctx context.Context, user *store.User, roleName string) (bool, error) { func (api *api) checkRolePrecedence(ctx context.Context, user *store.User, roleName string) (bool, error) {
role, err := api.store.Roles.GetByName(ctx, roleName) role, err := api.store.Roles.GetByName(ctx, roleName)
if err != nil { if err != nil {
+39 -3
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"log"
"net/http" "net/http"
"strconv" "strconv"
@@ -12,8 +11,39 @@ import (
) )
type userKey string type userKey string
type roleKey string
const userContextKey userKey = "user" const userContextKey userKey = "user"
const roleContextKey roleKey = "role"
// GetUser godoc
//
// @Summary Fetches all users
// @Description Fetches all users
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} store.User
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /admin/users [get]
func (api *api) getUsersHandler(w http.ResponseWriter, r *http.Request) {
role := getRoleFromContext(r)
isAdmin := false
if role.Level == 3 {
isAdmin = true
}
users, err := api.store.Users.GetAll(r.Context(), isAdmin)
if err != nil {
api.internalServerError(w, r, err)
return
}
api.jsonResponse(w, http.StatusOK, users)
}
// GetUser godoc // GetUser godoc
// //
@@ -78,7 +108,6 @@ type updateUserPayload struct {
// @Router /users/{id} [patch] // @Router /users/{id} [patch]
func (api *api) updateUserHandler(w http.ResponseWriter, r *http.Request) { func (api *api) updateUserHandler(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r) user := getUserFromContext(r)
log.Println(user)
var payload updateUserPayload var payload updateUserPayload
if err := readJSON(w, r, &payload); err != nil { if err := readJSON(w, r, &payload); err != nil {
@@ -103,7 +132,9 @@ func (api *api) updateUser(ctx context.Context, user *store.User) error {
return err return err
} }
api.cacheStore.Users.Delete(ctx, user.ID) if api.config.redisCfg.enabled {
api.cacheStore.Users.Delete(ctx, user.ID)
}
return nil return nil
} }
@@ -300,3 +331,8 @@ func getUserFromContext(r *http.Request) *store.User {
user, _ := r.Context().Value(userContextKey).(*store.User) user, _ := r.Context().Value(userContextKey).(*store.User)
return user return user
} }
func getRoleFromContext(r *http.Request) *store.Role {
role, _ := r.Context().Value(roleContextKey).(*store.Role)
return role
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS password_reset_requests;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS password_reset_requests (
token bytea PRIMARY KEY,
user_id bigint NOT NULL,
expiry TIMESTAMP(0)
WITH
TIME ZONE NOT NULL
);
+5 -1
View File
@@ -16,6 +16,10 @@ func main() {
} }
defer conn.Close() defer conn.Close()
store := store.NewStorage(conn) store, err := store.NewStorage(conn)
if err != nil {
log.Fatalf("could not create storage: %v", err)
}
db.Seed(store, conn) db.Seed(store, conn)
} }
+58
View File
@@ -24,6 +24,52 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/authentication/forgot-password": {
"post": {
"description": "Forgot Password",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Forgot Password",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.ForgotPasswordPayload"
}
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/token": { "/authentication/token": {
"post": { "post": {
"description": "Create a new token for the user", "description": "Create a new token for the user",
@@ -795,6 +841,18 @@ const docTemplate = `{
} }
} }
}, },
"main.ForgotPasswordPayload": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
}
}
},
"main.RegisterUserPayload": { "main.RegisterUserPayload": {
"type": "object", "type": "object",
"required": [ "required": [
+58
View File
@@ -16,6 +16,52 @@
}, },
"basePath": "/v1", "basePath": "/v1",
"paths": { "paths": {
"/authentication/forgot-password": {
"post": {
"description": "Forgot Password",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Forgot Password",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.ForgotPasswordPayload"
}
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/token": { "/authentication/token": {
"post": { "post": {
"description": "Create a new token for the user", "description": "Create a new token for the user",
@@ -787,6 +833,18 @@
} }
} }
}, },
"main.ForgotPasswordPayload": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
}
}
},
"main.RegisterUserPayload": { "main.RegisterUserPayload": {
"type": "object", "type": "object",
"required": [ "required": [
+39
View File
@@ -29,6 +29,14 @@ definitions:
- email - email
- password - password
type: object type: object
main.ForgotPasswordPayload:
properties:
email:
maxLength: 255
type: string
required:
- email
type: object
main.RegisterUserPayload: main.RegisterUserPayload:
properties: properties:
email: email:
@@ -230,6 +238,37 @@ info:
termsOfService: http://swagger.io/terms/ termsOfService: http://swagger.io/terms/
title: LK API Template title: LK API Template
paths: paths:
/authentication/forgot-password:
post:
consumes:
- application/json
description: Forgot Password
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.ForgotPasswordPayload'
produces:
- application/json
responses:
"201":
description: Token Created
schema:
type: string
"400":
description: Bad Request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Forgot Password
tags:
- authentication
/authentication/token: /authentication/token:
post: post:
consumes: consumes:
+3
View File
@@ -20,6 +20,7 @@ require (
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -31,6 +32,8 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
+7
View File
@@ -1,5 +1,7 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -42,6 +44,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -64,6 +70,7 @@ github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBU
github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
+4 -3
View File
@@ -3,9 +3,10 @@ package mailer
import "embed" import "embed"
const ( const (
FromName = "LK API" FromName = "LK API"
maxRetries = 3 maxRetries = 3
UserWelcomeTemplate = "user_activation.tmpl" UserWelcomeTemplate = "user_activation.tmpl"
ForgotPasswordTemplate = "forgot_password.tmpl"
) )
//go:embed "templates" //go:embed "templates"
@@ -0,0 +1,27 @@
{{define "subject"}} Reset Your Password - {{.ServiceName}} {{end}}
{{define "body"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hello {{.Username}},</p>
<p>We received a request to reset your password. If you didn't make this request, you can ignore this email.</p>
<p>To reset your password, click on the link below:</p>
<p><a href="{{.UpdateURL}}">{{.UpdateURL}}</a></p>
<p>If you want to reset your password manually, copy and paste the link above into your browser.</p>
<p>If you have any questions or need assistance, please contact our support team.</p>
<p>Thanks,</p>
<p>The {{.ServiceName}} Team</p>
</body>
</html>
{{end}}
-3
View File
@@ -1,7 +1,6 @@
package ratelimiter package ratelimiter
import ( import (
"log"
"sync" "sync"
"time" "time"
) )
@@ -26,8 +25,6 @@ func (rl *FixedWindowLimiter) Allow(ip string) (bool, time.Duration) {
count, exists := rl.clients[ip] count, exists := rl.clients[ip]
rl.RUnlock() rl.RUnlock()
log.Println(count, exists)
if !exists || count < rl.limit { if !exists || count < rl.limit {
rl.Lock() rl.Lock()
if !exists { if !exists {
+5
View File
@@ -48,6 +48,11 @@ func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token s
return args.Error(0) return args.Error(0)
} }
func (m *MockUserStore) PasswordResetRequest(ctx context.Context, userID int64, token string, expiration time.Duration) error {
args := m.Called(ctx, userID, token, expiration)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error { func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id) args := m.Called(id)
return args.Error(0) return args.Error(0)
+16 -4
View File
@@ -14,21 +14,33 @@ type Role struct {
type RoleStore struct { type RoleStore struct {
db *sql.DB db *sql.DB
getByNameStmt *sql.Stmt
} }
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) { func NewRoleStore(db *sql.DB) (*RoleStore, error) {
query := ` getByNameStmt, err := db.PrepareContext(context.Background(), `
SELECT id, name, level, description SELECT id, name, level, description
FROM roles FROM roles
WHERE name = $1 WHERE name = $1
` `)
if err != nil {
return nil, err
}
return &RoleStore{
db: db,
getByNameStmt: getByNameStmt,
}, nil
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
role := &Role{} role := &Role{}
err := s.db.QueryRowContext(ctx, query, name).Scan( err := s.getByNameStmt.QueryRowContext(ctx, name).Scan(
&role.ID, &role.ID,
&role.Name, &role.Name,
&role.Level, &role.Level,
+25 -4
View File
@@ -31,6 +31,7 @@ type Storage struct {
GetByEmail(context.Context, string) (*User, error) GetByEmail(context.Context, string) (*User, error)
Update(context.Context, *User) error Update(context.Context, *User) error
CreateAndInvite(context.Context, *User, string, time.Duration) error CreateAndInvite(context.Context, *User, string, time.Duration) error
PasswordResetRequest(context.Context, int64, string, time.Duration) error
Delete(context.Context, int64) error Delete(context.Context, int64) error
DeleteUser(context.Context, int64) error DeleteUser(context.Context, int64) error
} }
@@ -47,14 +48,20 @@ type Storage struct {
} }
} }
func NewStorage(db *sql.DB) Storage { func NewStorage(db *sql.DB) (Storage, error) {
userStore, roleStore, err := initStores(db)
if err != nil {
return Storage{}, err
}
return Storage{ return Storage{
Posts: &PostStore{db}, Posts: &PostStore{db},
Users: &UserStore{db}, Users: userStore,
Comments: &CommentStore{db}, Comments: &CommentStore{db},
Followers: &FollowerStore{db}, Followers: &FollowerStore{db},
Roles: &RoleStore{db}, Roles: roleStore,
} }, nil
} }
func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error { func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
@@ -70,3 +77,17 @@ func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
return tx.Commit() return tx.Commit()
} }
func initStores(db *sql.DB) (*UserStore, *RoleStore, error) {
userStore, err := NewUserStore(db)
if err != nil {
return &UserStore{}, &RoleStore{}, err
}
roleStore, err := NewRoleStore(db)
if err != nil {
return &UserStore{}, &RoleStore{}, err
}
return userStore, roleStore, nil
}
+196 -64
View File
@@ -8,6 +8,8 @@ import (
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
sq "github.com/Masterminds/squirrel"
) )
type User struct { type User struct {
@@ -29,6 +31,8 @@ type password struct {
hash []byte hash []byte
} }
var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
func (p *password) Set(password string) error { func (p *password) Set(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -42,22 +46,141 @@ func (p *password) Set(password string) error {
type UserStore struct { type UserStore struct {
db *sql.DB db *sql.DB
getByIDStmt *sql.Stmt
getByEmailStmt *sql.Stmt
getByInvitationStmt *sql.Stmt
updateStmt *sql.Stmt
deleteStmt *sql.Stmt
inviteCreateStmt *sql.Stmt
invitationDeleteStmt *sql.Stmt
passwordResetRequestCreateStmt *sql.Stmt
}
func NewUserStore(db *sql.DB) (*UserStore, error) {
store := &UserStore{
db: db,
}
var err error
store.getByIDStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at, roles.*
FROM users u
JOIN roles ON u.role_id = roles.id
WHERE u.id = $1 AND u.is_active = true
`)
if err != nil {
return nil, err
}
store.getByEmailStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at, roles.*
FROM users u
JOIN roles ON u.role_id = roles.id
WHERE u.email = $1 AND u.is_active = true
`)
if err != nil {
return nil, err
}
store.getByInvitationStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`)
if err != nil {
return nil, err
}
store.updateStmt, err = db.Prepare(`
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`)
if err != nil {
return nil, err
}
store.deleteStmt, err = db.Prepare(`
DELETE FROM users WHERE id = $1
`)
if err != nil {
return nil, err
}
store.invitationDeleteStmt, err = db.Prepare(`
DELETE FROM invitations WHERE user_id = $1
`)
if err != nil {
return nil, err
}
store.inviteCreateStmt, err = db.Prepare(`
INSERT INTO invitations (user_id, token, expiry) VALUES ($1, $2, $3)
`)
if err != nil {
return nil, err
}
store.passwordResetRequestCreateStmt, err = db.Prepare(`
INSERT INTO password_reset_requests (user_id, token, expiry) VALUES ($1, $2, $3)
`)
if err != nil {
return nil, err
}
return store, nil
}
func (s *UserStore) Close() error {
var errs []error
if err := s.getByIDStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.getByEmailStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.getByInvitationStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.updateStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.deleteStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.invitationDeleteStmt.Close(); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return errs[0]
}
return nil
} }
func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error { func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
INSERT INTO users (first_name, last_name, username, email, password, role_id)
VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := user.Role.Name role := user.Role.Name
if role == "" { if role == "" {
role = "user" role = "user"
} }
err := tx.QueryRowContext( query, _, err := psql.Insert("users").
Columns("first_name", "last_name", "username", "email", "password", "role_id").
Values(user.FirstName, user.LastName, user.Username, user.Email, user.Password.hash, sq.Expr("(SELECT id FROM roles WHERE name = ?)", role)).
Suffix("RETURNING id, created_at, updated_at").ToSql()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err = tx.QueryRowContext(
ctx, ctx,
query, query,
user.FirstName, user.FirstName,
@@ -81,28 +204,26 @@ func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
return nil return nil
} }
func (s *UserStore) GetAll(ctx context.Context, isAdmin bool) ([]User, error) {
}
func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) { func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE users.id = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
user := &User{} user := &User{}
err := s.db.QueryRowContext(ctx, query, id).Scan( err := s.getByIDStmt.QueryRowContext(ctx, id).Scan(
&user.ID, &user.ID,
&user.FirstName, &user.FirstName,
&user.LastName, &user.LastName,
&user.Username, &user.Username,
&user.Email, &user.Email,
&user.Password.hash, &user.Password.hash,
&user.IsActive,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,
&user.Role.ID, &user.RoleID,
&user.Role.Name, &user.Role.Name,
&user.Role.Level, &user.Role.Level,
&user.Role.Description, &user.Role.Description,
@@ -115,25 +236,16 @@ func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
return nil, err return nil, err
} }
} }
return user, nil return user, nil
} }
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) { func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE email = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
user := &User{} user := &User{}
err := s.db.QueryRowContext(ctx, query, email).Scan( err := s.getByEmailStmt.QueryRowContext(ctx, email).Scan(
&user.ID, &user.ID,
&user.FirstName, &user.FirstName,
&user.LastName, &user.LastName,
@@ -142,7 +254,8 @@ func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error)
&user.Password.hash, &user.Password.hash,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,
&user.Role.ID, &user.IsActive,
&user.RoleID,
&user.Role.Name, &user.Role.Name,
&user.Role.Level, &user.Role.Level,
&user.Role.Description, &user.Role.Description,
@@ -160,17 +273,11 @@ func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error)
} }
func (s *UserStore) Update(ctx context.Context, user *User) error { func (s *UserStore) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := s.db.ExecContext( _, err := s.updateStmt.ExecContext(
ctx, ctx,
query,
user.FirstName, user.FirstName,
user.LastName, user.LastName,
user.Username, user.Username,
@@ -199,10 +306,20 @@ func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token strin
}) })
} }
func (s *UserStore) PasswordResetRequest(ctx context.Context, userID int64, token string, expiration time.Duration) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.createPasswordResetRequest(ctx, tx, userID, token, expiration); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Activate(ctx context.Context, token string) error { func (s *UserStore) Activate(ctx context.Context, token string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error { return withTx(s.db, ctx, func(tx *sql.Tx) error {
// Find the user that this token belongs to // Find the user that this token belongs to
user, err := getUserFromInvitation(ctx, tx, token) user, err := s.getUserFromInvitation(ctx, tx, token)
if err != nil { if err != nil {
return err return err
} }
@@ -238,12 +355,18 @@ func (s *UserStore) Delete(ctx context.Context, id int64) error {
// * Soft delete user // * Soft delete user
func (s *UserStore) DeleteUser(ctx context.Context, id int64) error { func (s *UserStore) DeleteUser(ctx context.Context, id int64) error {
query := `UPDATE users SET is_active = false WHERE id = $1` query, _, err := psql.Update("users").
Set("is_active", false).
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := s.db.ExecContext(ctx, query, id) _, err = s.db.ExecContext(ctx, query, id)
if err != nil { if err != nil {
return err return err
} }
@@ -255,22 +378,18 @@ func (p *password) Compare(password string) error {
return bcrypt.CompareHashAndPassword(p.hash, []byte(password)) return bcrypt.CompareHashAndPassword(p.hash, []byte(password))
} }
func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) { func (s *UserStore) getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
query := `
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`
hashed := sha256.Sum256([]byte(token)) hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:]) hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
stmt := tx.Stmt(s.getByInvitationStmt)
defer stmt.Close()
user := &User{} user := &User{}
err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan( err := stmt.QueryRowContext(ctx, hashedToken, time.Now()).Scan(
&user.ID, &user.ID,
&user.FirstName, &user.FirstName,
&user.LastName, &user.LastName,
@@ -293,14 +412,28 @@ func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User
} }
func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error { func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error {
query := `
INSERT INTO invitations (user_id, token, expiry)
VALUES ($1, $2, $3)
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp)) stmt := tx.Stmt(s.inviteCreateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID, token, time.Now().Add(exp))
if err != nil {
return err
}
return nil
}
func (s *UserStore) createPasswordResetRequest(ctx context.Context, tx *sql.Tx, userID int64, token string, expiry time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.passwordResetRequestCreateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID, token, time.Now().Add(expiry))
if err != nil { if err != nil {
return err return err
} }
@@ -309,17 +442,14 @@ func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID
} }
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error { func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := tx.ExecContext( stmt := tx.Stmt(s.updateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(
ctx, ctx,
query,
user.FirstName, user.FirstName,
user.LastName, user.LastName,
user.Username, user.Username,
@@ -335,12 +465,13 @@ func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
} }
func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error { func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error {
query := `DELETE FROM invitations WHERE user_id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := tx.ExecContext(ctx, query, userID) stmt := tx.Stmt(s.invitationDeleteStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID)
if err != nil { if err != nil {
return err return err
} }
@@ -349,12 +480,13 @@ func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID
} }
func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error { func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error {
query := `DELETE FROM users WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := tx.ExecContext(ctx, query, id) stmt := tx.Stmt(s.deleteStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, id)
if err != nil { if err != nil {
return err return err
} }