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

umputun / stash / 19759573083

28 Nov 2025 09:25AM UTC coverage: 81.864% (-1.6%) from 83.465%
19759573083

Pull #17

github

umputun
refactor(server): remove internal package, improve test coverage

- move NormalizeKey to store package (where keys belong)
- duplicate sessionCookieNames locally in api, web, server packages
- delete internal/shared package entirely
- consolidate NormalizeKey tests into store/store_test.go
- add tests for web handlers (permission denied, validation errors)
- improve web package coverage from 75.8% to 81.2%
- overall coverage now at 82.3%
Pull Request #17: refactor(server): split into api/web subpackages with improved coverage

400 of 468 new or added lines in 11 files covered. (85.47%)

5 existing lines in 2 files now uncovered.

2126 of 2597 relevant lines covered (81.86%)

11.04 hits per line

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

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

4
//go:generate moq -out mocks/kvstore.go -pkg mocks -skip-ensure -fmt goimports . KVStore
5
//go:generate moq -out mocks/validator.go -pkg mocks -skip-ensure -fmt goimports . Validator
6
//go:generate moq -out mocks/authprovider.go -pkg mocks -skip-ensure -fmt goimports . AuthProvider
7
//go:generate moq -out mocks/gitservice.go -pkg mocks -skip-ensure -fmt goimports . GitService
8

9
import (
10
        "embed"
11
        "encoding/base64"
12
        "fmt"
13
        "html/template"
14
        "io/fs"
15
        "net/http"
16
        "net/url"
17
        "sort"
18
        "strconv"
19
        "strings"
20
        "time"
21
        "unicode/utf8"
22

23
        "github.com/go-pkgz/routegroup"
24

25
        "github.com/umputun/stash/app/git"
26
        "github.com/umputun/stash/app/store"
27
)
28

29
// sessionCookieNames defines cookie names for session authentication.
30
// __Host- prefix requires HTTPS, secure, path=/ (preferred for production).
31
// fallback cookie name works on HTTP for development.
32
var sessionCookieNames = []string{"__Host-stash-auth", "stash-auth"}
33

34
//go:embed static
35
var staticFS embed.FS
36

37
//go:embed templates
38
var templatesFS embed.FS
39

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

