all repos

onasty @ 688ee306df96529fa9a24172fddbb6091d427aca

a one-time notes service
17 files changed, 255 insertions(+), 37 deletions(-)
feat: add password support to notes (#41)

* refactor(e2e): move out notes tests that requires being authorized to separate file

* fix(migrations): add down for `notes_authors`

* feat(migrations): add passwords to notes

* feat: add Password field to note

* feat(noterepo): write password

* refactor(notesrv): add hasher to deps

* fixup! refactor(notesrv): add hasher to deps

* feat(notesrv): store password

* fix(api/note): pass password field..

* fixup! feat(notesrv): store password

* refactor(dockerfile): set all go envs in ENV

* fixup! feat(notesrv): store password

* refactor(logger): support alternative way of spelling text

* it does somethings

* docs(noterepo): add doc comments in interface

)

* fixup! docs(noterepo): add doc comments in interface

* refactor(notesrv): handle if user provided note password

* refactor(api): make it possible for body to be empty

* test(e2e): notes with passwords

* fix(noterepo): get note only if it doesn't have password and has correct slug

* refactor(notesrv): setup separate hasher for note service

* refactor(notesrv): that also probably not required
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2024-11-20 18:46:32 +0200
Parent: f234ee1
M .env.example
···
        2
        2
         APP_URL=http://localhost:8000

      
        3
        3
         SERVER_PORT=8000

      
        4
        4
         PASSWORD_SALT=onasty

      
        
        5
        +NOTE_PASSWORD_SALT=secret

      
        5
        6
         METRICS_PORT=8001

      
        6
        7
         

      
        7
        8
         LOG_LEVEL=debug

      
M Dockerfile
···
        8
        8
         COPY cmd cmd

      
        9
        9
         COPY internal internal

      
        10
        10
         

      
        11
        
        -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /onasty ./cmd/server

      
        
        11
        +ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64

      
        
        12
        +RUN go build -trimpath -ldflags='-w -s' -o /onasty ./cmd/server

      
        12
        13
         

      
        13
        14
         FROM alpine:3.20

      
        14
        15
         COPY --from=builder /onasty /onasty

      
M cmd/server/main.go
···
        68
        68
         		return err

      
        69
        69
         	}

      
        70
        70
         

      
        71
        
        -	sha256Hasher := hasher.NewSHA256Hasher(cfg.PasswordSalt)

      
        
        71
        +	userPasswordHasher := hasher.NewSHA256Hasher(cfg.PasswordSalt)

      
        
        72
        +	notePasswordHasher := hasher.NewSHA256Hasher(cfg.NotePassowrdSalt)

      
        72
        73
         	jwtTokenizer := jwtutil.NewJWTUtil(cfg.JwtSigningKey, cfg.JwtAccessTokenTTL)

      
        73
        74
         	mailGunMailer := mailer.NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey)

      
        74
        75
         

      ···
        81
        82
         		userepo,

      
        82
        83
         		sessionrepo,

      
        83
        84
         		vertokrepo,

      
        84
        
        -		sha256Hasher,

      
        
        85
        +		userPasswordHasher,

      
        85
        86
         		jwtTokenizer,

      
        86
        87
         		mailGunMailer,

      
        87
        88
         		usercache,

      ···
        91
        92
         	)

      
        92
        93
         

      
        93
        94
         	noterepo := noterepo.New(psqlDB)

      
        94
        
        -	notesrv := notesrv.New(noterepo)

      
        
        95
        +	notesrv := notesrv.New(noterepo, notePasswordHasher)

      
        95
        96
         

      
        96
        97
         	rateLimiterConfig := ratelimit.Config{

      
        97
        98
         		RPS:   cfg.RateLimiterRPS,

      
A e2e/apiv1_notes_authoirzed_test.go
···
        
        1
        +package e2e_test

      
        
        2
        +

      
        
        3
        +import "net/http"

      
        
        4
        +

      
        
        5
        +func (e *AppTestSuite) TestNoteV1_Create_authorized() {

      
        
        6
        +	uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password")

      
        
        7
        +	httpResp := e.httpRequest(

      
        
        8
        +		http.MethodPost,

      
        
        9
        +		"/api/v1/note",

      
        
        10
        +		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        11
        +			Content: "some random ass content for the test",

      
        
        12
        +		}),

      
        
        13
        +		toks.AccessToken,

      
        
        14
        +	)

      
        
        15
        +

      
        
        16
        +	var body apiv1NoteCreateResponse

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

      
        
        18
        +

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

      
        
        20
        +	dbNoteAuthor := e.getLastNoteAuthorsRecordByAuthorID(uid)

      
        
        21
        +

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

      
        
        23
        +	e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String())

      
        
        24
        +}

      
