ultisuite-backend/internal/api/drive/handlers.go
R3D347HR4Y 96147de108 Implement file management enhancements in Drive API
- Added new endpoints for listing trash, recent, and starred files.
- Implemented chunked file uploads to support large file handling.
- Introduced copy and rename functionalities for file management.
- Enhanced error handling with specific drive-related error responses.
- Updated validation for copy and rename requests.
- Improved service methods to handle new functionalities and ensure quota checks.
- Updated project checklist to reflect completion of file management features.
2026-05-22 19:33:02 +02:00

368 lines
11 KiB
Go

package drive
import (
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/permission"
)
type Handler struct {
svc *Service
logger *slog.Logger
}
func NewHandler(nc *nextcloud.Client) *Handler {
return &Handler{
svc: NewService(nc),
logger: slog.Default().With("component", "drive-api"),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead)
write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite)
admin := middleware.RequirePermission(permission.ResourceDrive, permission.LevelAdmin)
r.With(read).Get("/files/*", h.ListFiles)
r.With(read).Get("/trash", h.ListTrash)
r.With(read).Get("/recent", h.ListRecent)
r.With(read).Get("/starred", h.ListStarred)
r.With(read).Get("/starred/*", h.ListStarred)
r.With(read).Get("/download/*", h.Download)
r.With(write).Post("/files/*", h.Upload)
r.With(write).Delete("/files/*", h.DeleteFile)
r.With(write).Post("/folders/*", h.CreateFolder)
r.With(write).Post("/move", h.Move)
r.With(write).Post("/copy", h.Copy)
r.With(write).Post("/rename", h.Rename)
r.With(admin).Post("/shares", h.CreateShare)
return r
}
func (h *Handler) ListFiles(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
path := chi.URLParam(r, "*")
result, err := h.svc.ListFiles(r.Context(), claims.Sub, path, params)
if err != nil {
h.logger.Error("list files", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if chunk, ok, verr := chunkUploadFromHeaders(r); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
} else if ok {
if err := h.svc.UploadChunk(r.Context(), claims.Sub, chunk.UploadID, path, chunk.ChunkUpload, r.Body, r.Header.Get("Content-Type")); err != nil {
h.logger.Error("upload chunk", "error", err)
writeDriveError(w, r, err)
return
}
status := http.StatusAccepted
message := "chunk_uploaded"
if chunk.Complete {
status = http.StatusCreated
message = "uploaded"
}
apiresponse.WriteJSON(w, status, map[string]any{
"status": message,
"path": path,
"upload_id": chunk.UploadID,
"index": chunk.Index,
"total": chunk.Total,
"complete": chunk.Complete,
})
return
}
if err := h.svc.Upload(r.Context(), claims.Sub, path, r.Body, r.Header.Get("Content-Type"), r.ContentLength); err != nil {
h.logger.Error("upload", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"status": "uploaded", "path": path})
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
body, contentType, err := h.svc.Download(r.Context(), claims.Sub, path)
if err != nil {
writeDriveError(w, r, err)
return
}
defer body.Close()
w.Header().Set("Content-Type", contentType)
io.Copy(w, body)
}
func (h *Handler) DeleteFile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.Delete(r.Context(), claims.Sub, path); err != nil {
h.logger.Error("delete file", "error", err)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) CreateFolder(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
path := chi.URLParam(r, "*")
if verr := validatePath(path); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.CreateFolder(r.Context(), claims.Sub, path); err != nil {
h.logger.Error("create folder", "error", err)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusCreated)
}
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req moveRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateMoveRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.Move(r.Context(), claims.Sub, req.Source, req.Destination); err != nil {
h.logger.Error("move", "error", err)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req copyRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateCopyRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.Copy(r.Context(), claims.Sub, req.Source, req.Destination); err != nil {
h.logger.Error("copy", "error", err)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req renameRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateRenameRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
if err := h.svc.Rename(r.Context(), claims.Sub, req.Path, req.NewName); err != nil {
h.logger.Error("rename", "error", err)
writeDriveError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) ListTrash(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
result, err := h.svc.ListTrash(r.Context(), claims.Sub, params)
if err != nil {
h.logger.Error("list trash", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListRecent(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
result, err := h.svc.ListRecent(r.Context(), claims.Sub, params)
if err != nil {
h.logger.Error("list recent", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) ListStarred(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
basePath := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
result, err := h.svc.ListStarred(r.Context(), claims.Sub, basePath, params)
if err != nil {
h.logger.Error("list starred", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req createShareRequest
if err := apivalidate.DecodeJSON(w, r, maxJSONRequestBody, &req); err != nil {
return
}
if verr := validateCreateShareRequest(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
permissions := req.Permissions
if permissions == 0 && strings.TrimSpace(req.Role) != "" {
if mapped, ok := sharePermissionsForRole(req.Role); ok {
permissions = mapped
}
}
share, err := h.svc.CreateShare(r.Context(), claims.Sub, req.Path, req.ShareType, permissions)
if err != nil {
h.logger.Error("create share", "error", err)
writeDriveError(w, r, err)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, share)
}
func writeDriveError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, ErrNotFound):
apivalidate.WriteNotFound(w, r, "not found")
case errors.Is(err, ErrConflict):
apiresponse.WriteError(w, r, http.StatusConflict, "drive.conflict", "resource conflict", nil)
case errors.Is(err, ErrForbidden):
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "forbidden", nil)
case errors.Is(err, ErrQuotaExceeded):
apiresponse.WriteError(w, r, http.StatusInsufficientStorage, "drive.quota_exceeded", "quota exceeded", nil)
case errors.Is(err, ErrInvalid):
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
default:
apivalidate.WriteInternal(w, r)
}
}
type chunkRequest struct {
UploadID string
ChunkUpload
}
func chunkUploadFromHeaders(r *http.Request) (chunkRequest, bool, *apivalidate.ValidationError) {
uploadID := strings.TrimSpace(r.Header.Get("X-Upload-ID"))
if uploadID == "" {
return chunkRequest{}, false, nil
}
var details []apivalidate.FieldDetail
index, err := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Chunk-Index")))
if err != nil || index < 0 {
details = append(details, apivalidate.FieldDetail{Field: "X-Chunk-Index", Message: "required positive integer"})
}
total, err := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Chunk-Total")))
if err != nil || total <= 0 {
details = append(details, apivalidate.FieldDetail{Field: "X-Chunk-Total", Message: "required positive integer"})
}
totalSize := int64(-1)
if raw := strings.TrimSpace(r.Header.Get("X-Upload-Total-Size")); raw != "" {
parsed, parseErr := strconv.ParseInt(raw, 10, 64)
if parseErr != nil || parsed < 0 {
details = append(details, apivalidate.FieldDetail{Field: "X-Upload-Total-Size", Message: "invalid"})
} else {
totalSize = parsed
}
}
complete := strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Upload-Complete")), "true")
if total > 0 && index == total-1 {
complete = true
}
if len(details) > 0 {
return chunkRequest{}, true, apivalidate.NewValidationError(details...)
}
return chunkRequest{
UploadID: uploadID,
ChunkUpload: ChunkUpload{
Index: index,
Total: total,
TotalSize: totalSize,
Complete: complete,
},
}, true, nil
}