164 lines
4.5 KiB
Go
164 lines
4.5 KiB
Go
package upload
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
|
"github.com/google/uuid"
|
|
"github.com/minio/minio-go/v7"
|
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
)
|
|
|
|
const maxUploadSize = 1 << 30 // 1 GB per file
|
|
|
|
var allowedExtensions = map[string]bool{
|
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
|
".mp3": true, ".wav": true, ".ogg": true, ".flac": true,
|
|
".mp4": true, ".webm": true, ".mov": true,
|
|
".pdf": true, ".txt": true, ".md": true, ".json": true, ".csv": true,
|
|
".zip": true, ".tar": true, ".gz": true,
|
|
}
|
|
|
|
type Handler struct {
|
|
client *minio.Client
|
|
bucket string
|
|
}
|
|
|
|
func NewHandler(cfg *config.Config) (*Handler, error) {
|
|
endpoint := cfg.MinIO.Endpoint
|
|
if endpoint == "" {
|
|
return nil, fmt.Errorf("minio endpoint not configured")
|
|
}
|
|
|
|
client, err := minio.New(endpoint, &minio.Options{
|
|
Creds: credentials.NewStaticV4(cfg.MinIO.AccessKey, cfg.MinIO.SecretKey, ""),
|
|
Secure: cfg.MinIO.UseSSL,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("minio client: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
exists, err := client.BucketExists(ctx, cfg.MinIO.Bucket)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bucket check: %w", err)
|
|
}
|
|
if !exists {
|
|
if err := client.MakeBucket(ctx, cfg.MinIO.Bucket, minio.MakeBucketOptions{}); err != nil {
|
|
return nil, fmt.Errorf("make bucket: %w", err)
|
|
}
|
|
}
|
|
|
|
return &Handler{
|
|
client: client,
|
|
bucket: cfg.MinIO.Bucket,
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
|
|
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
|
http.Error(w, `{"error":"file too large or invalid form"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
http.Error(w, `{"error":"missing file"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Validate extension against allowlist
|
|
ext := ""
|
|
if idx := strings.LastIndex(header.Filename, "."); idx >= 0 {
|
|
ext = strings.ToLower(header.Filename[idx:])
|
|
}
|
|
if !allowedExtensions[ext] {
|
|
http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Server-side content-type detection (blocks HTML/JS that could be used for XSS)
|
|
buf := make([]byte, 512)
|
|
n, _ := file.Read(buf)
|
|
detectedType := http.DetectContentType(buf[:n])
|
|
file.Seek(0, io.SeekStart)
|
|
|
|
blockedTypes := []string{"text/html", "application/javascript", "application/x-executable"}
|
|
for _, blocked := range blockedTypes {
|
|
if strings.HasPrefix(detectedType, blocked) {
|
|
http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
contentType := header.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = detectedType
|
|
}
|
|
|
|
objectName := uuid.New().String() + ext
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
info, err := h.client.PutObject(ctx, h.bucket, objectName, file, header.Size, minio.PutObjectOptions{
|
|
ContentType: contentType,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, `{"error":"upload failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"url":"/files/%s","size":%d}`, objectName, info.Size)
|
|
}
|
|
|
|
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
|
objectName := strings.TrimPrefix(r.URL.Path, "/files/")
|
|
if objectName == "" {
|
|
http.Error(w, `{"error":"missing file"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Sanitize: reject path traversal attempts
|
|
if strings.Contains(objectName, "..") || strings.Contains(objectName, "/") {
|
|
http.Error(w, `{"error":"invalid path"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
obj, err := h.client.GetObject(ctx, h.bucket, objectName, minio.GetObjectOptions{})
|
|
if err != nil {
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
defer obj.Close()
|
|
|
|
stat, err := obj.Stat()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", stat.ContentType)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
|
|
// Inline display for images; attachment for everything else
|
|
if strings.HasPrefix(stat.ContentType, "image/") {
|
|
w.Header().Set("Content-Disposition", "inline")
|
|
} else {
|
|
w.Header().Set("Content-Disposition", "attachment")
|
|
}
|
|
io.Copy(w, obj)
|
|
}
|