M e2e/apiv1_notes_test.go
···
        12
        12
         	apiv1NoteCreateRequest struct {

      
        13
        13
         		Content              string    `json:"content"`

      
        14
        14
         		Slug                 string    `json:"slug"`

      
        
        15
        +		Password             string    `json:"password"`

      
        15
        16
         		BurnBeforeExpiration bool      `json:"burn_before_expiration"`

      
        16
        17
         		ExpiresAt            time.Time `json:"expires_at"`

      
        17
        18
         	}

      ···
        20
        21
         	}

      
        21
        22
         )

      
        22
        23
         

      
        23
        
        -func (e *AppTestSuite) TestNoteV1_Create_unauthorized() {

      
        
        24
        +func (e *AppTestSuite) TestNoteV1_Create() {

      
        24
        25
         	tests := []struct {

      
        25
        26
         		name   string

      
        26
        27
         		inp    apiv1NoteCreateRequest

      ···
        63
        64
         

      
        64
        65
         				dbNote := e.getNoteFromDBbySlug(inp.Slug)

      
        65
        66
         				e.NotEmpty(dbNote)

      
        
        67
        +			},

      
        
        68
        +		},

      
        
        69
        +		{

      
        
        70
        +			name: "set password",

      
        
        71
        +			inp: apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        72
        +				Content:  e.uuid(),

      
        
        73
        +				Password: e.uuid(),

      
        
        74
        +			},

      
        
        75
        +			assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) {

      
        
        76
        +				e.Equal(r.Code, http.StatusCreated)

      
        66
        77
         			},

      
        67
        78
         		},

      
        68
        79
         		{

      ···
        94
        105
         	}

      
        95
        106
         }

      
        96
        107
         

      
        97
        
        -func (e *AppTestSuite) TestNoteV1_Create_authorized() {

      
        98
        
        -	uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password")

      
        
        108
        +type apiv1NoteGetResponse struct {

      
        
        109
        +	Content   string    `json:"content"`

      
        
        110
        +	CreatedAt time.Time `json:"created_at"`

      
        
        111
        +	ExpiresAt time.Time `json:"expires_at"`

      
        
        112
        +}

      
        
        113
        +

      
        
        114
        +func (e *AppTestSuite) TestNoteV1_Get() {

      
        
        115
        +	content := e.uuid()

      
        99
        116
         	httpResp := e.httpRequest(

      
        100
        117
         		http.MethodPost,

      
        101
        118
         		"/api/v1/note",

      
        102
        119
         		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        103
        
        -			Content: "some random ass content for the test",

      
        
        120
        +			Content: content,

      
        104
        121
         		}),

      
        105
        
        -		toks.AccessToken,

      
        106
        122
         	)

      
        
        123
        +	e.Equal(http.StatusCreated, httpResp.Code)

      
        107
        124
         

      
        108
        
        -	var body apiv1NoteCreateResponse

      
        
        125
        +	var bodyCreated apiv1NoteCreateResponse

      
        
        126
        +	e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated)

      
        
        127
        +

      
        
        128
        +	httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil)

      
        
        129
        +	e.Equal(httpResp.Code, http.StatusOK)

      
        
        130
        +

      
        
        131
        +	var body apiv1NoteGetResponse

      
        109
        132
         	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        110
        133
         

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

      
        112
        
        -	dbNoteAuthor := e.getLastNoteAuthorsRecordByAuthorID(uid)

      
        
        134
        +	e.Equal(content, body.Content)

      
        113
        135
         

      
        114
        
        -	e.Equal(http.StatusCreated, httpResp.Code)

      
        115
        
        -	e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String())

      
        
        136
        +	dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug)

      
        
        137
        +	e.Empty(dbNote)

      
        116
        138
         }

      
        117
        139
         

      
        118
        
        -type apiv1NoteGetResponse struct {

      
        119
        
        -	Content   string    `json:"content"`

      
        120
        
        -	CreatedAt time.Time `json:"created_at"`

      
        121
        
        -	ExpiresAt time.Time `json:"expires_at"`

      
        
        140
        +type apiv1NoteGetRequest struct {

      
        
        141
        +	Password string `json:"password"`

      
        122
        142
         }

      
        123
        143
         

      
        124
        
        -func (e *AppTestSuite) TestNoteV1_Get() {

      
        
        144
        +func (e *AppTestSuite) TestNoteV1_GetWithPassword() {

      
        125
        145
         	content := e.uuid()

      
        
        146
        +	passwd := e.uuid()

      
        126
        147
         	httpResp := e.httpRequest(

      
        127
        148
         		http.MethodPost,

      
        128
        149
         		"/api/v1/note",

      
        129
        150
         		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        130
        
        -			Content: content,

      
        
        151
        +			Content:  content,

      
        
        152
        +			Password: passwd,

      
        131
        153
         		}),

      
        132
        154
         	)

      
        133
        155
         	e.Equal(http.StatusCreated, httpResp.Code)

      ···
        135
        157
         	var bodyCreated apiv1NoteCreateResponse

      
        136
        158
         	e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated)

      
        137
        159
         

      
        138
        
        -	httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil)

      
        
        160
        +	httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{

      
        
        161
        +		Password: passwd,

      
        
        162
        +	}))

      
        139
        163
         	e.Equal(httpResp.Code, http.StatusOK)

      
        140
        164
         

      
        141
        165
         	var body apiv1NoteGetResponse

      ···
        146
        170
         	dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug)

      
        147
        171
         	e.Empty(dbNote)

      
        148
        172
         }

      
        
        173
        +

      
        
        174
        +func (e *AppTestSuite) TestNoteV1_GetWithPassword_wrongNoPassword() {

      
        
        175
        +	content := e.uuid()

      
        
        176
        +	passwd := e.uuid()

      
        
        177
        +	httpResp := e.httpRequest(

      
        
        178
        +		http.MethodPost,

      
        
        179
        +		"/api/v1/note",

      
        
        180
        +		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        181
        +			Content:  content,

      
        
        182
        +			Password: passwd,

      
        
        183
        +		}),

      
        
        184
        +	)

      
        
        185
        +	e.Equal(http.StatusCreated, httpResp.Code)

      
        
        186
        +

      
        
        187
        +	var bodyCreated apiv1NoteCreateResponse

      
        
        188
        +	e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated)

      
        
        189
        +

      
        
        190
        +	httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil)

      
        
        191
        +	e.Equal(httpResp.Code, http.StatusNotFound)

      
        
        192
        +}

      
        
        193
        +

      
        
        194
        +func (e *AppTestSuite) TestNoteV1_GetWithPassword_wrong() {

      
        
        195
        +	content := e.uuid()

      
        
        196
        +	httpResp := e.httpRequest(

      
        
        197
        +		http.MethodPost,

      
        
        198
        +		"/api/v1/note",

      
        
        199
        +		e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct

      
        
        200
        +			Content:  content,

      
        
        201
        +			Password: e.uuid(),

      
        
        202
        +		}),

      
        
        203
        +	)

      
        
        204
        +	e.Equal(http.StatusCreated, httpResp.Code)

      
        
        205
        +

      
        
        206
        +	var bodyCreated apiv1NoteCreateResponse

      
        
        207
        +	e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated)

      
        
        208
        +

      
        
        209
        +	httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{

      
        
        210
        +		Password: e.uuid(),

      
        
        211
        +	}))

      
        
        212
        +	e.Equal(httpResp.Code, http.StatusNotFound)

      
        
        213
        +}

      
