ultisuite-backend/internal/api/drive/service.go
2026-06-07 21:55:22 +02:00

630 lines
18 KiB
Go

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/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")
)
type Service struct {
nc *nextcloud.Client
hub *realtime.Hub
db *pgxpool.Pool
automation driveAutomation
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)
}
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 := 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) 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 := 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 = "/"
}
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 := filterFiles(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
}
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) 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) 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 mapDriveError(s.nc.Move(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))
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
}
filePath = nextcloud.NormalizeClientFilePath(userID, nextcloud.NormalizeClientPath(filePath))
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, 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)
}
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(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"
)
func (s *Service) CreateNewFile(ctx context.Context, userID, parentPath, name string, kind NewFileKind) (string, error) {
if strings.TrimSpace(name) == "" {
return "", ErrInvalid
}
content, contentType := blankOfficeFile(kind)
if content == nil {
return "", ErrInvalid
}
target := path.Join(strings.TrimSuffix(parentPath, "/"), name)
if err := mapDriveError(s.nc.Upload(ctx, userID, target, bytes.NewReader(content), contentType)); err != nil {
return "", err
}
return target, 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 !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{}
}