36 files changed,
561 insertions(+),
301 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-04-22 00:54:46 +0300
Parent:
c2e1526
jump to
M
cmd/server/main.go
··· 76 76 } 77 77 78 78 userPasswordHasher := hasher.NewSHA256Hasher(cfg.PasswordSalt) 79 - notePasswordHasher := hasher.NewSHA256Hasher(cfg.NotePassowrdSalt) 79 + notePasswordHasher := hasher.NewSHA256Hasher(cfg.NotePasswordSalt) 80 80 jwtTokenizer := jwtutil.NewJWTUtil(cfg.JwtSigningKey, cfg.JwtAccessTokenTTL) 81 81 82 82 mailermq := mailermq.New(nc) ··· 115 115 ) 116 116 117 117 // http server 118 - srv := httpserver.NewServer(cfg.ServerPort, handler.Handler()) 118 + srv := httpserver.NewServer(handler.Handler(), httpConfig(cfg.HTTPPort, cfg)) 119 119 go func() { 120 - slog.Info("starting http server", "port", cfg.ServerPort) 120 + slog.Info("starting http server", "port", cfg.HTTPPort) 121 121 if err := srv.Start(); !errors.Is(err, http.ErrServerClosed) { 122 122 slog.Error("failed to start http server", "error", err) 123 123 } ··· 125 125 126 126 // metrics 127 127 if cfg.MetricsEnabled { 128 - mSrv := httpserver.NewServer(cfg.MetricsPort, metrics.Handler()) 128 + mSrv := httpserver.NewServer(metrics.Handler(), httpConfig(cfg.MetricsPort, cfg)) 129 129 go func() { 130 130 slog.Info("starting metrics server", "port", cfg.MetricsPort) 131 131 if err := mSrv.Start(); !errors.Is(err, http.ErrServerClosed) { ··· 153 153 154 154 return nil 155 155 } 156 + 157 +func httpConfig(port string, cfg *config.Config) httpserver.Config { 158 + return httpserver.Config{ 159 + Port: port, 160 + ReadTimeout: cfg.HTTPReadTimeout, 161 + WriteTimeout: cfg.HTTPWriteTimeout, 162 + MaxHeaderSizeMb: cfg.HTTPHeaderMaxSizeMb, 163 + } 164 +}
A
e2e/api_test.go
··· 1 +package e2e_test 2 + 3 +import "net/http" 4 + 5 +type apiPingResponse struct { 6 + Message string `json:"message"` 7 +} 8 + 9 +func (e *AppTestSuite) TestPing() { 10 + httpResp := e.httpRequest(http.MethodGet, "/api/ping", nil) 11 + 12 + var body apiPingResponse 13 + e.readBodyAndUnjsonify(httpResp.Body, &body) 14 + 15 + e.Equal(http.StatusOK, httpResp.Code) 16 + e.Equal(body.Message, "pong") 17 +}
M
e2e/apiv1_auth_test.go
··· 28 28 }), 29 29 ) 30 30 31 - dbUser := e.getUserFromDBByUsername(username) 31 + dbUser := e.getUserByUsername(username) 32 32 hashedPasswd, err := e.hasher.Hash(password) 33 33 e.require.NoError(err) 34 34 ··· 100 100 101 101 e.Equal(http.StatusCreated, httpResp.Code) 102 102 103 - user := e.getLastInsertedUserByEmail(email) 103 + user := e.getLastUserByEmail(email) 104 104 token := e.getVerificationTokenByUserID(user.ID) 105 105 httpResp = e.httpRequest(http.MethodGet, "/api/v1/auth/verify/"+token.Token, nil) 106 106 e.Equal(http.StatusOK, httpResp.Code) 107 107 108 - user = e.getLastInsertedUserByEmail(email) 108 + user = e.getLastUserByEmail(email) 109 109 e.Equal(user.Activated, true) 110 110 } 111 111 ··· 140 140 141 141 func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail_wrong() { 142 142 email, password := e.uuid()+"@"+e.uuid()+".com", "password" 143 - e.insertUserIntoDB(e.uuid(), email, password, true) 143 + e.insertUser(e.uuid(), email, password, true) 144 144 145 145 tests := []struct { 146 146 name string ··· 173 173 174 174 e.Equal(httpResp.Code, t.expectedCode) 175 175 176 - // no email should be sent 177 - // e.Empty(e.mailer.GetLastSentEmailToEmail(t.email)) 176 + // TODO: no email should be sent 178 177 } 179 178 } 180 179 ··· 182 181 email := e.uuid() + "email@email.com" 183 182 password := "qwerty" 184 183 185 - uid := e.insertUserIntoDB("test", email, password, true) 184 + uid := e.insertUser("test", email, password, true) 186 185 187 186 httpResp := e.httpRequest( 188 187 http.MethodPost, ··· 196 195 var body apiv1AuthSignInResponse 197 196 e.readBodyAndUnjsonify(httpResp.Body, &body) 198 197 199 - session := e.getLastUserSessionByUserID(uid) 198 + session := e.getLastSessionByUserID(uid) 200 199 parsedToken := e.parseJwtToken(body.AccessToken) 201 200 202 201 e.Equal(http.StatusOK, httpResp.Code) ··· 207 206 func (e *AppTestSuite) TestAuthV1_SignIn_wrong() { 208 207 password := "password" 209 208 email := e.uuid() + "@test.com" 210 - e.insertUserIntoDB(e.uuid(), email, "password", true) 209 + e.insertUser(e.uuid(), email, "password", true) 211 210 212 211 unactivatedEmail := e.uuid() + "@test.com" 213 - e.insertUserIntoDB(e.uuid(), unactivatedEmail, password, false) 212 + e.insertUser(e.uuid(), unactivatedEmail, password, false) 214 213 215 214 //exhaustruct:ignore 216 215 tests := []struct { ··· 223 222 expectedMsg string 224 223 }{ 225 224 { 226 - name: "unactivated user", 225 + name: "inactivated user", 227 226 email: unactivatedEmail, 228 227 password: password, 229 228 expectedCode: http.StatusBadRequest, ··· 234 233 name: "wrong email", 235 234 email: "wrong@email.com", 236 235 password: password, 237 - expectedCode: http.StatusUnauthorized, 236 + expectedCode: http.StatusBadRequest, 238 237 }, 239 238 { 240 239 name: "wrong password", ··· 282 281 var body apiv1AuthSignInResponse 283 282 e.readBodyAndUnjsonify(httpResp.Body, &body) 284 283 285 - sessionDB := e.getLastUserSessionByUserID(uid) 284 + sessionDB := e.getLastSessionByUserID(uid) 286 285 e.Equal(e.parseJwtToken(body.AccessToken).UserID, uid.String()) 287 286 288 287 e.Equal(httpResp.Code, http.StatusOK) ··· 307 306 func (e *AppTestSuite) TestAuthV1_Logout() { 308 307 uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password") 309 308 310 - sessionDB := e.getLastUserSessionByUserID(uid) 309 + sessionDB := e.getLastSessionByUserID(uid) 311 310 e.NotEmpty(sessionDB.RefreshToken) 312 311 313 312 httpResp := e.httpRequest(http.MethodPost, "/api/v1/auth/logout", nil, toks.AccessToken) 314 313 e.Equal(httpResp.Code, http.StatusNoContent) 315 314 316 - sessionDB = e.getLastUserSessionByUserID(uid) 315 + sessionDB = e.getLastSessionByUserID(uid) 317 316 e.Empty(sessionDB.RefreshToken) 318 317 } 319 318 ··· 340 339 341 340 e.Equal(httpResp.Code, http.StatusOK) 342 341 343 - userDB := e.getUserFromDBByUsername(username) 344 - hashedNewPassword, err := e.hasher.Hash(newPassword) 345 - e.require.NoError(err) 346 - 347 - e.Equal(userDB.Password, hashedNewPassword) 342 + userDB := e.getUserByUsername(username) 343 + e.Equal(userDB.Username, username) 344 + e.NoError(e.hasher.Compare(userDB.Password, newPassword)) 348 345 } 349 346 347 +// createAndSingIn creates an activated username, logs them in, 348 +// and returns their userID along with access and refresh tokens. 350 349 func (e *AppTestSuite) createAndSingIn( 351 350 email, username, password string, 352 351 ) (uuid.UUID, apiv1AuthSignInResponse) { 353 - uid := e.insertUserIntoDB(username, email, password, true) 352 + uid := e.insertUser(username, email, password, true) 354 353 httpResp := e.httpRequest( 355 354 http.MethodPost, 356 355 "/api/v1/auth/signin",
M
e2e/apiv1_notes_test.go
··· 46 46 _, err := uuid.FromString(body.Slug) 47 47 e.require.NoError(err) 48 48 49 - dbNote := e.getNoteFromDBbySlug(body.Slug) 49 + dbNote := e.getNoteBySlug(body.Slug) 50 50 e.NotEmpty(dbNote) 51 51 }, 52 52 }, ··· 62 62 var body apiv1NoteCreateResponse 63 63 e.readBodyAndUnjsonify(r.Body, &body) 64 64 65 - dbNote := e.getNoteFromDBbySlug(inp.Slug) 65 + dbNote := e.getNoteBySlug(inp.Slug) 66 66 e.NotEmpty(dbNote) 67 67 }, 68 68 }, ··· 89 89 var body apiv1NoteCreateResponse 90 90 e.readBodyAndUnjsonify(r.Body, &body) 91 91 92 - dbNote := e.getNoteFromDBbySlug(body.Slug) 92 + dbNote := e.getNoteBySlug(body.Slug) 93 93 e.NotEmpty(dbNote) 94 94 95 95 e.Equal(dbNote.Content, inp.Content) ··· 134 134 135 135 e.Equal(content, body.Content) 136 136 137 - dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) 137 + dbNote := e.getNoteBySlug(bodyCreated.Slug) 138 138 e.Equal(dbNote.Content, "") 139 139 e.Equal(dbNote.ReadAt.IsZero(), false) 140 140 } ··· 173 173 174 174 e.Equal(content, body.Content) 175 175 176 - dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) 176 + dbNote := e.getNoteBySlug(bodyCreated.Slug) 177 177 e.Equal(dbNote.Content, "") 178 178 e.Equal(dbNote.ReadAt.IsZero(), false) 179 179 }
M
e2e/e2e_test.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "net/http" 8 - "os" 9 8 "testing" 10 9 "time" 11 10 ··· 199 198 } 200 199 201 200 func (e *AppTestSuite) getConfig() *config.Config { 202 - return &config.Config{ //nolint:exhaustruct 203 - AppEnv: "testing", 204 - AppURL: "", 205 - ServerPort: "3000", 206 - PasswordSalt: "salty-password", 207 - JwtSigningKey: "jwt-key", 208 - JwtAccessTokenTTL: time.Hour, 209 - JwtRefreshTokenTTL: 24 * time.Hour, 210 - VerificationTokenTTL: 24 * time.Hour, 211 - LogShowLine: os.Getenv("LOG_SHOW_LINE") == "true", 212 - LogFormat: "text", 213 - LogLevel: "debug", 214 - CacheUsersTTL: time.Second, 215 - } 201 + e.T().Setenv("APP_ENV", "test") 202 + e.T().Setenv("APP_URL", "localhost") 203 + e.T().Setenv("PASSWORD_SALT", "salty-password") 204 + e.T().Setenv("NOTE_PASSWORD_SALT", "salty-noted-password") 205 + e.T().Setenv("JWT_SIGNING_KEY", "jwt-key") 206 + e.T().Setenv("LOG_SHOW_LINE", "true") 207 + e.T().Setenv("LOG_FORMAT", "text") 208 + e.T().Setenv("LOG_LEVEL", "debug") 209 + 210 + return config.NewConfig() 216 211 }
M
e2e/e2e_utils_db_test.go
··· 10 10 "github.com/olexsmir/onasty/internal/models" 11 11 ) 12 12 13 -func (e *AppTestSuite) getUserFromDBByUsername(username string) models.User { 13 +// getUserByUsername queries user from db by it's username 14 +func (e *AppTestSuite) getUserByUsername(username string) models.User { 14 15 query, args, err := pgq. 15 16 Select("id", "username", "email", "password", "created_at", "last_login_at"). 16 17 From("users"). ··· 26 27 return user 27 28 } 28 29 29 -func (e *AppTestSuite) insertUserIntoDB(uname, email, passwd string, activated ...bool) uuid.UUID { 30 +// insertUser inserts user into db 31 +func (e *AppTestSuite) insertUser(uname, email, passwd string, activated ...bool) uuid.UUID { 30 32 p, err := e.hasher.Hash(passwd) 31 33 e.require.NoError(err) 32 34 ··· 50 52 return id 51 53 } 52 54 53 -func (e *AppTestSuite) getLastUserSessionByUserID(uid uuid.UUID) models.Session { 55 +// getLastSessionByUserID gets last inserted [models.Session] for particular user 56 +func (e *AppTestSuite) getLastSessionByUserID(uid uuid.UUID) models.Session { 54 57 query, args, err := pgq. 55 58 Select("refresh_token", "expires_at"). 56 59 From("sessions"). ··· 67 70 } 68 71 69 72 e.require.NoError(err) 73 + session.UserID = uid 70 74 return session 71 75 } 72 76 73 -func (e *AppTestSuite) getLastInsertedUserByEmail(em string) models.User { 77 +// getLastUserByEmail gets last inserted [models.User] by user's email 78 +func (e *AppTestSuite) getLastUserByEmail(em string) models.User { 74 79 query, args, err := pgq. 75 - Select("id", "username", "activated", "email", "password"). 80 + Select("id", "username", "activated", "email", "password", "created_at", "last_login_at"). 76 81 From("users"). 77 82 Where(pgq.Eq{"email": em}). 78 83 OrderBy("created_at DESC"). ··· 82 87 83 88 var u models.User 84 89 err = e.postgresDB.QueryRow(e.ctx, query, args...). 85 - Scan(&u.ID, &u.Username, &u.Activated, &u.Email, &u.Password) 90 + Scan(&u.ID, &u.Username, &u.Activated, &u.Email, &u.Password, &u.CreatedAt, &u.LastLoginAt) 86 91 if errors.Is(err, pgx.ErrNoRows) { 87 92 return models.User{} //nolint:exhaustruct 88 93 } ··· 91 96 return u 92 97 } 93 98 94 -type noteModel struct { 95 - ID uuid.UUID 96 - Content string 97 - Slug string 98 - BurnBeforeExpiration bool 99 - Password string 100 - IsRead bool 101 - ReadAt *time.Time 102 - CreatedAt time.Time 103 - ExpiresAt time.Time 104 -} 105 - 106 -func (e *AppTestSuite) getNoteFromDBbySlug(slug string) noteModel { 99 +// getNoteBySlug gets [models.Note] by slug 100 +func (e *AppTestSuite) getNoteBySlug(slug string) models.Note { 107 101 query, args, err := pgq. 108 102 Select( 109 103 "id", ··· 119 113 SQL() 120 114 e.require.NoError(err) 121 115 122 - var note noteModel 116 + var note models.Note 123 117 err = e.postgresDB.QueryRow(e.ctx, query, args...). 124 118 Scan(¬e.ID, ¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) 125 119 if errors.Is(err, pgx.ErrNoRows) { 126 - return noteModel{} //nolint:exhaustruct 120 + return models.Note{} //nolint:exhaustruct 127 121 } 128 122 129 123 e.require.NoError(err)
M
e2e/e2e_utils_test.go
··· 61 61 return u.String() 62 62 } 63 63 64 -// parseJwtToken util func that parses jwt token and returns payload 64 +// parseJwtToken gets payload from the jwt token 65 65 func (e *AppTestSuite) parseJwtToken(t string) jwtutil.Payload { 66 66 r, err := e.jwtTokenizer.Parse(t) 67 67 e.require.NoError(err)
M
internal/config/config.go
··· 8 8 ) 9 9 10 10 type Config struct { 11 - AppEnv string 12 - AppURL string 13 - ServerPort string 14 - NatsURL string 11 + AppEnv string 12 + AppURL string 13 + NatsURL string 14 + 15 + HTTPPort string 16 + HTTPWriteTimeout time.Duration 17 + HTTPReadTimeout time.Duration 18 + HTTPHeaderMaxSizeMb int 15 19 16 20 PostgresDSN string 17 21 PasswordSalt string 18 - NotePassowrdSalt string 22 + NotePasswordSalt string 19 23 20 24 RedisAddr string 21 25 RedisPassword string ··· 44 48 45 49 func NewConfig() *Config { 46 50 return &Config{ 47 - AppEnv: getenvOrDefault("APP_ENV", "debug"), 48 - AppURL: getenvOrDefault("APP_URL", ""), 49 - ServerPort: getenvOrDefault("SERVER_PORT", "3000"), 50 - NatsURL: getenvOrDefault("NATS_URL", ""), 51 + AppEnv: getenvOrDefault("APP_ENV", "debug"), 52 + AppURL: getenvOrDefault("APP_URL", ""), 53 + NatsURL: getenvOrDefault("NATS_URL", ""), 54 + 55 + HTTPPort: getenvOrDefault("HTTP_PORT", "3000"), 56 + HTTPWriteTimeout: mustParseDuration(getenvOrDefault("HTTP_WRITE_TIMEOUT", "10s")), 57 + HTTPReadTimeout: mustParseDuration(getenvOrDefault("HTTP_READ_TIMEOUT", "10s")), 58 + HTTPHeaderMaxSizeMb: mustGetenvOrDefaultInt("HTTP_HEADER_MAX_SIZE_MB", 1), 51 59 52 60 PostgresDSN: getenvOrDefault("POSTGRESQL_DSN", ""), 53 61 PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""), 54 - NotePassowrdSalt: getenvOrDefault("NOTE_PASSWORD_SALT", ""), 62 + NotePasswordSalt: getenvOrDefault("NOTE_PASSWORD_SALT", ""), 55 63 56 64 RedisAddr: getenvOrDefault("REDIS_ADDR", ""), 57 65 RedisPassword: getenvOrDefault("REDIS_PASSWORD", ""),
M
internal/dtos/note.go
··· 6 6 "github.com/gofrs/uuid/v5" 7 7 ) 8 8 9 -type NoteSlugDTO = string 9 +type NoteSlug = string 10 10 11 -type NoteDTO struct { 12 - Content string 13 - Slug string 14 - BurnBeforeExpiration bool 15 - Password string 16 - IsRead bool 17 - ReadAt *time.Time 18 - CreatedAt time.Time 19 - ExpiresAt time.Time 11 +type GetNote struct { 12 + Content string 13 + ReadAt time.Time 14 + CreatedAt time.Time 15 + ExpiresAt time.Time 20 16 } 21 17 22 -type CreateNoteDTO struct { 18 +type CreateNote struct { 23 19 Content string 24 20 UserID uuid.UUID 25 - Slug string 21 + Slug NoteSlug 26 22 BurnBeforeExpiration bool 27 23 Password string 28 24 CreatedAt time.Time
M
internal/dtos/user.go
··· 2 2 3 3 import ( 4 4 "time" 5 - 6 - "github.com/gofrs/uuid/v5" 7 5 ) 8 6 9 -type UserDTO struct { 10 - ID uuid.UUID 7 +type SignUp struct { 11 8 Username string 12 9 Email string 13 10 Password string 14 - Activated bool 15 11 CreatedAt time.Time 16 12 LastLoginAt time.Time 17 13 } 18 14 19 -type ResetUserPasswordDTO struct { 15 +type SignIn struct { 16 + Email string 17 + Password string 18 +} 19 + 20 +type ChangeUserPassword struct { 20 21 CurrentPassword string 21 22 NewPassword string 22 23 } 23 24 24 -type CreateUserDTO struct { 25 - Username string 26 - Email string 27 - Password string 28 - CreatedAt time.Time 29 - LastLoginAt time.Time 30 -} 31 - 32 -type SignInDTO struct { 33 - Email string 34 - Password string 25 +type Tokens struct { 26 + Access string 27 + Refresh string 35 28 }
M
internal/events/mailermq/mailermq.go
··· 17 17 nc *nats.Conn 18 18 } 19 19 20 -const sendMailSubject = "mailer.send" 21 - 22 20 func New(nc *nats.Conn) *MailerMQ { 23 21 return &MailerMQ{ 24 22 nc: nc, ··· 53 51 return err 54 52 } 55 53 56 - resp, err := m.nc.RequestWithContext(ctx, sendMailSubject, req) 54 + resp, err := m.nc.RequestWithContext(ctx, "mailer.send", req) 57 55 if err != nil { 58 56 return err 59 57 }
M
internal/hasher/hasher.go
··· 1 1 package hasher 2 2 3 +import "errors" 4 + 5 +var ErrMismatchedHashes = errors.New("hashes are mismatched") 6 + 3 7 type Hasher interface { 4 8 // Hash takes a string as input and returns its hash 5 9 Hash(str string) (string, error) 10 + 11 + // Compare takes two hashes and compares them 12 + // in case of mismatch returns [ErrMismatchedHashes] 13 + Compare(hash, plain string) error 6 14 }
M
internal/hasher/sha256.go
··· 20 20 } 21 21 return hex.EncodeToString(hash.Sum([]byte(h.salt))), nil 22 22 } 23 + 24 +func (h *SHA256Hasher) Compare(hash, plain string) error { 25 + expected, err := h.Hash(plain) 26 + if err != nil { 27 + return err 28 + } 29 + 30 + if expected != hash { 31 + return ErrMismatchedHashes 32 + } 33 + return nil 34 +}
A
internal/hasher/sha256_test.go
··· 1 +package hasher 2 + 3 +import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/require" 7 +) 8 + 9 +func TestSHA256Hasher_Hash(t *testing.T) { 10 + hasher := NewSHA256Hasher("salt") 11 + 12 + hashed, err := hasher.Hash("qwerty123") 13 + require.NoError(t, err) 14 + require.NotEmpty(t, hashed) 15 +} 16 + 17 +func TestSHA256Hasher_Compared(t *testing.T) { 18 + hasher := NewSHA256Hasher("salt") 19 + input := "qwerty123" 20 + 21 + t.Run("valid", func(t *testing.T) { 22 + hashed, err := hasher.Hash(input) 23 + require.NoError(t, err) 24 + require.NotEmpty(t, hashed) 25 + 26 + err = hasher.Compare(hashed, input) 27 + require.NoError(t, err) 28 + }) 29 + 30 + t.Run("hashes mismatch", func(t *testing.T) { 31 + hashed, err := hasher.Hash(input + "4") 32 + require.NoError(t, err) 33 + require.NotEmpty(t, hashed) 34 + 35 + err = hasher.Compare(hashed, input) 36 + require.ErrorIs(t, err, ErrMismatchedHashes) 37 + }) 38 +}
M
internal/jwtutil/jwtutil.go
··· 12 12 var ErrUnexpectedSigningMethod = errors.New("unexpected signing method") 13 13 14 14 type JWTTokenizer interface { 15 - // AccessToken generates a new access token with the given payload 15 + // AccessToken generates a new access token with the given [Payload]. 16 16 AccessToken(pl Payload) (string, error) 17 17 18 - // RefreshToken generates a new refresh token 18 + // RefreshToken generates a random string of 64 chars. 19 19 RefreshToken() (string, error) 20 20 21 - // Parse parses the token and returns the payload 21 + // Parse parses the token and returns its [Payload]. 22 22 Parse(token string) (Payload, error) 23 23 } 24 24 25 +// Payload the access token payload 25 26 type Payload struct { 26 27 UserID string 27 28 }
A
internal/jwtutil/jwtutil_test.go
··· 1 +package jwtutil 2 + 3 +import ( 4 + "testing" 5 + "time" 6 + 7 + "github.com/stretchr/testify/assert" 8 + "github.com/stretchr/testify/require" 9 +) 10 + 11 +func TestJWTUtil_AccessToken(t *testing.T) { 12 + jwt := NewJWTUtil("key", time.Hour) 13 + payload := Payload{UserID: "user.123"} 14 + 15 + token, err := jwt.AccessToken(payload) 16 + require.NoError(t, err) 17 + assert.NotEmpty(t, token) 18 +} 19 + 20 +func TestJWTUtil_RefreshToken(t *testing.T) { 21 + jwt := NewJWTUtil("key", time.Hour) 22 + 23 + tok, err := jwt.RefreshToken() 24 + require.NoError(t, err) 25 + assert.Len(t, tok, 64) 26 + 27 + secondTok, err := jwt.RefreshToken() 28 + require.NoError(t, err) 29 + 30 + // tokens should be unique 31 + assert.NotEqual(t, tok, secondTok) 32 +} 33 + 34 +func TestJWTUtil_Parse(t *testing.T) { 35 + jwt := NewJWTUtil("key", time.Hour) 36 + payload := Payload{UserID: "qwerty"} 37 + 38 + token, err := jwt.AccessToken(payload) 39 + require.NoError(t, err) 40 + assert.NotEmpty(t, token) 41 + 42 + parsedPayload, err := jwt.Parse(token) 43 + require.NoError(t, err) 44 + 45 + assert.Equal(t, payload, parsedPayload) 46 +} 47 + 48 +func TestJWTUtil_Parse_expired(t *testing.T) { 49 + ttl := 100 * time.Millisecond 50 + jwt := NewJWTUtil("key", ttl) 51 + payload := Payload{UserID: "qwerty"} 52 + 53 + token, err := jwt.AccessToken(payload) 54 + require.NoError(t, err) 55 + assert.NotEmpty(t, token) 56 + 57 + time.Sleep(ttl) 58 + _, err = jwt.Parse(token) 59 + require.Error(t, err) 60 +}
M
internal/models/user.go
··· 11 11 var ( 12 12 ErrUserEmailIsAlreadyInUse = errors.New("user: email is already in use") 13 13 ErrUsernameIsAlreadyInUse = errors.New("user: username is already in use") 14 - ErrUserIsAlreeadyVerified = errors.New("user: user is already verified") 14 + ErrUserIsAlreadyVerified = errors.New("user: user is already verified") 15 15 16 16 ErrVerificationTokenNotFound = errors.New("user: verification token not found") 17 17 ErrUserIsNotActivated = errors.New("user: user is not activated") 18 18 19 19 ErrUserNotFound = errors.New("user: not found") 20 20 ErrUserWrongCredentials = errors.New("user: wrong credentials") 21 + 22 + ErrUserInvalidEmail = errors.New("user: invalid email") 23 + ErrUserInvalidPassword = errors.New("user: password too short, minimum 6 chars") 24 + ErrUserInvalidUsername = errors.New("user: username is required") 21 25 ) 22 26 23 27 type User struct { ··· 33 37 func (u User) Validate() error { 34 38 _, err := mail.ParseAddress(u.Email) 35 39 if err != nil { 36 - return errors.New("user: invalid email") //nolint:err113 40 + return ErrUserInvalidEmail 37 41 } 38 42 39 43 if len(u.Password) < 6 { 40 - return errors.New("user: password too short, minimum 6 chars") //nolint:err113 44 + return ErrUserInvalidPassword 41 45 } 42 46 43 47 if len(u.Username) == 0 { 44 - return errors.New("user: username is required") //nolint:err113 48 + return ErrUserInvalidUsername 45 49 } 46 50 47 51 return nil 48 52 } 53 + 54 +func (u User) IsActivated() bool { 55 + return u.Activated 56 +}
M
internal/service/notesrv/input.go
··· 5 5 // GetNoteBySlugInput used as input for [GetBySlugAndRemoveIfNeeded] 6 6 type GetNoteBySlugInput struct { 7 7 // Slug is a note's slug :) *Required* 8 - Slug dtos.NoteSlugDTO 8 + Slug dtos.NoteSlug 9 9 10 10 // Password is a note's password. 11 11 // Optional, needed only if note has one.
M
internal/service/notesrv/notesrv.go
··· 17 17 // Create creates note 18 18 // if slug is empty it will be generated, otherwise used as is 19 19 // if userID is empty it means user isn't authorized so it will be used 20 - Create(ctx context.Context, note dtos.CreateNoteDTO, userID uuid.UUID) (dtos.NoteSlugDTO, error) 20 + Create(ctx context.Context, note dtos.CreateNote, userID uuid.UUID) (dtos.NoteSlug, error) 21 21 22 22 // GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed 23 - GetBySlugAndRemoveIfNeeded(ctx context.Context, input GetNoteBySlugInput) (dtos.NoteDTO, error) 23 + GetBySlugAndRemoveIfNeeded( 24 + ctx context.Context, 25 + input GetNoteBySlugInput, 26 + ) (dtos.GetNote, error) 24 27 } 25 28 26 29 var _ NoteServicer = (*NoteSrv)(nil) ··· 41 44 42 45 func (n *NoteSrv) Create( 43 46 ctx context.Context, 44 - inp dtos.CreateNoteDTO, 47 + inp dtos.CreateNote, 45 48 userID uuid.UUID, 46 -) (dtos.NoteSlugDTO, error) { 49 +) (dtos.NoteSlug, error) { 47 50 slog.DebugContext(ctx, "creating", "inp", inp) 48 51 49 52 if inp.Slug == "" { ··· 58 61 inp.Password = hashedPassword 59 62 } 60 63 61 - if err := n.noterepo.Create(ctx, inp); err != nil { 64 + //nolint:exhaustruct // ID - cannot be predicted, and ReadAt will be set on read 65 + note := models.Note{ 66 + Content: inp.Content, 67 + Slug: inp.Slug, 68 + Password: inp.Password, 69 + BurnBeforeExpiration: inp.BurnBeforeExpiration, 70 + CreatedAt: inp.CreatedAt, 71 + ExpiresAt: inp.ExpiresAt, 72 + } 73 + if err := note.Validate(); err != nil { 74 + return "", err 75 + } 76 + 77 + if err := n.noterepo.Create(ctx, note); err != nil { 62 78 return "", err 63 79 } 64 80 ··· 74 90 func (n *NoteSrv) GetBySlugAndRemoveIfNeeded( 75 91 ctx context.Context, 76 92 inp GetNoteBySlugInput, 77 -) (dtos.NoteDTO, error) { 93 +) (dtos.GetNote, error) { 78 94 note, err := n.getNote(ctx, inp) 79 95 if err != nil { 80 - return dtos.NoteDTO{}, err 96 + return dtos.GetNote{}, err 81 97 } 82 98 83 - m := models.Note{ //nolint:exhaustruct 84 - ExpiresAt: note.ExpiresAt, 85 - BurnBeforeExpiration: note.BurnBeforeExpiration, 99 + if note.IsExpired() { 100 + return dtos.GetNote{}, models.ErrNoteExpired 86 101 } 87 102 88 - if m.IsExpired() { 89 - return dtos.NoteDTO{}, models.ErrNoteExpired 103 + respNote := dtos.GetNote{ 104 + Content: note.Content, 105 + ReadAt: note.ReadAt, 106 + CreatedAt: note.CreatedAt, 107 + ExpiresAt: note.ExpiresAt, 90 108 } 91 109 92 110 // since not every note should be burn before expiration 93 111 // we return early if it's not 94 - if m.ShouldBeBurnt() { 95 - return note, nil 112 + if note.ShouldBeBurnt() { 113 + return respNote, nil 96 114 } 97 115 98 - return note, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now()) 116 + return respNote, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now()) 99 117 } 100 118 101 -func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (dtos.NoteDTO, error) { 119 +func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (models.Note, error) { 102 120 if r, err := n.cache.GetNote(ctx, inp.Slug); err == nil { 103 121 return r, nil 104 122 } 105 123 106 124 note, err := n.getNoteFromDBasedOnInput(ctx, inp) 107 125 if err != nil { 108 - return dtos.NoteDTO{}, err 126 + return models.Note{}, err 109 127 } 110 128 111 - if note.ReadAt != nil && !note.ReadAt.IsZero() { 129 + if !note.IsRead() { 112 130 if err = n.cache.SetNote(ctx, inp.Slug, note); err != nil { 113 131 slog.ErrorContext(ctx, "notecache", "err", err) 114 132 } ··· 120 138 func (n *NoteSrv) getNoteFromDBasedOnInput( 121 139 ctx context.Context, 122 140 inp GetNoteBySlugInput, 123 -) (dtos.NoteDTO, error) { 141 +) (models.Note, error) { 124 142 if inp.HasPassword() { 125 143 hashedPassword, err := n.hasher.Hash(inp.Password) 126 144 if err != nil { 127 - return dtos.NoteDTO{}, err 145 + return models.Note{}, err 128 146 } 129 147 130 148 return n.noterepo.GetBySlugAndPassword(ctx, inp.Slug, hashedPassword)
M
internal/service/usersrv/usersrv.go
··· 19 19 ) 20 20 21 21 type UserServicer interface { 22 - SignUp(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) 23 - SignIn(ctx context.Context, inp dtos.SignInDTO) (dtos.TokensDTO, error) 24 - RefreshTokens(ctx context.Context, refreshToken string) (dtos.TokensDTO, error) 22 + SignUp(ctx context.Context, inp dtos.SignUp) (uuid.UUID, error) 23 + SignIn(ctx context.Context, inp dtos.SignIn) (dtos.Tokens, error) 24 + RefreshTokens(ctx context.Context, refreshToken string) (dtos.Tokens, error) 25 25 Logout(ctx context.Context, userID uuid.UUID) error 26 26 27 - ChangePassword(ctx context.Context, userID uuid.UUID, inp dtos.ResetUserPasswordDTO) error 27 + ChangePassword(ctx context.Context, userID uuid.UUID, inp dtos.ChangeUserPassword) error 28 28 29 29 Verify(ctx context.Context, verificationKey string) error 30 - ResendVerificationEmail(ctx context.Context, credentials dtos.SignInDTO) error 30 + ResendVerificationEmail(ctx context.Context, credentials dtos.SignIn) error 31 31 32 32 ParseJWTToken(token string) (jwtutil.Payload, error) 33 33 ··· 73 73 } 74 74 } 75 75 76 -func (u *UserSrv) SignUp(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) { 76 +func (u *UserSrv) SignUp(ctx context.Context, inp dtos.SignUp) (uuid.UUID, error) { 77 77 hashedPassword, err := u.hasher.Hash(inp.Password) 78 78 if err != nil { 79 79 return uuid.UUID{}, err 80 80 } 81 81 82 - uid, err := u.userstore.Create(ctx, dtos.CreateUserDTO{ 82 + user := models.User{ 83 + ID: uuid.Nil, // nil, because it does not get used here 83 84 Username: inp.Username, 84 85 Email: inp.Email, 86 + Activated: false, 85 87 Password: hashedPassword, 86 88 CreatedAt: inp.CreatedAt, 87 89 LastLoginAt: inp.LastLoginAt, 88 - }) 90 + } 91 + if err = user.Validate(); err != nil { 92 + return uuid.Nil, err 93 + } 94 + 95 + userID, err := u.userstore.Create(ctx, user) 89 96 if err != nil { 90 97 return uuid.Nil, err 91 98 } 92 99 93 - vtok := uuid.Must(uuid.NewV4()).String() 94 - if err := u.vertokrepo.Create(ctx, vtok, uid, time.Now(), time.Now().Add(u.verificationTokenTTL)); err != nil { 100 + verificationToken := uuid.Must(uuid.NewV4()).String() 101 + if err := u.vertokrepo.Create( 102 + ctx, 103 + verificationToken, 104 + userID, 105 + time.Now(), 106 + time.Now().Add(u.verificationTokenTTL), 107 + ); err != nil { 95 108 return uuid.Nil, err 96 109 } 97 110 98 111 if err := u.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ 99 112 Receiver: inp.Email, 100 - Token: vtok, 113 + Token: verificationToken, 101 114 }); err != nil { 102 115 return uuid.Nil, err 103 116 } 104 117 105 - return uid, nil 118 + return userID, nil 106 119 } 107 120 108 -func (u *UserSrv) SignIn(ctx context.Context, inp dtos.SignInDTO) (dtos.TokensDTO, error) { 109 - hashedPassword, err := u.hasher.Hash(inp.Password) 121 +func (u *UserSrv) SignIn(ctx context.Context, inp dtos.SignIn) (dtos.Tokens, error) { 122 + user, err := u.userstore.GetByEmail(ctx, inp.Email) 110 123 if err != nil { 111 - return dtos.TokensDTO{}, err 124 + return dtos.Tokens{}, err 112 125 } 113 126 114 - user, err := u.userstore.GetUserByCredentials(ctx, inp.Email, hashedPassword) 115 - if err != nil { 116 - if errors.Is(err, models.ErrUserNotFound) { 117 - return dtos.TokensDTO{}, models.ErrUserWrongCredentials 127 + if err = u.hasher.Compare(user.Password, inp.Password); err != nil { 128 + if errors.Is(err, hasher.ErrMismatchedHashes) { 129 + return dtos.Tokens{}, models.ErrUserWrongCredentials 118 130 } 119 - return dtos.TokensDTO{}, err 131 + return dtos.Tokens{}, err 120 132 } 121 133 122 - if !user.Activated { 123 - return dtos.TokensDTO{}, models.ErrUserIsNotActivated 134 + if !user.IsActivated() { 135 + return dtos.Tokens{}, models.ErrUserIsNotActivated 124 136 } 125 137 126 - tokens, err := u.getTokens(user.ID) 138 + tokens, err := u.createTokens(user.ID) 127 139 if err != nil { 128 - return dtos.TokensDTO{}, err 140 + return dtos.Tokens{}, err 129 141 } 130 142 131 143 if err := u.sessionstore.Set(ctx, user.ID, tokens.Refresh, time.Now().Add(u.refreshTokenTTL)); err != nil { 132 - return dtos.TokensDTO{}, err 144 + return dtos.Tokens{}, err 133 145 } 134 146 135 - return dtos.TokensDTO{ 147 + return dtos.Tokens{ 136 148 Access: tokens.Access, 137 149 Refresh: tokens.Refresh, 138 150 }, nil ··· 142 154 return u.sessionstore.Delete(ctx, userID) 143 155 } 144 156 145 -func (u *UserSrv) RefreshTokens(ctx context.Context, rtoken string) (dtos.TokensDTO, error) { 157 +func (u *UserSrv) RefreshTokens(ctx context.Context, rtoken string) (dtos.Tokens, error) { 146 158 userID, err := u.sessionstore.GetUserIDByRefreshToken(ctx, rtoken) 147 159 if err != nil { 148 - return dtos.TokensDTO{}, err 160 + return dtos.Tokens{}, err 149 161 } 150 162 151 - tokens, err := u.getTokens(userID) 163 + tokens, err := u.createTokens(userID) 152 164 if err != nil { 153 - return dtos.TokensDTO{}, err 165 + return dtos.Tokens{}, err 154 166 } 155 167 156 168 if err := u.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh); err != nil { 157 - return dtos.TokensDTO{}, err 169 + return dtos.Tokens{}, err 158 170 } 159 171 160 - return dtos.TokensDTO{ 172 + return dtos.Tokens{ 161 173 Access: tokens.Access, 162 174 Refresh: tokens.Refresh, 163 175 }, nil ··· 166 178 func (u *UserSrv) ChangePassword( 167 179 ctx context.Context, 168 180 userID uuid.UUID, 169 - inp dtos.ResetUserPasswordDTO, 181 + inp dtos.ChangeUserPassword, 170 182 ) error { 183 + // TODO: compare current password with providede, and assert on mismatch 184 + 171 185 oldPass, err := u.hasher.Hash(inp.CurrentPassword) 172 186 if err != nil { 173 187 return err ··· 194 208 return u.userstore.MarkUserAsActivated(ctx, uid) 195 209 } 196 210 197 -func (u *UserSrv) ResendVerificationEmail(ctx context.Context, inp dtos.SignInDTO) error { 198 - hashedPassword, err := u.hasher.Hash(inp.Password) 211 +func (u *UserSrv) ResendVerificationEmail(ctx context.Context, inp dtos.SignIn) error { 212 + user, err := u.userstore.GetByEmail(ctx, inp.Email) 199 213 if err != nil { 200 214 return err 201 215 } 202 216 203 - user, err := u.userstore.GetUserByCredentials(ctx, inp.Email, hashedPassword) 204 - if err != nil { 205 - if errors.Is(err, models.ErrUserNotFound) { 206 - return models.ErrUserWrongCredentials 207 - } 208 - return err 217 + if err = u.hasher.Compare(user.Password, inp.Password); err != nil { 218 + return models.ErrUserWrongCredentials 209 219 } 210 220 211 221 if user.Activated { 212 - return models.ErrUserIsAlreeadyVerified 222 + return models.ErrUserIsAlreadyVerified 213 223 } 214 224 215 225 token, err := u.vertokrepo.GetTokenOrUpdateTokenByUserID( ··· 236 246 } 237 247 238 248 func (u UserSrv) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) { 239 - if r, err := u.cache.GetIsExists(ctx, id.String()); err == nil { 249 + r, err := u.cache.GetIsExists(ctx, id.String()) 250 + if err == nil { 240 251 return r, nil 241 - } else { //nolint:revive 242 - slog.ErrorContext(ctx, "usercache", "err", err) 243 252 } 253 + 254 + slog.ErrorContext(ctx, "usercache", "err", err) 244 255 245 256 isExists, err := u.userstore.CheckIfUserExists(ctx, id) 246 257 if err != nil { ··· 248 259 } 249 260 250 261 if err := u.cache.SetIsExists(ctx, id.String(), isExists); err != nil { 251 - slog.Error("usercache", "err", err) 262 + slog.ErrorContext(ctx, "usercache", "err", err) 252 263 } 253 264 254 265 return isExists, nil 255 266 } 256 267 257 -func (u UserSrv) CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) { 258 - if r, err := u.cache.GetIsActivated(ctx, userID.String()); err == nil { 268 +func (u *UserSrv) CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) { 269 + r, err := u.cache.GetIsActivated(ctx, userID.String()) 270 + if err == nil { 259 271 return r, nil 260 - } else { //nolint:revive 261 - slog.ErrorContext(ctx, "usercache", "err", err) 262 272 } 263 273 264 - isActivated, err := u.userstore.CheckIfUserExists(ctx, userID) 274 + slog.ErrorContext(ctx, "usercache", "err", err) 275 + 276 + isActivated, err := u.userstore.CheckIfUserIsActivated(ctx, userID) 265 277 if err != nil { 266 278 return false, err 267 279 } 268 280 269 281 if err := u.cache.SetIsActivated(ctx, userID.String(), isActivated); err != nil { 270 - slog.Error("usercache", "err", err) 282 + slog.ErrorContext(ctx, "usercache", "err", err) 271 283 } 272 284 273 285 return isActivated, nil 274 286 } 275 287 276 -func (u UserSrv) getTokens(userID uuid.UUID) (dtos.TokensDTO, error) { 288 +func (u UserSrv) createTokens(userID uuid.UUID) (dtos.Tokens, error) { 277 289 accessToken, err := u.jwtTokenizer.AccessToken(jwtutil.Payload{UserID: userID.String()}) 278 290 if err != nil { 279 - return dtos.TokensDTO{}, err 291 + return dtos.Tokens{}, err 280 292 } 281 293 282 294 refreshToken, err := u.jwtTokenizer.RefreshToken() 283 295 if err != nil { 284 - return dtos.TokensDTO{}, err 296 + return dtos.Tokens{}, err 285 297 } 286 298 287 - return dtos.TokensDTO{ 299 + return dtos.Tokens{ 288 300 Access: accessToken, 289 301 Refresh: refreshToken, 290 302 }, err
M
internal/store/psql/noterepo/noterepo.go
··· 15 15 16 16 type NoteStorer interface { 17 17 // Create creates a note. 18 - Create(ctx context.Context, inp dtos.CreateNoteDTO) error 18 + Create(ctx context.Context, note models.Note) error 19 19 20 20 // GetBySlug gets a note by slug. 21 21 // Returns [models.ErrNoteNotFound] if note is not found. 22 - GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) 22 + GetBySlug(ctx context.Context, slug dtos.NoteSlug) (models.Note, error) 23 23 24 24 // GetBySlugAndPassword gets a note by slug and password. 25 25 // the "password" should be hashed. ··· 27 27 // Returns [models.ErrNoteNotFound] if note is not found. 28 28 GetBySlugAndPassword( 29 29 ctx context.Context, 30 - slug dtos.NoteSlugDTO, 30 + slug dtos.NoteSlug, 31 31 password string, 32 - ) (dtos.NoteDTO, error) 32 + ) (models.Note, error) 33 33 34 34 // RemoveBySlug marks note as read, deletes it's content, and keeps meta data 35 35 // Returns [models.ErrNoteNotFound] if note is not found. 36 - RemoveBySlug(ctx context.Context, slug dtos.NoteSlugDTO, readAt time.Time) error 36 + RemoveBySlug(ctx context.Context, slug dtos.NoteSlug, readAt time.Time) error 37 37 38 38 // SetAuthorIDBySlug assigns author to note by slug. 39 39 // Returns [models.ErrNoteNotFound] if note is not found. 40 - SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlugDTO, authorID uuid.UUID) error 40 + SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error 41 41 } 42 42 43 43 var _ NoteStorer = (*NoteRepo)(nil) ··· 50 50 return &NoteRepo{db} 51 51 } 52 52 53 -func (s *NoteRepo) Create(ctx context.Context, inp dtos.CreateNoteDTO) error { 53 +func (s *NoteRepo) Create(ctx context.Context, inp models.Note) error { 54 54 query, args, err := pgq. 55 55 Insert("notes"). 56 - Columns("content", "slug", "password", "burn_before_expiration ", "created_at", "expires_at"). 56 + Columns("content", "slug", "password", "burn_before_expiration", "created_at", "expires_at"). 57 57 Values(inp.Content, inp.Slug, inp.Password, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt). 58 58 SQL() 59 59 if err != nil { ··· 68 68 return err 69 69 } 70 70 71 -func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) { 71 +func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlug) (models.Note, error) { 72 72 query, args, err := pgq. 73 73 Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at"). 74 74 From("notes"). ··· 76 76 Where(pgq.Eq{"slug": slug}). 77 77 SQL() 78 78 if err != nil { 79 - return dtos.NoteDTO{}, err 79 + return models.Note{}, err 80 80 } 81 81 82 - var note dtos.NoteDTO 82 + var note models.Note 83 83 err = s.db.QueryRow(ctx, query, args...). 84 84 Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) 85 85 86 86 if errors.Is(err, pgx.ErrNoRows) { 87 - return dtos.NoteDTO{}, models.ErrNoteNotFound 87 + return models.Note{}, models.ErrNoteNotFound 88 88 } 89 89 90 90 return note, err ··· 92 92 93 93 func (s *NoteRepo) GetBySlugAndPassword( 94 94 ctx context.Context, 95 - slug dtos.NoteSlugDTO, 95 + slug dtos.NoteSlug, 96 96 passwd string, 97 -) (dtos.NoteDTO, error) { 97 +) (models.Note, error) { 98 98 query, args, err := pgq. 99 99 Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at"). 100 100 From("notes"). ··· 104 104 }). 105 105 SQL() 106 106 if err != nil { 107 - return dtos.NoteDTO{}, err 107 + return models.Note{}, err 108 108 } 109 109 110 - var note dtos.NoteDTO 110 + var note models.Note 111 111 err = s.db.QueryRow(ctx, query, args...). 112 112 Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) 113 113 114 114 if errors.Is(err, pgx.ErrNoRows) { 115 - return dtos.NoteDTO{}, models.ErrNoteNotFound 115 + return models.Note{}, models.ErrNoteNotFound 116 116 } 117 117 118 118 return note, err ··· 120 120 121 121 func (s *NoteRepo) RemoveBySlug( 122 122 ctx context.Context, 123 - slug dtos.NoteSlugDTO, 123 + slug dtos.NoteSlug, 124 124 readAt time.Time, 125 125 ) error { 126 126 query, args, err := pgq. ··· 129 129 Set("read_at", readAt). 130 130 Where(pgq.Eq{ 131 131 "slug": slug, 132 - "read_at": nil, 132 + "read_at": time.Time{}, // check if time is null 133 133 }). 134 134 SQL() 135 135 if err != nil { ··· 146 146 147 147 func (s *NoteRepo) SetAuthorIDBySlug( 148 148 ctx context.Context, 149 - slug dtos.NoteSlugDTO, 149 + slug dtos.NoteSlug, 150 150 authorID uuid.UUID, 151 151 ) error { 152 152 tx, err := s.db.Begin(ctx)
M
internal/store/psql/userepo/userepo.go
··· 7 7 "github.com/gofrs/uuid/v5" 8 8 "github.com/henvic/pgq" 9 9 "github.com/jackc/pgx/v5" 10 - "github.com/olexsmir/onasty/internal/dtos" 11 10 "github.com/olexsmir/onasty/internal/models" 12 11 "github.com/olexsmir/onasty/internal/store/psqlutil" 13 12 ) 14 13 15 14 type UserStorer interface { 16 - Create(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) 15 + Create(ctx context.Context, inp models.User) (uuid.UUID, error) 17 16 18 17 // GetUserByCredentials returns user by email and password 19 18 // the password should be hashed 20 - GetUserByCredentials(ctx context.Context, email, password string) (dtos.UserDTO, error) 19 + GetByEmail(ctx context.Context, email string) (models.User, error) 21 20 22 21 GetUserIDByEmail(ctx context.Context, email string) (uuid.UUID, error) 23 22 MarkUserAsActivated(ctx context.Context, id uuid.UUID) error ··· 46 45 } 47 46 } 48 47 49 -func (r *UserRepo) Create(ctx context.Context, inp dtos.CreateUserDTO) (uuid.UUID, error) { 48 +func (r *UserRepo) Create(ctx context.Context, inp models.User) (uuid.UUID, error) { 50 49 query, args, err := pgq. 51 50 Insert("users"). 52 - Columns("username", "email", "password", "created_at", "last_login_at"). 53 - Values(inp.Username, inp.Email, inp.Password, inp.CreatedAt, inp.LastLoginAt). 51 + Columns("username", "email", "password", "activated", "created_at", "last_login_at"). 52 + Values(inp.Username, inp.Email, inp.Password, inp.Activated, inp.CreatedAt, inp.LastLoginAt). 54 53 Returning("id"). 55 54 SQL() 56 55 if err != nil { ··· 72 71 return id, err 73 72 } 74 73 75 -func (r *UserRepo) GetUserByCredentials( 74 +func (r *UserRepo) GetByEmail( 76 75 ctx context.Context, 77 - email, password string, 78 -) (dtos.UserDTO, error) { 76 + email string, 77 +) (models.User, error) { 79 78 query, args, err := pgq. 80 79 Select("id", "username", "email", "password", "activated", "created_at", "last_login_at"). 81 80 From("users"). 82 - Where(pgq.Eq{ 83 - "email": email, 84 - "password": password, 85 - }). 81 + Where(pgq.Eq{"email": email}). 86 82 SQL() 87 83 if err != nil { 88 - return dtos.UserDTO{}, err 84 + return models.User{}, err 89 85 } 90 86 91 - var user dtos.UserDTO 87 + var user models.User 92 88 err = r.db.QueryRow(ctx, query, args...). 93 89 Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.Activated, &user.CreatedAt, &user.LastLoginAt) 94 90 if errors.Is(err, pgx.ErrNoRows) { 95 - return dtos.UserDTO{}, models.ErrUserNotFound 91 + return models.User{}, models.ErrUserNotFound 96 92 } 97 93 98 94 return user, err
M
internal/store/rdb/notecache/notecache.go
··· 7 7 "strings" 8 8 "time" 9 9 10 - "github.com/olexsmir/onasty/internal/dtos" 10 + "github.com/olexsmir/onasty/internal/models" 11 11 "github.com/olexsmir/onasty/internal/store/rdb" 12 12 ) 13 13 14 14 type NoteCacher interface { 15 - SetNote(ctx context.Context, slug string, note dtos.NoteDTO) error 16 - GetNote(ctx context.Context, slug string) (dtos.NoteDTO, error) 15 + SetNote(ctx context.Context, slug string, note models.Note) error 16 + GetNote(ctx context.Context, slug string) (models.Note, error) 17 17 } 18 18 19 19 type NoteCache struct { ··· 28 28 } 29 29 } 30 30 31 -func (n *NoteCache) SetNote(ctx context.Context, slug string, note dtos.NoteDTO) error { 31 +func (n *NoteCache) SetNote(ctx context.Context, slug string, note models.Note) error { 32 32 var buf bytes.Buffer 33 33 if err := gob.NewEncoder(&buf).Encode(note); err != nil { 34 34 return err ··· 38 38 return err 39 39 } 40 40 41 -func (n *NoteCache) GetNote(ctx context.Context, slug string) (dtos.NoteDTO, error) { 41 +func (n *NoteCache) GetNote(ctx context.Context, slug string) (models.Note, error) { 42 42 val, err := n.rdb.Get(ctx, getKey(slug)).Bytes() 43 43 if err != nil { 44 - return dtos.NoteDTO{}, err 44 + return models.Note{}, err 45 45 } 46 46 47 - var note dtos.NoteDTO 47 + var note models.Note 48 48 if err = gob.NewDecoder(bytes.NewReader(val)).Decode(¬e); err != nil { 49 - return dtos.NoteDTO{}, err 49 + return models.Note{}, err 50 50 } 51 51 52 52 return note, err
M
internal/transport/http/apiv1/auth.go
··· 6 6 7 7 "github.com/gin-gonic/gin" 8 8 "github.com/olexsmir/onasty/internal/dtos" 9 - "github.com/olexsmir/onasty/internal/models" 10 9 ) 11 10 12 11 type signUpRequest struct { ··· 22 21 return 23 22 } 24 23 25 - user := models.User{ //nolint:exhaustruct 24 + if _, err := a.usersrv.SignUp(c.Request.Context(), dtos.SignUp{ 26 25 Username: req.Username, 27 26 Email: req.Email, 28 27 Password: req.Password, 29 28 CreatedAt: time.Now(), 30 29 LastLoginAt: time.Now(), 31 - } 32 - if err := user.Validate(); err != nil { 33 - // TODO: find a way to return all errors at once 34 - newErrorStatus(c, http.StatusBadRequest, err.Error()) 35 - return 36 - } 37 - 38 - if _, err := a.usersrv.SignUp(c.Request.Context(), dtos.CreateUserDTO{ 39 - Username: user.Username, 40 - Email: user.Email, 41 - Password: user.Password, 42 - CreatedAt: user.CreatedAt, 43 - LastLoginAt: user.LastLoginAt, 44 30 }); err != nil { 45 31 errorResponse(c, err) 46 32 return ··· 66 52 return 67 53 } 68 54 69 - toks, err := a.usersrv.SignIn(c.Request.Context(), dtos.SignInDTO{ 55 + toks, err := a.usersrv.SignIn(c.Request.Context(), dtos.SignIn{ 70 56 Email: req.Email, 71 57 Password: req.Password, 72 58 }) ··· 120 106 return 121 107 } 122 108 123 - if err := a.usersrv.ResendVerificationEmail(c.Request.Context(), dtos.SignInDTO{ 124 - Email: req.Email, 125 - Password: req.Password, 126 - }); err != nil { 109 + if err := a.usersrv.ResendVerificationEmail( 110 + c.Request.Context(), 111 + dtos.SignIn{ 112 + Email: req.Email, 113 + Password: req.Password, 114 + }); err != nil { 127 115 errorResponse(c, err) 128 116 return 129 117 } ··· 155 143 if err := a.usersrv.ChangePassword( 156 144 c.Request.Context(), 157 145 a.getUserID(c), 158 - dtos.ResetUserPasswordDTO{ 146 + dtos.ChangeUserPassword{ 159 147 CurrentPassword: req.CurrentPassword, 160 148 NewPassword: req.NewPassword, 161 149 }); err != nil {
M
internal/transport/http/apiv1/note.go
··· 45 45 return 46 46 } 47 47 48 - slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNoteDTO{ 48 + slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{ 49 49 Content: note.Content, 50 50 UserID: a.getUserID(c), 51 51 Slug: note.Slug, ··· 67 67 } 68 68 69 69 type getNoteBySlugResponse struct { 70 - Content string `json:"content,omitempty"` 71 - ReadAt *time.Time `json:"read_at,omitempty"` 72 - CratedAt time.Time `json:"crated_at"` 73 - ExpiresAt time.Time `json:"expires_at"` 70 + Content string `json:"content,omitempty"` 71 + ReadAt time.Time `json:"read_at"` 72 + CratedAt time.Time `json:"crated_at"` 73 + ExpiresAt time.Time `json:"expires_at"` 74 74 } 75 75 76 76 func (a *APIV1) getNoteBySlugHandler(c *gin.Context) { ··· 80 80 return 81 81 } 82 82 83 - slug := c.Param("slug") 84 83 note, err := a.notesrv.GetBySlugAndRemoveIfNeeded( 85 84 c.Request.Context(), 86 85 notesrv.GetNoteBySlugInput{ 87 - Slug: slug, 86 + Slug: c.Param("slug"), 88 87 Password: req.Password, 89 88 }, 90 89 ) ··· 94 93 } 95 94 96 95 status := http.StatusOK 97 - if note.ReadAt != nil && !note.ReadAt.IsZero() { 96 + if !note.ReadAt.IsZero() { 98 97 status = http.StatusNotFound 99 98 } 100 99
M
internal/transport/http/apiv1/response.go
··· 18 18 func errorResponse(c *gin.Context, err error) { 19 19 if errors.Is(err, models.ErrUserEmailIsAlreadyInUse) || 20 20 errors.Is(err, models.ErrUsernameIsAlreadyInUse) || 21 - errors.Is(err, models.ErrNoteContentIsEmpty) || 22 - errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) || 21 + errors.Is(err, models.ErrUserIsAlreadyVerified) || 23 22 errors.Is(err, models.ErrUserIsNotActivated) || 24 - errors.Is(err, models.ErrUserIsAlreeadyVerified) { 23 + errors.Is(err, models.ErrUserInvalidEmail) || 24 + errors.Is(err, models.ErrUserInvalidPassword) || 25 + errors.Is(err, models.ErrUserInvalidUsername) || 26 + // notes 27 + errors.Is(err, models.ErrNoteContentIsEmpty) || 28 + errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) { 25 29 newError(c, http.StatusBadRequest, err.Error()) 26 30 return 27 31 }
M
internal/transport/http/httpserver/httpserver.go
··· 10 10 http *http.Server 11 11 } 12 12 13 -func NewServer(port string, handler http.Handler) *Server { 14 - // TODO: add those settings to the config module 13 +type Config struct { 14 + // Port http server port 15 + Port string 16 + 17 + // ReadTimeout read timeout 18 + ReadTimeout time.Duration 19 + 20 + // WriteTimeout write timeout 21 + WriteTimeout time.Duration 22 + 23 + // MaxHeaderSizeMb max size of headers in megabytes 24 + MaxHeaderSizeMb int 25 +} 26 + 27 +func NewServer(handler http.Handler, cfg Config) *Server { 15 28 return &Server{ 16 29 http: &http.Server{ 17 - Addr: ":" + port, 30 + Addr: ":" + cfg.Port, 18 31 Handler: handler, 19 - ReadTimeout: 10 * time.Second, 20 - WriteTimeout: 10 * time.Second, 21 - MaxHeaderBytes: 1 << 20, // 1mb 32 + ReadTimeout: cfg.ReadTimeout, 33 + WriteTimeout: cfg.WriteTimeout, 34 + MaxHeaderBytes: cfg.MaxHeaderSizeMb << 20, 22 35 }, 23 36 } 24 37 }
M
internal/transport/http/ratelimit/ratelimit.go
··· 43 43 } 44 44 } 45 45 46 -// Retrieve and return the rate limiter for the current visitor if it 47 -// already exists. Otherwise create a new rate limiter and add it to 46 +// getVisitor Retrieve and return the rate limiter for the current visitor 47 +// if it already exists. Otherwise create a new rate limiter and add it to 48 48 // the visitors map, using the IP address as the key. 49 49 func (r *rateLimiter) getVisitor(ip visitorIP) *rate.Limiter { 50 50 r.mu.RLock() ··· 71 71 return v.limiter 72 72 } 73 73 74 -// Every minute check the map for visitors that haven't been seen for 75 -// more than 3 minutes and delete the entries. 74 +// cleanUpVisitors checks the map of visitors that haven't been seed 75 +// for more than [Config].TTL and delete those entries 76 76 func (r *rateLimiter) cleanupVisitors() { 77 + r.mu.Lock() 78 + defer r.mu.Unlock() 79 + 80 + for ip, v := range r.visitors { 81 + if time.Since(v.lastSeen) > r.ttl { 82 + delete(r.visitors, ip) 83 + } 84 + } 85 +} 86 + 87 +// cleanupVisitorsLoop runs [rateLimiter.cleanupVisitors] every minute 88 +func (r *rateLimiter) cleanupVisitorsLoop() { 77 89 for { 78 90 time.Sleep(time.Minute) 79 - 80 - r.mu.Lock() 81 - for ip, v := range r.visitors { 82 - if time.Since(v.lastSeen) > r.ttl { 83 - delete(r.visitors, ip) 84 - } 85 - } 86 - r.mu.Unlock() 91 + r.cleanupVisitors() 87 92 } 88 93 } 89 94 ··· 101 106 // MiddlewareWithConfig returns a new rate limiting middleware with the given config 102 107 func MiddlewareWithConfig(c Config) gin.HandlerFunc { 103 108 lmt := newLimiter(c.RPS, c.Burst, c.TTL) 104 - go lmt.cleanupVisitors() 109 + go lmt.cleanupVisitorsLoop() 105 110 106 111 return func(c *gin.Context) { 107 112 visitor := lmt.getVisitor(visitorIP(c.ClientIP()))
A
internal/transport/http/ratelimit/ratelimit_test.go
··· 1 +package ratelimit 2 + 3 +import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "testing" 7 + "time" 8 + 9 + "github.com/gin-gonic/gin" 10 + "github.com/stretchr/testify/assert" 11 +) 12 + 13 +func TestRateLimiter_getVisitor(t *testing.T) { 14 + limiter := newLimiter(10, 20, time.Second) 15 + ip := visitorIP("127.0.0.1") 16 + 17 + visitor := limiter.getVisitor(ip) 18 + assert.NotNil(t, visitor) 19 + 20 + visitorAgain := limiter.getVisitor(ip) 21 + assert.Equal(t, visitor, visitorAgain) 22 + 23 + assert.Len(t, limiter.visitors, 1) 24 +} 25 + 26 +// TODO: rewrite to use "testing/synctest" when it gets merged 27 +func TestRateLimiter_cleanupVisitors(t *testing.T) { 28 + limiter := newLimiter(10, 20, time.Second/2) 29 + limiter.getVisitor("192.168.9.1") 30 + assert.Len(t, limiter.visitors, 1) 31 + 32 + time.Sleep(time.Second) 33 + limiter.cleanupVisitors() 34 + assert.Empty(t, limiter.visitors) 35 +} 36 + 37 +func TestMiddleware(t *testing.T) { 38 + gin.SetMode(gin.TestMode) 39 + tests := map[string]struct { 40 + config Config 41 + requests int 42 + expectedCode int 43 + }{ 44 + "allows requests with in limit": { 45 + config: Config{ 46 + RPS: 2, 47 + Burst: 2, 48 + TTL: time.Minute, 49 + }, 50 + requests: 1, 51 + expectedCode: http.StatusOK, 52 + }, 53 + "blocks requests over limit": { 54 + config: Config{ 55 + RPS: 1, 56 + Burst: 1, 57 + TTL: time.Minute, 58 + }, 59 + requests: 2, 60 + expectedCode: http.StatusTooManyRequests, 61 + }, 62 + "allows burst requests": { 63 + config: Config{ 64 + RPS: 1, 65 + Burst: 3, 66 + TTL: time.Minute, 67 + }, 68 + requests: 3, 69 + expectedCode: http.StatusOK, 70 + }, 71 + } 72 + 73 + for name, tt := range tests { 74 + t.Run(name, func(t *testing.T) { 75 + handler := MiddlewareWithConfig(tt.config) 76 + var lastCode int 77 + 78 + for range tt.requests { 79 + w := httptest.NewRecorder() 80 + c, _ := gin.CreateTestContext(w) 81 + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) 82 + 83 + handler(c) 84 + lastCode = w.Code 85 + } 86 + 87 + assert.Equal(t, tt.expectedCode, lastCode) 88 + }) 89 + } 90 +}
M
mailer/main.go
··· 9 9 "os/signal" 10 10 "strings" 11 11 "syscall" 12 + "time" 12 13 13 14 "github.com/nats-io/nats.go" 14 15 "github.com/nats-io/nats.go/micro" ··· 62 63 } 63 64 64 65 if cfg.MetricsEnabled { 65 - srv := httpserver.NewServer(cfg.MetricsPort, MetricsHandler()) 66 + srv := httpserver.NewServer(MetricsHandler(), httpserver.Config{ 67 + Port: cfg.MetricsPort, 68 + ReadTimeout: 10 * time.Second, 69 + WriteTimeout: 10 * time.Second, 70 + MaxHeaderSizeMb: 1, 71 + }) 66 72 go func() { 67 73 slog.Info("starting metrics server", "port", cfg.MetricsPort) 68 74 if err := srv.Start(); !errors.Is(err, http.ErrServerClosed) {
M
migrations/20250401121105_notes_add_read.up.sql
··· 1 1 ALTER TABLE notes 2 - ADD COLUMN "read_at" timestamptz; 2 + ADD COLUMN "read_at" timestamptz NOT NULL DEFAULT '0001-01-01 00:00:00';