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,6 +2,7 @@ APP_ENV=debug

APP_URL=http://localhost:8000 SERVER_PORT=8000 PASSWORD_SALT=onasty +NOTE_PASSWORD_SALT=secret METRICS_PORT=8001 LOG_LEVEL=debug
M Dockerfile

@@ -8,7 +8,8 @@

COPY cmd cmd COPY internal internal -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /onasty ./cmd/server +ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 +RUN go build -trimpath -ldflags='-w -s' -o /onasty ./cmd/server FROM alpine:3.20 COPY --from=builder /onasty /onasty
M cmd/server/main.go

@@ -68,7 +68,8 @@ if err != nil {

return err } - sha256Hasher := hasher.NewSHA256Hasher(cfg.PasswordSalt) + userPasswordHasher := hasher.NewSHA256Hasher(cfg.PasswordSalt) + notePasswordHasher := hasher.NewSHA256Hasher(cfg.NotePassowrdSalt) jwtTokenizer := jwtutil.NewJWTUtil(cfg.JwtSigningKey, cfg.JwtAccessTokenTTL) mailGunMailer := mailer.NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey)

@@ -81,7 +82,7 @@ usersrv := usersrv.New(

userepo, sessionrepo, vertokrepo, - sha256Hasher, + userPasswordHasher, jwtTokenizer, mailGunMailer, usercache,

@@ -91,7 +92,7 @@ cfg.AppURL,

) noterepo := noterepo.New(psqlDB) - notesrv := notesrv.New(noterepo) + notesrv := notesrv.New(noterepo, notePasswordHasher) rateLimiterConfig := ratelimit.Config{ RPS: cfg.RateLimiterRPS,
A e2e/apiv1_notes_authoirzed_test.go

@@ -0,0 +1,24 @@

+package e2e_test + +import "net/http" + +func (e *AppTestSuite) TestNoteV1_Create_authorized() { + uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password") + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: "some random ass content for the test", + }), + toks.AccessToken, + ) + + var body apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + dbNote := e.getNoteFromDBbySlug(body.Slug) + dbNoteAuthor := e.getLastNoteAuthorsRecordByAuthorID(uid) + + e.Equal(http.StatusCreated, httpResp.Code) + e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String()) +}
M e2e/apiv1_notes_test.go

@@ -12,6 +12,7 @@ type (

apiv1NoteCreateRequest struct { Content string `json:"content"` Slug string `json:"slug"` + Password string `json:"password"` BurnBeforeExpiration bool `json:"burn_before_expiration"` ExpiresAt time.Time `json:"expires_at"` }

@@ -20,7 +21,7 @@ Slug string `json:"slug"`

} ) -func (e *AppTestSuite) TestNoteV1_Create_unauthorized() { +func (e *AppTestSuite) TestNoteV1_Create() { tests := []struct { name string inp apiv1NoteCreateRequest

@@ -63,6 +64,16 @@ e.readBodyAndUnjsonify(r.Body, &body)

dbNote := e.getNoteFromDBbySlug(inp.Slug) e.NotEmpty(dbNote) + }, + }, + { + name: "set password", + inp: apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: e.uuid(), + Password: e.uuid(), + }, + assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) { + e.Equal(r.Code, http.StatusCreated) }, }, {

@@ -94,40 +105,51 @@ tt.assert(httpResp, tt.inp)

} } -func (e *AppTestSuite) TestNoteV1_Create_authorized() { - uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password") +type apiv1NoteGetResponse struct { + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (e *AppTestSuite) TestNoteV1_Get() { + content := e.uuid() httpResp := e.httpRequest( http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct - Content: "some random ass content for the test", + Content: content, }), - toks.AccessToken, ) + e.Equal(http.StatusCreated, httpResp.Code) - var body apiv1NoteCreateResponse + var bodyCreated apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) + + httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil) + e.Equal(httpResp.Code, http.StatusOK) + + var body apiv1NoteGetResponse e.readBodyAndUnjsonify(httpResp.Body, &body) - dbNote := e.getNoteFromDBbySlug(body.Slug) - dbNoteAuthor := e.getLastNoteAuthorsRecordByAuthorID(uid) + e.Equal(content, body.Content) - e.Equal(http.StatusCreated, httpResp.Code) - e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String()) + dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) + e.Empty(dbNote) } -type apiv1NoteGetResponse struct { - Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` +type apiv1NoteGetRequest struct { + Password string `json:"password"` } -func (e *AppTestSuite) TestNoteV1_Get() { +func (e *AppTestSuite) TestNoteV1_GetWithPassword() { content := e.uuid() + passwd := e.uuid() httpResp := e.httpRequest( http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct - Content: content, + Content: content, + Password: passwd, }), ) e.Equal(http.StatusCreated, httpResp.Code)

@@ -135,7 +157,9 @@

var bodyCreated apiv1NoteCreateResponse e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) - httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil) + httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{ + Password: passwd, + })) e.Equal(httpResp.Code, http.StatusOK) var body apiv1NoteGetResponse

