@@ -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
@@ -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
@@ -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,
@@ -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()) +}
@@ -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) +}
@@ -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{
@@ -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", ""),
@@ -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 }
@@ -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")
@@ -18,6 +18,7 @@ type Note struct {
ID uuid.UUID Content string Slug string + Password string BurnBeforeExpiration bool CreatedAt time.Time ExpiresAt time.Time
@@ -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 != "" +}
@@ -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) +}
@@ -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(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.CreatedAt, ¬e.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
@@ -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
@@ -0,0 +1,2 @@
+ALTER TABLE notes + DROP COLUMN "password";
@@ -0,0 +1,2 @@
+ALTER TABLE notes + ADD COLUMN "password" text;