M e2e/e2e_test.go
···
        123
        123
         	)

      
        124
        124
         

      
        125
        125
         	noterepo := noterepo.New(e.postgresDB)

      
        126
        
        -	notesrv := notesrv.New(noterepo)

      
        
        126
        +	notesrv := notesrv.New(noterepo, e.hasher)

      
        127
        127
         

      
        128
        128
         	// for testing purposes, it's ok to have high values ig

      
        129
        129
         	ratelimitCfg := ratelimit.Config{

      
M internal/config/config.go
···
        12
        12
         	AppURL     string

      
        13
        13
         	ServerPort string

      
        14
        14
         

      
        15
        
        -	PostgresDSN  string

      
        16
        
        -	PasswordSalt string

      
        
        15
        +	PostgresDSN      string

      
        
        16
        +	PasswordSalt     string

      
        
        17
        +	NotePassowrdSalt string

      
        17
        18
         

      
        18
        19
         	RedisAddr     string

      
        19
        20
         	RedisPassword string

      ···
        48
        49
         		AppURL:     getenvOrDefault("APP_URL", ""),

      
        49
        50
         		ServerPort: getenvOrDefault("SERVER_PORT", "3000"),

      
        50
        51
         

      
        51
        
        -		PostgresDSN:  getenvOrDefault("POSTGRESQL_DSN", ""),

      
        52
        
        -		PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""),

      
        
        52
        +		PostgresDSN:      getenvOrDefault("POSTGRESQL_DSN", ""),

      
        
        53
        +		PasswordSalt:     getenvOrDefault("PASSWORD_SALT", ""),

      
        
        54
        +		NotePassowrdSalt: getenvOrDefault("NOTE_PASSWORD_SALT", ""),

      
        53
        55
         

      
        54
        56
         		RedisAddr:     getenvOrDefault("REDIS_ADDR", ""),

      
        55
        57
         		RedisPassword: getenvOrDefault("REDIS_PASSWORD", ""),

      
