• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / stash / 20474962966

24 Dec 2025 12:40AM UTC coverage: 83.75% (+0.3%) from 83.43%
20474962966

Pull #45

github

umputun
refactor: address code review findings for secrets vault

- bypass cache for secrets to avoid storing decrypted values
- add GetInfo ErrSecretsNotConfigured check for consistency
- remove unused NewSQLite backward-compat wrapper
- extract initSecretsEncryptor helper to reduce duplication
- break templateData into embedded structs (pagination, secrets, history, conflict)
- extract commitToGit helper in keys.go
- add handleSecretsFilterToggle test
- fix e2e test selector for empty key list case
- move completed plan to docs/plans/completed/
Pull Request #45: feat: implement secrets vault with encrypted storage

334 of 381 new or added lines in 9 files covered. (87.66%)

2 existing lines in 2 files now uncovered.

3118 of 3723 relevant lines covered (83.75%)

81.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.38
/app/server/web/handler.go
1
// Package web provides HTTP handlers for the web UI.
2
package web
3

4
import (
5
        "context"
6
        "embed"
7
        "encoding/base64"
8
        "fmt"
9
        "html/template"
10
        "io/fs"
11
        "net/http"
12
        "net/url"
13
        "sort"
14
        "strconv"
15
        "strings"
16
        "time"
17
        "unicode/utf8"
18

19
        "github.com/go-pkgz/routegroup"
20

21
        "github.com/umputun/stash/app/enum"
22
        "github.com/umputun/stash/app/git"
23
        "github.com/umputun/stash/app/server/internal/cookie"
24
        "github.com/umputun/stash/app/store"
25
)
26

27
//go:generate moq -out mocks/kvstore.go -pkg mocks -skip-ensure -fmt goimports . KVStore
28
//go:generate moq -out mocks/validator.go -pkg mocks -skip-ensure -fmt goimports . Validator
29
//go:generate moq -out mocks/authprovider.go -pkg mocks -skip-ensure -fmt goimports . AuthProvider
30
//go:generate moq -out mocks/gitservice.go -pkg mocks -skip-ensure -fmt goimports . GitService
31

32
//go:embed static
33
var staticFS embed.FS
34

35
//go:embed templates
36
var templatesFS embed.FS
37

38
// StaticFS returns the embedded static filesystem for external use.
39
func StaticFS() (fs.FS, error) {
×
40
        sub, err := fs.Sub(staticFS, "static")
×
41
        if err != nil {
×
42
                return nil, fmt.Errorf("failed to get static sub-filesystem: %w", err)
×
43
        }
×
44
        return sub, nil
×
45
}
46

47
// KVStore defines the interface for key-value storage operations.
48
type KVStore interface {
49
        GetWithFormat(ctx context.Context, key string) ([]byte, string, error)
50
        GetInfo(ctx context.Context, key string) (store.KeyInfo, error)
51
        Set(ctx context.Context, key string, value []byte, format string) error
52
        SetWithVersion(ctx context.Context, key string, value []byte, format string, expectedVersion time.Time) error
53
        Delete(ctx context.Context, key string) error
54
        List(ctx context.Context, filter enum.SecretsFilter) ([]store.KeyInfo, error)
55
        SecretsEnabled() bool
56
}
57

58
// Validator defines the interface for format validation.
59
type Validator interface {
60
        Validate(format string, value []byte) error
61
        IsValidFormat(format string) bool
62
        SupportedFormats() []string
63
}
64

65
// AuthProvider defines the interface for authentication operations.
66
type AuthProvider interface {
67
        Enabled() bool
68
        GetSessionUser(ctx context.Context, token string) (string, bool)
69
        FilterUserKeys(username string, keys []string) []string
70
        CheckUserPermission(username, key string, write bool) bool
71
        UserCanWrite(username string) bool
72

73
        IsValidUser(username, password string) bool
74
        CreateSession(ctx context.Context, username string) (string, error)
75
        InvalidateSession(ctx context.Context, token string)
76
        LoginTTL() time.Duration
77
}
78

79
// GitService defines the interface for git operations.
80
type GitService interface {
81
        Commit(req git.CommitRequest) error
82
        Delete(key string, author git.Author) error
83
        History(key string, limit int) ([]git.HistoryEntry, error)
84
        GetRevision(key string, rev string) ([]byte, string, error)
85
}
86

