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

umputun / stash / 20474227667

23 Dec 2025 11:49PM UTC coverage: 83.059% (-0.4%) from 83.43%
20474227667

Pull #45

github

umputun
fix(e2e): wait for edit button before clicking

Wait for button visibility after HTMX content swap to ensure
handlers are attached. Fixes flaky tests on slow CI runners.
Pull Request #45: feat: implement secrets vault with encrypted storage

261 of 316 new or added lines in 9 files covered. (82.59%)

2 existing lines in 2 files now uncovered.

3074 of 3701 relevant lines covered (83.06%)

81.94 hits per line

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

76.98
/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) {
77✔
107
        tmpl, err := parseTemplates()
77✔
108
        if err != nil {
77✔
109
                return nil, fmt.Errorf("failed to parse templates: %w", err)
×
110
        }
×
111

112
        return &Handler{
77✔
113
                store:       st,
77✔
114
                validator:   val,
77✔
115
                auth:        auth,
77✔
116
                highlighter: NewHighlighter(),
77✔
117
                tmpl:        tmpl,
77✔
118
                baseURL:     cfg.BaseURL,
77✔
119
                pageSize:    cfg.PageSize,
77✔
120
                git:         gs,
77✔
121
        }, nil
77✔
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 {
77✔
156
        // sortModeLabel returns a human-readable label for the sort mode.
77✔
157
        sortModeLabel := func(mode enum.SortMode) string {
96✔
158
                s := mode.String()
19✔
159
                return strings.ToUpper(s[:1]) + s[1:]
19✔
160
        }
19✔
161

162
        return template.FuncMap{
77✔
163
                "formatTime": func(t time.Time) string {
98✔
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) {
77✔
184
        tmpl := template.New("").Funcs(templateFuncs())
77✔
185

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

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

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

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

229
        return tmpl, nil
77✔
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
// templateData holds data passed to templates.
239
type templateData struct {
240
        Keys           []keyWithPermission
241
        Key            string
242
        Value          string
243
        HighlightedVal template.HTML // syntax-highlighted value for view modal
244
        Format         string        // format type (text, json, yaml, etc.)
245
        Formats        []string      // available format options
246
        IsBinary       bool
247
        IsNew          bool
248
        Theme          enum.Theme
249
        ViewMode       enum.ViewMode
250
        SortMode       enum.SortMode
251
        Search         string
252
        Error          string
253
        CanForce       bool // allow force submit despite error (for validation errors, not conflicts)
254
        AuthEnabled    bool
255
        BaseURL        string
256
        ModalWidth     int
257
        TextareaHeight int
258
        CanWrite       bool   // user has write permission (for showing edit controls)
259
        Username       string // current logged-in username
260

261
        // conflict detection fields
262
        UpdatedAt       int64  // unix timestamp when key was loaded (for optimistic locking)
263
        Conflict        bool   // true when a conflict was detected on save
264
        ServerValue     string // current server value (shown during conflict)
265
        ServerFormat    string // current server format (shown during conflict)
266
        ServerUpdatedAt int64  // server's updated_at timestamp (for retry after conflict)
267

268
        // pagination fields
269
        Page       int  // current page (1-based)
270
        TotalPages int  // total number of pages
271
        TotalKeys  int  // total keys after filtering (before pagination)
272
        HasPrev    bool // has previous page
273
        HasNext    bool // has next page
274

275
        // secrets filter fields
276
        SecretsFilter  enum.SecretsFilter // current filter mode (all/secrets/keys)
277
        SecretsEnabled bool               // secrets feature enabled
278

279
        // history fields
280
        GitEnabled bool               // git integration enabled
281
        History    []git.HistoryEntry // commit history entries
282
        RevHash    string             // specific revision hash being viewed
283
}
284

285
// getTheme returns the current theme from cookie, defaulting to system.
286
func (h *Handler) getTheme(r *http.Request) enum.Theme {
32✔
287
        if c, err := r.Cookie("theme"); err == nil {
37✔
288
                if theme, err := enum.ParseTheme(c.Value); err == nil {
9✔
289
                        return theme
4✔
290
                }
4✔
291
        }
292
        return enum.ThemeSystem
28✔
293
}
294

295
// getViewMode returns the current view mode from cookie, defaulting to grid.
296
func (h *Handler) getViewMode(r *http.Request) enum.ViewMode {
23✔
297
        if c, err := r.Cookie("view_mode"); err == nil {
30✔
298
                if mode, err := enum.ParseViewMode(c.Value); err == nil {
13✔
299
                        return mode
6✔
300
                }
6✔
301
        }
302
        return enum.ViewModeGrid
17✔
303
}
304

305
// getSortMode returns the current sort mode from cookie, defaulting to updated.
306
func (h *Handler) getSortMode(r *http.Request) enum.SortMode {
26✔
307
        if c, err := r.Cookie("sort_mode"); err == nil {
39✔
308
                if mode, err := enum.ParseSortMode(c.Value); err == nil {
25✔
309
                        return mode
12✔
310
                }
12✔
311
        }
312
        return enum.SortModeUpdated
14✔
313
}
314

315
// getSecretsFilter returns the current secrets filter from cookie, defaulting to all.
316
func (h *Handler) getSecretsFilter(r *http.Request) enum.SecretsFilter {
17✔
317
        if c, err := r.Cookie("secrets_filter"); err == nil {
17✔
NEW
318
                if filter, err := enum.ParseSecretsFilter(c.Value); err == nil {
×
NEW
319
                        return filter
×
NEW
320
                }
×
321
        }
322
        return enum.SecretsFilterAll
17✔
323
}
324

325
// listParams holds view state parameters extracted from cookies and Set-Cookie headers.
326
type listParams struct {
327
        viewMode      enum.ViewMode
328
        sortMode      enum.SortMode
329
        secretsFilter enum.SecretsFilter
330
}
331

332
// getListParams extracts view/sort/filter state, checking Set-Cookie header for just-set values.
333
// this is needed when toggle handlers set cookie and then call handleKeyList in same request.
334
func (h *Handler) getListParams(w http.ResponseWriter, r *http.Request) listParams {
13✔
335
        p := listParams{
13✔
336
                viewMode:      h.getViewMode(r),
13✔
337
                sortMode:      h.getSortMode(r),
13✔
338
                secretsFilter: h.getSecretsFilter(r),
13✔
339
        }
13✔
340
        for _, c := range w.Header()["Set-Cookie"] {
20✔
341
                switch {
7✔
342
                case strings.Contains(c, "view_mode=cards"):
2✔
343
                        p.viewMode = enum.ViewModeCards
2✔
344
                case strings.Contains(c, "view_mode=grid"):
1✔
345
                        p.viewMode = enum.ViewModeGrid
1✔
346
                case strings.Contains(c, "sort_mode=key"):
1✔
347
                        p.sortMode = enum.SortModeKey
1✔
348
                case strings.Contains(c, "sort_mode=size"):
1✔
349
                        p.sortMode = enum.SortModeSize
1✔
350
                case strings.Contains(c, "sort_mode=created"):
1✔
351
                        p.sortMode = enum.SortModeCreated
1✔
352
                case strings.Contains(c, "sort_mode=updated"):
1✔
353
                        p.sortMode = enum.SortModeUpdated
1✔
NEW
354
                case strings.Contains(c, "secrets_filter=all"):
×
NEW
355
                        p.secretsFilter = enum.SecretsFilterAll
×
NEW
356
                case strings.Contains(c, "secrets_filter=secretsonly"):
×
NEW
357
                        p.secretsFilter = enum.SecretsFilterSecretsOnly
×
NEW
358
                case strings.Contains(c, "secrets_filter=keysonly"):
×
NEW
359
                        p.secretsFilter = enum.SecretsFilterKeysOnly
×
360
                }
361
        }
362
        return p
13✔
363
}
364

365
// url returns a URL path with the base URL prefix.
366
func (h *Handler) url(path string) string {
7✔
367
        return h.baseURL + path
7✔
368
}
7✔
369

370
// cookiePath returns the path for cookies (base URL with trailing slash or "/").
371
func (h *Handler) cookiePath() string {
16✔
372
        if h.baseURL == "" {
30✔
373
                return "/"
14✔
374
        }
14✔
375
        return h.baseURL + "/"
2✔
376
}
377

378
// getCurrentUser returns the username from the session cookie, or empty string if not logged in.
379
func (h *Handler) getCurrentUser(r *http.Request) string {
65✔
380
        for _, cookieName := range cookie.SessionCookieNames {
195✔
381
                if c, err := r.Cookie(cookieName); err == nil {
134✔
382
                        if username, ok := h.auth.GetSessionUser(r.Context(), c.Value); ok {
6✔
383
                                return username
2✔
384
                        }
2✔
385
                }
386
        }
387
        return ""
63✔
388
}
389

390
// getIdentityForLog returns identity string for audit logging.
391
// returns "user:xxx" for authenticated users, "anonymous" otherwise.
392
func (h *Handler) getIdentityForLog(r *http.Request) string {
7✔
393
        if username := h.getCurrentUser(r); username != "" {
8✔
394
                return "user:" + username
1✔
395
        }
1✔
396
        return "anonymous"
6✔
397
}
398

399
// getAuthor returns git author for the given username.
400
func (h *Handler) getAuthor(username string) git.Author {
3✔
401
        if username == "" {
4✔
402
                return git.DefaultAuthor()
1✔
403
        }
1✔
404
        return git.Author{Name: username, Email: username + "@stash"}
2✔
405
}
406

407
// sortByMode sorts a slice by the given mode using a key accessor.
408
func (h *Handler) sortByMode(keys []keyWithPermission, mode enum.SortMode) {
20✔
409
        switch mode {
20✔
410
        case enum.SortModeKey:
2✔
411
                sort.Slice(keys, func(i, j int) bool {
5✔
412
                        return strings.ToLower(keys[i].Key) < strings.ToLower(keys[j].Key)
3✔
413
                })
3✔
414
        case enum.SortModeSize:
2✔
415
                sort.Slice(keys, func(i, j int) bool {
5✔
416
                        return keys[i].Size > keys[j].Size // largest first
3✔
417
                })
3✔
418
        case enum.SortModeCreated:
2✔
419
                sort.Slice(keys, func(i, j int) bool {
5✔
420
                        return keys[i].CreatedAt.After(keys[j].CreatedAt) // newest first
3✔
421
                })
3✔
422
        default: // updated
14✔
423
                sort.Slice(keys, func(i, j int) bool {
36✔
424
                        return keys[i].UpdatedAt.After(keys[j].UpdatedAt) // newest first
22✔
425
                })
22✔
426
        }
427
}
428

429
// valueForDisplay converts a byte slice to a display string, detecting binary content.
430
func (h *Handler) valueForDisplay(value []byte) (string, bool) {
7✔
431
        if !utf8.Valid(value) {
8✔
432
                return base64.StdEncoding.EncodeToString(value), true
1✔
433
        }
1✔
434
        return string(value), false
6✔
435
}
436

437
// valueFromForm converts form input back to bytes, handling binary encoding.
438
func (h *Handler) valueFromForm(value string, isBinary bool) ([]byte, error) {
15✔
439
        if isBinary {
19✔
440
                decoded, err := base64.StdEncoding.DecodeString(value)
4✔
441
                if err != nil {
7✔
442
                        return nil, fmt.Errorf("decode base64: %w", err)
3✔
443
                }
3✔
444
                return decoded, nil
1✔
445
        }
446
        return []byte(value), nil
11✔
447
}
448

449
// filterBySearch filters keys by search term.
450
func (h *Handler) filterBySearch(keys []keyWithPermission, search string) []keyWithPermission {
17✔
451
        if search == "" {
30✔
452
                return keys
13✔
453
        }
13✔
454
        search = strings.ToLower(search)
4✔
455
        var filtered []keyWithPermission
4✔
456
        for _, k := range keys {
15✔
457
                if strings.Contains(strings.ToLower(k.Key), search) {
16✔
458
                        filtered = append(filtered, k)
5✔
459
                }
5✔
460
        }
461
        return filtered
4✔
462
}
463

464
// paginateResult holds the result of pagination.
465
type paginateResult struct {
466
        keys       []keyWithPermission
467
        page       int
468
        totalPages int
469
        hasPrev    bool
470
        hasNext    bool
471
}
472

473
// paginate applies pagination to a slice of keys and returns pagination info.
474
// page is 1-based, pageSize is the max keys per page.
475
func (h *Handler) paginate(keys []keyWithPermission, page, pageSize int) paginateResult {
26✔
476
        total := len(keys)
26✔
477
        if pageSize <= 0 {
41✔
478
                return paginateResult{keys: keys, page: 1, totalPages: 1}
15✔
479
        }
15✔
480

481
        totalPages := (total + pageSize - 1) / pageSize
11✔
482
        if totalPages == 0 {
12✔
483
                totalPages = 1
1✔
484
        }
1✔
485
        if page < 1 {
13✔
486
                page = 1
2✔
487
        }
2✔
488
        if page > totalPages {
12✔
489
                page = totalPages
1✔
490
        }
1✔
491

492
        start := (page - 1) * pageSize
11✔
493
        end := start + pageSize
11✔
494
        if start >= total {
12✔
495
                return paginateResult{page: page, totalPages: totalPages, hasPrev: page > 1}
1✔
496
        }
1✔
497
        if end > total {
13✔
498
                end = total
3✔
499
        }
3✔
500
        return paginateResult{keys: keys[start:end], page: page, totalPages: totalPages, hasPrev: page > 1, hasNext: page < totalPages}
10✔
501
}
502

503
// filterKeysByPermission filters keys based on user permissions and wraps with write permission info.
504
func (h *Handler) filterKeysByPermission(username string, keys []store.KeyInfo) []keyWithPermission {
19✔
505
        keyNames := make([]string, len(keys))
19✔
506
        for i, k := range keys {
53✔
507
                keyNames[i] = k.Key
34✔
508
        }
34✔
509
        allowedKeys := h.auth.FilterUserKeys(username, keyNames)
19✔
510
        allowedSet := make(map[string]bool, len(allowedKeys))
19✔
511
        for _, k := range allowedKeys {
49✔
512
                allowedSet[k] = true
30✔
513
        }
30✔
514
        var filtered []keyWithPermission
19✔
515
        for _, k := range keys {
53✔
516
                if allowedSet[k.Key] {
64✔
517
                        filtered = append(filtered, keyWithPermission{
30✔
518
                                KeyInfo:  k,
30✔
519
                                CanWrite: h.auth.CheckUserPermission(username, k.Key, true),
30✔
520
                        })
30✔
521
                }
30✔
522
        }
523
        return filtered
19✔
524
}
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