package handlers import ( "encoding/json" "fmt" "io" "mime" "net/http" "path" "strings" "github.com/gorilla/mux" "github.com/noteapp/backend/internal/application/services" "github.com/noteapp/backend/internal/interfaces/middleware" ) const maxUploadSize = 100 << 20 // 100 MB // FileHandler exposes S3 file explorer endpoints scoped to spaces. type FileHandler struct { fileService *services.FileService } // NewFileHandler creates a new FileHandler. func NewFileHandler(fileService *services.FileService) *FileHandler { return &FileHandler{fileService: fileService} } // extractContext extracts and validates spaceId (URL) and userId (JWT context). func (h *FileHandler) extractContext(r *http.Request) (spaceID, userID string, err error) { spaceID = mux.Vars(r)["spaceId"] if spaceID == "" { return "", "", fmt.Errorf("missing spaceId") } userID, err = middleware.GetUserIDFromContext(r.Context()) return } // cleanKey sanitises a user-supplied relative key (strips leading slash, resolves .). func cleanKey(raw string) string { k := strings.TrimLeft(strings.TrimSpace(raw), "/") if c := path.Clean(k); c != "." { return c } return "" } // cleanPrefix sanitises a user-supplied relative prefix. func cleanPrefix(raw string) string { p := strings.TrimLeft(strings.TrimSpace(raw), "/") if c := path.Clean(p); c != "." { return c } return "" } // respondError maps service errors to appropriate HTTP status codes. func respondError(w http.ResponseWriter, err error) { msg := err.Error() switch { case strings.Contains(msg, "access denied"), strings.Contains(msg, "disabled"): http.Error(w, msg, http.StatusForbidden) default: http.Error(w, msg, http.StatusBadRequest) } } // ListFiles handles GET /api/v1/spaces/{spaceId}/files/list?prefix= func (h *FileHandler) ListFiles(w http.ResponseWriter, r *http.Request) { spaceID, userID, err := h.extractContext(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } relPrefix := cleanPrefix(r.URL.Query().Get("prefix")) objects, err := h.fileService.ListObjects(r.Context(), userID, spaceID, relPrefix) if err != nil { respondError(w, err) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "objects": objects, "prefix": relPrefix, }) } // GetFile handles GET /api/v1/spaces/{spaceId}/files/object?key= // Also accepts ?token= as a fallback auth mechanism so markdown images render in-browser. func (h *FileHandler) GetFile(w http.ResponseWriter, r *http.Request) { spaceID, userID, err := h.extractContext(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } relKey := cleanKey(r.URL.Query().Get("key")) if relKey == "" { http.Error(w, "key is required", http.StatusBadRequest) return } body, contentType, err := h.fileService.GetObjectContent(r.Context(), userID, spaceID, relKey) if err != nil { if strings.Contains(err.Error(), "access denied") { http.Error(w, "access denied", http.StatusForbidden) return } http.Error(w, "file not found", http.StatusNotFound) return } defer body.Close() w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "private, max-age=3600") io.Copy(w, body) //nolint:errcheck } // UploadFile handles POST /api/v1/spaces/{spaceId}/files/upload (multipart/form-data) // Form fields: // - path: optional relative folder within the space (e.g. "docs/2024") // - files: one or more file uploads func (h *FileHandler) UploadFile(w http.ResponseWriter, r *http.Request) { spaceID, userID, err := h.extractContext(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := r.ParseMultipartForm(maxUploadSize); err != nil { http.Error(w, "request too large", http.StatusRequestEntityTooLarge) return } relFolder := cleanPrefix(r.FormValue("path")) fileHeaders := r.MultipartForm.File["files"] if len(fileHeaders) == 0 { http.Error(w, "no files provided", http.StatusBadRequest) return } var uploaded []string for _, fh := range fileHeaders { filename := path.Base(fh.Filename) if filename == "." || filename == "" { continue } var relKey string if relFolder != "" { relKey = relFolder + "/" + filename } else { relKey = filename } // Detect content-type from header then extension ct := fh.Header.Get("Content-Type") if ct == "" || ct == "application/octet-stream" { if ext := path.Ext(filename); ext != "" { if t := mime.TypeByExtension(ext); t != "" { ct = t } } } if ct == "" { ct = "application/octet-stream" } f, err := fh.Open() if err != nil { http.Error(w, "failed to read uploaded file", http.StatusInternalServerError) return } uploadErr := h.fileService.UploadObject(r.Context(), userID, spaceID, relKey, ct, f, fh.Size) f.Close() if uploadErr != nil { respondError(w, uploadErr) return } uploaded = append(uploaded, relKey) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{"uploaded": uploaded}) } // CreateFolder handles POST /api/v1/spaces/{spaceId}/files/folder // JSON body: {"path": "new-folder-name"} func (h *FileHandler) CreateFolder(w http.ResponseWriter, r *http.Request) { spaceID, userID, err := h.extractContext(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var body struct { Path string `json:"path"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } relPath := cleanPrefix(body.Path) if relPath == "" { http.Error(w, "path is required", http.StatusBadRequest) return } if err := h.fileService.CreateFolder(r.Context(), userID, spaceID, relPath); err != nil { respondError(w, err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"path": relPath}) } // DeleteFile handles DELETE /api/v1/spaces/{spaceId}/files/object?key= func (h *FileHandler) DeleteFile(w http.ResponseWriter, r *http.Request) { spaceID, userID, err := h.extractContext(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } relKey := cleanKey(r.URL.Query().Get("key")) if relKey == "" { http.Error(w, "key is required", http.StatusBadRequest) return } if err := h.fileService.DeleteObject(r.Context(), userID, spaceID, relKey); err != nil { if strings.Contains(err.Error(), "access denied") { http.Error(w, "access denied", http.StatusForbidden) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // DeleteFolder handles DELETE /api/v1/spaces/{spaceId}/files/folder?prefix= func (h *FileHandler) DeleteFolder(w http.ResponseWriter, r *http.Request) { spaceID, userID, err := h.extractContext(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } relPrefix := cleanPrefix(r.URL.Query().Get("prefix")) if relPrefix == "" { http.Error(w, "prefix is required", http.StatusBadRequest) return } if err := h.fileService.DeleteFolder(r.Context(), userID, spaceID, relPrefix); err != nil { if strings.Contains(err.Error(), "access denied") { http.Error(w, "access denied", http.StatusForbidden) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) }