87
// Config holds web handler configuration.
88
type Config struct {
89
        BaseURL  string
90
        PageSize int
91
}
92

93
// Handler handles web UI requests.
94
type Handler struct {
95
        store       KVStore
96
        validator   Validator
97
        auth        AuthProvider
98
        highlighter *Highlighter
99
        tmpl        *template.Template
100
        baseURL     string
101
        pageSize    int
102
        git         GitService
103
}
104

105
// New creates a new web handler.
106
func New(st KVStore, auth AuthProvider, val Validator, gs GitService, cfg Config) (*Handler, error) {
78✔
107
        tmpl, err := parseTemplates()
78✔
108
        if err != nil {
78✔
109
                return nil, fmt.Errorf("failed to parse templates: %w", err)
×
110
        }
×
111

112
        return &Handler{
78✔
113
                store:       st,
78✔
114
                validator:   val,
78✔
115
                auth:        auth,
78✔
116
                highlighter: NewHighlighter(),
78✔
117
                tmpl:        tmpl,
78✔
118
                baseURL:     cfg.BaseURL,
78✔
119
                pageSize:    cfg.PageSize,
78✔
120
                git:         gs,
78✔
121
        }, nil
78✔
122
}
123

124
// Register registers web UI routes on the given router.
125
func (h *Handler) Register(r *routegroup.Bundle) {
×
126
        r.HandleFunc("GET /{$}", h.handleIndex)
×
127
        r.HandleFunc("GET /web/keys", h.handleKeyList)
×
128
        r.HandleFunc("GET /web/keys/new", h.handleKeyNew)
×
129
        r.HandleFunc("GET /web/keys/view/{key...}", h.handleKeyView)
×
130
        r.HandleFunc("GET /web/keys/edit/{key...}", h.handleKeyEdit)
×
131
        r.HandleFunc("GET /web/keys/history/{key...}", h.handleKeyHistory)
×
132
        r.HandleFunc("GET /web/keys/revision/{key...}", h.handleKeyRevision)
×
133
        r.HandleFunc("POST /web/keys/restore/{key...}", h.handleKeyRestore)
×
134
        r.HandleFunc("POST /web/keys", h.handleKeyCreate)
×
135
        r.HandleFunc("PUT /web/keys/{key...}", h.handleKeyUpdate)
×
136
        r.HandleFunc("DELETE /web/keys/{key...}", h.handleKeyDelete)
×
137
        r.HandleFunc("POST /web/theme", h.handleThemeToggle)
×
138
        r.HandleFunc("POST /web/view-mode", h.handleViewModeToggle)
×
139
        r.HandleFunc("POST /web/sort", h.handleSortToggle)
×
NEW
140
        r.HandleFunc("POST /web/secrets-filter", h.handleSecretsFilterToggle)
×
UNCOV
141
}
×
142

143
// RegisterAuth registers auth routes (login/logout) on the given router.
144
func (h *Handler) RegisterAuth(r *routegroup.Bundle) {
×
145
        r.HandleFunc("GET /login", h.handleLoginForm)
×
146
        r.HandleFunc("POST /logout", h.handleLogout)
×
147
}
×
148

149
// RegisterLogin registers the login POST handler with custom middleware.
150
func (h *Handler) RegisterLogin(r *routegroup.Bundle, middleware func(http.Handler) http.Handler) {
×
151
        r.Handle("POST /login", middleware(http.HandlerFunc(h.handleLogin)))
×
152
}
×
153

