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

umputun / stash / 19853570984

02 Dec 2025 09:24AM UTC coverage: 82.757% (-0.2%) from 82.927%
19853570984

Pull #22

github

umputun
refactor: use enum.SortMode type instead of strings

- Change getSortMode to return enum.SortMode
- Change sortByMode to accept enum.SortMode
- Change sortModeLabel to accept enum.SortMode
- Add SortMode.Next() method to eliminate switch for cycling
- Simplify sortModeLabel to capitalize first letter
- Update tests to use enum types
Pull Request #22: Migrate manual enums to go-pkgz/enum

42 of 42 new or added lines in 6 files covered. (100.0%)

15 existing lines in 2 files now uncovered.

2275 of 2749 relevant lines covered (82.76%)

88.26 hits per line

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

87.94
/app/server/api/handler.go
1
// Package api provides HTTP handlers for the KV API.
2
package api
3

4
import (
5
        "errors"
6
        "io"
7
        "net/http"
8
        "strings"
9

10
        log "github.com/go-pkgz/lgr"
11
        "github.com/go-pkgz/rest"
12
        "github.com/go-pkgz/routegroup"
13

14
        "github.com/umputun/stash/app/git"
15
        "github.com/umputun/stash/app/store"
16
)
17

18
//go:generate moq -out mocks/kvstore.go -pkg mocks -skip-ensure -fmt goimports . KVStore
19
//go:generate moq -out mocks/authprovider.go -pkg mocks -skip-ensure -fmt goimports . AuthProvider
20
//go:generate moq -out mocks/formatvalidator.go -pkg mocks -skip-ensure -fmt goimports . FormatValidator
21
//go:generate moq -out mocks/gitservice.go -pkg mocks -skip-ensure -fmt goimports . GitService
22

23
// sessionCookieNames defines cookie names for session authentication.
24
// __Host- prefix requires HTTPS, secure, path=/ (preferred for production).
25
// fallback cookie name works on HTTP for development.
26
var sessionCookieNames = []string{"__Host-stash-auth", "stash-auth"}
27

28
// GitService defines the interface for git operations.
29
type GitService interface {
30
        Commit(req git.CommitRequest) error
31
        Delete(key string, author git.Author) error
32
}
33

34
// KVStore defines the interface for key-value storage operations.
35
type KVStore interface {
36
        Get(key string) ([]byte, error)
37
        GetWithFormat(key string) ([]byte, string, error)
38
        Set(key string, value []byte, format string) error
39
        Delete(key string) error
40
        List() ([]store.KeyInfo, error)
41
}
42

43
// AuthProvider defines the interface for authentication operations.
44
type AuthProvider interface {
45
        Enabled() bool
46
        GetSessionUser(token string) (string, bool)
47
        FilterUserKeys(username string, keys []string) []string
48
        FilterTokenKeys(token string, keys []string) []string
49
        FilterPublicKeys(keys []string) []string
50
        HasTokenACL(token string) bool
51
}
52

53
// FormatValidator defines the interface for format validation.
54
type FormatValidator interface {
55
        IsValidFormat(format string) bool
56
}
57

58
// Handler handles API requests for /kv/* endpoints.
59
type Handler struct {
60
        store           KVStore
61
        auth            AuthProvider
62
        formatValidator FormatValidator
63
        git             GitService
64
}
65

66
// New creates a new API handler.
67
func New(st KVStore, auth AuthProvider, fv FormatValidator, gs GitService) *Handler {
28✔
68
        return &Handler{
28✔
69
                store:           st,
28✔
70
                auth:            auth,
28✔
71
                formatValidator: fv,
28✔
72
                git:             gs,
28✔
73
        }
28✔
74
}
28✔
75

