Files
goclaw/internal/http/media_upload.go
T
Goon 75c570e951 feat(security): credentialed exec + HTTP RBAC + API key cache (#197)
- Secure CLI credential injection via AES-256-GCM encrypted env vars
- API key management with fine-grained RBAC scopes
- resolveAuth/requireAuth middleware across all 25+ HTTP handlers
- In-memory API key cache with TTL, negative caching, pubsub invalidation
- Sandbox-first execution (fails if unavailable, no silent fallback)
- Credential scrubbing, constant-time token comparison, Admin-only CLI creds
- SQL migration 000020: secure_cli_binaries + api_keys tables
- 14 unit tests for cache and RBAC with race detector

Closes #197
2026-03-15 20:13:18 +07:00

93 lines
2.5 KiB
Go

package http
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
)
const (
// maxUploadSize is the default max upload size (50MB).
maxUploadSize int64 = 50 * 1024 * 1024
)
// MediaUploadHandler handles media file uploads for WebSocket clients.
type MediaUploadHandler struct {
token string
}
// NewMediaUploadHandler creates a media upload handler.
func NewMediaUploadHandler(token string) *MediaUploadHandler {
return &MediaUploadHandler{token: token}
}
// RegisterRoutes registers the upload endpoint.
func (h *MediaUploadHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/media/upload", h.auth(h.handleUpload))
}
func (h *MediaUploadHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(h.token, "", next)
}
func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgFileTooLarge)})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMissingFileField)})
return
}
defer file.Close()
// Sanitize filename: strip path, prevent traversal.
origName := filepath.Base(header.Filename)
if origName == "." || origName == "/" || strings.Contains(origName, "..") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)})
return
}
ext := filepath.Ext(origName)
if ext == "" {
ext = ".bin"
}
// Save to temp with unique name.
tmpName := fmt.Sprintf("ws_upload_%d%s", time.Now().UnixNano(), ext)
tmpPath := filepath.Join(os.TempDir(), tmpName)
out, err := os.Create(tmpPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create temp file")})
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
os.Remove(tmpPath)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
mimeType := media.DetectMIMEType(origName)
writeJSON(w, http.StatusOK, map[string]any{
"path": tmpPath,
"mime_type": mimeType,
"filename": origName,
})
}