154
// templateFuncs returns custom template functions.
155
func templateFuncs() template.FuncMap {
78✔
156
        // sortModeLabel returns a human-readable label for the sort mode.
78✔
157
        sortModeLabel := func(mode enum.SortMode) string {
101✔
158
                s := mode.String()
23✔
159
                return strings.ToUpper(s[:1]) + s[1:]
23✔
160
        }
23✔
161

162
        return template.FuncMap{
78✔
163
                "formatTime": func(t time.Time) string {
99✔
164
                        return t.Format("2006-01-02 15:04")
21✔
165
                },
21✔
166
                "formatSize": func(size int) string {
10✔
167
                        if size < 1024 {
20✔
168
                                return strconv.Itoa(size) + " B"
10✔
169
                        }
10✔
170
                        if size < 1024*1024 {
×
171
                                return strconv.FormatFloat(float64(size)/1024, 'f', 1, 64) + " KB"
×
172
                        }
×
173
                        return strconv.FormatFloat(float64(size)/(1024*1024), 'f', 1, 64) + " MB"
×
174
                },
175
                "urlEncode":     url.PathEscape,
176
                "sortModeLabel": sortModeLabel,
177
                "add":           func(a, b int) int { return a + b },
4✔
178
                "sub":           func(a, b int) int { return a - b },
2✔
179
        }
180
}
181

182
// parseTemplates parses all templates from embedded filesystem.
183
func parseTemplates() (*template.Template, error) {
78✔
184
        tmpl := template.New("").Funcs(templateFuncs())
78✔
185

78✔
186
        // parse base template
78✔
187
        baseContent, err := templatesFS.ReadFile("templates/base.html")
78✔
188
        if err != nil {
78✔
189
                return nil, fmt.Errorf("read base.html: %w", err)
×
190
        }
×
191
        tmpl, err = tmpl.Parse(string(baseContent))
78✔
192
        if err != nil {
78✔
193
                return nil, fmt.Errorf("parse base.html: %w", err)
×
194
        }
×
195

196
        // parse login template
197
        loginContent, err := templatesFS.ReadFile("templates/login.html")
78✔
198
        if err != nil {
78✔
199
                return nil, fmt.Errorf("read login.html: %w", err)
×
200
        }
×
201
        _, err = tmpl.New("login.html").Parse(string(loginContent))
78✔
202
        if err != nil {
78✔
203
                return nil, fmt.Errorf("parse login.html: %w", err)
×
204
        }
×
205

206
        // parse index template
207
        indexContent, err := templatesFS.ReadFile("templates/index.html")
78✔
208
        if err != nil {
78✔
209
                return nil, fmt.Errorf("read index.html: %w", err)
×
210
        }
×
211
        _, err = tmpl.New("index.html").Parse(string(indexContent))
78✔
212
        if err != nil {
78✔
213
                return nil, fmt.Errorf("parse index.html: %w", err)
×
214
        }
×
215

216
        // parse partials
217
        partials := []string{"keys-table", "form", "view", "history", "revision"}
78✔
218
        for _, name := range partials {
468✔
219
                content, readErr := templatesFS.ReadFile("templates/partials/" + name + ".html")
390✔
220
                if readErr != nil {
390✔
221
                        return nil, fmt.Errorf("read partial %s: %w", name, readErr)
×
222
                }
×
223
                _, parseErr := tmpl.New(name).Parse(string(content))
390✔
224
                if parseErr != nil {
390✔
225
                        return nil, fmt.Errorf("parse partial %s: %w", name, parseErr)
×
226
                }
×
227
        }
228

229
        return tmpl, nil
78✔
230
}
231

232
// keyWithPermission wraps KeyInfo with per-key write permission.
233
type keyWithPermission struct {
234
        store.KeyInfo
235
        CanWrite bool // user has write permission for this specific key
236
}
237

238
// conflictData holds conflict detection fields for optimistic locking.
239
type conflictData struct {
240
        UpdatedAt       int64  // unix timestamp when key was loaded (for optimistic locking)
241
        Conflict        bool   // true when a conflict was detected on save
242
        ServerValue     string // current server value (shown during conflict)
243
        ServerFormat    string // current server format (shown during conflict)
244
        ServerUpdatedAt int64  // server's updated_at timestamp (for retry after conflict)
245
}
246

247
// paginationData holds pagination state.
248
type paginationData struct {
249
        Page       int  // current page (1-based)
250
        TotalPages int  // total number of pages
251
        TotalKeys  int  // total keys after filtering (before pagination)
252
        HasPrev    bool // has previous page
253
        HasNext    bool // has next page
254
}
255

256
// secretsData holds secrets filter state.
257
type secretsData struct {
258
        SecretsFilter  enum.SecretsFilter // current filter mode (all/secrets/keys)
259
        SecretsEnabled bool               // secrets feature enabled
260
}
261

