package drive import ( "bytes" "context" "errors" "io" "net/http" "os" "path" "strconv" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/automation" "github.com/ultisuite/ulti-backend/internal/filescan" "github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/publicshare" "github.com/ultisuite/ulti-backend/internal/realtime" ) var ( ErrNotFound = errors.New("not found") ErrConflict = errors.New("conflict") ErrForbidden = errors.New("forbidden") ErrQuotaExceeded = errors.New("quota exceeded") ErrInvalid = errors.New("invalid request") ErrMalware = errors.New("malware detected") ) type Service struct { nc *nextcloud.Client hub *realtime.Hub db *pgxpool.Pool automation driveAutomation scanner *filescan.Scanner maxUploadBytes int64 quotaReserveByte int64 } type driveAutomation interface { OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload automation.DrivePayload) } func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service { return &Service{ nc: nc, hub: hub, db: db, maxUploadBytes: envInt64("ULTID_DRIVE_MAX_UPLOAD_BYTES", 0), quotaReserveByte: envInt64("ULTID_DRIVE_QUOTA_RESERVED_BYTES", 0), } } func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { if s.nc == nil { return "", errors.New("nextcloud unavailable") } return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) } func (s *Service) notifyFileChanged(platformUserID, path string) { if s.hub == nil || platformUserID == "" { return } s.hub.Broadcast(platformUserID, realtime.NewDriveFileChangedEvent(path)) } // PublishFileChanged notifies connected clients that a drive path changed. func (s *Service) PublishFileChanged(platformUserID, filePath string) { s.notifyFileChanged(platformUserID, nextcloud.NormalizeClientPath(filePath)) } func (s *Service) notifyShareUpdated(platformUserID, path string) { if s.hub == nil || platformUserID == "" { return } s.hub.Broadcast(platformUserID, realtime.NewDriveShareUpdatedEvent(path)) } type FilesList struct { Files []nextcloud.FileInfo `json:"files"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } func (s *Service) ListFilterCorpus(ctx context.Context, userID, path string) (FilesList, error) { if path == "" { path = "/" } files, err := s.nc.ListFilesRecursive(ctx, userID, path, 0) if err != nil { return FilesList{}, mapDriveError(err) } files = nextcloud.FilterHiddenUltidocSidecars(files) total := int64(len(files)) return FilesList{ Files: files, Pagination: query.ListParams{Page: 1, PageSize: len(files)}.Meta(&total), }, nil } 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 := visibleDriveFiles(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 := visibleDriveFiles(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 := visibleDriveFiles(files, params.Q) page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) return FilesList{ Files: page, Pagination: params.Meta(&total), }, nil } func (s *Service) ListSharedWithMe(ctx context.Context, userID string, params query.ListParams) (FilesList, error) { files, err := s.nc.ListSharedWithMe(ctx, userID) if err != nil { return FilesList{}, mapDriveError(err) } filtered := visibleDriveFiles(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 = "/" } limit := params.Limit() if limit <= 0 { limit = 50 } collect := limit + params.Offset() if collect <= 0 || collect > 500 { collect = 500 } starred, err := s.nc.ListFavorites(ctx, userID, basePath, int(collect)) if err != nil { return FilesList{}, mapDriveError(err) } filtered := visibleDriveFiles(starred, params.Q) page, total := paginate.Slice(filtered, params.Offset(), 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 } reader := body if s.scanner != nil { data, _, err := s.scanner.ScanReader(ctx, path, body, contentLength) if err != nil { if errors.Is(err, filescan.ErrMalicious) { return ErrMalware } return err } reader = bytes.NewReader(data) } return mapDriveError(s.nc.Upload(ctx, userID, path, reader, 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 } if s.scanner != nil { if err := s.scanAssembledUpload(ctx, userID, targetPath); err != nil { return err } } return nil } func (s *Service) scanAssembledUpload(ctx context.Context, userID, targetPath string) error { body, _, err := s.nc.Download(ctx, userID, targetPath) if err != nil { return mapDriveError(err) } defer body.Close() data, scanResult, err := s.scanner.ScanReader(ctx, targetPath, body, -1) if err != nil { if errors.Is(err, filescan.ErrMalicious) { _ = s.nc.Delete(ctx, userID, targetPath) return ErrMalware } return err } _ = data _ = scanResult 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) Preview(ctx context.Context, userID, filePath string, width, height int) (io.ReadCloser, string, error) { filePath = nextcloud.NormalizeClientFilePath(userID, filePath) fileID, err := s.nc.FileID(ctx, userID, filePath) if err != nil { return nil, "", mapDriveError(err) } body, contentType, err := s.nc.Preview(ctx, userID, fileID, width, height) if err != nil { return nil, "", mapDriveError(err) } return body, contentType, nil } func (s *Service) StatFile(ctx context.Context, userID, filePath string) (nextcloud.FileInfo, error) { filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath)) file, err := s.nc.StatFile(ctx, userID, filePath) if err != nil { return nextcloud.FileInfo{}, mapDriveError(err) } return file, nil } func (s *Service) GetFileByID(ctx context.Context, userID string, fileID int64) (nextcloud.FileInfo, error) { file, err := s.nc.FindFileByID(ctx, userID, fileID) if err != nil { if strings.Contains(err.Error(), "file not found") { return nextcloud.FileInfo{}, ErrNotFound } return nextcloud.FileInfo{}, mapDriveError(err) } return file, 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 { source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source)) destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination)) return s.moveWithUltidocSidecar(ctx, userID, source, destination) } func (s *Service) Copy(ctx context.Context, userID, source, destination string) error { source = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(source)) destination = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(destination)) if err := mapDriveError(s.nc.Copy(ctx, userID, source, destination)); err != nil { return err } return s.syncUltidocSidecarCopy(ctx, userID, source, destination) } func (s *Service) Rename(ctx context.Context, userID, filePath, newName string) error { if strings.Contains(newName, "/") { return ErrInvalid } filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath)) dir := path.Dir("/" + strings.TrimPrefix(filePath, "/")) destination := path.Join(dir, newName) return s.moveWithUltidocSidecar(ctx, userID, filePath, destination) } func (s *Service) CreateShare(ctx context.Context, userID, filePath string, req createShareRequest, permissions int) (*nextcloud.ShareInfo, error) { filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath)) opts, err := s.buildCreateShareOptions(ctx, req, permissions) if err != nil { return nil, err } share, err := s.nc.CreateShare(ctx, userID, filePath, opts) if err != nil { return nil, mapDriveError(err) } s.rewriteShareURL(share) if shouldSendShareEmail(opts, req) { if sendErr := s.nc.SendShareEmail(ctx, userID, share.ID, ""); sendErr != nil { return share, nil } } return share, nil } func (s *Service) UserExists(ctx context.Context, email string) (bool, error) { userID := nextcloud.UserIDFromClaims(strings.TrimSpace(email), "") if userID == "" { return false, nil } return s.nc.UserExists(ctx, userID) } func (s *Service) SendShareEmail(ctx context.Context, userID, shareID, password string) error { return mapDriveError(s.nc.SendShareEmail(ctx, userID, shareID, password)) } func shouldSendShareEmail(opts nextcloud.CreateShareOptions, req createShareRequest) bool { // Fallback when NC did not send the invitation email on create (common for mail shares). if opts.ShareType != 4 { return false } return req.SendMail == nil || *req.SendMail } func (s *Service) buildCreateShareOptions(ctx context.Context, req createShareRequest, permissions int) (nextcloud.CreateShareOptions, error) { opts := nextcloud.CreateShareOptions{Permissions: permissions} mode := strings.TrimSpace(strings.ToLower(req.Mode)) sendMail := req.SendMail == nil || *req.SendMail switch mode { case "internal": opts.ShareType = 3 opts.Label = "internal" opts.AccessMode = "internal" case "contact": email := strings.TrimSpace(strings.ToLower(req.ShareWith)) if email == "" { return opts, ErrInvalid } registered, err := s.UserExists(ctx, email) if err != nil { return opts, err } opts.Note = strings.TrimSpace(req.Note) opts.SendMail = sendMail if registered { opts.ShareType = 0 opts.ShareWith = nextcloud.UserIDFromClaims(email, "") opts.AccessMode = "user" } else { opts.ShareType = 4 opts.ShareWith = email opts.AccessMode = "email" } case "public": opts.ShareType = 3 opts.AccessMode = "public" default: opts.ShareType = req.ShareType if opts.ShareType == 0 { opts.ShareType = 3 } if opts.ShareType == 3 { opts.AccessMode = "public" } } return opts, nil } func (s *Service) GetQuota(ctx context.Context, userID string) (nextcloud.UserQuota, error) { quota, err := s.nc.GetQuota(ctx, userID) if err != nil { return nextcloud.UserQuota{}, mapDriveError(err) } return quota, nil } func (s *Service) ListShares(ctx context.Context, userID, filePath string) ([]nextcloud.ShareInfo, error) { filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath)) shares, err := s.nc.ListShares(ctx, userID, filePath) if err != nil { return nil, mapDriveError(err) } s.rewriteShareURLs(shares) return shares, nil } func (s *Service) GetPublicShare(ctx context.Context, token, path, password string) (*nextcloud.PublicShareView, error) { view, err := s.nc.GetPublicShare(ctx, token, path, password) if err != nil { return nil, mapPublicShareError(err) } if view != nil && len(view.Files) > 0 { view.Files = nextcloud.FilterHiddenUltidocSidecars(view.Files) } s.recordPublicShareAccess(ctx, token) return view, nil } func (s *Service) DownloadPublicShare(ctx context.Context, token, filePath, password string) (io.ReadCloser, string, error) { body, contentType, err := s.nc.DownloadPublicShare(ctx, token, filePath, password) if err != nil { return nil, "", mapPublicShareError(err) } s.recordPublicShareAccess(ctx, token) return body, contentType, nil } func (s *Service) PreviewPublicShare(ctx context.Context, token, filePath, password string, width, height int) (io.ReadCloser, string, error) { body, contentType, err := s.nc.PreviewPublicShare(ctx, token, filePath, password, width, height) if err != nil { return nil, "", mapPublicShareError(err) } s.recordPublicShareAccess(ctx, token) return body, contentType, nil } func (s *Service) recordPublicShareAccess(ctx context.Context, token string) { publicshare.RecordAccess(ctx, s.db, token) } func (s *Service) rewriteShareURL(share *nextcloud.ShareInfo) { if share == nil || s.nc == nil { return } if share.ShareType == 3 && strings.TrimSpace(share.Token) != "" { if u := s.nc.PublicShareURL(share.Token); u != "" { share.URL = u } } } func (s *Service) rewriteShareURLs(shares []nextcloud.ShareInfo) { for i := range shares { s.rewriteShareURL(&shares[i]) } } func mapPublicShareError(err error) error { if err == nil { return nil } if errors.Is(err, nextcloud.ErrPublicSharePasswordRequired) { return ErrForbidden } if errors.Is(err, nextcloud.ErrInvalidPublicShare) { return ErrInvalid } return mapDriveError(err) } func (s *Service) UpdateShare(ctx context.Context, userID, shareID string, permissions int, expireDate, password string) (*nextcloud.ShareInfo, error) { share, err := s.nc.UpdateShare(ctx, userID, shareID, permissions, expireDate, password) if err != nil { return nil, mapDriveError(err) } return share, nil } func (s *Service) DeleteShare(ctx context.Context, userID, shareID string) error { return mapDriveError(s.nc.DeleteShare(ctx, userID, shareID)) } func (s *Service) RestoreTrash(ctx context.Context, userID, trashName string) error { return mapDriveError(s.nc.RestoreFromTrash(ctx, userID, trashName)) } func (s *Service) DeleteTrash(ctx context.Context, userID, trashName string) error { return mapDriveError(s.nc.DeleteFromTrash(ctx, userID, trashName)) } func (s *Service) EmptyTrash(ctx context.Context, userID string) error { return mapDriveError(s.nc.EmptyTrash(ctx, userID)) } func (s *Service) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error { filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath)) return mapDriveError(s.nc.SetFavorite(ctx, userID, filePath, favorite)) } func (s *Service) Search(ctx context.Context, userID string, opts SearchOptions, params query.ListParams) (FilesList, error) { q := strings.TrimSpace(params.Q) if q == "" { return FilesList{ Files: []nextcloud.FileInfo{}, Pagination: params.Meta(ptrInt64(0)), }, nil } limit := params.Limit() if limit <= 0 { if opts.Suggest { limit = 8 } else { limit = 100 } } scope := nextcloud.SearchScope(opts.Scope) if scope == "" { scope = nextcloud.SearchScopeAll } files, err := s.nc.SearchFiles(ctx, userID, nextcloud.SearchOptions{ Query: q, Scope: scope, BasePath: opts.BasePath, Suggest: opts.Suggest, Limit: limit + params.Offset(), }) if err != nil { return FilesList{}, mapDriveError(err) } page, total := paginate.Slice(nextcloud.FilterHiddenUltidocSidecars(files), params.Offset(), limit) return FilesList{ Files: page, Pagination: params.Meta(&total), }, nil } type SearchOptions struct { Scope string BasePath string Suggest bool } func ptrInt64(v int64) *int64 { return &v } type NewFileKind string const ( NewFileDocument NewFileKind = "document" NewFileSpreadsheet NewFileKind = "spreadsheet" NewFilePresentation NewFileKind = "presentation" NewFileDrawing NewFileKind = "drawing" ) func (s *Service) CreateNewFile(ctx context.Context, userID, parentPath, name string, kind NewFileKind) (string, int64, error) { if strings.TrimSpace(name) == "" { return "", 0, ErrInvalid } content, contentType := blankOfficeFile(kind) if content == nil { return "", 0, ErrInvalid } target := path.Join(strings.TrimSuffix(parentPath, "/"), name) if err := mapDriveError(s.nc.Upload(ctx, userID, target, bytes.NewReader(content), contentType)); err != nil { return "", 0, err } fileID, err := s.nc.FileID(ctx, userID, target) if err != nil { return target, 0, nil } return target, fileID, 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 } func visibleDriveFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo { return filterFiles(nextcloud.FilterHiddenUltidocSidecars(files), q) } 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 !quotaAllowsUpload(quota.Free, incomingBytes, s.quotaReserveByte) { return ErrQuotaExceeded } return nil } // quotaAllowsUpload mirrors Nextcloud quota semantics: negative free means unknown or unlimited. func quotaAllowsUpload(free, incomingBytes, reserve int64) bool { if incomingBytes <= 0 { return true } if free < 0 { return true } if free == 0 { return false } return incomingBytes+reserve <= free } func mapDriveError(err error) error { if err == nil { return nil } if errors.Is(err, nextcloud.ErrDAVCredentialsMissing) { return ErrForbidden } var statusErr *nextcloud.HTTPStatusError if !errors.As(err, &statusErr) { return err } switch statusErr.StatusCode { case http.StatusNotFound: return ErrNotFound case http.StatusConflict, http.StatusPreconditionFailed: 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{} }