ultisuite-backend/internal/api/drive/service.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

270 lines
7.1 KiB
Go

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
maxUploadBytes int64
quotaReserveByte int64
}
func NewService(nc *nextcloud.Client) *Service {
return &Service{
nc: nc,
maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0),
quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0),
}
}
type FilesList struct {
Files []nextcloud.FileInfo `json:"files"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListFiles(ctx context.Context, userID, path string, params query.ListParams) (FilesList, error) {
if path == "" {
path = "/"
}
files, err := s.nc.ListFiles(ctx, userID, path)
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) 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) {
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 mapDriveError(s.nc.Delete(ctx, userID, path))
}
func (s *Service) CreateFolder(ctx context.Context, userID, path string) error {
return mapDriveError(s.nc.CreateFolder(ctx, userID, path))
}
func (s *Service) Move(ctx context.Context, userID, source, destination string) error {
return mapDriveError(s.nc.Move(ctx, userID, source, destination))
}
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 {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
return files
}
out := make([]nextcloud.FileInfo, 0, len(files))
for _, f := range files {
if strings.Contains(strings.ToLower(f.Name), q) ||
strings.Contains(strings.ToLower(f.Path), q) {
out = append(out, f)
}
}
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{}
}