262
// historyData holds git history state.
263
type historyData struct {
264
        GitEnabled bool               // git integration enabled
265
        History    []git.HistoryEntry // commit history entries
266
        RevHash    string             // specific revision hash being viewed
267
}
268

269
// templateData holds data passed to templates.
270
type templateData struct {
271
        // key display fields
272
        Keys           []keyWithPermission
273
        Key            string
274
        Value          string
275
        HighlightedVal template.HTML // syntax-highlighted value for view modal
276
        Format         string        // format type (text, json, yaml, etc.)
277
        Formats        []string      // available format options
278
        IsBinary       bool
279
        IsNew          bool
280

281
        // display settings
282
        Theme    enum.Theme
283
        ViewMode enum.ViewMode
284
        SortMode enum.SortMode
285

286
        // form state
287
        Search   string
288
        Error    string
289
        CanForce bool // allow force submit despite error (for validation errors, not conflicts)
290

291
        // auth and permissions
292
        AuthEnabled bool
293
        BaseURL     string
294
        CanWrite    bool   // user has write permission (for showing edit controls)
295
        Username    string // current logged-in username
296

297
        // modal sizing
298
        ModalWidth     int
299
        TextareaHeight int
300

301
        // embedded groups
302
        conflictData
303
        paginationData
304
        secretsData
305
        historyData
306
}
307

308
// getTheme returns the current theme from cookie, defaulting to system.
309
func (h *Handler) getTheme(r *http.Request) enum.Theme {
36✔
310
        if c, err := r.Cookie("theme"); err == nil {
41✔
311
                if theme, err := enum.ParseTheme(c.Value); err == nil {
9✔
312
                        return theme
4✔
313
                }
4✔
314
        }
315
        return enum.ThemeSystem
32✔
316
}
317

318
// getViewMode returns the current view mode from cookie, defaulting to grid.
319
func (h *Handler) getViewMode(r *http.Request) enum.ViewMode {
27✔
320
        if c, err := r.Cookie("view_mode"); err == nil {
34✔
321
                if mode, err := enum.ParseViewMode(c.Value); err == nil {
13✔
322
                        return mode
6✔
323
                }
6✔
324
        }
325
        return enum.ViewModeGrid
21✔
326
}
327

328
// getSortMode returns the current sort mode from cookie, defaulting to updated.
329
func (h *Handler) getSortMode(r *http.Request) enum.SortMode {
30✔
330
        if c, err := r.Cookie("sort_mode"); err == nil {
43✔
331
                if mode, err := enum.ParseSortMode(c.Value); err == nil {
25✔
332
                        return mode
12✔
333
                }
12✔
334
        }
335
        return enum.SortModeUpdated
18✔
336
}
337

338
// getSecretsFilter returns the current secrets filter from cookie, defaulting to all.
339
func (h *Handler) getSecretsFilter(r *http.Request) enum.SecretsFilter {
25✔
340
        if c, err := r.Cookie("secrets_filter"); err == nil {
31✔
341
                if filter, err := enum.ParseSecretsFilter(c.Value); err == nil {
12✔
342
                        return filter
6✔
343
                }
6✔
344
        }
345
        return enum.SecretsFilterAll
19✔
346
}
347

348
// listParams holds view state parameters extracted from cookies and Set-Cookie headers.
349
type listParams struct {
350
        viewMode      enum.ViewMode
351
        sortMode      enum.SortMode
352
        secretsFilter enum.SecretsFilter
353
}
354

