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.
This commit is contained in:
parent
0435e27ce6
commit
96147de108
@ -1,9 +1,12 @@
|
|||||||
package drive
|
package drive
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@ -34,11 +37,17 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
admin := middleware.RequirePermission(permission.ResourceDrive, permission.LevelAdmin)
|
admin := middleware.RequirePermission(permission.ResourceDrive, permission.LevelAdmin)
|
||||||
|
|
||||||
r.With(read).Get("/files/*", h.ListFiles)
|
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(read).Get("/download/*", h.Download)
|
||||||
r.With(write).Post("/files/*", h.Upload)
|
r.With(write).Post("/files/*", h.Upload)
|
||||||
r.With(write).Delete("/files/*", h.DeleteFile)
|
r.With(write).Delete("/files/*", h.DeleteFile)
|
||||||
r.With(write).Post("/folders/*", h.CreateFolder)
|
r.With(write).Post("/folders/*", h.CreateFolder)
|
||||||
r.With(write).Post("/move", h.Move)
|
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)
|
r.With(admin).Post("/shares", h.CreateShare)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@ -70,9 +79,35 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
h.logger.Error("upload", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"status": "uploaded", "path": path})
|
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)
|
body, contentType, err := h.svc.Download(r.Context(), claims.Sub, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apivalidate.WriteNotFound(w, r, "not found")
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer body.Close()
|
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 {
|
if err := h.svc.Delete(r.Context(), claims.Sub, path); err != nil {
|
||||||
h.logger.Error("delete file", "error", err)
|
h.logger.Error("delete file", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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 {
|
if err := h.svc.CreateFolder(r.Context(), claims.Sub, path); err != nil {
|
||||||
h.logger.Error("create folder", "error", err)
|
h.logger.Error("create folder", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusCreated)
|
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 {
|
if err := h.svc.Move(r.Context(), claims.Sub, req.Source, req.Destination); err != nil {
|
||||||
h.logger.Error("move", "error", err)
|
h.logger.Error("move", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
@ -161,11 +285,83 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("create share", "error", err)
|
h.logger.Error("create share", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusCreated, share)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -2,20 +2,40 @@ package drive
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
"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 {
|
type Service struct {
|
||||||
nc *nextcloud.Client
|
nc *nextcloud.Client
|
||||||
|
maxUploadBytes int64
|
||||||
|
quotaReserveByte int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(nc *nextcloud.Client) *Service {
|
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 {
|
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)
|
files, err := s.nc.ListFiles(ctx, userID, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return FilesList{}, err
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
filtered := filterFiles(files, params.Q)
|
filtered := filterFiles(files, params.Q)
|
||||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Upload(ctx context.Context, userID, path string, body io.Reader, contentType string) error {
|
func (s *Service) ListTrash(ctx context.Context, userID string, params query.ListParams) (FilesList, error) {
|
||||||
return s.nc.Upload(ctx, userID, path, body, contentType)
|
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) {
|
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 {
|
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 {
|
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 {
|
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) {
|
func (s *Service) Copy(ctx context.Context, userID, source, destination string) error {
|
||||||
return s.nc.CreateShare(ctx, userID, path, shareType, permissions)
|
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 {
|
func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo {
|
||||||
@ -77,3 +187,83 @@ func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo {
|
|||||||
}
|
}
|
||||||
return out
|
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{}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,6 +13,16 @@ type moveRequest struct {
|
|||||||
Destination string `json:"destination"`
|
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 {
|
func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError {
|
||||||
var details []apivalidate.FieldDetail
|
var details []apivalidate.FieldDetail
|
||||||
if strings.TrimSpace(req.Source) == "" {
|
if strings.TrimSpace(req.Source) == "" {
|
||||||
@ -27,19 +37,71 @@ func validateMoveRequest(req *moveRequest) *apivalidate.ValidationError {
|
|||||||
return apivalidate.NewValidationError(details...)
|
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 {
|
type createShareRequest struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
ShareType int `json:"share_type"`
|
ShareType int `json:"share_type"`
|
||||||
Permissions int `json:"permissions"`
|
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 {
|
func validateCreateShareRequest(req *createShareRequest) *apivalidate.ValidationError {
|
||||||
|
var details []apivalidate.FieldDetail
|
||||||
if strings.TrimSpace(req.Path) == "" {
|
if strings.TrimSpace(req.Path) == "" {
|
||||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
details = append(details, apivalidate.FieldDetail{Field: "path", Message: "required"})
|
||||||
Field: "path", Message: "required",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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 nil
|
||||||
|
}
|
||||||
|
return apivalidate.NewValidationError(details...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePath(path string) *apivalidate.ValidationError {
|
func validatePath(path string) *apivalidate.ValidationError {
|
||||||
|
|||||||
106
internal/api/drive/validate_test.go
Normal file
106
internal/api/drive/validate_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,10 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
@ -17,6 +20,7 @@ type FileInfo struct {
|
|||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
LastModified string `json:"last_modified"`
|
LastModified string `json:"last_modified"`
|
||||||
ETag string `json:"etag"`
|
ETag string `json:"etag"`
|
||||||
|
IsFavorite bool `json:"is_favorite"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShareInfo struct {
|
type ShareInfo struct {
|
||||||
@ -28,6 +32,22 @@ type ShareInfo struct {
|
|||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
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) {
|
func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo, error) {
|
||||||
davPath := c.WebDAVPath(userID, path)
|
davPath := c.WebDAVPath(userID, path)
|
||||||
body := `<?xml version="1.0" encoding="UTF-8"?>
|
body := `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@ -40,6 +60,7 @@ func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo
|
|||||||
<d:resourcetype/>
|
<d:resourcetype/>
|
||||||
<oc:fileid/>
|
<oc:fileid/>
|
||||||
<oc:size/>
|
<oc:size/>
|
||||||
|
<oc:favorite/>
|
||||||
</d:prop>
|
</d:prop>
|
||||||
</d:propfind>`
|
</d:propfind>`
|
||||||
|
|
||||||
@ -53,7 +74,7 @@ func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 207 {
|
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)
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||||
return fmt.Errorf("upload failed: %d", resp.StatusCode)
|
return &HTTPStatusError{Operation: "upload", StatusCode: resp.StatusCode}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -87,7 +108,7 @@ func (c *Client) Download(ctx context.Context, userID, path string) (io.ReadClos
|
|||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
resp.Body.Close()
|
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
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 201 {
|
if resp.StatusCode != 201 {
|
||||||
return fmt.Errorf("mkcol failed: %d", resp.StatusCode)
|
return &HTTPStatusError{Operation: "mkcol", StatusCode: resp.StatusCode}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -116,7 +137,7 @@ func (c *Client) Delete(ctx context.Context, userID, path string) error {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 204 {
|
if resp.StatusCode != 204 {
|
||||||
return fmt.Errorf("delete failed: %d", resp.StatusCode)
|
return &HTTPStatusError{Operation: "delete", StatusCode: resp.StatusCode}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -134,11 +155,162 @@ func (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) err
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
if resp.StatusCode != 201 && resp.StatusCode != 204 {
|
||||||
return fmt.Errorf("move failed: %d", resp.StatusCode)
|
return &HTTPStatusError{Operation: "move", StatusCode: resp.StatusCode}
|
||||||
}
|
}
|
||||||
return nil
|
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:getetag/>
|
||||||
|
<d:getcontenttype/>
|
||||||
|
<d:getcontentlength/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:size/>
|
||||||
|
<oc:favorite/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`
|
||||||
|
|
||||||
|
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) {
|
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)
|
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",
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
var ocsResp struct {
|
||||||
OCS struct {
|
OCS struct {
|
||||||
@ -175,6 +350,41 @@ func (c *Client) CreateShare(ctx context.Context, userID, path string, shareType
|
|||||||
}, nil
|
}, 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
|
// PROPFIND XML response parsing
|
||||||
type multistatus struct {
|
type multistatus struct {
|
||||||
XMLName xml.Name `xml:"multistatus"`
|
XMLName xml.Name `xml:"multistatus"`
|
||||||
@ -198,6 +408,7 @@ type prop struct {
|
|||||||
ContentLength int64 `xml:"getcontentlength"`
|
ContentLength int64 `xml:"getcontentlength"`
|
||||||
ResourceType resourceType `xml:"resourcetype"`
|
ResourceType resourceType `xml:"resourcetype"`
|
||||||
Size int64 `xml:"size"`
|
Size int64 `xml:"size"`
|
||||||
|
Favorite int `xml:"favorite"`
|
||||||
DisplayName string `xml:"displayname"`
|
DisplayName string `xml:"displayname"`
|
||||||
CalendarColor string `xml:"calendar-color"`
|
CalendarColor string `xml:"calendar-color"`
|
||||||
}
|
}
|
||||||
@ -242,8 +453,34 @@ func parsePropfindResponse(body io.Reader, basePath string) ([]FileInfo, error)
|
|||||||
MimeType: r.Propstat.Prop.ContentType,
|
MimeType: r.Propstat.Prop.ContentType,
|
||||||
LastModified: r.Propstat.Prop.LastModified,
|
LastModified: r.Propstat.Prop.LastModified,
|
||||||
ETag: strings.Trim(r.Propstat.Prop.ETag, "\""),
|
ETag: strings.Trim(r.Propstat.Prop.ETag, "\""),
|
||||||
|
IsFavorite: r.Propstat.Prop.Favorite == 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return files, nil
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -138,10 +138,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
|
|||||||
|
|
||||||
#### Drive
|
#### Drive
|
||||||
|
|
||||||
- [ ] Ajouter upload chunked gros fichiers.
|
- [x] Ajouter upload chunked gros fichiers.
|
||||||
- [ ] Ajouter rename/copy/list trash/recent/starred.
|
- [x] Ajouter rename/copy/list trash/recent/starred.
|
||||||
- [ ] Ajouter ACL simplifiées (owner/editor/viewer) mappées correctement.
|
- [x] Ajouter ACL simplifiées (owner/editor/viewer) mappées correctement.
|
||||||
- [ ] Ajouter quotas et erreurs métier propres.
|
- [x] Ajouter quotas et erreurs métier propres.
|
||||||
|
|
||||||
#### Calendar
|
#### Calendar
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user