76
// Register registers API routes on the given router.
UNCOV
77
func (h *Handler) Register(r *routegroup.Bundle) {
×
78
        r.HandleFunc("GET /{$}", h.handleList)     // list keys (must be before {key...})
×
79
        r.HandleFunc("GET /{key...}", h.handleGet) // get specific key
×
80
        r.HandleFunc("PUT /{key...}", h.handleSet) // set key
×
81
        r.HandleFunc("DELETE /{key...}", h.handleDelete)
×
82
}
×
83

84
// handleList returns all keys the caller has read access to.
85
// GET /kv
86
// Optional query params: ?prefix=app/config (filter by prefix)
87
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
3✔
88
        keys, err := h.store.List()
3✔
89
        if err != nil {
4✔
90
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to list keys")
1✔
91
                return
1✔
92
        }
1✔
93

94
        // extract key names for filtering
95
        keyNames := make([]string, len(keys))
2✔
96
        for i, k := range keys {
7✔
97
                keyNames[i] = k.Key
5✔
98
        }
5✔
99

100
        // filter by auth permissions
101
        filteredNames := h.filterKeysByAuth(r, keyNames)
2✔
102
        if filteredNames == nil {
2✔
UNCOV
103
                // no valid auth, but this shouldn't happen since tokenAuth middleware already checked
×
104
                rest.SendErrorJSON(w, r, log.Default(), http.StatusUnauthorized, nil, "unauthorized")
×
105
                return
×
106
        }
×
107

108
        // convert filtered names back to KeyInfo slice
109
        nameSet := make(map[string]bool, len(filteredNames))
2✔
110
        for _, name := range filteredNames {
7✔
111
                nameSet[name] = true
5✔
112
        }
5✔
113
        filtered := make([]store.KeyInfo, 0, len(filteredNames))
2✔
114
        for _, k := range keys {
7✔
115
                if nameSet[k.Key] {
10✔
116
                        filtered = append(filtered, k)
5✔
117
                }
5✔
118
        }
119

120
        // filter by prefix if specified
121
        prefix := r.URL.Query().Get("prefix")
2✔
122
        if prefix != "" {
3✔
123
                var prefixed []store.KeyInfo
1✔
124
                for _, k := range filtered {
4✔
125
                        if strings.HasPrefix(k.Key, prefix) {
5✔
126
                                prefixed = append(prefixed, k)
2✔
127
                        }
2✔
128
                }
129
                filtered = prefixed
1✔
130
        }
131

132
        log.Printf("[DEBUG] list keys: %d found, %d after auth filter", len(keys), len(filtered))
2✔
133
        rest.RenderJSON(w, filtered)
2✔
134
}
135

136
// filterKeysByAuth filters keys based on the caller's auth credentials.
137
// returns nil if auth is required but caller has no valid credentials.
138
// priority: session cookie > Bearer token > public ACL
139
func (h *Handler) filterKeysByAuth(r *http.Request, keys []string) []string {
7✔
140
        // no auth = return all keys
7✔
141
        if h.auth == nil || !h.auth.Enabled() {
10✔
142
                return keys
3✔
143
        }
3✔
144

145
        // check session cookie first (authenticated user has priority over public)
146
        for _, cookieName := range sessionCookieNames {
12✔
147
                cookie, err := r.Cookie(cookieName)
8✔
148
                if err != nil {
15✔
149
                        continue
7✔
150
                }
151
                username, valid := h.auth.GetSessionUser(cookie.Value)
1✔
152
                if valid {
2✔
153
                        return h.auth.FilterUserKeys(username, keys)
1✔
154
                }
1✔
155
        }
156

157
        // check Bearer token (authenticated token has priority over public)
158
        authHeader := r.Header.Get("Authorization")
3✔
159
        if token, found := strings.CutPrefix(authHeader, "Bearer "); found {
4✔
160
                if filtered := h.auth.FilterTokenKeys(token, keys); filtered != nil {
2✔
161
                        return filtered
1✔
162
                }
1✔
163
        }
164

165
        // fall back to public access for unauthenticated requests
166
        if filtered := h.auth.FilterPublicKeys(keys); filtered != nil {
3✔
167
                return filtered
1✔
168
        }
1✔
169

170
        return nil // no valid auth
1✔
171
}
172