355
// getListParams extracts view/sort/filter state, checking Set-Cookie header for just-set values.
356
// this is needed when toggle handlers set cookie and then call handleKeyList in same request.
357
func (h *Handler) getListParams(w http.ResponseWriter, r *http.Request) listParams {
17✔
358
        p := listParams{
17✔
359
                viewMode:      h.getViewMode(r),
17✔
360
                sortMode:      h.getSortMode(r),
17✔
361
                secretsFilter: h.getSecretsFilter(r),
17✔
362
        }
17✔
363
        for _, c := range w.Header()["Set-Cookie"] {
28✔
364
                switch {
11✔
365
                case strings.Contains(c, "view_mode=cards"):
2✔
366
                        p.viewMode = enum.ViewModeCards
2✔
367
                case strings.Contains(c, "view_mode=grid"):
1✔
368
                        p.viewMode = enum.ViewModeGrid
1✔
369
                case strings.Contains(c, "sort_mode=key"):
1✔
370
                        p.sortMode = enum.SortModeKey
1✔
371
                case strings.Contains(c, "sort_mode=size"):
1✔
372
                        p.sortMode = enum.SortModeSize
1✔
373
                case strings.Contains(c, "sort_mode=created"):
1✔
374
                        p.sortMode = enum.SortModeCreated
1✔
375
                case strings.Contains(c, "sort_mode=updated"):
1✔
376
                        p.sortMode = enum.SortModeUpdated
1✔
377
                case strings.Contains(c, "secrets_filter=all"):
1✔
378
                        p.secretsFilter = enum.SecretsFilterAll
1✔
379
                case strings.Contains(c, "secrets_filter=secretsonly"):
2✔
380
                        p.secretsFilter = enum.SecretsFilterSecretsOnly
2✔
381
                case strings.Contains(c, "secrets_filter=keysonly"):
1✔
382
                        p.secretsFilter = enum.SecretsFilterKeysOnly
1✔
383
                }
384
        }
385
        return p
17✔
386
}
387

388
// url returns a URL path with the base URL prefix.
389
func (h *Handler) url(path string) string {
7✔
390
        return h.baseURL + path
7✔
391
}
7✔
392

393
// cookiePath returns the path for cookies (base URL with trailing slash or "/").
394
func (h *Handler) cookiePath() string {
20✔
395
        if h.baseURL == "" {
38✔
396
                return "/"
18✔
397
        }
18✔
398
        return h.baseURL + "/"
2✔
399
}
400

401
// getCurrentUser returns the username from the session cookie, or empty string if not logged in.
402
func (h *Handler) getCurrentUser(r *http.Request) string {
69✔
403
        for _, cookieName := range cookie.SessionCookieNames {
207✔
404
                if c, err := r.Cookie(cookieName); err == nil {
142✔
405
                        if username, ok := h.auth.GetSessionUser(r.Context(), c.Value); ok {
6✔
406
                                return username
2✔
407
                        }
2✔
408
                }
409
        }
410
        return ""
67✔
411
}
412

413
// getIdentityForLog returns identity string for audit logging.
414
// returns "user:xxx" for authenticated users, "anonymous" otherwise.
415
func (h *Handler) getIdentityForLog(r *http.Request) string {
7✔
416
        if username := h.getCurrentUser(r); username != "" {
8✔
417
                return "user:" + username
1✔
418
        }
1✔
419
        return "anonymous"
6✔
420
}
421

422
// getAuthor returns git author for the given username.
423
func (h *Handler) getAuthor(username string) git.Author {
3✔
424
        if username == "" {
4✔
425
                return git.DefaultAuthor()
1✔
426
        }
1✔
427
        return git.Author{Name: username, Email: username + "@stash"}
2✔
428
}
429

430
// sortByMode sorts a slice by the given mode using a key accessor.
431
func (h *Handler) sortByMode(keys []keyWithPermission, mode enum.SortMode) {
24✔
432
        switch mode {
24✔
433
        case enum.SortModeKey:
2✔
434
                sort.Slice(keys, func(i, j int) bool {
5✔
435
                        return strings.ToLower(keys[i].Key) < strings.ToLower(keys[j].Key)
3✔
436
                })
3✔
437
        case enum.SortModeSize:
2✔
438
                sort.Slice(keys, func(i, j int) bool {
5✔
439
                        return keys[i].Size > keys[j].Size // largest first
3✔
440
                })
3✔
441
        case enum.SortModeCreated:
2✔
442
                sort.Slice(keys, func(i, j int) bool {
5✔
443
                        return keys[i].CreatedAt.After(keys[j].CreatedAt) // newest first
3✔
444
                })
3✔
445
        default: // updated
18✔
446
                sort.Slice(keys, func(i, j int) bool {
40✔
447
                        return keys[i].UpdatedAt.After(keys[j].UpdatedAt) // newest first
22✔
448
                })
22✔
449
        }
450
}
451