M internal/dtos/note.go
···
        12
        12
         	Content              string

      
        13
        13
         	Slug                 string

      
        14
        14
         	BurnBeforeExpiration bool

      
        
        15
        +	Password             string

      
        15
        16
         	CreatedAt            time.Time

      
        16
        17
         	ExpiresAt            time.Time

      
        17
        18
         }

      ···
        21
        22
         	UserID               uuid.UUID

      
        22
        23
         	Slug                 string

      
        23
        24
         	BurnBeforeExpiration bool

      
        
        25
        +	Password             string

      
        24
        26
         	CreatedAt            time.Time

      
        25
        27
         	ExpiresAt            time.Time

      
        26
        28
         }

      
M internal/logger/logger.go
···
        34
        34
         	switch format {

      
        35
        35
         	case "json":

      
        36
        36
         		slogHandler = slog.NewJSONHandler(os.Stdout, handlerOptions)

      
        37
        
        -	case "text":

      
        
        37
        +	case "text", "txt":

      
        38
        38
         		slogHandler = slog.NewTextHandler(os.Stdout, handlerOptions)

      
        39
        39
         	default:

      
        40
        40
         		return nil, errors.New("unknown log format")

      
M internal/models/notes.go
···
        18
        18
         	ID                   uuid.UUID

      
        19
        19
         	Content              string

      
        20
        20
         	Slug                 string

      
        
        21
        +	Password             string

      
        21
        22
         	BurnBeforeExpiration bool

      
        22
        23
         	CreatedAt            time.Time

      
        23
        24
         	ExpiresAt            time.Time

      
A internal/service/notesrv/input.go
···
        
        1
        +package notesrv

      
        
        2
        +

      
        
        3
        +import "github.com/olexsmir/onasty/internal/dtos"

      
        
        4
        +

      
        
        5
        +// GetNoteBySlugInput used as input for [GetBySlugAndRemoveIfNeeded]

      
        
        6
        +type GetNoteBySlugInput struct {

      
        
        7
        +	// Slug is a note's slug :) *Required*

      
        
        8
        +	Slug dtos.NoteSlugDTO

      
        
        9
        +

      
        
        10
        +	// Password is a note's password.

      
        
        11
        +	// Optional, needed only if note has one.

      
        
        12
        +	Password string

      
        
        13
        +}

      
        
        14
        +

      
        
        15
        +func (i GetNoteBySlugInput) HasPassword() bool {

      
        
        16
        +	return i.Password != ""

      
        
        17
        +}

      
M internal/service/notesrv/notesrv.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"context"

      
        
        5
        +	"log/slog"

      
        5
        6
         

      
        6
        7
         	"github.com/gofrs/uuid/v5"

      
        7
        8
         	"github.com/olexsmir/onasty/internal/dtos"

      
        
        9
        +	"github.com/olexsmir/onasty/internal/hasher"

      
        8
        10
         	"github.com/olexsmir/onasty/internal/models"

      
        9
        11
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        10
        12
         )

      ···
        14
        16
         	// if slug is empty it will be generated, otherwise used as is

      
        15
        17
         	// if userID is empty it means user isn't authorized so it will be used

      
        16
        18
         	Create(ctx context.Context, note dtos.CreateNoteDTO, userID uuid.UUID) (dtos.NoteSlugDTO, error)

      
        17
        
        -	GetBySlugAndRemoveIfNeeded(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error)

      
        
        19
        +

      
        
        20
        +	// GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed

      
        
        21
        +	GetBySlugAndRemoveIfNeeded(ctx context.Context, input GetNoteBySlugInput) (dtos.NoteDTO, error)

      
        18
        22
         }

      
        19
        23
         

      
        20
        24
         var _ NoteServicer = (*NoteSrv)(nil)

      
        21
        25
         

      
        22
        26
         type NoteSrv struct {

      
        23
        27
         	noterepo noterepo.NoteStorer

      
        
        28
        +	hasher   hasher.Hasher

      
        24
        29
         }

      
        25
        30
         

      
        26
        
        -func New(noterepo noterepo.NoteStorer) *NoteSrv {

      
        
        31
        +func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher) *NoteSrv {

      
        27
        32
         	return &NoteSrv{

      
        28
        33
         		noterepo: noterepo,

      
        
        34
        +		hasher:   hasher,

      
        29
        35
         	}

      
        30
        36
         }

      
        31
        37
         

      ···
        34
        40
         	inp dtos.CreateNoteDTO,

      
        35
        41
         	userID uuid.UUID,

      
        36
        42
         ) (dtos.NoteSlugDTO, error) {

      
        
        43
        +	slog.DebugContext(ctx, "creating", "inp", inp)

      
        
        44
        +

      
        37
        45
         	if inp.Slug == "" {

      
        38
        46
         		inp.Slug = uuid.Must(uuid.NewV4()).String()

      
        39
        47
         	}

      
        40
        48
         

      
        41
        
        -	err := n.noterepo.Create(ctx, inp)

      
        42
        
        -	if err != nil {

      
        
        49
        +	if inp.Password != "" {

      
        
        50
        +		hashedPassword, err := n.hasher.Hash(inp.Password)

      
        
        51
        +		if err != nil {

      
        
        52
        +			return "", err

      
        
        53
        +		}

      
        
        54
        +		inp.Password = hashedPassword

      
        
        55
        +	}

      
        
        56
        +

      
        
        57
        +	if err := n.noterepo.Create(ctx, inp); err != nil {

      
        43
        58
         		return "", err

      
        44
        59
         	}

      
        45
        60
         

      ···
        54
        69
         

      
        55
        70
         func (n *NoteSrv) GetBySlugAndRemoveIfNeeded(

      
        56
        71
         	ctx context.Context,

      
        57
        
        -	slug dtos.NoteSlugDTO,

      
        
        72
        +	inp GetNoteBySlugInput,

      
        58
        73
         ) (dtos.NoteDTO, error) {

      
        59
        
        -	note, err := n.noterepo.GetBySlug(ctx, slug)

      
        
        74
        +	note, err := n.getNoteFromDBasedOnInput(ctx, inp)

      
        60
        75
         	if err != nil {

      
        61
        76
         		return dtos.NoteDTO{}, err

      
        62
        77
         	}

      ···
        80
        95
         	// to shot user that note was already seen

      
        81
        96
         	return note, n.noterepo.DeleteBySlug(ctx, note.Slug)

      
        82
        97
         }

      
        
        98
        +

      
        
        99
        +func (n *NoteSrv) getNoteFromDBasedOnInput(

      
        
        100
        +	ctx context.Context,

      
        
        101
        +	inp GetNoteBySlugInput,

      
        
        102
        +) (dtos.NoteDTO, error) {

      
        
        103
        +	if inp.HasPassword() {

      
        
        104
        +		hashedPassword, err := n.hasher.Hash(inp.Password)

      
        
        105
        +		if err != nil {

      
        
        106
        +			return dtos.NoteDTO{}, err

      
        
        107
        +		}

      
        
        108
        +

      
        
        109
        +		return n.noterepo.GetBySlugAndPassword(ctx, inp.Slug, hashedPassword)

      
        
        110
        +	}

      
        
        111
        +	return n.noterepo.GetBySlug(ctx, inp.Slug)

      
        
        112
        +}

      
M internal/store/psql/noterepo/noterepo.go
···
        13
        13
         )

      
        14
        14
         

      
        15
        15
         type NoteStorer interface {

      
        
        16
        +	// Create creates a note.

      
        16
        17
         	Create(ctx context.Context, inp dtos.CreateNoteDTO) error

      
        
        18
        +

      
        
        19
        +	// GetBySlug gets a note by slug.

      
        
        20
        +	// Returns [models.ErrNoteNotFound] if note is not found.

      
        17
        21
         	GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error)

      
        
        22
        +

      
        
        23
        +	// GetBySlugAndPassword gets a note by slug and password.

      
        
        24
        +	// the "password" should be hashed.

      
        
        25
        +	//

      
        
        26
        +	// Returns [models.ErrNoteNotFound] if note is not found.

      
        
        27
        +	GetBySlugAndPassword(

      
        
        28
        +		ctx context.Context,

      
        
        29
        +		slug dtos.NoteSlugDTO,

      
        
        30
        +		password string,

      
        
        31
        +	) (dtos.NoteDTO, error)

      
        
        32
        +

      
        
        33
        +	// DeleteBySlug deletes note by slug or returns [models.ErrNoteNotFound] if note if not found.

      
        18
        34
         	DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error

      
        19
        35
         

      
        
        36
        +	// SetAuthorIDBySlug assigns author to note by slug.

      
        
        37
        +	// Returns [models.ErrNoteNotFound] if note is not found.

      
        20
        38
         	SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlugDTO, authorID uuid.UUID) error

      
        21
        39
         }

      
        22
        40
         

      ···
        33
        51
         func (s *NoteRepo) Create(ctx context.Context, inp dtos.CreateNoteDTO) error {

      
        34
        52
         	query, args, err := pgq.

      
        35
        53
         		Insert("notes").

      
        36
        
        -		Columns("content", "slug", "burn_before_expiration ", "created_at", "expires_at").

      
        37
        
        -		Values(inp.Content, inp.Slug, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt).

      
        
        54
        +		Columns("content", "slug", "password", "burn_before_expiration ", "created_at", "expires_at").

      
        
        55
        +		Values(inp.Content, inp.Slug, inp.Password, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt).

      
        38
        56
         		SQL()

      
        39
        57
         	if err != nil {

      
        40
        58
         		return err

      ···
        52
        70
         	query, args, err := pgq.

      
        53
        71
         		Select("content", "slug", "burn_before_expiration", "created_at", "expires_at").

      
        54
        72
         		From("notes").

      
        55
        
        -		Where("slug = ?", slug).

      
        
        73
        +		Where("(password is null or password = '')").

      
        
        74
        +		Where(pgq.Eq{"slug": slug}).

      
        
        75
        +		SQL()

      
        
        76
        +	if err != nil {

      
        
        77
        +		return dtos.NoteDTO{}, err

      
        
        78
        +	}

      
        
        79
        +

      
        
        80
        +	var note dtos.NoteDTO

      
        
        81
        +	err = s.db.QueryRow(ctx, query, args...).

      
        
        82
        +		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt)

      
        
        83
        +

      
        
        84
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        85
        +		return dtos.NoteDTO{}, models.ErrNoteNotFound

      
        
        86
        +	}

      
        
        87
        +

      
        
        88
        +	return note, err

      
        
        89
        +}

      
        
        90
        +

      
        
        91
        +func (s *NoteRepo) GetBySlugAndPassword(

      
        
        92
        +	ctx context.Context,

      
        
        93
        +	slug dtos.NoteSlugDTO,

      
        
        94
        +	passwd string,

      
        
        95
        +) (dtos.NoteDTO, error) {

      
        
        96
        +	query, args, err := pgq.

      
        
        97
        +		Select("content", "slug", "burn_before_expiration", "created_at", "expires_at").

      
        
        98
        +		From("notes").

      
        
        99
        +		Where(pgq.Eq{

      
        
        100
        +			"slug":     slug,

      
        
        101
        +			"password": passwd,

      
        
        102
        +		}).

      
        56
        103
         		SQL()

      
        57
        104
         	if err != nil {

      
        58
        105
         		return dtos.NoteDTO{}, err

      
M internal/transport/http/apiv1/note.go
···
        1
        1
         package apiv1

      
        2
        2
         

      
        3
        3
         import (

      
        
        4
        +	"errors"

      
        
        5
        +	"io"

      
        4
        6
         	"net/http"

      
        5
        7
         	"time"

      
        6
        8
         

      
        7
        9
         	"github.com/gin-gonic/gin"

      
        8
        10
         	"github.com/olexsmir/onasty/internal/dtos"

      
        9
        11
         	"github.com/olexsmir/onasty/internal/models"

      
        
        12
        +	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        10
        13
         )

      
        11
        14
         

      
        12
        15
         type createNoteRequest struct {

      
        13
        16
         	Content              string    `json:"content"`

      
        14
        17
         	Slug                 string    `json:"slug"`

      
        
        18
        +	Password             string    `json:"password"`

      
        15
        19
         	BurnBeforeExpiration bool      `json:"burn_before_expiration"`

      
        16
        20
         	ExpiresAt            time.Time `json:"expires_at"`

      
        17
        21
         }

      ···
        32
        36
         		Slug:                 req.Slug,

      
        33
        37
         		BurnBeforeExpiration: req.BurnBeforeExpiration,

      
        34
        38
         		CreatedAt:            time.Now(),

      
        
        39
        +		Password:             req.Password,

      
        35
        40
         		ExpiresAt:            req.ExpiresAt,

      
        36
        41
         	}

      
        37
        42
         

      ···
        44
        49
         		Content:              note.Content,

      
        45
        50
         		UserID:               a.getUserID(c),

      
        46
        51
         		Slug:                 note.Slug,

      
        
        52
        +		Password:             note.Password,

      
        47
        53
         		BurnBeforeExpiration: note.BurnBeforeExpiration,

      
        48
        54
         		CreatedAt:            note.CreatedAt,

      
        49
        55
         		ExpiresAt:            note.ExpiresAt,

      ···
        56
        62
         	c.JSON(http.StatusCreated, createNoteResponse{slug})

      
        57
        63
         }

      
        58
        64
         

      
        
        65
        +type getNoteBySlugRequest struct {

      
        
        66
        +	Password string `json:"password,omitempty"`

      
        
        67
        +}

      
        
        68
        +

      
        59
        69
         type getNoteBySlugResponse struct {

      
        60
        70
         	Content   string    `json:"content"`

      
        61
        71
         	CratedAt  time.Time `json:"crated_at"`

      ···
        63
        73
         }

      
        64
        74
         

      
        65
        75
         func (a *APIV1) getNoteBySlugHandler(c *gin.Context) {

      
        
        76
        +	var req getNoteBySlugRequest

      
        
        77
        +	if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {

      
        
        78
        +		newError(c, http.StatusBadRequest, "invalid request")

      
        
        79
        +		return

      
        
        80
        +	}

      
        
        81
        +

      
        66
        82
         	slug := c.Param("slug")

      
        67
        
        -	note, err := a.notesrv.GetBySlugAndRemoveIfNeeded(c.Request.Context(), slug)

      
        
        83
        +	note, err := a.notesrv.GetBySlugAndRemoveIfNeeded(

      
        
        84
        +		c.Request.Context(),

      
        
        85
        +		notesrv.GetNoteBySlugInput{

      
        
        86
        +			Slug:     slug,

      
        
        87
        +			Password: req.Password,

      
        
        88
        +		},

      
        
        89
        +	)

      
        68
        90
         	if err != nil {

      
        69
        91
         		errorResponse(c, err)

      
        70
        92
         		return

      
M migrations/20240724122920_notes_authors.down.sql
···
        
        1
        +drop table notes_authors;

      
A migrations/20241027112517_notes_add_passwords.down.sql
···
        
        1
        +ALTER TABLE notes

      
        
        2
        +    DROP COLUMN "password";

      
A migrations/20241027112517_notes_add_passwords.up.sql
···
        
        1
        +ALTER TABLE notes

      
        
        2
        +    ADD COLUMN "password" text;