From 96147de108262b053c4bd9244d377f833d5bd7c6 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 22 May 2026 19:33:02 +0200 Subject: [PATCH] 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. --- internal/api/drive/handlers.go | 212 ++++++++++++++++++++++- internal/api/drive/service.go | 212 +++++++++++++++++++++-- internal/api/drive/validate.go | 70 +++++++- internal/api/drive/validate_test.go | 106 ++++++++++++ internal/nextcloud/drive.go | 249 +++++++++++++++++++++++++++- project-plan/checklist-execution.md | 8 +- 6 files changed, 824 insertions(+), 33 deletions(-) create mode 100644 internal/api/drive/validate_test.go diff --git a/internal/api/drive/handlers.go b/internal/api/drive/handlers.go index 0f65b46..d323ca7 100644 --- a/internal/api/drive/handlers.go +++ b/internal/api/drive/handlers.go @@ -1,9 +1,12 @@ package drive import ( + "errors" "io" "log/slog" "net/http" + "strconv" + "strings" "github.com/go-chi/chi/v5" @@ -34,11 +37,17 @@ func (h *Handler) Routes() chi.Router { 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 @@ -70,9 +79,35 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { return } - if err := h.svc.Upload(r.Context(), claims.Sub, path, r.Body, r.Header.Get("Content-Type")); err != nil { + 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) - apivalidate.WriteInternal(w, r) + writeDriveError(w, r, err) return } apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"status": "uploaded", "path": path}) @@ -88,7 +123,7 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { body, contentType, err := h.svc.Download(r.Context(), claims.Sub, path) if err != nil { - apivalidate.WriteNotFound(w, r, "not found") + writeDriveError(w, r, err) return } defer body.Close() @@ -107,7 +142,7 @@ func (h *Handler) DeleteFile(w http.ResponseWriter, r *http.Request) { if err := h.svc.Delete(r.Context(), claims.Sub, path); err != nil { h.logger.Error("delete file", "error", err) - apivalidate.WriteInternal(w, r) + writeDriveError(w, r, err) return } w.WriteHeader(http.StatusNoContent) @@ -123,7 +158,7 @@ func (h *Handler) CreateFolder(w http.ResponseWriter, r *http.Request) { if err := h.svc.CreateFolder(r.Context(), claims.Sub, path); err != nil { h.logger.Error("create folder", "error", err) - apivalidate.WriteInternal(w, r) + writeDriveError(w, r, err) return } w.WriteHeader(http.StatusCreated) @@ -143,12 +178,101 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) { if err := h.svc.Move(r.Context(), claims.Sub, req.Source, req.Destination); err != nil { h.logger.Error("move", "error", err) - apivalidate.WriteInternal(w, r) + 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()) @@ -161,11 +285,83 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) { return } - share, err := h.svc.CreateShare(r.Context(), claims.Sub, req.Path, req.ShareType, req.Permissions) + 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) - apivalidate.WriteInternal(w, r) + 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 +} diff --git a/internal/api/drive/service.go b/internal/api/drive/service.go index e7b9d02..caa158f 100644 --- a/internal/api/drive/service.go +++ b/internal/api/drive/service.go @@ -2,20 +2,40 @@ package drive import ( "context" + "errors" "io" + "net/http" + "os" + "path" + "strconv" "strings" + "time" "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) +var ( + ErrNotFound = errors.New("not found") + ErrConflict = errors.New("conflict") + ErrForbidden = errors.New("forbidden") + ErrQuotaExceeded = errors.New("quota exceeded") + ErrInvalid = errors.New("invalid request") +) + type Service struct { - nc *nextcloud.Client + nc *nextcloud.Client + maxUploadBytes int64 + quotaReserveByte int64 } func NewService(nc *nextcloud.Client) *Service { - return &Service{nc: nc} + return &Service{ + nc: nc, + maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0), + quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0), + } } type FilesList struct { @@ -29,7 +49,7 @@ func (s *Service) ListFiles(ctx context.Context, userID, path string, params que } files, err := s.nc.ListFiles(ctx, userID, path) if err != nil { - return FilesList{}, err + return FilesList{}, mapDriveError(err) } filtered := filterFiles(files, params.Q) page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) @@ -39,28 +59,118 @@ func (s *Service) ListFiles(ctx context.Context, userID, path string, params que }, nil } -func (s *Service) Upload(ctx context.Context, userID, path string, body io.Reader, contentType string) error { - return s.nc.Upload(ctx, userID, path, body, contentType) +func (s *Service) ListTrash(ctx context.Context, userID string, params query.ListParams) (FilesList, error) { + files, err := s.nc.ListTrash(ctx, userID) + if err != nil { + return FilesList{}, mapDriveError(err) + } + filtered := filterFiles(files, params.Q) + page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) + return FilesList{ + Files: page, + Pagination: params.Meta(&total), + }, nil +} + +func (s *Service) ListRecent(ctx context.Context, userID string, params query.ListParams) (FilesList, error) { + files, err := s.nc.ListRecent(ctx, userID, params.Limit()) + if err != nil { + return FilesList{}, mapDriveError(err) + } + filtered := filterFiles(files, params.Q) + page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) + return FilesList{ + Files: page, + Pagination: params.Meta(&total), + }, nil +} + +func (s *Service) ListStarred(ctx context.Context, userID, basePath string, params query.ListParams) (FilesList, error) { + if basePath == "" { + basePath = "/" + } + files, err := s.nc.ListFiles(ctx, userID, basePath) + if err != nil { + return FilesList{}, mapDriveError(err) + } + starred := make([]nextcloud.FileInfo, 0, len(files)) + for _, f := range files { + if f.IsFavorite { + starred = append(starred, f) + } + } + filtered := filterFiles(starred, params.Q) + page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) + return FilesList{ + Files: page, + Pagination: params.Meta(&total), + }, nil +} + +func (s *Service) Upload(ctx context.Context, userID, path string, body io.Reader, contentType string, contentLength int64) error { + if err := s.ensureQuota(ctx, userID, contentLength); err != nil { + return err + } + return mapDriveError(s.nc.Upload(ctx, userID, path, body, contentType)) +} + +func (s *Service) UploadChunk(ctx context.Context, userID, uploadID, targetPath string, chunk ChunkUpload, body io.Reader, contentType string) error { + if err := mapDriveError(s.nc.UploadChunk(ctx, userID, uploadID, chunkName(chunk.Index), body, contentType)); err != nil { + return err + } + if !chunk.Complete { + return nil + } + + if err := s.ensureQuota(ctx, userID, chunk.TotalSize); err != nil { + _ = s.nc.AbortChunkUpload(ctx, userID, uploadID) + return err + } + if err := mapDriveError(s.nc.AssembleChunks(ctx, userID, uploadID, targetPath, chunk.TotalSize)); err != nil { + return err + } + return nil } func (s *Service) Download(ctx context.Context, userID, path string) (io.ReadCloser, string, error) { - return s.nc.Download(ctx, userID, path) + body, contentType, err := s.nc.Download(ctx, userID, path) + if err != nil { + return nil, "", mapDriveError(err) + } + return body, contentType, nil } func (s *Service) Delete(ctx context.Context, userID, path string) error { - return s.nc.Delete(ctx, userID, path) + return mapDriveError(s.nc.Delete(ctx, userID, path)) } func (s *Service) CreateFolder(ctx context.Context, userID, path string) error { - return s.nc.CreateFolder(ctx, userID, path) + return mapDriveError(s.nc.CreateFolder(ctx, userID, path)) } func (s *Service) Move(ctx context.Context, userID, source, destination string) error { - return s.nc.Move(ctx, userID, source, destination) + return mapDriveError(s.nc.Move(ctx, userID, source, destination)) } -func (s *Service) CreateShare(ctx context.Context, userID, path string, shareType, permissions int) (*nextcloud.ShareInfo, error) { - return s.nc.CreateShare(ctx, userID, path, shareType, permissions) +func (s *Service) Copy(ctx context.Context, userID, source, destination string) error { + return mapDriveError(s.nc.Copy(ctx, userID, source, destination)) +} + +func (s *Service) Rename(ctx context.Context, userID, filePath, newName string) error { + if strings.Contains(newName, "/") { + return ErrInvalid + } + dir := path.Dir("/" + strings.TrimPrefix(filePath, "/")) + destination := path.Join(dir, newName) + return mapDriveError(s.nc.Move(ctx, userID, filePath, destination)) +} + +func (s *Service) CreateShare(ctx context.Context, userID, filePath string, shareType, permissions int) (*nextcloud.ShareInfo, error) { + share, err := s.nc.CreateShare(ctx, userID, filePath, shareType, permissions) + if err != nil { + return nil, mapDriveError(err) + } + return share, nil } func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo { @@ -77,3 +187,83 @@ func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo { } return out } + +type ChunkUpload struct { + Index int + Total int + TotalSize int64 + Complete bool +} + +func chunkName(index int) string { + return strconv.FormatInt(int64(index), 10) +} + +func (s *Service) ensureQuota(ctx context.Context, userID string, incomingBytes int64) error { + if s.maxUploadBytes > 0 && incomingBytes > s.maxUploadBytes { + return ErrQuotaExceeded + } + quota, err := s.nc.GetQuota(ctx, userID) + if err != nil { + return mapDriveError(err) + } + if incomingBytes <= 0 { + return nil + } + if quota.Free <= 0 { + return ErrQuotaExceeded + } + if incomingBytes+s.quotaReserveByte > quota.Free { + return ErrQuotaExceeded + } + return nil +} + +func mapDriveError(err error) error { + if err == nil { + return nil + } + var statusErr *nextcloud.HTTPStatusError + if !errors.As(err, &statusErr) { + return err + } + switch statusErr.StatusCode { + case http.StatusNotFound: + return ErrNotFound + case http.StatusConflict: + return ErrConflict + case http.StatusForbidden, http.StatusUnauthorized: + return ErrForbidden + case http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge: + return ErrQuotaExceeded + case http.StatusBadRequest: + return ErrInvalid + default: + return err + } +} + +func envInt64(key string, fallback int64) int64 { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fallback + } + return n +} + +func parseModTime(value string) time.Time { + if value == "" { + return time.Time{} + } + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t + } + if t, err := time.Parse(time.RFC1123, value); err == nil { + return t + } + return time.Time{} +} diff --git a/internal/api/drive/validate.go b/internal/api/drive/validate.go index 397dedd..f950c98 100644 --- a/internal/api/drive/validate.go +++ b/internal/api/drive/validate.go @@ -13,6 +13,16 @@ type moveRequest struct { Destination string `json:"destination"` } +type copyRequest struct { + Source string `json:"source"` + Destination string `json:"destination"` +} + +type renameRequest struct { + Path string `json:"path"` + NewName string `json:"new_name"` +} + func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError { var details []apivalidate.FieldDetail if strings.TrimSpace(req.Source) == "" { @@ -27,19 +37,71 @@ func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError { return apivalidate.NewValidationError(details...) } +func validateCopyRequest(req *copyRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + if strings.TrimSpace(req.Source) == "" { + details = append(details, apivalidate.FieldDetail{Field: "source", Message: "required"}) + } + if strings.TrimSpace(req.Destination) == "" { + details = append(details, apivalidate.FieldDetail{Field: "destination", Message: "required"}) + } + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} + +func validateRenameRequest(req *renameRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail + if strings.TrimSpace(req.Path) == "" { + details = append(details, apivalidate.FieldDetail{Field: "path", Message: "required"}) + } + newName := strings.TrimSpace(req.NewName) + if newName == "" { + details = append(details, apivalidate.FieldDetail{Field: "new_name", Message: "required"}) + } else if strings.Contains(newName, "/") { + details = append(details, apivalidate.FieldDetail{Field: "new_name", Message: "invalid"}) + } + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) +} + type createShareRequest struct { Path string `json:"path"` ShareType int `json:"share_type"` Permissions int `json:"permissions"` + Role string `json:"role"` +} + +func sharePermissionsForRole(role string) (int, bool) { + switch strings.TrimSpace(strings.ToLower(role)) { + case "owner": + return 31, true + case "editor": + return 15, true + case "viewer": + return 1, true + default: + return 0, false + } } func validateCreateShareRequest(req *createShareRequest) *apivalidate.ValidationError { + var details []apivalidate.FieldDetail if strings.TrimSpace(req.Path) == "" { - return apivalidate.NewValidationError(apivalidate.FieldDetail{ - Field: "path", Message: "required", - }) + details = append(details, apivalidate.FieldDetail{Field: "path", Message: "required"}) } - return nil + if role := strings.TrimSpace(req.Role); role != "" { + if _, ok := sharePermissionsForRole(role); !ok { + details = append(details, apivalidate.FieldDetail{Field: "role", Message: "invalid"}) + } + } + if len(details) == 0 { + return nil + } + return apivalidate.NewValidationError(details...) } func validatePath(path string) *apivalidate.ValidationError { diff --git a/internal/api/drive/validate_test.go b/internal/api/drive/validate_test.go new file mode 100644 index 0000000..95f90d0 --- /dev/null +++ b/internal/api/drive/validate_test.go @@ -0,0 +1,106 @@ +package drive + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" +) + +func hasFieldDetail(err *apivalidate.ValidationError, field, message string) bool { + if err == nil { + return false + } + for _, d := range err.Details { + if d.Field == field && d.Message == message { + return true + } + } + return false +} + +func TestValidateMoveRequest(t *testing.T) { + if validateMoveRequest(&moveRequest{Source: "/a", Destination: "/b"}) != nil { + t.Fatal("expected valid move request") + } + if err := validateMoveRequest(&moveRequest{Destination: "/b"}); !hasFieldDetail(err, "source", "required") { + t.Fatal("expected missing source error") + } + if err := validateMoveRequest(&moveRequest{Source: "/a"}); !hasFieldDetail(err, "destination", "required") { + t.Fatal("expected missing destination error") + } + if err := validateMoveRequest(&moveRequest{}); len(err.Details) != 2 { + t.Fatalf("expected two validation errors, got %d", len(err.Details)) + } +} + +func TestValidateCopyRequest(t *testing.T) { + if validateCopyRequest(©Request{Source: "/a", Destination: "/b"}) != nil { + t.Fatal("expected valid copy request") + } + if err := validateCopyRequest(©Request{Destination: "/b"}); !hasFieldDetail(err, "source", "required") { + t.Fatal("expected missing source error") + } + if err := validateCopyRequest(©Request{Source: "/a"}); !hasFieldDetail(err, "destination", "required") { + t.Fatal("expected missing destination error") + } + if err := validateCopyRequest(©Request{}); len(err.Details) != 2 { + t.Fatalf("expected two validation errors, got %d", len(err.Details)) + } +} + +func TestValidateRenameRequest(t *testing.T) { + if validateRenameRequest(&renameRequest{Path: "/docs", NewName: "report.pdf"}) != nil { + t.Fatal("expected valid rename request") + } + if err := validateRenameRequest(&renameRequest{NewName: "report.pdf"}); !hasFieldDetail(err, "path", "required") { + t.Fatal("expected missing path error") + } + if err := validateRenameRequest(&renameRequest{Path: "/docs"}); !hasFieldDetail(err, "new_name", "required") { + t.Fatal("expected missing new_name error") + } + if err := validateRenameRequest(&renameRequest{Path: "/docs", NewName: "bad/name"}); !hasFieldDetail(err, "new_name", "invalid") { + t.Fatal("expected invalid new_name error for slash") + } +} + +func TestValidateCreateShareRequest(t *testing.T) { + if validateCreateShareRequest(&createShareRequest{Path: "/docs/file.pdf"}) != nil { + t.Fatal("expected valid share request with path only") + } + if validateCreateShareRequest(&createShareRequest{Path: "/docs/file.pdf", Role: "editor", Permissions: 0}) != nil { + t.Fatal("expected role without permissions to be accepted") + } + for _, role := range []string{"owner", "editor", "viewer", "Owner", " EDITOR "} { + if validateCreateShareRequest(&createShareRequest{Path: "/docs/file.pdf", Role: role}) != nil { + t.Fatalf("expected valid role %q", role) + } + } + if err := validateCreateShareRequest(&createShareRequest{Role: "editor"}); !hasFieldDetail(err, "path", "required") { + t.Fatal("expected missing path error") + } + if err := validateCreateShareRequest(&createShareRequest{Path: "/docs/file.pdf", Role: "admin"}); !hasFieldDetail(err, "role", "invalid") { + t.Fatal("expected invalid role error") + } +} + +func TestSharePermissionsForRole(t *testing.T) { + tests := []struct { + role string + permissions int + ok bool + }{ + {role: "owner", permissions: 31, ok: true}, + {role: "editor", permissions: 15, ok: true}, + {role: "viewer", permissions: 1, ok: true}, + {role: "Owner", permissions: 31, ok: true}, + {role: " EDITOR ", permissions: 15, ok: true}, + {role: "admin", permissions: 0, ok: false}, + {role: "", permissions: 0, ok: false}, + } + for _, tt := range tests { + got, ok := sharePermissionsForRole(tt.role) + if ok != tt.ok || got != tt.permissions { + t.Fatalf("sharePermissionsForRole(%q) = (%d, %v), want (%d, %v)", tt.role, got, ok, tt.permissions, tt.ok) + } + } +} diff --git a/internal/nextcloud/drive.go b/internal/nextcloud/drive.go index f6c6086..fc4032c 100644 --- a/internal/nextcloud/drive.go +++ b/internal/nextcloud/drive.go @@ -6,7 +6,10 @@ import ( "encoding/xml" "fmt" "io" + "net/http" + "strconv" "strings" + "time" ) type FileInfo struct { @@ -17,6 +20,7 @@ type FileInfo struct { MimeType string `json:"mime_type"` LastModified string `json:"last_modified"` ETag string `json:"etag"` + IsFavorite bool `json:"is_favorite"` } type ShareInfo struct { @@ -28,6 +32,22 @@ type ShareInfo struct { ExpiresAt string `json:"expires_at,omitempty"` } +type HTTPStatusError struct { + Operation string + StatusCode int +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("%s failed: %d", e.Operation, e.StatusCode) +} + +type UserQuota struct { + Used int64 `json:"used"` + Free int64 `json:"free"` + Total int64 `json:"total"` + Relative int64 `json:"relative"` +} + func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo, error) { davPath := c.WebDAVPath(userID, path) body := ` @@ -40,6 +60,7 @@ func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo + ` @@ -53,7 +74,7 @@ func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo defer resp.Body.Close() if resp.StatusCode != 207 { - return nil, fmt.Errorf("propfind failed: %d", resp.StatusCode) + return nil, &HTTPStatusError{Operation: "propfind", StatusCode: resp.StatusCode} } return parsePropfindResponse(resp.Body, davPath) @@ -73,7 +94,7 @@ func (c *Client) Upload(ctx context.Context, userID, path string, content io.Rea defer resp.Body.Close() if resp.StatusCode != 201 && resp.StatusCode != 204 { - return fmt.Errorf("upload failed: %d", resp.StatusCode) + return &HTTPStatusError{Operation: "upload", StatusCode: resp.StatusCode} } return nil } @@ -87,7 +108,7 @@ func (c *Client) Download(ctx context.Context, userID, path string) (io.ReadClos if resp.StatusCode != 200 { resp.Body.Close() - return nil, "", fmt.Errorf("download failed: %d", resp.StatusCode) + return nil, "", &HTTPStatusError{Operation: "download", StatusCode: resp.StatusCode} } return resp.Body, resp.Header.Get("Content-Type"), nil @@ -102,7 +123,7 @@ func (c *Client) CreateFolder(ctx context.Context, userID, path string) error { defer resp.Body.Close() if resp.StatusCode != 201 { - return fmt.Errorf("mkcol failed: %d", resp.StatusCode) + return &HTTPStatusError{Operation: "mkcol", StatusCode: resp.StatusCode} } return nil } @@ -116,7 +137,7 @@ func (c *Client) Delete(ctx context.Context, userID, path string) error { defer resp.Body.Close() if resp.StatusCode != 204 { - return fmt.Errorf("delete failed: %d", resp.StatusCode) + return &HTTPStatusError{Operation: "delete", StatusCode: resp.StatusCode} } return nil } @@ -134,11 +155,162 @@ func (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) err defer resp.Body.Close() if resp.StatusCode != 201 && resp.StatusCode != 204 { - return fmt.Errorf("move failed: %d", resp.StatusCode) + return &HTTPStatusError{Operation: "move", StatusCode: resp.StatusCode} } return nil } +func (c *Client) Copy(ctx context.Context, userID, srcPath, destPath string) error { + davSrc := c.WebDAVPath(userID, srcPath) + destURL := c.baseURL + c.WebDAVPath(userID, destPath) + resp, err := c.DoAsUser(ctx, "COPY", davSrc, nil, userID, map[string]string{ + "Destination": destURL, + "Overwrite": "F", + }) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + return &HTTPStatusError{Operation: "copy", StatusCode: resp.StatusCode} + } + return nil +} + +func (c *Client) UploadChunk(ctx context.Context, userID, uploadID, chunkName string, content io.Reader, contentType string) error { + uploadPath := fmt.Sprintf("/remote.php/dav/uploads/%s/%s/%s", userID, uploadID, chunkName) + headers := map[string]string{} + if contentType != "" { + headers["Content-Type"] = contentType + } + resp, err := c.DoAsUser(ctx, "PUT", uploadPath, content, userID, headers) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + return &HTTPStatusError{Operation: "upload chunk", StatusCode: resp.StatusCode} + } + return nil +} + +func (c *Client) AssembleChunks(ctx context.Context, userID, uploadID, destinationPath string, totalSize int64) error { + source := fmt.Sprintf("/remote.php/dav/uploads/%s/%s/.file", userID, uploadID) + destination := c.baseURL + c.WebDAVPath(userID, destinationPath) + headers := map[string]string{ + "Destination": destination, + } + if totalSize > 0 { + headers["OC-Total-Length"] = strconv.FormatInt(totalSize, 10) + } + resp, err := c.DoAsUser(ctx, "MOVE", source, nil, userID, headers) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + return &HTTPStatusError{Operation: "assemble chunks", StatusCode: resp.StatusCode} + } + return nil +} + +func (c *Client) AbortChunkUpload(ctx context.Context, userID, uploadID string) error { + uploadPath := fmt.Sprintf("/remote.php/dav/uploads/%s/%s", userID, uploadID) + resp, err := c.DoAsUser(ctx, "DELETE", uploadPath, nil, userID, nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + return &HTTPStatusError{Operation: "abort upload", StatusCode: resp.StatusCode} + } + return nil +} + +func (c *Client) ListTrash(ctx context.Context, userID string) ([]FileInfo, error) { + basePath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash", userID) + body := ` + + + + + + + + + + +` + + resp, err := c.DoAsUser(ctx, "PROPFIND", basePath, strings.NewReader(body), userID, map[string]string{ + "Depth": "1", + "Content-Type": "application/xml", + }) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 207 { + return nil, &HTTPStatusError{Operation: "list trash", StatusCode: resp.StatusCode} + } + return parsePropfindResponse(resp.Body, basePath) +} + +func (c *Client) ListRecent(ctx context.Context, userID string, limit int) ([]FileInfo, error) { + path := "/ocs/v2.php/apps/files/api/v1/recent" + if limit > 0 { + path = fmt.Sprintf("%s?limit=%d", path, limit) + } + resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, map[string]string{ + "Accept": "application/json", + }) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, &HTTPStatusError{Operation: "list recent", StatusCode: resp.StatusCode} + } + + var payload struct { + OCS struct { + Data []struct { + Path string `json:"path"` + Name string `json:"name"` + MimeType string `json:"mimetype"` + ETag string `json:"etag"` + Type string `json:"type"` + Size int64 `json:"size"` + MTime any `json:"mtime"` + } `json:"data"` + } `json:"ocs"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + files := make([]FileInfo, 0, len(payload.OCS.Data)) + for _, item := range payload.OCS.Data { + fileType := "file" + if strings.EqualFold(item.Type, "dir") || strings.EqualFold(item.Type, "directory") { + fileType = "directory" + } + lastModified := "" + if ts := parseAnyInt64(item.MTime); ts > 0 { + lastModified = time.Unix(ts, 0).UTC().Format(time.RFC3339) + } + files = append(files, FileInfo{ + Path: item.Path, + Name: item.Name, + Type: fileType, + Size: item.Size, + MimeType: item.MimeType, + LastModified: lastModified, + ETag: strings.Trim(item.ETag, "\""), + }) + } + return files, nil +} + func (c *Client) CreateShare(ctx context.Context, userID, path string, shareType int, permissions int) (*ShareInfo, error) { formData := fmt.Sprintf("path=%s&shareType=%d&permissions=%d", path, shareType, permissions) resp, err := c.DoAsUser(ctx, "POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", @@ -150,6 +322,9 @@ func (c *Client) CreateShare(ctx context.Context, userID, path string, shareType return nil, err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, &HTTPStatusError{Operation: "create share", StatusCode: resp.StatusCode} + } var ocsResp struct { OCS struct { @@ -175,6 +350,41 @@ func (c *Client) CreateShare(ctx context.Context, userID, path string, shareType }, nil } +func (c *Client) GetQuota(ctx context.Context, userID string) (UserQuota, error) { + resp, err := c.DoAsUser(ctx, "GET", fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", userID), nil, userID, map[string]string{ + "Accept": "application/json", + }) + if err != nil { + return UserQuota{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return UserQuota{}, &HTTPStatusError{Operation: "get quota", StatusCode: resp.StatusCode} + } + + var payload struct { + OCS struct { + Data struct { + Quota struct { + Free any `json:"free"` + Used any `json:"used"` + Total any `json:"total"` + Relative int64 `json:"relative"` + } `json:"quota"` + } `json:"data"` + } `json:"ocs"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return UserQuota{}, err + } + return UserQuota{ + Used: parseAnyInt64(payload.OCS.Data.Quota.Used), + Free: parseAnyInt64(payload.OCS.Data.Quota.Free), + Total: parseAnyInt64(payload.OCS.Data.Quota.Total), + Relative: payload.OCS.Data.Quota.Relative, + }, nil +} + // PROPFIND XML response parsing type multistatus struct { XMLName xml.Name `xml:"multistatus"` @@ -198,6 +408,7 @@ type prop struct { ContentLength int64 `xml:"getcontentlength"` ResourceType resourceType `xml:"resourcetype"` Size int64 `xml:"size"` + Favorite int `xml:"favorite"` DisplayName string `xml:"displayname"` CalendarColor string `xml:"calendar-color"` } @@ -242,8 +453,34 @@ func parsePropfindResponse(body io.Reader, basePath string) ([]FileInfo, error) MimeType: r.Propstat.Prop.ContentType, LastModified: r.Propstat.Prop.LastModified, ETag: strings.Trim(r.Propstat.Prop.ETag, "\""), + IsFavorite: r.Propstat.Prop.Favorite == 1, }) } return files, nil } + +func parseInt64(raw string) int64 { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return 0 + } + return n +} + +func parseAnyInt64(raw any) int64 { + switch v := raw.(type) { + case nil: + return 0 + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case string: + return parseInt64(v) + default: + return parseInt64(fmt.Sprintf("%v", v)) + } +} diff --git a/project-plan/checklist-execution.md b/project-plan/checklist-execution.md index 1ff8eda..270c849 100644 --- a/project-plan/checklist-execution.md +++ b/project-plan/checklist-execution.md @@ -138,10 +138,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon #### Drive -- [ ] Ajouter upload chunked gros fichiers. -- [ ] Ajouter rename/copy/list trash/recent/starred. -- [ ] Ajouter ACL simplifiées (owner/editor/viewer) mappées correctement. -- [ ] Ajouter quotas et erreurs métier propres. +- [x] Ajouter upload chunked gros fichiers. +- [x] Ajouter rename/copy/list trash/recent/starred. +- [x] Ajouter ACL simplifiées (owner/editor/viewer) mappées correctement. +- [x] Ajouter quotas et erreurs métier propres. #### Calendar