20 files changed,
328 insertions(+),
220 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-04-14 01:27:30 +0300
Parent:
4073d2b
jump to
M
.golangci.yaml
··· 1 +version: "2" 1 2 run: 2 - timeout: 3m 3 3 tests: true 4 4 5 5 linters: 6 - # fast: true 7 - disable-all: true 6 + default: none 8 7 enable: 9 8 - errcheck # checking for unchecked errors 10 - - gosimple # specializes in simplifying a code 11 9 - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string 12 10 - staticcheck # is a go vet on steroids, applying a ton of static analysis checks 13 11 - ineffassign # detects when assignments to existing variables are not used 14 - - typecheck # like the front-end of a Go compiler, parses and type-checks Go code 15 12 - unused # checks for unused constants, variables, functions and types 16 - ## disabled by default 17 13 - asasalint # checks for pass []any as any in variadic func(...any) 18 14 - asciicheck # checks that your code does not contain non-ASCII identifiers 19 15 - bidichk # checks for dangerous unicode character sequences ··· 32 28 - goconst # finds repeated strings that could be replaced by a constant 33 29 - gocritic # provides diagnostics that check for bugs, performance and style issues 34 30 - gocyclo # computes and checks the cyclomatic complexity of functions 35 - - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt 36 - - gofumpt 37 31 - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod 38 32 - goprintffuncname # checks that printf-like functions are named with f at the end 39 33 - gosec # inspects source code for security problems ··· 59 53 - rowserrcheck # checks whether Err of rows is checked successfully 60 54 - sloglint # ensure consistent code style when using log/slog 61 55 - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed 62 - - stylecheck # is a replacement for golint 63 - - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 64 56 - testableexamples # checks if examples are testable (have an expected output) 65 57 - testifylint # checks usage of github.com/stretchr/testify 66 58 - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes ··· 76 68 - contextcheck # check whether the function uses a non-inherited context 77 69 - ireturn # accept interfaces, return concrete types 78 70 79 -linters-settings: 80 - cyclop: 81 - # The maximal code complexity to report. 82 - # Default: 10 83 - max-complexity: 30 84 - # The maximal average package complexity. 85 - # If it's higher than 0.0 (float) the check is enabled 86 - # Default: 0.0 87 - package-average: 10.0 71 + settings: 72 + cyclop: 73 + max-complexity: 30 74 + package-average: 10 88 75 89 - errcheck: 90 - # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. 91 - # Such cases aren't reported by default. 92 - # Default: false 93 - check-type-assertions: true 76 + errcheck: 77 + check-type-assertions: true 94 78 95 - funlen: 96 - # Checks the number of lines in a function. 97 - # If lower than 0, disable the check. 98 - # Default: 60 99 - lines: 100 100 - # Checks the number of statements in a function. 101 - # If lower than 0, disable the check. 102 - # Default: 40 103 - statements: 50 104 - # Ignore comments when counting lines. 105 - # Default false 106 - ignore-comments: true 79 + exhaustruct: 80 + exclude: 81 + - log/slog\.HandlerOptions 82 + - net/http\.Server 83 + - github.com/golang-jwt/jwt/v5\.RegisteredClaims 107 84 108 - gocritic: 109 - # Settings passed to gocritic. 110 - # The settings key is the name of a supported gocritic checker. 111 - # The list of supported checkers can be find in https://go-critic.github.io/overview. 112 - settings: 113 - captLocal: 114 - # Whether to restrict checker to params only. 115 - # Default: true 116 - paramsOnly: false 117 - underef: 118 - # Whether to skip (*x).method() calls where x is a pointer receiver. 119 - # Default: true 120 - skipRecvDeref: false 121 - 122 - govet: 123 - enable-all: true 124 - disable: 125 - - fieldalignment # too strict 85 + funlen: 86 + lines: 100 87 + statements: 50 88 + ignore-comments: true 126 89 127 - nakedret: 128 - # the gods will judge me but I just don't like naked returns at all 129 - max-func-lines: 0 90 + gocritic: 91 + settings: 92 + captLocal: 93 + paramsOnly: false 94 + underef: 95 + skipRecvDeref: false 130 96 131 - exhaustruct: 132 - exclude: 133 - - 'log/slog\.HandlerOptions' 134 - - 'net/http\.Server' 97 + govet: 98 + disable: 99 + - fieldalignment 100 + enable-all: true 135 101 136 - - 'github.com/golang-jwt/jwt/v5\.RegisteredClaims' 102 + exclusions: 103 + generated: lax 104 + presets: 105 + - comments 106 + - common-false-positives 107 + - legacy 108 + - std-error-handling 109 + rules: 110 + - linters: 111 + - gocritic 112 + source: //noinspection 113 + - linters: 114 + - bodyclose 115 + - dupl 116 + - err113 117 + - funlen 118 + - goconst 119 + - gosec 120 + - lll 121 + - noctx 122 + - wrapcheck 123 + path: _test\.go 124 + paths: 125 + - third_party$ 126 + - builtin$ 127 + - examples$ 137 128 138 129 issues: 139 - # Maximum count of issues with the same text. 140 - # Set to 0 to disable. 141 - # Default: 3 142 130 max-same-issues: 50 143 131 144 - exclude-rules: 145 - - source: "//noinspection" 146 - linters: [ gocritic ] 147 - - path: "_test\\.go" 148 - linters: 149 - - bodyclose 150 - - dupl 151 - - funlen 152 - - goerr113 153 - - goconst 154 - - gosec 155 - - noctx 156 - - wrapcheck 157 - - lll 132 +formatters: 133 + enable: 134 + - gofumpt 135 + - goimports 136 + exclusions: 137 + generated: lax 138 + paths: 139 + - third_party$ 140 + - builtin$ 141 + - examples$
M
Dockerfile
··· 9 9 COPY internal internal 10 10 11 11 ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 12 -RUN go build -trimpath -ldflags='-w -s' -o /onasty ./cmd/server 12 +RUN --mount=type=cache,target=/root/.cache/go-build \ 13 + --mount=type=cache,target=/go/pkg/mod \ 14 + go build -trimpath -ldflags='-w -s' -o /onasty ./cmd/server 13 15 14 16 FROM alpine:3.21 15 17 COPY --from=builder /onasty /onasty
M
cmd/server/main.go
··· 25 25 "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" 26 26 "github.com/olexsmir/onasty/internal/store/psqlutil" 27 27 "github.com/olexsmir/onasty/internal/store/rdb" 28 + "github.com/olexsmir/onasty/internal/store/rdb/notecache" 28 29 "github.com/olexsmir/onasty/internal/store/rdb/usercache" 29 30 httptransport "github.com/olexsmir/onasty/internal/transport/http" 30 31 "github.com/olexsmir/onasty/internal/transport/http/httpserver" ··· 98 99 cfg.AppURL, 99 100 ) 100 101 102 + notecache := notecache.New(redisDB, cfg.CacheNoteTTL) 101 103 noterepo := noterepo.New(psqlDB) 102 - notesrv := notesrv.New(noterepo, notePasswordHasher) 104 + notesrv := notesrv.New(noterepo, notePasswordHasher, notecache) 103 105 104 106 rateLimiterConfig := ratelimit.Config{ 105 107 RPS: cfg.RateLimiterRPS,
M
e2e/apiv1_notes_test.go
··· 106 106 } 107 107 108 108 type apiv1NoteGetResponse struct { 109 - Content string `json:"content"` 110 - CreatedAt time.Time `json:"created_at"` 111 - ExpiresAt time.Time `json:"expires_at"` 109 + Content string `json:"content"` 110 + ReadAt *time.Time `json:"read_at"` 111 + CreatedAt time.Time `json:"created_at"` 112 + ExpiresAt time.Time `json:"expires_at"` 112 113 } 113 114 114 115 func (e *AppTestSuite) TestNoteV1_Get() { ··· 134 135 e.Equal(content, body.Content) 135 136 136 137 dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) 137 - e.Empty(dbNote) 138 + e.Equal(dbNote.Content, "") 139 + e.Equal(dbNote.ReadAt.IsZero(), false) 138 140 } 139 141 140 142 type apiv1NoteGetRequest struct { ··· 157 159 var bodyCreated apiv1NoteCreateResponse 158 160 e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) 159 161 160 - httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{ 161 - Password: passwd, 162 - })) 162 + httpResp = e.httpRequest( 163 + http.MethodGet, 164 + "/api/v1/note/"+bodyCreated.Slug, 165 + e.jsonify(apiv1NoteGetRequest{ 166 + Password: passwd, 167 + }), 168 + ) 163 169 e.Equal(httpResp.Code, http.StatusOK) 164 170 165 171 var body apiv1NoteGetResponse ··· 168 174 e.Equal(content, body.Content) 169 175 170 176 dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug) 171 - e.Empty(dbNote) 177 + e.Equal(dbNote.Content, "") 178 + e.Equal(dbNote.ReadAt.IsZero(), false) 172 179 } 173 180 174 181 func (e *AppTestSuite) TestNoteV1_GetWithPassword_wrongNoPassword() { ··· 206 213 var bodyCreated apiv1NoteCreateResponse 207 214 e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated) 208 215 209 - httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, e.jsonify(apiv1NoteGetRequest{ 210 - Password: e.uuid(), 211 - })) 216 + httpResp = e.httpRequest( 217 + http.MethodGet, 218 + "/api/v1/note/"+bodyCreated.Slug, 219 + e.jsonify(apiv1NoteGetRequest{ 220 + Password: e.uuid(), 221 + }), 222 + ) 212 223 e.Equal(httpResp.Code, http.StatusNotFound) 213 224 }
M
e2e/e2e_test.go
··· 25 25 "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" 26 26 "github.com/olexsmir/onasty/internal/store/psqlutil" 27 27 "github.com/olexsmir/onasty/internal/store/rdb" 28 + "github.com/olexsmir/onasty/internal/store/rdb/notecache" 28 29 "github.com/olexsmir/onasty/internal/store/rdb/usercache" 29 30 httptransport "github.com/olexsmir/onasty/internal/transport/http" 30 31 "github.com/olexsmir/onasty/internal/transport/http/ratelimit" ··· 119 120 cfg.AppURL, 120 121 ) 121 122 123 + notecache := notecache.New(e.redisDB, cfg.CacheUsersTTL) 122 124 noterepo := noterepo.New(e.postgresDB) 123 - notesrv := notesrv.New(noterepo, e.hasher) 125 + notesrv := notesrv.New(noterepo, e.hasher, notecache) 124 126 125 127 // for testing purposes, it's ok to have high values ig 126 128 ratelimitCfg := ratelimit.Config{
M
e2e/e2e_utils_db_test.go
··· 91 91 return u 92 92 } 93 93 94 -func (e *AppTestSuite) getNoteFromDBbySlug(slug string) models.Note { 94 +type noteModel struct { 95 + ID uuid.UUID 96 + Content string 97 + Slug string 98 + BurnBeforeExpiration bool 99 + Password string 100 + IsRead bool 101 + ReadAt *time.Time 102 + CreatedAt time.Time 103 + ExpiresAt time.Time 104 +} 105 + 106 +func (e *AppTestSuite) getNoteFromDBbySlug(slug string) noteModel { 95 107 query, args, err := pgq. 96 - Select("id", "content", "slug", "burn_before_expiration", "created_at", "expires_at"). 108 + Select( 109 + "id", 110 + "content", 111 + "slug", 112 + "burn_before_expiration", 113 + "read_at", 114 + "created_at", 115 + "expires_at", 116 + ). 97 117 From("notes"). 98 118 Where(pgq.Eq{"slug": slug}). 99 119 SQL() 100 120 e.require.NoError(err) 101 121 102 - var note models.Note 122 + var note noteModel 103 123 err = e.postgresDB.QueryRow(e.ctx, query, args...). 104 - Scan(¬e.ID, ¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.CreatedAt, ¬e.ExpiresAt) 124 + Scan(¬e.ID, ¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) 105 125 if errors.Is(err, pgx.ErrNoRows) { 106 - return models.Note{} //nolint:exhaustruct 126 + return noteModel{} //nolint:exhaustruct 107 127 } 108 128 109 129 e.require.NoError(err)
M
internal/config/config.go
··· 22 22 RedisDB int 23 23 24 24 CacheUsersTTL time.Duration 25 + CacheNoteTTL time.Duration 25 26 26 27 JwtSigningKey string 27 28 JwtAccessTokenTTL time.Duration 28 29 JwtRefreshTokenTTL time.Duration 29 30 30 - MailgunFrom string 31 - MailgunDomain string 32 - MailgunAPIKey string 33 31 VerificationTokenTTL time.Duration 34 32 35 33 MetricsEnabled bool ··· 60 58 RedisDB: mustGetenvOrDefaultInt(getenvOrDefault("REDIS_DB", "0"), 0), 61 59 62 60 CacheUsersTTL: mustParseDuration(getenvOrDefault("CACHE_USERS_TTL", "1h")), 61 + CacheNoteTTL: mustParseDuration(getenvOrDefault("CACHE_NOTE_TTL", "1h")), 63 62 64 63 JwtSigningKey: getenvOrDefault("JWT_SIGNING_KEY", ""), 65 64 JwtAccessTokenTTL: mustParseDuration( ··· 69 68 getenvOrDefault("JWT_REFRESH_TOKEN_TTL", "24h"), 70 69 ), 71 70 72 - MailgunFrom: getenvOrDefault("MAILGUN_FROM", ""), 73 - MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""), 74 - MailgunAPIKey: getenvOrDefault("MAILGUN_API_KEY", ""), 75 71 VerificationTokenTTL: mustParseDuration( 76 72 getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h"), 77 73 ),
M
internal/models/notes.go
··· 20 20 Slug string 21 21 Password string 22 22 BurnBeforeExpiration bool 23 + ReadAt time.Time 23 24 CreatedAt time.Time 24 25 ExpiresAt time.Time 25 26 } ··· 45 46 return !n.ExpiresAt.IsZero() && 46 47 n.BurnBeforeExpiration 47 48 } 49 + 50 +func (n Note) IsRead() bool { 51 + return !n.ReadAt.IsZero() 52 +}
M
internal/models/notes_test.go
··· 8 8 ) 9 9 10 10 func TestNote_Validate(t *testing.T) { 11 - tests := []struct { 12 - name string 13 - note Note 14 - willError bool 15 - error error 16 - }{ 17 - // NOTE: there no need to test if note is expired since it tested in IsExpired test 18 - { 19 - name: "ok", 20 - note: Note{ //nolint:exhaustruct 21 - Content: "some wired ass content", 22 - ExpiresAt: time.Now().Add(time.Hour), 23 - }, 24 - willError: false, 25 - error: nil, 26 - }, 27 - { 28 - name: "content missing", 29 - note: Note{Content: ""}, //nolint:exhaustruct 30 - willError: true, 31 - error: ErrNoteContentIsEmpty, 32 - }, 33 - } 11 + // NOTE: there no need to test if note is expired since it tested in IsExpired test 34 12 35 - for _, tt := range tests { 36 - t.Run(tt.name, func(t *testing.T) { 37 - err := tt.note.Validate() 38 - if tt.willError { 39 - assert.EqualError(t, err, tt.error.Error()) 40 - } else { 41 - assert.NoError(t, err) 42 - } 43 - }) 44 - } 13 + t.Run("should pass the validation only if content provided", func(t *testing.T) { 14 + n := Note{Content: "the content"} //nolint:exhaustruct 15 + assert.NoError(t, n.Validate()) 16 + }) 17 + t.Run("should pass validation with content and correct expiration time", func(t *testing.T) { 18 + n := Note{ //nolint:exhaustruct 19 + Content: "content", 20 + ExpiresAt: time.Now().Add(time.Minute), 21 + } 22 + assert.NoError(t, n.Validate()) 23 + }) 24 + t.Run("should fail if content is missing", func(t *testing.T) { 25 + n := Note{Content: ""} //nolint:exhaustruct 26 + assert.EqualError(t, n.Validate(), ErrNoteContentIsEmpty.Error()) 27 + }) 28 + t.Run("should fail if content is missing and other fields are set", func(t *testing.T) { 29 + n := Note{ //nolint:exhaustruct 30 + Slug: "some-slug", 31 + Password: "some-password", 32 + BurnBeforeExpiration: false, 33 + } 34 + assert.EqualError(t, n.Validate(), ErrNoteContentIsEmpty.Error()) 35 + }) 36 + t.Run("should fail if expiration time is in the past", func(t *testing.T) { 37 + n := Note{Content: "content", ExpiresAt: time.Now().Add(-time.Hour)} //nolint:exhaustruct 38 + assert.EqualError(t, n.Validate(), ErrNoteExpired.Error()) 39 + }) 45 40 } 46 41 47 42 func TestNote_IsExpired(t *testing.T) { 48 - tests := []struct { 49 - name string 50 - note Note 51 - expected bool 52 - }{ 53 - { 54 - name: "expired", 55 - note: Note{ExpiresAt: time.Now().Add(-time.Hour)}, //nolint:exhaustruct 56 - expected: true, 57 - }, 58 - { 59 - name: "not expired", 60 - note: Note{ExpiresAt: time.Now().Add(time.Hour)}, //nolint:exhaustruct 61 - expected: false, 62 - }, 63 - { 64 - name: "zero expiration", 65 - note: Note{ExpiresAt: time.Time{}}, //nolint:exhaustruct 66 - expected: false, 67 - }, 68 - } 69 - 70 - for _, tt := range tests { 71 - t.Run(tt.name, func(t *testing.T) { 72 - assert.Equal(t, tt.expected, tt.note.IsExpired()) 73 - }) 74 - } 43 + t.Run("should be expired", func(t *testing.T) { 44 + note := Note{ExpiresAt: time.Now().Add(-time.Hour)} //nolint:exhaustruct 45 + assert.True(t, note.IsExpired()) 46 + }) 47 + t.Run("should be not expired", func(t *testing.T) { 48 + note := Note{ExpiresAt: time.Now().Add(time.Hour)} //nolint:exhaustruct 49 + assert.False(t, note.IsExpired()) 50 + }) 51 + t.Run("should be not expired when [ExpiredAt] is zero", func(t *testing.T) { 52 + note := Note{ExpiresAt: time.Time{}} //nolint:exhaustruct 53 + assert.False(t, note.IsExpired()) 54 + }) 75 55 } 76 56 77 57 func TestNote_ShouldBeBurnt(t *testing.T) { 78 - tests := []struct { 79 - name string 80 - note Note 81 - expected bool 82 - }{ 83 - { 84 - name: "should be burnt", 85 - note: Note{ //nolint:exhaustruct 86 - BurnBeforeExpiration: true, 87 - ExpiresAt: time.Now().Add(time.Hour), 88 - }, 89 - expected: true, 90 - }, 91 - { 92 - name: "could not be burnt, no expiration time", 93 - note: Note{ //nolint:exhaustruct 94 - BurnBeforeExpiration: true, 95 - ExpiresAt: time.Time{}, 96 - }, 97 - expected: false, 98 - }, 99 - { 100 - name: "could not be burnt, burn when expiration and burn is false", 101 - note: Note{ //nolint:exhaustruct 102 - BurnBeforeExpiration: false, 103 - ExpiresAt: time.Time{}, 104 - }, 105 - expected: false, 106 - }, 107 - } 58 + t.Run("should be burnt", func(t *testing.T) { 59 + note := Note{ //nolint:exhaustruct 60 + BurnBeforeExpiration: true, 61 + ExpiresAt: time.Now().Add(time.Hour), 62 + } 63 + assert.True(t, note.ShouldBeBurnt()) 64 + }) 65 + t.Run("should not be burnt", func(t *testing.T) { 66 + note := Note{ //nolint:exhaustruct 67 + BurnBeforeExpiration: true, 68 + ExpiresAt: time.Time{}, 69 + } 70 + assert.False(t, note.ShouldBeBurnt()) 71 + }) 72 + t.Run("could not be burnt when expiration and shouldBurn set to false", func(t *testing.T) { 73 + note := Note{ //nolint:exhaustruct 74 + BurnBeforeExpiration: false, 75 + ExpiresAt: time.Time{}, 76 + } 77 + assert.False(t, note.ShouldBeBurnt()) 78 + }) 79 +} 108 80 109 - for _, tt := range tests { 110 - t.Run(tt.name, func(t *testing.T) { 111 - assert.Equal(t, tt.expected, tt.note.ShouldBeBurnt()) 112 - }) 113 - } 81 +func TestNote_IsRead(t *testing.T) { 82 + t.Run("should be unread", func(t *testing.T) { 83 + n := Note{ReadAt: time.Time{}} //nolint:exhaustruct 84 + assert.False(t, n.IsRead()) 85 + }) 86 + t.Run("should be read", func(t *testing.T) { 87 + n := Note{ReadAt: time.Now()} //nolint:exhaustruct 88 + assert.True(t, n.IsRead()) 89 + }) 114 90 }
M
internal/service/notesrv/notesrv.go
··· 3 3 import ( 4 4 "context" 5 5 "log/slog" 6 + "time" 6 7 7 8 "github.com/gofrs/uuid/v5" 8 9 "github.com/olexsmir/onasty/internal/dtos" 9 10 "github.com/olexsmir/onasty/internal/hasher" 10 11 "github.com/olexsmir/onasty/internal/models" 11 12 "github.com/olexsmir/onasty/internal/store/psql/noterepo" 13 + "github.com/olexsmir/onasty/internal/store/rdb/notecache" 12 14 ) 13 15 14 16 type NoteServicer interface { ··· 26 28 type NoteSrv struct { 27 29 noterepo noterepo.NoteStorer 28 30 hasher hasher.Hasher 31 + cache notecache.NoteCacher 29 32 } 30 33 31 -func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher) *NoteSrv { 34 +func New(noterepo noterepo.NoteStorer, hasher hasher.Hasher, cache notecache.NoteCacher) *NoteSrv { 32 35 return &NoteSrv{ 33 36 noterepo: noterepo, 34 37 hasher: hasher, 38 + cache: cache, 35 39 } 36 40 } 37 41 ··· 71 75 ctx context.Context, 72 76 inp GetNoteBySlugInput, 73 77 ) (dtos.NoteDTO, error) { 74 - note, err := n.getNoteFromDBasedOnInput(ctx, inp) 78 + note, err := n.getNote(ctx, inp) 75 79 if err != nil { 76 80 return dtos.NoteDTO{}, err 77 81 } ··· 91 95 return note, nil 92 96 } 93 97 94 - // TODO: in future not remove, leave some metadata 95 - // to shot user that note was already seen 96 - return note, n.noterepo.DeleteBySlug(ctx, note.Slug) 98 + return note, n.noterepo.RemoveBySlug(ctx, inp.Slug, time.Now()) 99 +} 100 + 101 +func (n *NoteSrv) getNote(ctx context.Context, inp GetNoteBySlugInput) (dtos.NoteDTO, error) { 102 + if r, err := n.cache.GetNote(ctx, inp.Slug); err == nil { 103 + return r, nil 104 + } 105 + 106 + note, err := n.getNoteFromDBasedOnInput(ctx, inp) 107 + if err != nil { 108 + return dtos.NoteDTO{}, err 109 + } 110 + 111 + if note.ReadAt != nil && !note.ReadAt.IsZero() { 112 + if err = n.cache.SetNote(ctx, inp.Slug, note); err != nil { 113 + slog.ErrorContext(ctx, "notecache", "err", err) 114 + } 115 + } 116 + 117 + return note, err 97 118 } 98 119 99 120 func (n *NoteSrv) getNoteFromDBasedOnInput(
M
internal/store/psql/noterepo/noterepo.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 + "time" 6 7 7 8 "github.com/gofrs/uuid/v5" 8 9 "github.com/henvic/pgq" ··· 30 31 password string, 31 32 ) (dtos.NoteDTO, error) 32 33 33 - // DeleteBySlug deletes note by slug or returns [models.ErrNoteNotFound] if note if not found. 34 - DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error 34 + // RemoveBySlug marks note as read, deletes it's content, and keeps meta data 35 + // Returns [models.ErrNoteNotFound] if note is not found. 36 + RemoveBySlug(ctx context.Context, slug dtos.NoteSlugDTO, readAt time.Time) error 35 37 36 38 // SetAuthorIDBySlug assigns author to note by slug. 37 39 // Returns [models.ErrNoteNotFound] if note is not found. ··· 68 70 69 71 func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) { 70 72 query, args, err := pgq. 71 - Select("content", "slug", "burn_before_expiration", "created_at", "expires_at"). 73 + Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at"). 72 74 From("notes"). 73 75 Where("(password is null or password = '')"). 74 76 Where(pgq.Eq{"slug": slug}). ··· 79 81 80 82 var note dtos.NoteDTO 81 83 err = s.db.QueryRow(ctx, query, args...). 82 - Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.CreatedAt, ¬e.ExpiresAt) 84 + Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) 83 85 84 86 if errors.Is(err, pgx.ErrNoRows) { 85 87 return dtos.NoteDTO{}, models.ErrNoteNotFound ··· 94 96 passwd string, 95 97 ) (dtos.NoteDTO, error) { 96 98 query, args, err := pgq. 97 - Select("content", "slug", "burn_before_expiration", "created_at", "expires_at"). 99 + Select("content", "slug", "burn_before_expiration", "read_at", "created_at", "expires_at"). 98 100 From("notes"). 99 101 Where(pgq.Eq{ 100 102 "slug": slug, ··· 107 109 108 110 var note dtos.NoteDTO 109 111 err = s.db.QueryRow(ctx, query, args...). 110 - Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.CreatedAt, ¬e.ExpiresAt) 112 + Scan(¬e.Content, ¬e.Slug, ¬e.BurnBeforeExpiration, ¬e.ReadAt, ¬e.CreatedAt, ¬e.ExpiresAt) 111 113 112 114 if errors.Is(err, pgx.ErrNoRows) { 113 115 return dtos.NoteDTO{}, models.ErrNoteNotFound ··· 116 118 return note, err 117 119 } 118 120 119 -func (s *NoteRepo) DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error { 121 +func (s *NoteRepo) RemoveBySlug( 122 + ctx context.Context, 123 + slug dtos.NoteSlugDTO, 124 + readAt time.Time, 125 +) error { 120 126 query, args, err := pgq. 121 - Delete("notes"). 122 - Where(pgq.Eq{"slug": slug}). 127 + Update("notes"). 128 + Set("content", ""). 129 + Set("read_at", readAt). 130 + Where(pgq.Eq{ 131 + "slug": slug, 132 + "read_at": nil, 133 + }). 123 134 SQL() 124 135 if err != nil { 125 136 return err
A
internal/store/rdb/notecache/notecache.go
··· 1 +package notecache 2 + 3 +import ( 4 + "bytes" 5 + "context" 6 + "encoding/gob" 7 + "strings" 8 + "time" 9 + 10 + "github.com/olexsmir/onasty/internal/dtos" 11 + "github.com/olexsmir/onasty/internal/store/rdb" 12 +) 13 + 14 +type NoteCacher interface { 15 + SetNote(ctx context.Context, slug string, note dtos.NoteDTO) error 16 + GetNote(ctx context.Context, slug string) (dtos.NoteDTO, error) 17 +} 18 + 19 +type NoteCache struct { 20 + rdb *rdb.DB 21 + ttl time.Duration 22 +} 23 + 24 +func New(rdb *rdb.DB, ttl time.Duration) *NoteCache { 25 + return &NoteCache{ 26 + rdb: rdb, 27 + ttl: ttl, 28 + } 29 +} 30 + 31 +func (n *NoteCache) SetNote(ctx context.Context, slug string, note dtos.NoteDTO) error { 32 + var buf bytes.Buffer 33 + if err := gob.NewEncoder(&buf).Encode(note); err != nil { 34 + return err 35 + } 36 + 37 + _, err := n.rdb.Set(ctx, getKey(slug), buf.Bytes(), n.ttl).Result() 38 + return err 39 +} 40 + 41 +func (n *NoteCache) GetNote(ctx context.Context, slug string) (dtos.NoteDTO, error) { 42 + val, err := n.rdb.Get(ctx, getKey(slug)).Bytes() 43 + if err != nil { 44 + return dtos.NoteDTO{}, err 45 + } 46 + 47 + var note dtos.NoteDTO 48 + if err = gob.NewDecoder(bytes.NewReader(val)).Decode(¬e); err != nil { 49 + return dtos.NoteDTO{}, err 50 + } 51 + 52 + return note, err 53 +} 54 + 55 +func getKey(slug string) string { 56 + var sb strings.Builder 57 + sb.WriteString("note:") 58 + sb.WriteString(slug) 59 + return sb.String() 60 +}
M
internal/transport/http/apiv1/note.go
··· 67 67 } 68 68 69 69 type getNoteBySlugResponse struct { 70 - Content string `json:"content"` 71 - CratedAt time.Time `json:"crated_at"` 72 - ExpiresAt time.Time `json:"expires_at"` 70 + Content string `json:"content,omitempty"` 71 + ReadAt *time.Time `json:"read_at,omitempty"` 72 + CratedAt time.Time `json:"crated_at"` 73 + ExpiresAt time.Time `json:"expires_at"` 73 74 } 74 75 75 76 func (a *APIV1) getNoteBySlugHandler(c *gin.Context) { ··· 92 93 return 93 94 } 94 95 95 - c.JSON(http.StatusOK, getNoteBySlugResponse{ 96 + status := http.StatusOK 97 + if note.ReadAt != nil && !note.ReadAt.IsZero() { 98 + status = http.StatusNotFound 99 + } 100 + 101 + c.JSON(status, getNoteBySlugResponse{ 96 102 Content: note.Content, 103 + ReadAt: note.ReadAt, 97 104 CratedAt: note.CreatedAt, 98 105 ExpiresAt: note.ExpiresAt, 99 106 })
M
mailer/Dockerfile
··· 9 9 COPY mailer mailer 10 10 11 11 ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 12 -RUN go build -trimpath -ldflags='-w -s' -o /mailer ./mailer 13 - 12 +RUN --mount=type=cache,target=/root/.cache/go-build \ 13 + --mount=type=cache,target=/go/pkg/mod \ 14 + go build -trimpath -ldflags='-w -s' -o /mailer ./mailer 14 15 15 16 FROM alpine:3.21 16 17 COPY --from=builder /mailer /mailer
A
migrations/20250401121105_notes_add_read.down.sql
··· 1 +ALTER TABLE notes 2 + DROP COLUMN read_at;
A
migrations/20250401121105_notes_add_read.up.sql
··· 1 +ALTER TABLE notes 2 + ADD COLUMN "read_at" timestamptz;