173
// handleGet retrieves the value for a key.
174
// GET /kv/{key...}
175
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
4✔
176
        key := store.NormalizeKey(r.PathValue("key"))
4✔
177
        if key == "" {
5✔
178
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
179
                return
1✔
180
        }
1✔
181

182
        value, format, err := h.store.GetWithFormat(key)
3✔
183
        if errors.Is(err, store.ErrNotFound) {
4✔
184
                rest.SendErrorJSON(w, r, log.Default(), http.StatusNotFound, err, "key not found")
1✔
185
                return
1✔
186
        }
1✔
187
        if err != nil {
2✔
UNCOV
188
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to get key")
×
189
                return
×
190
        }
×
191

192
        log.Printf("[DEBUG] get %s (%d bytes, format=%s)", key, len(value), format)
2✔
193

2✔
194
        w.Header().Set("Content-Type", h.formatToContentType(format))
2✔
195
        w.WriteHeader(http.StatusOK)
2✔
196
        if _, err := w.Write(value); err != nil {
2✔
UNCOV
197
                log.Printf("[WARN] failed to write response: %v", err)
×
198
        }
×
199
}
200

201
// formatToContentType maps storage format to HTTP Content-Type.
202
func (h *Handler) formatToContentType(format string) string {
11✔
203
        switch format {
11✔
204
        case "json":
2✔
205
                return "application/json"
2✔
206
        case "yaml":
1✔
207
                return "application/yaml"
1✔
208
        case "xml":
1✔
209
                return "application/xml"
1✔
210
        case "toml":
1✔
211
                return "application/toml"
1✔
212
        case "hcl", "ini", "text":
4✔
213
                return "text/plain"
4✔
214
        case "shell":
1✔
215
                return "text/x-shellscript"
1✔
216
        default:
1✔
217
                return "application/octet-stream"
1✔
218
        }
219
}
220

221
// handleSet stores a value for a key.
222
// PUT /kv/{key...}
223
// Accepts format via X-Stash-Format header or ?format= query param (defaults to "text").
224
func (h *Handler) handleSet(w http.ResponseWriter, r *http.Request) {
7✔
225
        key := store.NormalizeKey(r.PathValue("key"))
7✔
226
        if key == "" {
8✔
227
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
228
                return
1✔
229
        }
1✔
230

231
        value, err := io.ReadAll(r.Body)
6✔
232
        if err != nil {
6✔
UNCOV
233
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "failed to read body")
×
UNCOV
234
                return
×
UNCOV
235
        }
×
236

237
        // get format from header or query param, default to "text"
238
        format := r.Header.Get("X-Stash-Format")
6✔
239
        if format == "" {
10✔
240
                format = r.URL.Query().Get("format")
4✔
241
        }
4✔
242
        if !h.formatValidator.IsValidFormat(format) {
10✔
243
                format = "text"
4✔
244
        }
4✔
245

246
        if err := h.store.Set(key, value, format); err != nil {
7✔
247
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to set key")
1✔
248
                return
1✔
249
        }
1✔
250

251
        log.Printf("[INFO] set %q (%d bytes, format=%s) by %s", key, len(value), format, h.getIdentityForLog(r))
5✔
252

5✔
253
        // commit to git if enabled
5✔
254
        if h.git != nil {
6✔
255
                req := git.CommitRequest{Key: key, Value: value, Operation: "set", Format: format, Author: h.getAuthorFromRequest(r)}
1✔
256
                if err := h.git.Commit(req); err != nil {
1✔
UNCOV
257
                        log.Printf("[WARN] git commit failed for %s: %v", key, err)
×
UNCOV
258
                }
×
259
        }
260

261
        w.WriteHeader(http.StatusOK)
5✔
262
}
263

264
// handleDelete removes a key from the store.
265
// DELETE /kv/{key...}
266
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
5✔
267
        key := store.NormalizeKey(r.PathValue("key"))
