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) }