All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s
274 lines
7.4 KiB
Go
274 lines
7.4 KiB
Go
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)
|
|
}
|