49
// KVStore defines the interface for key-value storage operations.
50
type KVStore interface {
51
        GetWithFormat(key string) ([]byte, string, error)
52
        GetInfo(key string) (store.KeyInfo, error)
53
        Set(key string, value []byte, format string) error
54
        Delete(key string) error
55
        List() ([]store.KeyInfo, error)
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(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
        // login methods
73
        IsValidUser(username, password string) bool
74
        CreateSession(username string) (string, error)
75
        InvalidateSession(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
}
84

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

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

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

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

122
// Register registers web UI routes on the given router.
NEW
123
func (h *Handler) Register(r *routegroup.Bundle) {
×
NEW
124
        r.HandleFunc("GET /{$}", h.handleIndex)
×
NEW
125
        r.HandleFunc("GET /web/keys", h.handleKeyList)
×
NEW
126
        r.HandleFunc("GET /web/keys/new", h.handleKeyNew)
×
NEW
127
        r.HandleFunc("GET /web/keys/view/{key...}", h.handleKeyView)
×
NEW
128
        r.HandleFunc("GET /web/keys/edit/{key...}", h.handleKeyEdit)
×
NEW
129
        r.HandleFunc("POST /web/keys", h.handleKeyCreate)
×
NEW
130
        r.HandleFunc("PUT /web/keys/{key...}", h.handleKeyUpdate)
×
NEW
131
        r.HandleFunc("DELETE /web/keys/{key...}", h.handleKeyDelete)
×
NEW
132
        r.HandleFunc("POST /web/theme", h.handleThemeToggle)
×
NEW
133
        r.HandleFunc("POST /web/view-mode", h.handleViewModeToggle)
×
NEW
134
        r.HandleFunc("POST /web/sort", h.handleSortToggle)
×
NEW
135
}
×
136

137
// RegisterAuth registers auth routes (login/logout) on the given router.
NEW
138
func (h *Handler) RegisterAuth(r *routegroup.Bundle) {
×
NEW
139
        r.HandleFunc("GET /login", h.handleLoginForm)
×
NEW
140
        r.HandleFunc("POST /logout", h.handleLogout)
×
NEW
141
}
×
142

143
// RegisterLogin registers the login POST handler with custom middleware.
NEW
144
func (h *Handler) RegisterLogin(r *routegroup.Bundle, middleware func(http.Handler) http.Handler) {
×
NEW
145
        r.Handle("POST /login", middleware(http.HandlerFunc(h.handleLogin)))
×
146
}
×
147

148
// templateFuncs returns custom template functions.
149
func templateFuncs() template.FuncMap {
60✔
150
        return template.FuncMap{
60✔
151
                "formatTime": func(t time.Time) string {
80✔
152
                        return t.Format("2006-01-02 15:04")
20✔
153
                },
20✔
154
                "formatSize": func(size int) string {
10✔
155
                        if size < 1024 {
20✔
156
                                return strconv.Itoa(size) + " B"
10✔
157
                        }
10✔
158
                        if size < 1024*1024 {
×
159
                                return strconv.FormatFloat(float64(size)/1024, 'f', 1, 64) + " KB"
×
160
                        }
×
161
                        return strconv.FormatFloat(float64(size)/(1024*1024), 'f', 1, 64) + " MB"
×
162
                },
163
                "urlEncode":     url.PathEscape,
164
                "sortModeLabel": sortModeLabel,
165
                "add":           func(a, b int) int { return a + b },
4✔
166
                "sub":           func(a, b int) int { return a - b },
2✔
167
        }
168
}
169

170
// parseTemplates parses all templates from embedded filesystem.
171
func parseTemplates() (*template.Template, error) {
60✔
172
        tmpl := template.New("").Funcs(templateFuncs())
60✔
173

60✔
174
        // parse base template
60✔
175
        baseContent, err := templatesFS.ReadFile("templates/base.html")
60✔
176
        if err != nil {
60✔
177
                return nil, fmt.Errorf("read base.html: %w", err)
×
178
        }
×
179
        tmpl, err = tmpl.Parse(string(baseContent))
60✔
180
        if err != nil {
60✔
181
                return nil, fmt.Errorf("parse base.html: %w", err)
×
182
        }
×
183

184
        // parse login template
185
        loginContent, err := templatesFS.ReadFile("templates/login.html")
60✔
186
        if err != nil {
60✔
187
                return nil, fmt.Errorf("read login.html: %w", err)
×
188
        }
×
189
        _, err = tmpl.New("login.html").Parse(string(loginContent))
60✔
190
        if err != nil {
60✔
191
                return nil, fmt.Errorf("parse login.html: %w", err)
×
192
        }
×
193

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

204
        // parse partials
205
        partials := []string{"keys-table", "form", "view"}
60✔
206
        for _, name := range partials {
240✔
207
                content, readErr := templatesFS.ReadFile("templates/partials/" + name + ".html")
180✔
208
                if readErr != nil {
180✔
209
                        return nil, fmt.Errorf("read partial %s: %w", name, readErr)
×
210
                }
×
211
                _, parseErr := tmpl.New(name).Parse(string(content))
180✔
212
                if parseErr != nil {
180✔
213
                        return nil, fmt.Errorf("parse partial %s: %w", name, parseErr)
×
214
                }
×
215
        }
216

217
        return tmpl, nil
60✔
218
}
219

220
// keyWithPermission wraps KeyInfo with per-key write permission.
221
type keyWithPermission struct {
222
        store.KeyInfo
223
        CanWrite bool // user has write permission for this specific key
224
}
225

226
// templateData holds data passed to templates.
227
type templateData struct {
228
        Keys           []keyWithPermission
229
        Key            string
230
        Value          string
231
        HighlightedVal template.HTML // syntax-highlighted value for view modal
232
        Format         string        // format type (text, json, yaml, etc.)
233
        Formats        []string      // available format options
234
        IsBinary       bool
235
        IsNew          bool
236
        Theme          string
237
        ViewMode       string
238
        SortMode       string
239
        Search         string
240
        Error          string
241
        CanForce       bool // allow force submit despite error (for validation errors, not conflicts)
242
        AuthEnabled    bool
243
        BaseURL        string
244
        ModalWidth     int
245
        TextareaHeight int
246
        CanWrite       bool   // user has write permission (for showing edit controls)
247
        Username       string // current logged-in username
248

249
        // conflict detection fields
250
        UpdatedAt       int64  // unix timestamp when key was loaded (for optimistic locking)
251
        Conflict        bool   // true when a conflict was detected on save
252
        ServerValue     string // current server value (shown during conflict)
253
        ServerFormat    string // current server format (shown during conflict)
254
        ServerUpdatedAt int64  // server's updated_at timestamp (for retry after conflict)
255

256
        // pagination fields
257
        Page       int  // current page (1-based)
258
        TotalPages int  // total number of pages
259
        TotalKeys  int  // total keys after filtering (before pagination)
260
        HasPrev    bool // has previous page
261
        HasNext    bool // has next page
262
}
263

264
// sortModeLabel returns a human-readable label for the sort mode.
265
func sortModeLabel(mode string) string {
25✔
266
        switch mode {
25✔
267
        case "key":
2✔
268
                return "Key"
2✔
269
        case "size":
2✔
270
                return "Size"
2✔
271
        case "created":
2✔
272
                return "Created"
2✔
273
        default:
19✔
274
                return "Updated"
19✔
275
        }
276
}
277

278
// getTheme returns the current theme from cookie.
279
func (h *Handler) getTheme(r *http.Request) string {
30✔
280
        cookie, err := r.Cookie("theme")
30✔
281
        if err != nil || cookie.Value == "" {
55✔
282
                return "" // use system preference
25✔
283
        }
25✔
284
        if cookie.Value == "dark" || cookie.Value == "light" {
9✔
285
                return cookie.Value
4✔
286
        }
4✔
287
        return ""
1✔
288
}
289

290
// getViewMode returns the current view mode from cookie, defaulting to "grid".
291
func (h *Handler) getViewMode(r *http.Request) string {
23✔
292
        if cookie, err := r.Cookie("view_mode"); err == nil {
30✔
293
                if cookie.Value == "cards" || cookie.Value == "grid" {
13✔
294
                        return cookie.Value
6✔
295
                }
6✔
296
        }
297
        return "grid"
17✔
298
}
299

300
// getSortMode returns the current sort mode from cookie, defaulting to "updated".
301
func (h *Handler) getSortMode(r *http.Request) string {
26✔
302
        if cookie, err := r.Cookie("sort_mode"); err == nil {
39✔
303
                switch cookie.Value {
13✔
304
                case "key", "size", "created", "updated":
12✔
305
                        return cookie.Value
12✔
306
                }
307
        }
308
        return "updated"
14✔
309
}
310

311
// url returns a URL path with the base URL prefix.
312
func (h *Handler) url(path string) string {
6✔
313
        return h.baseURL + path
6✔
314
}
6✔
315

316
// cookiePath returns the path for cookies (base URL with trailing slash or "/").
317
func (h *Handler) cookiePath() string {
15✔
318
        if h.baseURL == "" {
28✔
319
                return "/"
13✔
320
        }
13✔
321
        return h.baseURL + "/"
2✔
322
}
323

324
// getCurrentUser returns the username from the session cookie, or empty string if not logged in.
325
func (h *Handler) getCurrentUser(r *http.Request) string {
41✔
326
        for _, cookieName := range sessionCookieNames {
123✔
327
                if cookie, err := r.Cookie(cookieName); err == nil {
84✔
328
                        if username, ok := h.auth.GetSessionUser(cookie.Value); ok {
3✔
329
                                return username
1✔
330
                        }
1✔
331
                }
332
        }
333
        return ""
40✔
334
}
335

336
// getAuthor returns git author for the given username.
337
func (h *Handler) getAuthor(username string) git.Author {
3✔
338
        if username == "" {
4✔
339
                return git.DefaultAuthor()
1✔
340
        }
1✔
341
        return git.Author{Name: username, Email: username + "@stash"}
2✔
342
}
343

344
// sortByMode sorts a slice by the given mode using a key accessor.
345
func (h *Handler) sortByMode(keys []keyWithPermission, mode string) {
20✔
346
        switch mode {
20✔
347
        case "key":
2✔
348
                sort.Slice(keys, func(i, j int) bool {
5✔
349
                        return strings.ToLower(keys[i].Key) < strings.ToLower(keys[j].Key)
3✔
350
                })
3✔
351
        case "size":
2✔
352
                sort.Slice(keys, func(i, j int) bool {
5✔
353
                        return keys[i].Size > keys[j].Size // largest first
3✔
354
                })
3✔
355
        case "created":
2✔
356
                sort.Slice(keys, func(i, j int) bool {
5✔
357
                        return keys[i].CreatedAt.After(keys[j].CreatedAt) // newest first
3✔
358
                })
3✔
359
        default: // "updated"
14✔
360
                sort.Slice(keys, func(i, j int) bool {
36✔
361
                        return keys[i].UpdatedAt.After(keys[j].UpdatedAt) // newest first
22✔
362
                })
22✔
363
        }
364
}
365

366
// valueForDisplay converts a byte slice to a display string, detecting binary content.
367
func (h *Handler) valueForDisplay(value []byte) (string, bool) {
7✔
368
        if !utf8.Valid(value) {
8✔
369
                return base64.StdEncoding.EncodeToString(value), true
1✔
370
        }
1✔
371
        return string(value), false
6✔
372
}
373

374
// valueFromForm converts form input back to bytes, handling binary encoding.
375
func (h *Handler) valueFromForm(value string, isBinary bool) ([]byte, error) {
10✔
376
        if isBinary {
12✔
377
                decoded, err := base64.StdEncoding.DecodeString(value)
2✔
378
                if err != nil {
3✔
379
                        return nil, fmt.Errorf("decode base64: %w", err)
1✔
380
                }
1✔
381
                return decoded, nil
1✔
382
        }
383
        return []byte(value), nil
8✔
384
}
385

386
// filterBySearch filters keys by search term.
387
func (h *Handler) filterBySearch(keys []keyWithPermission, search string) []keyWithPermission {
17✔
388
        if search == "" {
30✔
389
                return keys
13✔
390
        }
13✔
391
        search = strings.ToLower(search)
4✔
392
        var filtered []keyWithPermission
4✔
393
        for _, k := range keys {
15✔
394
                if strings.Contains(strings.ToLower(k.Key), search) {
16✔
395
                        filtered = append(filtered, k)
5✔
396
                }
5✔
397
        }
398
        return filtered
4✔
399
}
400

401
// paginate applies pagination to a slice of keys and returns pagination info.
402
// page is 1-based, pageSize is the max keys per page.
403
func (h *Handler) paginate(keys []keyWithPermission, page, pageSize int) ([]keyWithPermission, int, int, bool, bool) {
26✔
404
        total := len(keys)
26✔
405
        if pageSize <= 0 {
41✔
406
                return keys, 1, 1, false, false
15✔
407
        }
15✔
408

409
        totalPages := (total + pageSize - 1) / pageSize
11✔
410
        if totalPages == 0 {
12✔
411
                totalPages = 1
1✔
412
        }
1✔
413
        if page < 1 {
13✔
414
                page = 1
2✔
415
        }
2✔
416
        if page > totalPages {
12✔
417
                page = totalPages
1✔
418
        }
1✔
419

420
        start := (page - 1) * pageSize
11✔
421
        end := start + pageSize
11✔
422
        if start >= total {
12✔
423
                return nil, page, totalPages, page > 1, false
1✔
424
        }
1✔
425
        if end > total {
13✔
426
                end = total
3✔
427
        }
3✔
428
        return keys[start:end], page, totalPages, page > 1, page < totalPages
10✔
429
}
430

431
// filterKeysByPermission filters keys based on user permissions and wraps with write permission info.
432
func (h *Handler) filterKeysByPermission(username string, keys []store.KeyInfo) []keyWithPermission {
19✔
433
        keyNames := make([]string, len(keys))
19✔
434
        for i, k := range keys {
53✔
435
                keyNames[i] = k.Key
34✔
436
        }
34✔
437
        allowedKeys := h.auth.FilterUserKeys(username, keyNames)
19✔
438
        allowedSet := make(map[string]bool, len(allowedKeys))
19✔
439
        for _, k := range allowedKeys {
49✔
440
                allowedSet[k] = true
30✔
441
        }
30✔
442
        var filtered []keyWithPermission
19✔
443
        for _, k := range keys {
53✔
444
                if allowedSet[k.Key] {
64✔
445
                        filtered = append(filtered, keyWithPermission{
30✔
446
                                KeyInfo:  k,
30✔
447
                                CanWrite: h.auth.CheckUserPermission(username, k.Key, true),
30✔
448
                        })
30✔
449
                }
30✔
450
        }
451
        return filtered
19✔
452
}
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