ultisuite-backend/internal/api/drive/service_roots.go
R3D347HR4Y 857b9afc43
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(drive): implement external URL resolution for mounted cloud files
- Added new functionality to resolve external URLs for files on Google Drive and Microsoft OneDrive mounts.
- Introduced `mount_cloud_service.go` to handle OAuth token extraction and URL resolution.
- Enhanced `mounts_service.go` to update mount configurations with OAuth tokens.
- Updated API routes to include a new endpoint for fetching external URLs.
- Implemented enrichment functions in `cloud_native.go` to mark files that should open in the provider's web editor.
- Added tests for cloud-native file enrichment in `cloud_native_test.go` to ensure correct behavior.
2026-06-13 13:44:43 +02:00

313 lines
9.4 KiB
Go

package drive
import (
"context"
"errors"
"fmt"
"io"
"path"
"strings"
"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/driveroot"
"github.com/ultisuite/ulti-backend/internal/drivestore"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
type resolvedRoot struct {
ref driveroot.Ref
davPath string
logicalDir string
ncUserID string
}
func (s *Service) ensureStore() *drivestore.Store {
if s.store == nil && s.db != nil {
s.store = drivestore.NewStore(s.db)
}
return s.store
}
func (s *Service) resolveRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) (resolvedRoot, error) {
ref.Path = nextcloud.NormalizeClientPath(ref.Path)
switch ref.Kind {
case driveroot.KindPersonal, "":
return resolvedRoot{
ref: driveroot.Personal(ref.Path),
davPath: s.nc.WebDAVPath(ncUserID, ref.Path),
logicalDir: ref.Path,
ncUserID: ncUserID,
}, nil
case driveroot.KindOrg:
store := s.ensureStore()
if store == nil {
return resolvedRoot{}, ErrInvalid
}
folder, err := store.GetOrgFolder(ctx, ref.RootID)
if err != nil {
if errors.Is(err, drivestore.ErrOrgFolderNotFound) {
return resolvedRoot{}, ErrNotFound
}
return resolvedRoot{}, err
}
return resolvedRoot{
ref: driveroot.Org(ref.RootID, ref.Path),
davPath: nextcloud.GroupFolderWebDAVPath(folder.NCFolderID, ref.Path),
logicalDir: ref.Path,
ncUserID: ncUserID,
}, nil
case driveroot.KindMount:
store := s.ensureStore()
if store == nil {
return resolvedRoot{}, ErrInvalid
}
mount, err := store.GetMount(ctx, ref.RootID)
if err != nil {
if errors.Is(err, drivestore.ErrMountNotFound) {
return resolvedRoot{}, ErrNotFound
}
return resolvedRoot{}, err
}
fullPath := joinMountPath(mount.MountPoint, ref.Path)
return resolvedRoot{
ref: driveroot.Mount(ref.RootID, ref.Path),
davPath: s.nc.WebDAVPath(ncUserID, fullPath),
logicalDir: ref.Path,
ncUserID: ncUserID,
}, nil
default:
return resolvedRoot{}, ErrInvalid
}
}
func joinMountPath(mountPoint, logicalPath string) string {
mp := strings.Trim(mountPoint, "/")
lp := strings.Trim(logicalPath, "/")
if mp == "" {
if lp == "" {
return "/"
}
return "/" + lp
}
if lp == "" {
return "/" + mp
}
return "/" + mp + "/" + lp
}
func (s *Service) resolveFileRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) (resolvedRoot, error) {
resolved, err := s.resolveRoot(ctx, ncUserID, ref)
if err != nil {
return resolvedRoot{}, err
}
if ref.Path != "/" && !strings.HasSuffix(resolved.ref.Path, "/") {
return resolved, nil
}
return resolved, nil
}
func (s *Service) resolveFileDAV(ctx context.Context, ncUserID string, ref driveroot.Ref) (resolvedRoot, string, error) {
base, err := s.resolveRoot(ctx, ncUserID, ref)
if err != nil {
return resolvedRoot{}, "", err
}
filePath := nextcloud.NormalizeClientPath(ref.Path)
if filePath == "/" {
return base, base.davPath, nil
}
davPath := base.davPath
if !strings.HasSuffix(davPath, "/") && filePath != "/" {
// base.davPath is directory; append file segment if needed
rel := strings.TrimPrefix(filePath, "/")
if base.ref.Path == "/" || strings.HasPrefix(filePath, base.ref.Path+"/") || base.ref.Path == filePath {
// already included in path from ref
}
_ = rel
}
// For file operations, ref.Path is full logical path within root
switch base.ref.Kind {
case driveroot.KindOrg:
folder, _ := s.ensureStore().GetOrgFolder(ctx, ref.RootID)
davPath = nextcloud.GroupFolderWebDAVPath(folder.NCFolderID, filePath)
case driveroot.KindMount:
mount, _ := s.ensureStore().GetMount(ctx, ref.RootID)
davPath = s.nc.WebDAVPath(ncUserID, joinMountPath(mount.MountPoint, filePath))
default:
davPath = s.nc.WebDAVPath(ncUserID, filePath)
}
return base, davPath, nil
}
func (s *Service) ListFilesAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, params query.ListParams) (FilesList, error) {
resolved, err := s.resolveRoot(ctx, ncUserID, ref)
if err != nil {
return FilesList{}, err
}
var files []nextcloud.FileInfo
if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
files, err = s.nc.ListFiles(ctx, ncUserID, ref.Path)
} else {
files, err = s.nc.ListFilesAtDAV(ctx, ncUserID, resolved.davPath, ref.Path)
}
if err != nil {
return FilesList{}, mapDriveError(err)
}
files = driveroot.EnrichFiles(files, resolved.ref)
if ref.Kind == driveroot.KindMount {
mount, mountErr := s.ensureStore().GetMount(ctx, ref.RootID)
if mountErr == nil {
files = driveroot.EnrichMountCloudNativeFiles(files, mount.BackendType)
}
}
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) StatFileAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) (nextcloud.FileInfo, error) {
_, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
if err != nil {
return nextcloud.FileInfo{}, err
}
var file nextcloud.FileInfo
if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
file, err = s.nc.StatFile(ctx, ncUserID, ref.Path)
} else {
file, err = s.nc.StatFileAtDAV(ctx, ncUserID, davPath, ref.Path)
}
if err != nil {
return nextcloud.FileInfo{}, mapDriveError(err)
}
file = driveroot.EnrichFile(file, ref)
if ref.Kind == driveroot.KindMount {
mount, mountErr := s.ensureStore().GetMount(ctx, ref.RootID)
if mountErr == nil {
driveroot.EnrichMountCloudNative(&file, mount.BackendType)
}
}
return file, nil
}
func (s *Service) UploadAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, content io.Reader, contentType string, contentLength int64) error {
_, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
if err != nil {
return err
}
if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
return s.Upload(ctx, ncUserID, ref.Path, content, contentType, contentLength)
}
return mapDriveError(s.nc.UploadAtDAV(ctx, ncUserID, davPath, contentType, content))
}
func (s *Service) DeleteAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) error {
_, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
if err != nil {
return err
}
if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
return s.Delete(ctx, ncUserID, ref.Path)
}
return mapDriveError(s.nc.DeleteAtDAV(ctx, ncUserID, davPath))
}
func (s *Service) CreateFolderAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) error {
_, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
if err != nil {
return err
}
if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
return s.CreateFolder(ctx, ncUserID, ref.Path)
}
return mapDriveError(s.nc.CreateFolderAtDAV(ctx, ncUserID, davPath))
}
func (s *Service) DownloadAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref) (io.ReadCloser, string, error) {
_, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
if err != nil {
return nil, "", err
}
if ref.Kind == driveroot.KindPersonal || ref.Kind == "" {
return s.Download(ctx, ncUserID, ref.Path)
}
body, contentType, err := s.nc.DownloadAtDAV(ctx, ncUserID, davPath)
if err != nil {
return nil, "", mapDriveError(err)
}
return body, contentType, nil
}
func (s *Service) MoveAtRoot(ctx context.Context, ncUserID string, source, destination driveroot.Ref) error {
srcDAV, err := s.davPathForRef(ctx, ncUserID, source)
if err != nil {
return err
}
destDAV, err := s.davPathForRef(ctx, ncUserID, destination)
if err != nil {
return err
}
if source.Kind == driveroot.KindPersonal && destination.Kind == driveroot.KindPersonal {
return s.Move(ctx, ncUserID, source.Path, destination.Path)
}
return mapDriveError(s.nc.MoveAtDAV(ctx, ncUserID, srcDAV, destDAV))
}
func (s *Service) CopyAtRoot(ctx context.Context, ncUserID string, source, destination driveroot.Ref) error {
srcDAV, err := s.davPathForRef(ctx, ncUserID, source)
if err != nil {
return err
}
destDAV, err := s.davPathForRef(ctx, ncUserID, destination)
if err != nil {
return err
}
if source.Kind == driveroot.KindPersonal && destination.Kind == driveroot.KindPersonal {
return s.Copy(ctx, ncUserID, source.Path, destination.Path)
}
return mapDriveError(s.nc.CopyAtDAV(ctx, ncUserID, srcDAV, destDAV))
}
func (s *Service) RenameAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, newName string) error {
if strings.Contains(newName, "/") {
return ErrInvalid
}
dir := path.Dir(strings.TrimPrefix(ref.Path, "/"))
if dir == "." {
dir = ""
}
destPath := "/" + strings.Trim(newName, "/")
if dir != "" {
destPath = "/" + dir + destPath
}
dest := driveroot.Ref{Kind: ref.Kind, RootID: ref.RootID, Path: destPath}
return s.MoveAtRoot(ctx, ncUserID, ref, dest)
}
func (s *Service) davPathForRef(ctx context.Context, ncUserID string, ref driveroot.Ref) (string, error) {
_, davPath, err := s.resolveFileDAV(ctx, ncUserID, ref)
return davPath, err
}
func (s *Service) platformUserID(ctx context.Context, externalID string) (string, error) {
if s.db == nil {
return "", fmt.Errorf("database not configured")
}
var id string
err := s.db.QueryRow(ctx, `SELECT id::text FROM users WHERE external_id = $1`, externalID).Scan(&id)
if err != nil {
return "", err
}
return id, nil
}
func extendServiceStore(s *Service, db *pgxpool.Pool) {
if s != nil && s.store == nil && db != nil {
s.store = drivestore.NewStore(db)
}
}