23 files changed,
574 insertions(+),
54 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-06-05 16:17:32 +0300
Parent:
ebcfde1
jump to
M
Taskfile.yml
··· 11 11 DOCKER_BUILDKIT: 1 12 12 COMPOSE_DOCKER_CLI_BUILD: 1 13 13 14 +vars: 15 + gotest: 16 + sh: 'command -v gotest >/dev/null && echo gotest || echo "go test"' 17 + 14 18 tasks: 15 19 run: 16 20 - docker compose up -d --build --remove-orphans core mailer ··· 31 35 - task: test:e2e 32 36 33 37 test:unit: 34 - - go test --count=1 -v --short ./... 38 + - '{{.gotest}} --count=1 -v --short ./...' 35 39 36 40 test:e2e: 37 - - go test --count=1 -v ./e2e/ 41 + - '{{.gotest}} --count=1 -v ./e2e/'
M
cmd/seed/notes.go
··· 32 32 expiresAt: time.Now().Add(24 * time.Hour), 33 33 }, 34 34 { //nolint:exhaustruct 35 - content: "that passworded note", 36 - slug: "passwd", 37 - burnBeforeExpiration: false, 38 - password: "pass", 35 + content: "that passworded note", 36 + slug: "passwd", 37 + password: "pass", 39 38 }, 40 39 { //nolint:exhaustruct 41 - content: "that note with author", 42 - slug: "user", 43 - burnBeforeExpiration: false, 44 - hasAuthor: true, 45 - authorID: 0, 40 + content: "that note with author", 41 + slug: "user", 42 + hasAuthor: true, 43 + authorID: 0, 46 44 }, 47 45 { //nolint:exhaustruct 48 - content: "that another authored note", 49 - slug: "user2", 50 - burnBeforeExpiration: false, 51 - hasAuthor: true, 52 - authorID: 0, 46 + content: "that another authored note", 47 + slug: "user2", 48 + hasAuthor: true, 49 + authorID: 0, 53 50 }, 54 51 { //nolint:exhaustruct 55 - content: "that another authored note", 56 - slug: "user2", 57 - password: "passwd", 58 - burnBeforeExpiration: false, 59 - hasAuthor: true, 60 - authorID: 0, 52 + content: "that another authored note", 53 + slug: "user3", 54 + password: "passwd", 55 + hasAuthor: true, 56 + authorID: 0, 61 57 }, 62 58 } 63 59
M
e2e/e2e_utils_db_test.go
··· 104 104 "content", 105 105 "slug", 106 106 "burn_before_expiration", 107 + "password", 107 108 "read_at", 108 109 "created_at", 109 110 "expires_at", ··· 115 116 116 117 var note models.Note 117 118 err = e.postgresDB.QueryRow(e.ctx, query, args...). 118 - Scan(¬e.ID, ¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) 119 + Scan(¬e.ID, ¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.Password, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) 119 120 if errors.Is(err, pgx.ErrNoRows) { 120 121 return models.Note{} //nolint:exhaustruct 121 122 }
M
internal/dtos/note.go
··· 24 24 CreatedAt time.Time 25 25 ExpiresAt time.Time 26 26 } 27 + 28 +type NoteDetailed struct { 29 + Content string 30 + Slug NoteSlug 31 + BurnBeforeExpiration bool 32 + HasPassword bool 33 + CreatedAt time.Time 34 + ExpiresAt time.Time 35 + ReadAt time.Time 36 +} 37 + 38 +type PatchNote struct { 39 + ExpiresAt *time.Time 40 + BurnBeforeExpiration *bool 41 +}
M
internal/jwtutil/jwtutil.go
··· 9 9 "github.com/golang-jwt/jwt/v5" 10 10 ) 11 11 12 -var ErrUnexpectedSigningMethod = errors.New("unexpected signing method") 12 +var ( 13 + ErrUnexpectedSigningMethod = errors.New("unexpected signing method") 14 + ErrTokenExpired = errors.New("token expired") 15 +) 13 16 14 17 type JWTTokenizer interface { 15 18 // AccessToken generates a new access token with the given [Payload]. ··· 65 68 } 66 69 return []byte(j.signingKey), nil 67 70 }) 71 + 72 + if errors.Is(err, jwt.ErrTokenExpired) { 73 + return Payload{}, ErrTokenExpired 74 + } 75 + 68 76 return Payload{ 69 77 UserID: claims.Subject, 70 78 }, err
M
internal/service/notesrv/notesrv.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "log/slog" 6 7 "time" 7 8 ··· 13 14 "github.com/olexsmir/onasty/internal/store/rdb/notecache" 14 15 ) 15 16 17 +var ErrNotePasswordNotProvided = errors.New("note: password was not provided") 18 + 16 19 type NoteServicer interface { 17 20 // Create creates note 18 21 // if slug is empty it will be generated, otherwise used as is 19 22 // if userID is empty it means user isn't authorized so it will be used 20 23 Create(ctx context.Context, note dtos.CreateNote, userID uuid.UUID) (dtos.NoteSlug, error) 21 24 22 - // GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed 25 + // GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed. 26 + // If notes is not found returns [models.ErrNoteNotFound]. 23 27 GetBySlugAndRemoveIfNeeded( 24 28 ctx context.Context, 25 29 input GetNoteBySlugInput, 26 30 ) (dtos.GetNote, error) 31 + 32 + // GetAllByAuthorID returns all notes by author id. 33 + GetAllByAuthorID( 34 + ctx context.Context, 35 + authorID uuid.UUID, 36 + ) ([]dtos.NoteDetailed, error) 37 + 38 + // UpdateExpirationTimeSettings updates expiresAt and burnBeforeExpiration. 39 + // If notes is not found returns [models.ErrNoteNotFound]. 40 + UpdateExpirationTimeSettings( 41 + ctx context.Context, 42 + patchData dtos.PatchNote, 43 + slug dtos.NoteSlug, 44 + userID uuid.UUID, 45 + ) error 46 + 47 + // UpdatePassword sets or updates notes password. 48 + // If notes is not found returns [models.ErrNoteNotFound]. 49 + UpdatePassword(ctx context.Context, slug dtos.NoteSlug, passwd string, userID uuid.UUID) error 50 + 51 + // DeleteBySlug deletes note by slug 52 + DeleteBySlug(ctx context.Context, slug dtos.NoteSlug, userID uuid.UUID) error 27 53 } 28 54 29 55 var _ NoteServicer = (*NoteSrv)(nil) ··· 114 140 } 115 141 116 142 return respNote, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now()) 143 +} 144 + 145 +func (n *NoteSrv) GetAllByAuthorID( 146 + ctx context.Context, 147 + authorID uuid.UUID, 148 +) ([]dtos.NoteDetailed, error) { 149 + notes, err := n.noterepo.GetAllByAuthorID(ctx, authorID) 150 + if err != nil { 151 + return nil, err 152 + } 153 + 154 + var resNotes []dtos.NoteDetailed 155 + for _, note := range notes { 156 + resNotes = append(resNotes, dtos.NoteDetailed{ 157 + Content: note.Content, 158 + Slug: note.Slug, 159 + BurnBeforeExpiration: note.BurnBeforeExpiration, 160 + HasPassword: note.Password != "", 161 + CreatedAt: note.CreatedAt, 162 + ExpiresAt: note.ExpiresAt, 163 + ReadAt: note.ReadAt, 164 + }) 165 + } 166 + 167 + return resNotes, nil 168 +} 169 + 170 +func (n *NoteSrv) UpdateExpirationTimeSettings( 171 + ctx context.Context, 172 + patchData dtos.PatchNote, 173 + slug dtos.NoteSlug, 174 + userID uuid.UUID, 175 +) error { 176 + return n.noterepo.UpdateExpirationTimeSettingsBySlug(ctx, slug, patchData, userID) 177 +} 178 + 179 +func (n *NoteSrv) UpdatePassword( 180 + ctx context.Context, 181 + slug dtos.NoteSlug, 182 + passwd string, 183 + userID uuid.UUID, 184 +) error { 185 + if len(passwd) == 0 { 186 + return ErrNotePasswordNotProvided 187 + } 188 + 189 + hashedPassword, err := n.hasher.Hash(passwd) 190 + if err != nil { 191 + return err 192 + } 193 + 194 + return n.noterepo.UpdatePasswordBySlug(ctx, slug, userID, hashedPassword) 195 +} 196 + 197 +func (n *NoteSrv) DeleteBySlug( 198 + ctx context.Context, 199 + slug dtos.NoteSlug, 200 + authorID uuid.UUID, 201 +) error { 202 + return n.noterepo.DeleteNoteBySlug(ctx, slug, authorID) 117 203 } 118 204 119 205 func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (models.Note, error) {
M
internal/store/psql/noterepo/noterepo.go
··· 21 21 // Returns [models.ErrNoteNotFound] if note is not found. 22 22 GetBySlug(ctx context.Context, slug dtos.NoteSlug) (models.Note, error) 23 23 24 + // GetAllByAuthorID returns all notes with specified author. 25 + GetAllByAuthorID(ctx context.Context, authorID uuid.UUID) ([]models.Note, error) 26 + 24 27 // GetBySlugAndPassword gets a note by slug and password. 25 28 // the "password" should be hashed. 26 29 // ··· 30 33 slug dtos.NoteSlug, 31 34 password string, 32 35 ) (models.Note, error) 36 + 37 + // UpdateExpirationTimeSettingsBySlug patches note by updating expiresAt and burnBeforeExpiration if one is passwd 38 + // Returns [models.ErrNoteNotFound] if note is not found. 39 + UpdateExpirationTimeSettingsBySlug( 40 + ctx context.Context, 41 + slug dtos.NoteSlug, 42 + patch dtos.PatchNote, 43 + authorID uuid.UUID, 44 + ) error 33 45 34 46 // RemoveBySlug marks note as read, deletes it's content, and keeps meta data 35 47 // Returns [models.ErrNoteNotFound] if note is not found. 36 48 RemoveBySlug(ctx context.Context, slug dtos.NoteSlug, readAt time.Time) error 37 49 50 + // DeleteNoteBySlug deletes(unlike [RemoveBySlug]) note by slug. 51 + // Returns [models.ErrNoteNotFound] if note is not found. 52 + DeleteNoteBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error 53 + 38 54 // SetAuthorIDBySlug assigns author to note by slug. 39 55 // Returns [models.ErrNoteNotFound] if note is not found. 40 56 SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error 57 + 58 + // UpdatePasswordBySlug updates or sets password on a note. 59 + UpdatePasswordBySlug( 60 + ctx context.Context, 61 + slug dtos.NoteSlug, 62 + authorID uuid.UUID, 63 + passwd string, 64 + ) error 41 65 } 42 66 43 67 var _ NoteStorer = (*NoteRepo)(nil) ··· 90 114 return note, err 91 115 } 92 116 117 +func (s *NoteRepo) GetAllByAuthorID( 118 + ctx context.Context, 119 + authorID uuid.UUID, 120 +) ([]models.Note, error) { 121 + query := `--sql 122 + select n.content, n.slug, n.burn_before_expiration, n.password, n.read_at, n.created_at, n.expires_at 123 + from notes n 124 + right join notes_authors na on n.id = na.note_id 125 + where na.user_id = $1` 126 + 127 + rows, err := s.db.Query(ctx, query, authorID.String()) 128 + if err != nil { 129 + return nil, err 130 + } 131 + 132 + defer rows.Close() 133 + 134 + var notes []models.Note 135 + for rows.Next() { 136 + var note models.Note 137 + if err := rows.Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.Password, 138 + ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt); err != nil { 139 + return nil, err 140 + } 141 + notes = append(notes, note) 142 + } 143 + 144 + return notes, rows.Err() 145 +} 146 + 93 147 func (s *NoteRepo) GetBySlugAndPassword( 94 148 ctx context.Context, 95 149 slug dtos.NoteSlug, ··· 118 172 return note, err 119 173 } 120 174 175 +func (s *NoteRepo) UpdateExpirationTimeSettingsBySlug( 176 + ctx context.Context, 177 + slug dtos.NoteSlug, 178 + patch dtos.PatchNote, 179 + authorID uuid.UUID, 180 +) error { 181 + query := `--sql 182 +update notes n 183 +set burn_before_expiration = COALESCE($1, n.burn_before_expiration), 184 + expires_at = COALESCE($2, n.expires_at) 185 +from notes_authors na 186 +where n.slug = $3 187 + and na.user_id = $4 188 + and na.note_id = n.id` 189 + 190 + ct, err := s.db.Exec(ctx, query, 191 + patch.BurnBeforeExpiration, patch.ExpiresAt, 192 + slug, authorID.String()) 193 + if err != nil { 194 + return err 195 + } 196 + 197 + if ct.RowsAffected() == 0 { 198 + return models.ErrNoteNotFound 199 + } 200 + 201 + return nil 202 +} 203 + 121 204 func (s *NoteRepo) RemoveBySlug( 122 205 ctx context.Context, 123 206 slug dtos.NoteSlug, ··· 144 227 return err 145 228 } 146 229 230 +func (s *NoteRepo) DeleteNoteBySlug( 231 + ctx context.Context, 232 + slug dtos.NoteSlug, 233 + authorID uuid.UUID, 234 +) error { 235 + query := `--sql 236 +delete from notes n 237 +using notes_authors na 238 +where n.slug = $1 239 + and na.user_id = $2` 240 + 241 + ct, err := s.db.Exec(ctx, query, slug, authorID.String()) 242 + if err != nil { 243 + return err 244 + } 245 + 246 + if ct.RowsAffected() == 0 { 247 + return models.ErrNoteNotFound 248 + } 249 + 250 + return nil 251 +} 252 + 147 253 func (s *NoteRepo) SetAuthorIDBySlug( 148 254 ctx context.Context, 149 255 slug dtos.NoteSlug, ··· 175 281 176 282 return tx.Commit(ctx) 177 283 } 284 + 285 +func (s *NoteRepo) UpdatePasswordBySlug( 286 + ctx context.Context, 287 + slug dtos.NoteSlug, 288 + authorID uuid.UUID, 289 + passwd string, 290 +) error { 291 + query := `--sql 292 +update notes n 293 +set password = $1 294 +from notes_authors na 295 +where n.slug = $2 296 + and na.user_id = $3 297 + and na.note_id = n.id` 298 + 299 + ct, err := s.db.Exec(ctx, query, passwd, slug, authorID.String()) 300 + if err != nil { 301 + return err 302 + } 303 + 304 + if ct.RowsAffected() == 0 { 305 + return models.ErrNoteNotFound 306 + } 307 + 308 + return nil 309 +}
M
internal/transport/http/apiv1/apiv1.go
··· 54 54 { 55 55 possiblyAuthorized.POST("", a.createNoteHandler) 56 56 } 57 + 58 + authorized := note.Group("", a.authorizedMiddleware) 59 + { 60 + authorized.GET("", a.getNotesHandler) 61 + authorized.PATCH(":slug/expires", a.updateNoteHandler) 62 + authorized.PATCH(":slug/password", a.setNotePasswordHandler) 63 + authorized.DELETE(":slug", a.deleteNoteHandler) 64 + } 57 65 } 58 66 }
M
internal/transport/http/apiv1/middleware.go
··· 90 90 91 91 // getUserId returns userId from the context 92 92 // getting user id is only possible if user is authorized 93 +// if userID is not set, [uuid.Nil] will be returned. 93 94 func (a *APIV1) getUserID(c *gin.Context) uuid.UUID { 94 95 userID, exists := c.Get(userIDCtxKey) 95 96 if !exists {
M
internal/transport/http/apiv1/note.go
··· 8 8 9 9 "github.com/gin-gonic/gin" 10 10 "github.com/olexsmir/onasty/internal/dtos" 11 - "github.com/olexsmir/onasty/internal/models" 12 11 "github.com/olexsmir/onasty/internal/service/notesrv" 13 12 ) 14 13 ··· 31 30 return 32 31 } 33 32 34 - note := models.Note{ //nolint:exhaustruct 33 + // TODO: burn_before_expiration shouldn't be set if user has not set or specified expires_at 34 + 35 + slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{ 35 36 Content: req.Content, 37 + UserID: a.getUserID(c), 36 38 Slug: req.Slug, 39 + Password: req.Password, 37 40 BurnBeforeExpiration: req.BurnBeforeExpiration, 38 41 CreatedAt: time.Now(), 39 - Password: req.Password, 40 42 ExpiresAt: req.ExpiresAt, 41 - } 42 - 43 - if err := note.Validate(); err != nil { 44 - newErrorStatus(c, http.StatusBadRequest, err.Error()) 45 - return 46 - } 47 - 48 - slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{ 49 - Content: note.Content, 50 - UserID: a.getUserID(c), 51 - Slug: note.Slug, 52 - Password: note.Password, 53 - BurnBeforeExpiration: note.BurnBeforeExpiration, 54 - CreatedAt: note.CreatedAt, 55 - ExpiresAt: note.ExpiresAt, 56 43 }, a.getUserID(c)) 57 44 if err != nil { 58 45 errorResponse(c, err) ··· 104 91 ExpiresAt: note.ExpiresAt, 105 92 }) 106 93 } 94 + 95 +type getNotesResponse struct { 96 + Content string `json:"content"` 97 + Slug string `json:"slug"` 98 + BurnBeforeExpiration bool `json:"burn_before_expiration"` 99 + HasPassword bool `json:"has_password"` 100 + CreatedAt time.Time `json:"created_at"` 101 + ExpiresAt time.Time `json:"expires_at,omitzero"` 102 + ReadAt time.Time `json:"read_at,omitzero"` 103 +} 104 + 105 +func (a *APIV1) getNotesHandler(c *gin.Context) { 106 + notes, err := a.notesrv.GetAllByAuthorID(c.Request.Context(), a.getUserID(c)) 107 + if err != nil { 108 + errorResponse(c, err) 109 + return 110 + } 111 + 112 + var response []getNotesResponse 113 + for _, note := range notes { 114 + response = append(response, getNotesResponse{ 115 + Content: note.Content, 116 + Slug: note.Slug, 117 + BurnBeforeExpiration: note.BurnBeforeExpiration, 118 + HasPassword: note.HasPassword, 119 + CreatedAt: note.CreatedAt, 120 + ExpiresAt: note.ExpiresAt, 121 + ReadAt: note.ReadAt, 122 + }) 123 + } 124 + 125 + c.JSON(http.StatusOK, response) 126 +} 127 + 128 +type updateNoteRequest struct { 129 + ExpiresAt *time.Time `json:"expires_at,omitempty"` 130 + BurnBeforeExpiration *bool `json:"burn_before_expiration,omitempty"` 131 +} 132 + 133 +func (a *APIV1) updateNoteHandler(c *gin.Context) { 134 + var req updateNoteRequest 135 + if err := c.ShouldBindJSON(&req); err != nil { 136 + newError(c, http.StatusBadRequest, "invalid request") 137 + return 138 + } 139 + 140 + // TODO: burn_before_expiration shouldn't be set if user has not set or specified expires_at 141 + 142 + if err := a.notesrv.UpdateExpirationTimeSettings( 143 + c.Request.Context(), 144 + dtos.PatchNote{ 145 + BurnBeforeExpiration: req.BurnBeforeExpiration, 146 + ExpiresAt: req.ExpiresAt, 147 + }, 148 + c.Param("slug"), 149 + a.getUserID(c), 150 + ); err != nil { 151 + errorResponse(c, err) 152 + return 153 + } 154 + 155 + c.Status(http.StatusOK) 156 +} 157 + 158 +func (a *APIV1) deleteNoteHandler(c *gin.Context) { 159 + if err := a.notesrv.DeleteBySlug( 160 + c.Request.Context(), 161 + c.Param("slug"), 162 + a.getUserID(c), 163 + ); err != nil { 164 + errorResponse(c, err) 165 + return 166 + } 167 + 168 + c.Status(http.StatusNoContent) 169 +} 170 + 171 +type setNotePasswordRequest struct { 172 + Password string `json:"password"` 173 +} 174 + 175 +func (a *APIV1) setNotePasswordHandler(c *gin.Context) { 176 + var req setNotePasswordRequest 177 + if err := c.ShouldBindJSON(&req); err != nil { 178 + newError(c, http.StatusBadRequest, "invalid request") 179 + return 180 + } 181 + 182 + if err := a.notesrv.UpdatePassword( 183 + c.Request.Context(), 184 + c.Param("slug"), 185 + req.Password, 186 + a.getUserID(c), 187 + ); err != nil { 188 + errorResponse(c, err) 189 + return 190 + } 191 + 192 + c.Status(http.StatusOK) 193 +}
M
internal/transport/http/apiv1/response.go
··· 6 6 "net/http" 7 7 8 8 "github.com/gin-gonic/gin" 9 + "github.com/olexsmir/onasty/internal/jwtutil" 9 10 "github.com/olexsmir/onasty/internal/models" 11 + "github.com/olexsmir/onasty/internal/service/notesrv" 10 12 "github.com/olexsmir/onasty/internal/service/usersrv" 11 13 ) 12 14 ··· 27 29 errors.Is(err, models.ErrUserInvalidPassword) || 28 30 errors.Is(err, models.ErrUserNotFound) || 29 31 // notes 32 + errors.Is(err, notesrv.ErrNotePasswordNotProvided) || 30 33 errors.Is(err, models.ErrNoteContentIsEmpty) || 31 34 errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) { 32 35 newError(c, http.StatusBadRequest, err.Error()) ··· 45 48 } 46 49 47 50 if errors.Is(err, ErrUnauthorized) || 51 + errors.Is(err, jwtutil.ErrTokenExpired) || 48 52 errors.Is(err, models.ErrUserWrongCredentials) { 49 53 newErrorStatus(c, http.StatusUnauthorized, err.Error()) 50 54 return
M
migrations/20240613092532_sessions.up.sql
··· 1 1 CREATE TABLE sessions ( 2 - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), 2 + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), 3 3 user_id uuid REFERENCES users (id), 4 4 refresh_token varchar(255) NOT NULL UNIQUE, 5 5 expires_at timestamptz NOT NULL
M
migrations/20240729115827_verification_tokens.up.sql
··· 1 1 CREATE TABLE verification_tokens ( 2 - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), 2 + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), 3 3 user_id uuid NOT NULL UNIQUE REFERENCES users (id), 4 4 token varchar(255) NOT NULL UNIQUE, 5 5 created_at timestamptz NOT NULL DEFAULT now(),
M
migrations/20250520211029_remove_username.down.sql
··· 1 1 ALTER TABLE users 2 - add column username varchar(255) NOT NULL UNIQUE; 2 + ADD COLUMN username varchar(255) NOT NULL UNIQUE;