@@ -11,6 +11,10 @@ env:
DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 +vars: + gotest: + sh: 'command -v gotest >/dev/null && echo gotest || echo "go test"' + tasks: run: - docker compose up -d --build --remove-orphans core mailer@@ -31,7 +35,7 @@ - task: test:unit
- task: test:e2e test:unit: - - go test --count=1 -v --short ./... + - '{{.gotest}} --count=1 -v --short ./...' test:e2e: - - go test --count=1 -v ./e2e/ + - '{{.gotest}} --count=1 -v ./e2e/'
@@ -32,32 +32,28 @@ password: "",
expiresAt: time.Now().Add(24 * time.Hour), }, { //nolint:exhaustruct - content: "that passworded note", - slug: "passwd", - burnBeforeExpiration: false, - password: "pass", + content: "that passworded note", + slug: "passwd", + password: "pass", }, { //nolint:exhaustruct - content: "that note with author", - slug: "user", - burnBeforeExpiration: false, - hasAuthor: true, - authorID: 0, + content: "that note with author", + slug: "user", + hasAuthor: true, + authorID: 0, }, { //nolint:exhaustruct - content: "that another authored note", - slug: "user2", - burnBeforeExpiration: false, - hasAuthor: true, - authorID: 0, + content: "that another authored note", + slug: "user2", + hasAuthor: true, + authorID: 0, }, { //nolint:exhaustruct - content: "that another authored note", - slug: "user2", - password: "passwd", - burnBeforeExpiration: false, - hasAuthor: true, - authorID: 0, + content: "that another authored note", + slug: "user3", + password: "passwd", + hasAuthor: true, + authorID: 0, }, }
@@ -104,6 +104,7 @@ "id",
"content", "slug", "burn_before_expiration", + "password", "read_at", "created_at", "expires_at",@@ -115,7 +116,7 @@ e.require.NoError(err)
var note models.Note err = e.postgresDB.QueryRow(e.ctx, query, args...). - Scan(¬e.ID, ¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) + Scan(¬e.ID, ¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.Password, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) if errors.Is(err, pgx.ErrNoRows) { return models.Note{} //nolint:exhaustruct }
@@ -24,3 +24,18 @@ Password string
CreatedAt time.Time ExpiresAt time.Time } + +type NoteDetailed struct { + Content string + Slug NoteSlug + BurnBeforeExpiration bool + HasPassword bool + CreatedAt time.Time + ExpiresAt time.Time + ReadAt time.Time +} + +type PatchNote struct { + ExpiresAt *time.Time + BurnBeforeExpiration *bool +}
@@ -9,7 +9,10 @@
"github.com/golang-jwt/jwt/v5" ) -var ErrUnexpectedSigningMethod = errors.New("unexpected signing method") +var ( + ErrUnexpectedSigningMethod = errors.New("unexpected signing method") + ErrTokenExpired = errors.New("token expired") +) type JWTTokenizer interface { // AccessToken generates a new access token with the given [Payload].@@ -65,6 +68,11 @@ return nil, ErrUnexpectedSigningMethod
} return []byte(j.signingKey), nil }) + + if errors.Is(err, jwt.ErrTokenExpired) { + return Payload{}, ErrTokenExpired + } + return Payload{ UserID: claims.Subject, }, err
@@ -2,6 +2,7 @@ package notesrv
import ( "context" + "errors" "log/slog" "time"@@ -13,17 +14,42 @@ "github.com/olexsmir/onasty/internal/store/psql/noterepo"
"github.com/olexsmir/onasty/internal/store/rdb/notecache" ) +var ErrNotePasswordNotProvided = errors.New("note: password was not provided") + type NoteServicer interface { // 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.CreateNote, userID uuid.UUID) (dtos.NoteSlug, error) - // GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed + // GetBySlugAndRemoveIfNeeded returns note by slug, and removes if if needed. + // If notes is not found returns [models.ErrNoteNotFound]. GetBySlugAndRemoveIfNeeded( ctx context.Context, input GetNoteBySlugInput, ) (dtos.GetNote, error) + + // GetAllByAuthorID returns all notes by author id. + GetAllByAuthorID( + ctx context.Context, + authorID uuid.UUID, + ) ([]dtos.NoteDetailed, error) + + // UpdateExpirationTimeSettings updates expiresAt and burnBeforeExpiration. + // If notes is not found returns [models.ErrNoteNotFound]. + UpdateExpirationTimeSettings( + ctx context.Context, + patchData dtos.PatchNote, + slug dtos.NoteSlug, + userID uuid.UUID, + ) error + + // UpdatePassword sets or updates notes password. + // If notes is not found returns [models.ErrNoteNotFound]. + UpdatePassword(ctx context.Context, slug dtos.NoteSlug, passwd string, userID uuid.UUID) error + + // DeleteBySlug deletes note by slug + DeleteBySlug(ctx context.Context, slug dtos.NoteSlug, userID uuid.UUID) error } var _ NoteServicer = (*NoteSrv)(nil)@@ -114,6 +140,66 @@ return respNote, nil
} return respNote, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now()) +} + +func (n *NoteSrv) GetAllByAuthorID( + ctx context.Context, + authorID uuid.UUID, +) ([]dtos.NoteDetailed, error) { + notes, err := n.noterepo.GetAllByAuthorID(ctx, authorID) + if err != nil { + return nil, err + } + + var resNotes []dtos.NoteDetailed + for _, note := range notes { + resNotes = append(resNotes, dtos.NoteDetailed{ + Content: note.Content, + Slug: note.Slug, + BurnBeforeExpiration: note.BurnBeforeExpiration, + HasPassword: note.Password != "", + CreatedAt: note.CreatedAt, + ExpiresAt: note.ExpiresAt, + ReadAt: note.ReadAt, + }) + } + + return resNotes, nil +} + +func (n *NoteSrv) UpdateExpirationTimeSettings( + ctx context.Context, + patchData dtos.PatchNote, + slug dtos.NoteSlug, + userID uuid.UUID, +) error { + return n.noterepo.UpdateExpirationTimeSettingsBySlug(ctx, slug, patchData, userID) +} + +func (n *NoteSrv) UpdatePassword( + ctx context.Context, + slug dtos.NoteSlug, + passwd string, + userID uuid.UUID, +) error { + if len(passwd) == 0 { + return ErrNotePasswordNotProvided + } + + hashedPassword, err := n.hasher.Hash(passwd) + if err != nil { + return err + } + + return n.noterepo.UpdatePasswordBySlug(ctx, slug, userID, hashedPassword) +} + +func (n *NoteSrv) DeleteBySlug( + ctx context.Context, + slug dtos.NoteSlug, + authorID uuid.UUID, +) error { + return n.noterepo.DeleteNoteBySlug(ctx, slug, authorID) } func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (models.Note, error) {
@@ -21,6 +21,9 @@ // GetBySlug gets a note by slug.
// Returns [models.ErrNoteNotFound] if note is not found. GetBySlug(ctx context.Context, slug dtos.NoteSlug) (models.Note, error) + // GetAllByAuthorID returns all notes with specified author. + GetAllByAuthorID(ctx context.Context, authorID uuid.UUID) ([]models.Note, error) + // GetBySlugAndPassword gets a note by slug and password. // the "password" should be hashed. //@@ -30,14 +33,35 @@ ctx context.Context,
slug dtos.NoteSlug, password string, ) (models.Note, error) + + // UpdateExpirationTimeSettingsBySlug patches note by updating expiresAt and burnBeforeExpiration if one is passwd + // Returns [models.ErrNoteNotFound] if note is not found. + UpdateExpirationTimeSettingsBySlug( + ctx context.Context, + slug dtos.NoteSlug, + patch dtos.PatchNote, + authorID uuid.UUID, + ) error // RemoveBySlug marks note as read, deletes it's content, and keeps meta data // Returns [models.ErrNoteNotFound] if note is not found. RemoveBySlug(ctx context.Context, slug dtos.NoteSlug, readAt time.Time) error + // DeleteNoteBySlug deletes(unlike [RemoveBySlug]) note by slug. + // Returns [models.ErrNoteNotFound] if note is not found. + DeleteNoteBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error + // SetAuthorIDBySlug assigns author to note by slug. // Returns [models.ErrNoteNotFound] if note is not found. SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlug, authorID uuid.UUID) error + + // UpdatePasswordBySlug updates or sets password on a note. + UpdatePasswordBySlug( + ctx context.Context, + slug dtos.NoteSlug, + authorID uuid.UUID, + passwd string, + ) error } var _ NoteStorer = (*NoteRepo)(nil)@@ -90,6 +114,36 @@
return note, err } +func (s *NoteRepo) GetAllByAuthorID( + ctx context.Context, + authorID uuid.UUID, +) ([]models.Note, error) { + query := `--sql + select n.content, n.slug, n.burn_before_expiration, n.password, n.read_at, n.created_at, n.expires_at + from notes n + right join notes_authors na on n.id = na.note_id + where na.user_id = $1` + + rows, err := s.db.Query(ctx, query, authorID.String()) + if err != nil { + return nil, err + } + + defer rows.Close() + + var notes []models.Note + for rows.Next() { + var note models.Note + if err := rows.Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.Password, + ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt); err != nil { + return nil, err + } + notes = append(notes, note) + } + + return notes, rows.Err() +} + func (s *NoteRepo) GetBySlugAndPassword( ctx context.Context, slug dtos.NoteSlug,@@ -118,6 +172,35 @@
return note, err } +func (s *NoteRepo) UpdateExpirationTimeSettingsBySlug( + ctx context.Context, + slug dtos.NoteSlug, + patch dtos.PatchNote, + authorID uuid.UUID, +) error { + query := `--sql +update notes n +set burn_before_expiration = COALESCE($1, n.burn_before_expiration), + expires_at = COALESCE($2, n.expires_at) +from notes_authors na +where n.slug = $3 + and na.user_id = $4 + and na.note_id = n.id` + + ct, err := s.db.Exec(ctx, query, + patch.BurnBeforeExpiration, patch.ExpiresAt, + slug, authorID.String()) + if err != nil { + return err + } + + if ct.RowsAffected() == 0 { + return models.ErrNoteNotFound + } + + return nil +} + func (s *NoteRepo) RemoveBySlug( ctx context.Context, slug dtos.NoteSlug,@@ -144,6 +227,29 @@
return err } +func (s *NoteRepo) DeleteNoteBySlug( + ctx context.Context, + slug dtos.NoteSlug, + authorID uuid.UUID, +) error { + query := `--sql +delete from notes n +using notes_authors na +where n.slug = $1 + and na.user_id = $2` + + ct, err := s.db.Exec(ctx, query, slug, authorID.String()) + if err != nil { + return err + } + + if ct.RowsAffected() == 0 { + return models.ErrNoteNotFound + } + + return nil +} + func (s *NoteRepo) SetAuthorIDBySlug( ctx context.Context, slug dtos.NoteSlug,@@ -175,3 +281,29 @@ }
return tx.Commit(ctx) } + +func (s *NoteRepo) UpdatePasswordBySlug( + ctx context.Context, + slug dtos.NoteSlug, + authorID uuid.UUID, + passwd string, +) error { + query := `--sql +update notes n +set password = $1 +from notes_authors na +where n.slug = $2 + and na.user_id = $3 + and na.note_id = n.id` + + ct, err := s.db.Exec(ctx, query, passwd, slug, authorID.String()) + if err != nil { + return err + } + + if ct.RowsAffected() == 0 { + return models.ErrNoteNotFound + } + + return nil +}
@@ -54,5 +54,13 @@ possiblyAuthorized := note.Group("", a.couldBeAuthorizedMiddleware)
{ possiblyAuthorized.POST("", a.createNoteHandler) } + + authorized := note.Group("", a.authorizedMiddleware) + { + authorized.GET("", a.getNotesHandler) + authorized.PATCH(":slug/expires", a.updateNoteHandler) + authorized.PATCH(":slug/password", a.setNotePasswordHandler) + authorized.DELETE(":slug", a.deleteNoteHandler) + } } }
@@ -90,6 +90,7 @@ }
// getUserId returns userId from the context // getting user id is only possible if user is authorized +// if userID is not set, [uuid.Nil] will be returned. func (a *APIV1) getUserID(c *gin.Context) uuid.UUID { userID, exists := c.Get(userIDCtxKey) if !exists {
@@ -8,7 +8,6 @@ "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" )@@ -31,28 +30,16 @@ newError(c, http.StatusBadRequest, "invalid request")
return } - note := models.Note{ //nolint:exhaustruct + // TODO: burn_before_expiration shouldn't be set if user has not set or specified expires_at + + slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{ Content: req.Content, + UserID: a.getUserID(c), Slug: req.Slug, + Password: req.Password, BurnBeforeExpiration: req.BurnBeforeExpiration, CreatedAt: time.Now(), - Password: req.Password, ExpiresAt: req.ExpiresAt, - } - - if err := note.Validate(); err != nil { - newErrorStatus(c, http.StatusBadRequest, err.Error()) - return - } - - slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNote{ - Content: note.Content, - UserID: a.getUserID(c), - Slug: note.Slug, - Password: note.Password, - BurnBeforeExpiration: note.BurnBeforeExpiration, - CreatedAt: note.CreatedAt, - ExpiresAt: note.ExpiresAt, }, a.getUserID(c)) if err != nil { errorResponse(c, err)@@ -104,3 +91,103 @@ CreatedAt: note.CreatedAt,
ExpiresAt: note.ExpiresAt, }) } + +type getNotesResponse struct { + Content string `json:"content"` + Slug string `json:"slug"` + BurnBeforeExpiration bool `json:"burn_before_expiration"` + HasPassword bool `json:"has_password"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at,omitzero"` + ReadAt time.Time `json:"read_at,omitzero"` +} + +func (a *APIV1) getNotesHandler(c *gin.Context) { + notes, err := a.notesrv.GetAllByAuthorID(c.Request.Context(), a.getUserID(c)) + if err != nil { + errorResponse(c, err) + return + } + + var response []getNotesResponse + for _, note := range notes { + response = append(response, getNotesResponse{ + Content: note.Content, + Slug: note.Slug, + BurnBeforeExpiration: note.BurnBeforeExpiration, + HasPassword: note.HasPassword, + CreatedAt: note.CreatedAt, + ExpiresAt: note.ExpiresAt, + ReadAt: note.ReadAt, + }) + } + + c.JSON(http.StatusOK, response) +} + +type updateNoteRequest struct { + ExpiresAt *time.Time `json:"expires_at,omitempty"` + BurnBeforeExpiration *bool `json:"burn_before_expiration,omitempty"` +} + +func (a *APIV1) updateNoteHandler(c *gin.Context) { + var req updateNoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + // TODO: burn_before_expiration shouldn't be set if user has not set or specified expires_at + + if err := a.notesrv.UpdateExpirationTimeSettings( + c.Request.Context(), + dtos.PatchNote{ + BurnBeforeExpiration: req.BurnBeforeExpiration, + ExpiresAt: req.ExpiresAt, + }, + c.Param("slug"), + a.getUserID(c), + ); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +} + +func (a *APIV1) deleteNoteHandler(c *gin.Context) { + if err := a.notesrv.DeleteBySlug( + c.Request.Context(), + c.Param("slug"), + a.getUserID(c), + ); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +type setNotePasswordRequest struct { + Password string `json:"password"` +} + +func (a *APIV1) setNotePasswordHandler(c *gin.Context) { + var req setNotePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.notesrv.UpdatePassword( + c.Request.Context(), + c.Param("slug"), + req.Password, + a.getUserID(c), + ); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +}
@@ -6,7 +6,9 @@ "log/slog"
"net/http" "github.com/gin-gonic/gin" + "github.com/olexsmir/onasty/internal/jwtutil" "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" )@@ -27,6 +29,7 @@ errors.Is(err, models.ErrUserInvalidEmail) ||
errors.Is(err, models.ErrUserInvalidPassword) || errors.Is(err, models.ErrUserNotFound) || // notes + errors.Is(err, notesrv.ErrNotePasswordNotProvided) || errors.Is(err, models.ErrNoteContentIsEmpty) || errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) { newError(c, http.StatusBadRequest, err.Error())@@ -45,6 +48,7 @@ return
} if errors.Is(err, ErrUnauthorized) || + errors.Is(err, jwtutil.ErrTokenExpired) || errors.Is(err, models.ErrUserWrongCredentials) { newErrorStatus(c, http.StatusUnauthorized, err.Error()) return
@@ -33,9 +33,6 @@
go func() { select { case <-ctx.Done(): - slog.ErrorContext(ctx, "failed to send email", - "template_name", templateName, - "err", ctx.Err()) return default: if err := s.mg.Send(ctx, receiver, t.Subject, t.Body); err != nil {
@@ -1,5 +1,5 @@
CREATE TABLE users ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), username varchar(255) NOT NULL UNIQUE, email varchar(255) NOT NULL UNIQUE, password varchar(255) NOT NULL,
@@ -1,5 +1,5 @@
CREATE TABLE sessions ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), user_id uuid REFERENCES users (id), refresh_token varchar(255) NOT NULL UNIQUE, expires_at timestamptz NOT NULL
@@ -1,5 +1,5 @@
CREATE TABLE notes ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), content text NOT NULL, slug varchar(255) NOT NULL UNIQUE, burn_before_expiration boolean DEFAULT FALSE,
@@ -1,5 +1,5 @@
CREATE TABLE verification_tokens ( - id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), user_id uuid NOT NULL UNIQUE REFERENCES users (id), token varchar(255) NOT NULL UNIQUE, created_at timestamptz NOT NULL DEFAULT now(),
@@ -1,2 +1,2 @@
ALTER TABLE users - add column username varchar(255) NOT NULL UNIQUE; + ADD COLUMN username varchar(255) NOT NULL UNIQUE;