all repos

onasty @ 040e38372994521cfeb76c57c76998be53bf6a17

a one-time notes service
36 files changed, 561 insertions(+), 301 deletions(-)
refactor: fix annoyances (#97)

* refactor(user): use model where needed instead of dto

* refactor(hasher): add .Compare method

* refactor: get user email instead of credentials, and check password hash manually

* refactor(usersrv): renaming, correct caching

* test: fix auth test

* fix(models): fix typo

* fix(models): return errors correctly

* refactor(dtos): correct naming

* chore(migrations): set default value for read_at

* refactor(notes): use models in internals instead of dtos

* refactor(dtos): delete unused struct

* refactor(dtos): reorganize how it's organized in files

* refactor(dtos): dont duplicate names in package and struct names

* fixup! refactor(dtos): dont duplicate names in package and struct names

* refactor(noterepo): write model and not dto

* refactor(userrepo): naming

* refactor(mailermq): idk why i added that variable in the first place

* docs(e2e): update doc

* refactor(e2e): naming and remove code duplication

* refactor(e2e): renaming

* fix(noterepo): remove whitespace from name of a field

* refactor(e2e): fix typos, add docs

* refactor(events): i was really into interface implementation checking

* chore(usersrv): fix formatting

* test(ratelimit): add tests

* refactor(ratelimit): keep naming consistent, and update comments

* refactor(e2e): fix typo in file name

* chore(jwtutil): update comments

* test(jwtutil): add tests

* fixup! chore(jwtutil): update comments

* test(hasher): test sha256 implementation

* test(e2e): test ping endpoint

* refactor(httpserver): add http server config

* chore(env): update example

* fix(mailer): update to new httpserver api

* fix(config): fix naming

* fix(e2e): actually apply defaults

* test(jwtutil): refactor

* fix(config): fix typos

* fix(metrics): change to the correct handler
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-04-22 00:54:46 +0300
Parent: c2e1526
M .env.example
···
        1
        1
         APP_ENV=debug

      
        2
        2
         APP_URL=http://localhost:8000

      
        3
        
        -SERVER_PORT=8000

      
        4
        3
         PASSWORD_SALT=onasty

      
        5
        4
         NOTE_PASSWORD_SALT=secret

      
        
        5
        +

      
        
        6
        +HTTP_PORT=8000

      
        6
        7
         

      
        7
        8
         METRICS_ENABLED=true

      
        8
        9
         METRICS_PORT=8001

      
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_authoirzed_test.goe2e/apiv1_notes_authorized_test.go
···
        16
        16
         	var body apiv1NoteCreateResponse

      
        17
        17
         	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        18
        18
         

      
        19
        
        -	dbNote := e.getNoteFromDBbySlug(body.Slug)

      
        
        19
        +	dbNote := e.getNoteBySlug(body.Slug)

      
        20
        20
         	dbNoteAuthor := e.getLastNoteAuthorsRecordByAuthorID(uid)

      
        21
        21
         

      
        22
        22
         	e.Equal(http.StatusCreated, httpResp.Code)

      
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(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.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

      
D internal/dtos/token.go
···
        1
        
        -package dtos

      
        2
        
        -

      
        3
        
        -type TokensDTO struct {

      
        4
        
        -	Access  string

      
        5
        
        -	Refresh string

      
        6
        
        -}

      
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/events.go
···
        11
        11
         	natsHeaderErrorMsg  = "Nats-Service-Error"

      
        12
        12
         )

      
        13
        13
         

      
        14
        
        -var _ error = (*Error)(nil)

      
        15
        
        -

      
        16
        14
         type Error struct {

      
        17
        15
         	Code    string

      
        18
        16
         	Message string

      
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(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.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(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.ReadAt, &note.CreatedAt, &note.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/psql/vertokrepo/vertokrepo.go
···
        82
        82
         	}

      
        83
        83
         

      
        84
        84
         	if isUsed {

      
        85
        
        -		return uuid.Nil, models.ErrUserIsAlreeadyVerified

      
        
        85
        +		return uuid.Nil, models.ErrUserIsAlreadyVerified

      
        86
        86
         	}

      
        87
        87
         

      
        88
        88
         	query := `--sql

      
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(&note); 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';