@@ -146,3 +170,44 @@

dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) e.Empty(dbNote) } + +func (e *AppTestSuite) TestNoteV1_GetWithPassword_wrongNoPassword() { + content := e.uuid() + passwd := e.uuid() + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: content, + Password: passwd, + }), + ) + e.Equal(http.StatusCreated, httpResp.Code) + + var bodyCreated apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) + + httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil) + e.Equal(httpResp.Code, http.StatusNotFound) +} + +func (e *AppTestSuite) TestNoteV1_GetWithPassword_wrong() { + content := e.uuid() + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/note", + e.jsonify(apiv1NoteCreateRequest{ //nolint:exhaustruct + Content: content, + Password: e.uuid(), + }), + ) + e.Equal(http.StatusCreated, httpResp.Code) + + var bodyCreated apiv1NoteCreateResponse + e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) + + httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{ + Password: e.uuid(), + })) + e.Equal(httpResp.Code, http.StatusNotFound) +}
M e2e/e2e_test.go

@@ -123,7 +123,7 @@ cfg.AppURL,

) noterepo := noterepo.New(e.postgresDB) - notesrv := notesrv.New(noterepo) + notesrv := notesrv.New(noterepo, e.hasher) // for testing purposes, it's ok to have high values ig ratelimitCfg := ratelimit.Config{
M internal/config/config.go

@@ -12,8 +12,9 @@ AppEnv string

AppURL string ServerPort string - PostgresDSN string - PasswordSalt string + PostgresDSN string + PasswordSalt string + NotePassowrdSalt string RedisAddr string RedisPassword string

@@ -48,8 +49,9 @@ AppEnv: getenvOrDefault("APP_ENV", "debug"),

AppURL: getenvOrDefault("APP_URL", ""), ServerPort: getenvOrDefault("SERVER_PORT", "3000"), - PostgresDSN: getenvOrDefault("POSTGRESQL_DSN", ""), - PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""), + PostgresDSN: getenvOrDefault("POSTGRESQL_DSN", ""), + PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""), + NotePassowrdSalt: getenvOrDefault("NOTE_PASSWORD_SALT", ""), RedisAddr: getenvOrDefault("REDIS_ADDR", ""), RedisPassword: getenvOrDefault("REDIS_PASSWORD", ""),
M internal/dtos/note.go

@@ -12,6 +12,7 @@ type NoteDTO struct {

Content string Slug string BurnBeforeExpiration bool + Password string CreatedAt time.Time ExpiresAt time.Time }

@@ -21,6 +22,7 @@ Content string

UserID uuid.UUID Slug string BurnBeforeExpiration bool + Password string CreatedAt time.Time ExpiresAt time.Time }
M internal/logger/logger.go

@@ -34,7 +34,7 @@ var slogHandler slog.Handler

switch format { case "json": slogHandler = slog.NewJSONHandler(os.Stdout, handlerOptions) - case "text": + case "text", "txt": slogHandler = slog.NewTextHandler(os.Stdout, handlerOptions) default: return nil, errors.New("unknown log format")
M internal/models/notes.go

@@ -18,6 +18,7 @@ type Note struct {

ID uuid.UUID Content string Slug string + Password string BurnBeforeExpiration bool CreatedAt time.Time ExpiresAt time.Time
A internal/service/notesrv/input.go

@@ -0,0 +1,17 @@

+package notesrv + +import "github.com/olexsmir/onasty/internal/dtos" + +// GetNoteBySlugInput used as input for [GetBySlugAndRemoveIfNeeded] +type GetNoteBySlugInput struct { + // Slug is a note's slug :) *Required* + Slug dtos.NoteSlugDTO + + // Password is a note's password. + // Optional, needed only if note has one. + Password string +} + +func (i GetNoteBySlugInput) HasPassword() bool { + return i.Password != "" +}
M internal/service/notesrv/notesrv.go

@@ -2,9 +2,11 @@ package notesrv

import ( "context" + "log/slog" "github.com/gofrs/uuid/v5" "github.com/olexsmir/onasty/internal/dtos" + "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/models" "github.com/olexsmir/onasty/internal/store/psql/noterepo" )

@@ -14,18 +16,22 @@ // Create creates note

// if slug is empty it will be generated, otherwise used as is // if userID is empty it means user isn't authorized so it will be used Create(ctx context.Context, note dtos.CreateNoteDTO, userID uuid.UUID) (dtos.NoteSlugDTO, error) - GetBySlugAndRemoveIfNeeded(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) + + // GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed + GetBySlugAndRemoveIfNeeded(ctx context.Context, input GetNoteBySlugInput) (dtos.NoteDTO, error) } var _ NoteServicer = (*NoteSrv)(nil) type NoteSrv struct { noterepo noterepo.NoteStorer + hasher hasher.Hasher } -func New(noterepo noterepo.NoteStorer) *NoteSrv { +func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher) *NoteSrv { return &NoteSrv{ noterepo: noterepo, + hasher: hasher, } }

@@ -34,12 +40,21 @@ ctx context.Context,

inp dtos.CreateNoteDTO, userID uuid.UUID, ) (dtos.NoteSlugDTO, error) { + slog.DebugContext(ctx, "creating", "inp", inp) + if inp.Slug == "" { inp.Slug = uuid.Must(uuid.NewV4()).String() } - err := n.noterepo.Create(ctx, inp) - if err != nil { + if inp.Password != "" { + hashedPassword, err := n.hasher.Hash(inp.Password) + if err != nil { + return "", err + } + inp.Password = hashedPassword + } + + if err := n.noterepo.Create(ctx, inp); err != nil { return "", err }

@@ -54,9 +69,9 @@ }

func (n *NoteSrv) GetBySlugAndRemoveIfNeeded( ctx context.Context, - slug dtos.NoteSlugDTO, + inp GetNoteBySlugInput, ) (dtos.NoteDTO, error) { - note, err := n.noterepo.GetBySlug(ctx, slug) + note, err := n.getNoteFromDBasedOnInput(ctx, inp) if err != nil { return dtos.NoteDTO{}, err }

@@ -80,3 +95,18 @@ // TODO: in future not remove, leave some metadata

// to shot user that note was already seen return note, n.noterepo.DeleteBySlug(ctx, note.Slug) } + +func (n *NoteSrv) getNoteFromDBasedOnInput( + ctx context.Context, + inp GetNoteBySlugInput, +) (dtos.NoteDTO, error) { + if inp.HasPassword() { + hashedPassword, err := n.hasher.Hash(inp.Password) + if err != nil { + return dtos.NoteDTO{}, err + } + + return n.noterepo.GetBySlugAndPassword(ctx, inp.Slug, hashedPassword) + } + return n.noterepo.GetBySlug(ctx, inp.Slug) +}
M internal/store/psql/noterepo/noterepo.go

@@ -13,10 +13,28 @@ "github.com/olexsmir/onasty/internal/store/psqlutil"

) type NoteStorer interface { + // Create creates a note. Create(ctx context.Context, inp dtos.CreateNoteDTO) error + + // GetBySlug gets a note by slug. + // Returns [models.ErrNoteNotFound] if note is not found. GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) + + // GetBySlugAndPassword gets a note by slug and password. + // the "password" should be hashed. + // + // Returns [models.ErrNoteNotFound] if note is not found. + GetBySlugAndPassword( + ctx context.Context, + slug dtos.NoteSlugDTO, + password string, + ) (dtos.NoteDTO, error) + + // DeleteBySlug deletes note by slug or returns [models.ErrNoteNotFound] if note if not found. DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error + // SetAuthorIDBySlug assigns author to note by slug. + // Returns [models.ErrNoteNotFound] if note is not found. SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlugDTO, authorID uuid.UUID) error }

@@ -33,8 +51,8 @@

func (s *NoteRepo) Create(ctx context.Context, inp dtos.CreateNoteDTO) error { query, args, err := pgq. Insert("notes"). - Columns("content", "slug", "burn_before_expiration ", "created_at", "expires_at"). - Values(inp.Content, inp.Slug, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt). + Columns("content", "slug", "password", "burn_before_expiration ", "created_at", "expires_at"). + Values(inp.Content, inp.Slug, inp.Password, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt). SQL() if err != nil { return err

@@ -52,7 +70,36 @@ func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) {

query, args, err := pgq. Select("content", "slug", "burn_before_expiration", "created_at", "expires_at"). From("notes"). - Where("slug = ?", slug). + Where("(password is null or password = '')"). + Where(pgq.Eq{"slug": slug}). + SQL() + if err != nil { + return dtos.NoteDTO{}, err + } + + var note dtos.NoteDTO + err = s.db.QueryRow(ctx, query, args...). + Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt) + + if errors.Is(err, pgx.ErrNoRows) { + return dtos.NoteDTO{}, models.ErrNoteNotFound + } + + return note, err +} + +func (s *NoteRepo) GetBySlugAndPassword( + ctx context.Context, + slug dtos.NoteSlugDTO, + passwd string, +) (dtos.NoteDTO, error) { + query, args, err := pgq. + Select("content", "slug", "burn_before_expiration", "created_at", "expires_at"). + From("notes"). + Where(pgq.Eq{ + "slug": slug, + "password": passwd, + }). SQL() if err != nil { return dtos.NoteDTO{}, err
M internal/transport/http/apiv1/note.go

@@ -1,17 +1,21 @@

package apiv1 import ( + "errors" + "io" "net/http" "time" "github.com/gin-gonic/gin" "github.com/olexsmir/onasty/internal/dtos" "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/service/notesrv" ) type createNoteRequest struct { Content string `json:"content"` Slug string `json:"slug"` + Password string `json:"password"` BurnBeforeExpiration bool `json:"burn_before_expiration"` ExpiresAt time.Time `json:"expires_at"` }

@@ -32,6 +36,7 @@ Content: req.Content,

Slug: req.Slug, BurnBeforeExpiration: req.BurnBeforeExpiration, CreatedAt: time.Now(), + Password: req.Password, ExpiresAt: req.ExpiresAt, }

@@ -44,6 +49,7 @@ slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNoteDTO{

Content: note.Content, UserID: a.getUserID(c), Slug: note.Slug, + Password: note.Password, BurnBeforeExpiration: note.BurnBeforeExpiration, CreatedAt: note.CreatedAt, ExpiresAt: note.ExpiresAt,

@@ -56,6 +62,10 @@

c.JSON(http.StatusCreated, createNoteResponse{slug}) } +type getNoteBySlugRequest struct { + Password string `json:"password,omitempty"` +} + type getNoteBySlugResponse struct { Content string `json:"content"` CratedAt time.Time `json:"crated_at"`

@@ -63,8 +73,20 @@ ExpiresAt time.Time `json:"expires_at"`

} func (a *APIV1) getNoteBySlugHandler(c *gin.Context) { + var req getNoteBySlugRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + newError(c, http.StatusBadRequest, "invalid request") + return + } + slug := c.Param("slug") - note, err := a.notesrv.GetBySlugAndRemoveIfNeeded(c.Request.Context(), slug) + note, err := a.notesrv.GetBySlugAndRemoveIfNeeded( + c.Request.Context(), + notesrv.GetNoteBySlugInput{ + Slug: slug, + Password: req.Password, + }, + ) if err != nil { errorResponse(c, err) return
M migrations/20240724122920_notes_authors.down.sql

@@ -0,0 +1,1 @@

+drop table notes_authors;
A migrations/20241027112517_notes_add_passwords.down.sql

@@ -0,0 +1,2 @@

+ALTER TABLE notes + DROP COLUMN "password";
A migrations/20241027112517_notes_add_passwords.up.sql

@@ -0,0 +1,2 @@

+ALTER TABLE notes + ADD COLUMN "password" text;