17 files changed,
255 insertions(+),
37 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2024-11-20 18:46:32 +0200
Parent:
f234ee1
jump to
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
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")
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(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.CreatedAt, ¬e.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
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;