Single-purpose bucket with randomized hex keys doesn't benefit from a prefix — no lifecycle policies or access controls scoped to it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
105 lines
2.8 KiB
Go
105 lines
2.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
)
|
|
|
|
const maxUploadSize = 10 << 20 // 10 MB
|
|
|
|
// Allowed MIME type prefixes and exact types for uploads.
|
|
var allowedContentTypes = map[string]bool{
|
|
"image/png": true,
|
|
"image/jpeg": true,
|
|
"image/gif": true,
|
|
"image/webp": true,
|
|
"image/svg+xml": true,
|
|
"application/pdf": true,
|
|
"text/plain": true,
|
|
"text/csv": true,
|
|
"application/json": true,
|
|
"video/mp4": true,
|
|
"video/webm": true,
|
|
"audio/mpeg": true,
|
|
"audio/wav": true,
|
|
"application/zip": true,
|
|
}
|
|
|
|
func isContentTypeAllowed(ct string) bool {
|
|
// Normalize: take only the media type, strip parameters like charset.
|
|
ct = strings.TrimSpace(strings.SplitN(ct, ";", 2)[0])
|
|
ct = strings.ToLower(ct)
|
|
return allowedContentTypes[ct]
|
|
}
|
|
|
|
func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
|
if h.Storage == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "file upload not configured")
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
|
|
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
|
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing file field: %v", err))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Sniff actual content type from file bytes instead of trusting the client header.
|
|
buf := make([]byte, 512)
|
|
n, err := file.Read(buf)
|
|
if err != nil && err != io.EOF {
|
|
writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
contentType := http.DetectContentType(buf[:n])
|
|
if !isContentTypeAllowed(contentType) {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType))
|
|
return
|
|
}
|
|
// Seek back so the full file is uploaded.
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to read file")
|
|
return
|
|
}
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
slog.Error("failed to generate file key", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
key := hex.EncodeToString(b) + path.Ext(header.Filename)
|
|
|
|
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
|
|
if err != nil {
|
|
slog.Error("file upload failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "upload failed")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"filename": header.Filename,
|
|
"link": link,
|
|
})
|
|
}
|