452
// valueForDisplay converts a byte slice to a display string, detecting binary content.
453
func (h *Handler) valueForDisplay(value []byte) (string, bool) {
7✔
454
        if !utf8.Valid(value) {
8✔
455
                return base64.StdEncoding.EncodeToString(value), true
1✔
456
        }
1✔
457
        return string(value), false
6✔
458
}
459

460
// valueFromForm converts form input back to bytes, handling binary encoding.
461
func (h *Handler) valueFromForm(value string, isBinary bool) ([]byte, error) {
15✔
462
        if isBinary {
19✔
463
                decoded, err := base64.StdEncoding.DecodeString(value)
4✔
464
                if err != nil {
7✔
465
                        return nil, fmt.Errorf("decode base64: %w", err)
3✔
466
                }
3✔
467
                return decoded, nil
1✔
468
        }
469
        return []byte(value), nil
11✔
470
}
471

472
// filterBySearch filters keys by search term.
473
func (h *Handler) filterBySearch(keys []keyWithPermission, search string) []keyWithPermission {
21✔
474
        if search == "" {
38✔
475
                return keys
17✔
476
        }
17✔
477
        search = strings.ToLower(search)
4✔
478
        var filtered []keyWithPermission
4✔
479
        for _, k := range keys {
15✔
480
                if strings.Contains(strings.ToLower(k.Key), search) {
16✔
481
                        filtered = append(filtered, k)
5✔
482
                }
5✔
483
        }
484
        return filtered
4✔
485
}
486

487
// paginateResult holds the result of pagination.
488
type paginateResult struct {
489
        keys       []keyWithPermission
490
        page       int
491
        totalPages int
492
        hasPrev    bool
493
        hasNext    bool
494
}
495

496
// paginate applies pagination to a slice of keys and returns pagination info.
497
// page is 1-based, pageSize is the max keys per page.
498
func (h *Handler) paginate(keys []keyWithPermission, page, pageSize int) paginateResult {
30✔
499
        total := len(keys)
30✔
500
        if pageSize <= 0 {
49✔
501
                return paginateResult{keys: keys, page: 1, totalPages: 1}
19✔
502
        }
19✔
503

504
        totalPages := (total + pageSize - 1) / pageSize
11✔
505
        if totalPages == 0 {
12✔
506
                totalPages = 1
1✔
507
        }
1✔
508
        if page < 1 {
13✔
509
                page = 1
2✔
510
        }
2✔
511
        if page > totalPages {
12✔
512
                page = totalPages
1✔
513
        }
1✔
514

515
        start := (page - 1) * pageSize
11✔
516
        end := start + pageSize
11✔
517
        if start >= total {
12✔
518
                return paginateResult{page: page, totalPages: totalPages, hasPrev: page > 1}
1✔
519
        }
1✔
520
        if end > total {
13✔
521
                end = total
3✔
522
        }
3✔
523
        return paginateResult{keys: keys[start:end], page: page, totalPages: totalPages, hasPrev: page > 1, hasNext: page < totalPages}
10✔
524
}
525

526
// filterKeysByPermission filters keys based on user permissions and wraps with write permission info.
527
func (h *Handler) filterKeysByPermission(username string, keys []store.KeyInfo) []keyWithPermission {
23✔
528
        keyNames := make([]string, len(keys))
23✔
529
        for i, k := range keys {
57✔
530
                keyNames[i] = k.Key
34✔
531
        }
34✔
532
        allowedKeys := h.auth.FilterUserKeys(username, keyNames)
23✔
533
        allowedSet := make(map[string]bool, len(allowedKeys))
23✔
534
        for _, k := range allowedKeys {
53✔
535
                allowedSet[k] = true
30✔
536
        }
30✔
537
        var filtered []keyWithPermission
23✔
538
        for _, k := range keys {
57✔
539
                if allowedSet[k.Key] {
64✔
540
                        filtered = append(filtered, keyWithPermission{
30✔
541
                                KeyInfo:  k,
30✔
542
                                CanWrite: h.auth.CheckUserPermission(username, k.Key, true),
30✔
543
                        })
30✔
544
                }
30✔
545
        }
546
        return filtered
23✔
547
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc