feat: file explorer
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s
This commit is contained in:
273
backend/internal/interfaces/handlers/file_handler.go
Normal file
273
backend/internal/interfaces/handlers/file_handler.go
Normal file
@@ -0,0 +1,273 @@
|
||||
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)
|
||||
}
|
||||
@@ -41,8 +41,14 @@ func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
// Extract token from Authorization header.
|
||||
// For GET /files/object, also accept ?token= so markdown images render in-browser.
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" && r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/object") {
|
||||
if tok := r.URL.Query().Get("token"); tok != "" {
|
||||
authHeader = "Bearer " + tok
|
||||
}
|
||||
}
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user