5✔
268
        if key == "" {
6✔
269
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
270
                return
1✔
271
        }
1✔
272

273
        err := h.store.Delete(key)
4✔
274
        if errors.Is(err, store.ErrNotFound) {
5✔
275
                rest.SendErrorJSON(w, r, log.Default(), http.StatusNotFound, err, "key not found")
1✔
276
                return
1✔
277
        }
1✔
278
        if err != nil {
4✔
279
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to delete key")
1✔
280
                return
1✔
281
        }
1✔
282

283
        log.Printf("[INFO] delete %q by %s", key, h.getIdentityForLog(r))
2✔
284

2✔
285
        // delete from git if enabled
2✔
286
        if h.git != nil {
3✔
287
                if err := h.git.Delete(key, h.getAuthorFromRequest(r)); err != nil {
1✔
UNCOV
288
                        log.Printf("[WARN] git delete failed for %s: %v", key, err)
×
UNCOV
289
                }
×
290
        }
291

292
        w.WriteHeader(http.StatusNoContent)
2✔
293
}
294

295
// identityType represents the type of identity detected from a request.
296
type identityType int
297

298
const (
299
        identityAnonymous identityType = iota
300
        identityUser
301
        identityToken
302
)
303

304
// identity holds information about who made a request.
305
type identity struct {
306
        typ  identityType
307
        name string // username or token prefix
308
}
309

310
// getIdentity extracts identity from request context.
311
// returns user identity from session cookie, token identity from Authorization header, or anonymous.
312
func (h *Handler) getIdentity(r *http.Request) identity {
17✔
313
        if h.auth == nil {
19✔
314
                return identity{typ: identityAnonymous}
2✔
315
        }
2✔
316

317
        // check session cookie for web UI users
318
        for _, cookieName := range sessionCookieNames {
45✔
319
                cookie, err := r.Cookie(cookieName)
30✔
320
                if err != nil {
57✔
321
                        continue
27✔
322
                }
323
                if username, valid := h.auth.GetSessionUser(cookie.Value); valid && username != "" {
6✔
324
                        return identity{typ: identityUser, name: username}
3✔
325
                }
3✔
326
        }
327

328
        // check API token from Authorization header
329
        if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
14✔
330
                token := strings.TrimPrefix(authHeader, "Bearer ")
2✔
331
                if h.auth.HasTokenACL(token) {
4✔
332
                        prefix := token
2✔
333
                        if len(prefix) > 8 {
4✔
334
                                prefix = prefix[:8]
2✔
335
                        }
2✔
336
                        return identity{typ: identityToken, name: "token:" + prefix}
2✔
337
                }
338
        }
339

340
        return identity{typ: identityAnonymous}
10✔
341
}
342

343
// getAuthorFromRequest extracts the git author from request context.
344
// returns username from session cookie for web UI users, token prefix for API tokens, default author otherwise.
345
func (h *Handler) getAuthorFromRequest(r *http.Request) git.Author {
5✔
346
        id := h.getIdentity(r)
5✔
347
        switch id.typ {
5✔
348
        case identityUser, identityToken:
2✔
349
                return git.Author{Name: id.name, Email: id.name + "@stash"}
2✔
350
        default:
3✔
351
                return git.DefaultAuthor()
3✔
352
        }
353
}
354

355
// getIdentityForLog returns identity string for audit logging.
356
// returns "user:xxx" for web UI users, "token:xxx" for API tokens, "anonymous" otherwise.
357
func (h *Handler) getIdentityForLog(r *http.Request) string {
9✔
358
        id := h.getIdentity(r)
9✔
359
        switch id.typ {
9✔
360
        case identityUser:
1✔
361
                return "user:" + id.name
1✔
UNCOV
362
        case identityToken:
×
UNCOV
363
                return id.name // already has "token:" prefix
×
364
        default:
8✔
365
                return "anonymous"
8✔
366
        }
367
}
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