Skip to content

Commit e23885c

Browse files
committed
Edge case fixes: race conditions, resource leaks, and input validation
- Fix race condition in batch.go with mutex protection for results slice - Fix potential channel deadlock in ffmpeg_version.go SSE streaming - Add Close() method to GCS provider to prevent connection pool leaks - Add nil checks to handler constructors (batch, ffmpeg_version, api_keys) - Add ExpiresIn validation (1-365 days) to API key creation - Extract isAdmin/requireAdmin helpers to reduce code duplication
1 parent 17edb5b commit e23885c

File tree

4 files changed

+77
-71
lines changed

4 files changed

+77
-71
lines changed

internal/handlers/api_keys.go

Lines changed: 31 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,43 @@ type APIKeyHandler struct {
1717

1818
// NewAPIKeyHandler creates a new API key handler
1919
func NewAPIKeyHandler(rotationService *services.SecretRotationService, logger zerolog.Logger) *APIKeyHandler {
20+
if rotationService == nil {
21+
panic("rotationService cannot be nil")
22+
}
2023
return &APIKeyHandler{
2124
rotationService: rotationService,
2225
logger: logger,
2326
}
2427
}
2528

29+
// isAdmin checks if the current user has admin role
30+
func isAdmin(c *gin.Context) bool {
31+
roles := c.GetStringSlice("roles")
32+
for _, role := range roles {
33+
if role == "admin" {
34+
return true
35+
}
36+
}
37+
return false
38+
}
39+
40+
// requireAdmin returns an error response if the user is not an admin, returns true if admin
41+
func requireAdmin(c *gin.Context) bool {
42+
if !isAdmin(c) {
43+
c.JSON(http.StatusForbidden, gin.H{
44+
"error": "Forbidden",
45+
"message": "Admin role required",
46+
})
47+
return false
48+
}
49+
return true
50+
}
51+
2652
// CreateAPIKeyRequest represents the request to create a new API key
2753
type CreateAPIKeyRequest struct {
2854
Name string `json:"name" binding:"required,min=3,max=100"`
2955
Permissions []string `json:"permissions"`
30-
ExpiresIn int `json:"expires_in_days,omitempty"` // Optional, defaults to 90 days
56+
ExpiresIn int `json:"expires_in_days,omitempty" binding:"omitempty,min=1,max=365"` // Optional, defaults to 90 days, max 1 year
3157
}
3258

3359
// RotateAPIKeyRequest represents the request to rotate an API key
@@ -183,21 +209,7 @@ func (h *APIKeyHandler) RotateAPIKey(c *gin.Context) {
183209

184210
// RotateJWTSecret rotates the JWT signing secret (admin only)
185211
func (h *APIKeyHandler) RotateJWTSecret(c *gin.Context) {
186-
// Check admin role
187-
roles := c.GetStringSlice("roles")
188-
isAdmin := false
189-
for _, role := range roles {
190-
if role == "admin" {
191-
isAdmin = true
192-
break
193-
}
194-
}
195-
196-
if !isAdmin {
197-
c.JSON(http.StatusForbidden, gin.H{
198-
"error": "Forbidden",
199-
"message": "Admin role required",
200-
})
212+
if !requireAdmin(c) {
201213
return
202214
}
203215

@@ -242,21 +254,7 @@ func (h *APIKeyHandler) UpdateRateLimits(c *gin.Context) {
242254
return
243255
}
244256

245-
// Check admin role
246-
roles := c.GetStringSlice("roles")
247-
isAdmin := false
248-
for _, role := range roles {
249-
if role == "admin" {
250-
isAdmin = true
251-
break
252-
}
253-
}
254-
255-
if !isAdmin {
256-
c.JSON(http.StatusForbidden, gin.H{
257-
"error": "Forbidden",
258-
"message": "Admin role required",
259-
})
257+
if !requireAdmin(c) {
260258
return
261259
}
262260

@@ -319,21 +317,7 @@ func (h *APIKeyHandler) UpdateRateLimits(c *gin.Context) {
319317

320318
// CheckRotationStatus checks which secrets are due for rotation (admin only)
321319
func (h *APIKeyHandler) CheckRotationStatus(c *gin.Context) {
322-
// Check admin role
323-
roles := c.GetStringSlice("roles")
324-
isAdmin := false
325-
for _, role := range roles {
326-
if role == "admin" {
327-
isAdmin = true
328-
break
329-
}
330-
}
331-
332-
if !isAdmin {
333-
c.JSON(http.StatusForbidden, gin.H{
334-
"error": "Forbidden",
335-
"message": "Admin role required",
336-
})
320+
if !requireAdmin(c) {
337321
return
338322
}
339323

@@ -361,21 +345,7 @@ func (h *APIKeyHandler) CheckRotationStatus(c *gin.Context) {
361345

362346
// CleanupExpiredKeys removes expired keys past their grace period (admin only)
363347
func (h *APIKeyHandler) CleanupExpiredKeys(c *gin.Context) {
364-
// Check admin role
365-
roles := c.GetStringSlice("roles")
366-
isAdmin := false
367-
for _, role := range roles {
368-
if role == "admin" {
369-
isAdmin = true
370-
break
371-
}
372-
}
373-
374-
if !isAdmin {
375-
c.JSON(http.StatusForbidden, gin.H{
376-
"error": "Forbidden",
377-
"message": "Admin role required",
378-
})
348+
if !requireAdmin(c) {
379349
return
380350
}
381351

internal/handlers/batch.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ type BatchHandler struct {
2626

2727
// NewBatchHandler creates a new batch handler
2828
func NewBatchHandler(analysisService *services.AnalysisService, logger zerolog.Logger) *BatchHandler {
29+
if analysisService == nil {
30+
panic("analysisService cannot be nil")
31+
}
2932
return &BatchHandler{
3033
analysisService: analysisService,
3134
maxBatchSize: 100, // Default max batch size
@@ -357,6 +360,7 @@ func (h *BatchHandler) processBatchAsync(batchID uuid.UUID, req BatchAnalysisReq
357360
// Process files concurrently with limited concurrency
358361
sem := make(chan struct{}, 5) // Limit to 5 concurrent analyses
359362
var wg sync.WaitGroup
363+
var resultsMutex sync.Mutex
360364
results := make([]BatchResultItem, len(req.Files))
361365

362366
for i, file := range req.Files {
@@ -382,7 +386,9 @@ func (h *BatchHandler) processBatchAsync(batchID uuid.UUID, req BatchAnalysisReq
382386

383387
if cancelled {
384388
result.Status = "cancelled"
389+
resultsMutex.Lock()
385390
results[index] = result
391+
resultsMutex.Unlock()
386392
return
387393
}
388394

@@ -400,8 +406,12 @@ func (h *BatchHandler) processBatchAsync(batchID uuid.UUID, req BatchAnalysisReq
400406
if err != nil {
401407
result.Status = "failed"
402408
result.Error = err.Error()
409+
resultsMutex.Lock()
403410
results[index] = result
404-
h.updateBatchProgress(batchID, results)
411+
resultsCopy := make([]BatchResultItem, len(results))
412+
copy(resultsCopy, results)
413+
resultsMutex.Unlock()
414+
h.updateBatchProgress(batchID, resultsCopy)
405415
return
406416
}
407417

@@ -420,8 +430,12 @@ func (h *BatchHandler) processBatchAsync(batchID uuid.UUID, req BatchAnalysisReq
420430
result.Status = "completed"
421431
}
422432

433+
resultsMutex.Lock()
423434
results[index] = result
424-
h.updateBatchProgress(batchID, results)
435+
resultsCopy := make([]BatchResultItem, len(results))
436+
copy(resultsCopy, results)
437+
resultsMutex.Unlock()
438+
h.updateBatchProgress(batchID, resultsCopy)
425439
}(i, file)
426440
}
427441

internal/handlers/ffmpeg_version.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ type FFmpegVersionHandler struct {
1717

1818
// NewFFmpegVersionHandler creates a new FFmpeg version handler
1919
func NewFFmpegVersionHandler(updater *services.FFmpegUpdater, logger zerolog.Logger) *FFmpegVersionHandler {
20+
if updater == nil {
21+
panic("updater cannot be nil")
22+
}
2023
return &FFmpegVersionHandler{
2124
updater: updater,
2225
logger: logger,
@@ -121,13 +124,18 @@ func (h *FFmpegVersionHandler) UpdateFFmpeg(c *gin.Context) {
121124

122125
// Perform the update
123126
progressChan := make(chan int, 100)
124-
errorChan := make(chan error, 1)
127+
doneChan := make(chan error, 1)
125128

126129
go func() {
127130
err := h.updater.DownloadUpdate(ctx, updateInfo.Available, func(percent int) {
128-
progressChan <- percent
131+
select {
132+
case progressChan <- percent:
133+
default:
134+
// Channel full, skip this update
135+
}
129136
})
130-
errorChan <- err
137+
doneChan <- err
138+
close(doneChan)
131139
close(progressChan)
132140
}()
133141

@@ -140,11 +148,17 @@ func (h *FFmpegVersionHandler) UpdateFFmpeg(c *gin.Context) {
140148
select {
141149
case progress, ok := <-progressChan:
142150
if !ok {
143-
// Channel closed, check for error
144-
if err := <-errorChan; err != nil {
145-
h.logger.Error().Err(err).Msg("FFmpeg update failed")
146-
c.SSEvent("error", gin.H{"data": err.Error()})
147-
} else {
151+
// Progress channel closed, get result from done channel
152+
select {
153+
case err := <-doneChan:
154+
if err != nil {
155+
h.logger.Error().Err(err).Msg("FFmpeg update failed")
156+
c.SSEvent("error", gin.H{"data": err.Error()})
157+
} else {
158+
c.SSEvent("complete", gin.H{"data": "FFmpeg updated successfully"})
159+
}
160+
default:
161+
// Already consumed or not available
148162
c.SSEvent("complete", gin.H{"data": "FFmpeg updated successfully"})
149163
}
150164
return false

internal/storage/gcs.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,11 @@ func (g *GCSProvider) GetSignedURL(ctx context.Context, key string, expiration i
9595

9696
return url, nil
9797
}
98+
99+
// Close closes the GCS client and releases resources
100+
func (g *GCSProvider) Close() error {
101+
if g.client != nil {
102+
return g.client.Close()
103+
}
104+
return nil
105+
}

0 commit comments

Comments
 (0)