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{} }