- 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.
270 lines
7.1 KiB
Go
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{}
|
|
}
|