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.
This commit is contained in:
parent
951c88b1ca
commit
857b9afc43
98
internal/api/drive/mount_cloud_service.go
Normal file
98
internal/api/drive/mount_cloud_service.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package drive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/driveroot"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mountOAuthAccessToken(configEnc []byte) string {
|
||||||
|
if len(configEnc) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal(configEnc, &cfg); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if raw, ok := cfg["token"].(string); ok {
|
||||||
|
return nextcloud.ParseOAuthAccessToken(raw)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveMountExternalURL returns the provider web URL for a file on an external mount.
|
||||||
|
func (s *Service) ResolveMountExternalURL(ctx context.Context, ncUserID, mountID, platformUserID, logicalPath string) (string, error) {
|
||||||
|
store := s.ensureStore()
|
||||||
|
if store == nil {
|
||||||
|
return "", fmt.Errorf("store not configured")
|
||||||
|
}
|
||||||
|
mount, err := store.GetMount(ctx, mountID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if mount.OwnerUserID == nil || *mount.OwnerUserID != platformUserID {
|
||||||
|
return "", ErrForbidden
|
||||||
|
}
|
||||||
|
logicalPath = nextcloud.NormalizeClientPath(logicalPath)
|
||||||
|
if logicalPath == "/" {
|
||||||
|
return "", ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := s.StatFileAtRoot(ctx, ncUserID, driveroot.Mount(mountID, logicalPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if file.ExternalURL != "" {
|
||||||
|
return file.ExternalURL, nil
|
||||||
|
}
|
||||||
|
if !file.OpenExternally {
|
||||||
|
return "", ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
configEnc, err := store.GetMountConfig(ctx, mountID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
accessToken := mountOAuthAccessToken(configEnc)
|
||||||
|
if accessToken == "" {
|
||||||
|
return "", fmt.Errorf("mount oauth token not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver := &nextcloud.MountCloudResolver{}
|
||||||
|
backend := driveroot.NormalizeMountBackend(mount.BackendType)
|
||||||
|
switch backend {
|
||||||
|
case "google":
|
||||||
|
if id := providerIDFromETag(file.ETag); id != "" {
|
||||||
|
link, err := resolver.GoogleDriveWebViewLink(ctx, accessToken, id)
|
||||||
|
if err == nil && link != "" {
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolver.ResolveGoogleDrivePath(ctx, accessToken, logicalPath)
|
||||||
|
case "microsoft":
|
||||||
|
return resolver.ResolveMicrosoftDrivePath(ctx, accessToken, logicalPath)
|
||||||
|
default:
|
||||||
|
return "", ErrInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerIDFromETag(etag string) string {
|
||||||
|
id := strings.Trim(strings.TrimSpace(etag), "\"")
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range id {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(id) < 10 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@ -294,5 +294,17 @@ func (s *Service) CompleteMountOAuth(ctx context.Context, mountID, platformUserI
|
|||||||
_ = store.UpdateMountStatus(ctx, mount.ID, "error", err.Error(), mount.NCMountID)
|
_ = store.UpdateMountStatus(ctx, mount.ID, "error", err.Error(), mount.NCMountID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
configEnc, _ := store.GetMountConfig(ctx, mount.ID)
|
||||||
|
cfg := map[string]any{}
|
||||||
|
if len(configEnc) > 0 {
|
||||||
|
_ = json.Unmarshal(configEnc, &cfg)
|
||||||
|
}
|
||||||
|
cfg["token"] = token
|
||||||
|
cfg["configured"] = true
|
||||||
|
cfg["client_id"] = creds.ClientID
|
||||||
|
cfg["client_secret"] = creds.ClientSecret
|
||||||
|
if merged, err := json.Marshal(cfg); err == nil {
|
||||||
|
_ = store.UpdateMountConfig(ctx, mount.ID, merged)
|
||||||
|
}
|
||||||
return store.UpdateMountStatus(ctx, mount.ID, "active", "", mount.NCMountID)
|
return store.UpdateMountStatus(ctx, mount.ID, "active", "", mount.NCMountID)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ func (h *Handler) registerOrgAndMountRoutes(r chi.Router, read, write func(http.
|
|||||||
r.With(write).Post("/mounts/{mountID}/oauth/complete", h.CompleteMountOAuth)
|
r.With(write).Post("/mounts/{mountID}/oauth/complete", h.CompleteMountOAuth)
|
||||||
r.With(read).Get("/mounts/{mountID}/files/*", h.ListMountFiles)
|
r.With(read).Get("/mounts/{mountID}/files/*", h.ListMountFiles)
|
||||||
r.With(read).Get("/mounts/{mountID}/files/info/*", h.GetMountFileInfo)
|
r.With(read).Get("/mounts/{mountID}/files/info/*", h.GetMountFileInfo)
|
||||||
|
r.With(read).Get("/mounts/{mountID}/files/external-url/*", h.GetMountFileExternalURL)
|
||||||
r.With(read).Get("/mounts/{mountID}/download/*", h.DownloadMountFile)
|
r.With(read).Get("/mounts/{mountID}/download/*", h.DownloadMountFile)
|
||||||
r.With(read).Get("/mounts/{mountID}/preview/*", h.PreviewMountFile)
|
r.With(read).Get("/mounts/{mountID}/preview/*", h.PreviewMountFile)
|
||||||
r.With(write).Post("/mounts/{mountID}/files/*", h.UploadMountFile)
|
r.With(write).Post("/mounts/{mountID}/files/*", h.UploadMountFile)
|
||||||
@ -93,6 +94,27 @@ func (h *Handler) GetMountFileInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.getRootFileInfo(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
|
h.getRootFileInfo(w, r, driveroot.KindMount, chi.URLParam(r, "mountID"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetMountFileExternalURL(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platformUserID, err := h.svc.platformUserID(r.Context(), claims.Sub)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mountID := chi.URLParam(r, "mountID")
|
||||||
|
path := nextcloud.NormalizeClientPath(chi.URLParam(r, "*"))
|
||||||
|
externalURL, err := h.svc.ResolveMountExternalURL(r.Context(), ncUser, mountID, platformUserID, path)
|
||||||
|
if err != nil {
|
||||||
|
writeDriveError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"external_url": externalURL})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) getRootFileInfo(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
|
func (h *Handler) getRootFileInfo(w http.ResponseWriter, r *http.Request, kind driveroot.Kind, rootID string) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||||
|
|||||||
@ -156,6 +156,12 @@ func (s *Service) ListFilesAtRoot(ctx context.Context, ncUserID string, ref driv
|
|||||||
return FilesList{}, mapDriveError(err)
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
files = driveroot.EnrichFiles(files, resolved.ref)
|
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)
|
filtered := visibleDriveFiles(files, params.Q)
|
||||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||||
return FilesList{
|
return FilesList{
|
||||||
@ -178,7 +184,14 @@ func (s *Service) StatFileAtRoot(ctx context.Context, ncUserID string, ref drive
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nextcloud.FileInfo{}, mapDriveError(err)
|
return nextcloud.FileInfo{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
return driveroot.EnrichFile(file, ref), nil
|
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 {
|
func (s *Service) UploadAtRoot(ctx context.Context, ncUserID string, ref driveroot.Ref, content io.Reader, contentType string, contentLength int64) error {
|
||||||
|
|||||||
128
internal/driveroot/cloud_native.go
Normal file
128
internal/driveroot/cloud_native.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package driveroot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeMountBackend maps stored backend_type to a provider family.
|
||||||
|
func NormalizeMountBackend(backendType string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(backendType)) {
|
||||||
|
case "googledrive", "google":
|
||||||
|
return "google"
|
||||||
|
case "onedrive", "microsoft":
|
||||||
|
return "microsoft"
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(strings.ToLower(backendType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGoogleWorkspaceNativeMime(mimeType string) bool {
|
||||||
|
switch strings.TrimSpace(mimeType) {
|
||||||
|
case "application/vnd.google-apps.document",
|
||||||
|
"application/vnd.google-apps.spreadsheet",
|
||||||
|
"application/vnd.google-apps.presentation":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMicrosoftOfficeMime(mimeType, name string) bool {
|
||||||
|
mime := strings.ToLower(strings.TrimSpace(mimeType))
|
||||||
|
switch mime {
|
||||||
|
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.ms-powerpoint":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := ""
|
||||||
|
if dot := strings.LastIndex(name, "."); dot >= 0 {
|
||||||
|
ext = strings.ToLower(name[dot+1:])
|
||||||
|
}
|
||||||
|
switch ext {
|
||||||
|
case "doc", "docx", "xls", "xlsx", "ppt", "pptx":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerIDFromETag(etag string) string {
|
||||||
|
id := strings.Trim(strings.TrimSpace(etag), "\"")
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Google Drive file ids are alphanumeric plus _ and -.
|
||||||
|
for _, r := range id {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(id) < 10 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleWorkspaceWebURL builds a browser editor URL for native Google Workspace files.
|
||||||
|
func GoogleWorkspaceWebURL(mimeType, fileID string) string {
|
||||||
|
fileID = strings.TrimSpace(fileID)
|
||||||
|
if fileID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(mimeType) {
|
||||||
|
case "application/vnd.google-apps.document":
|
||||||
|
return "https://docs.google.com/document/d/" + fileID + "/edit"
|
||||||
|
case "application/vnd.google-apps.spreadsheet":
|
||||||
|
return "https://docs.google.com/spreadsheets/d/" + fileID + "/edit"
|
||||||
|
case "application/vnd.google-apps.presentation":
|
||||||
|
return "https://docs.google.com/presentation/d/" + fileID + "/edit"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldOpenMountExternally(backend, mimeType, name string) bool {
|
||||||
|
backend = NormalizeMountBackend(backend)
|
||||||
|
if backend == "google" && isGoogleWorkspaceNativeMime(mimeType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if backend == "microsoft" && isMicrosoftOfficeMime(mimeType, name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichMountCloudNative marks mount files that should open in the provider's web editor.
|
||||||
|
func EnrichMountCloudNative(file *nextcloud.FileInfo, mountBackend string) {
|
||||||
|
if file == nil || file.Type == "directory" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backend := NormalizeMountBackend(mountBackend)
|
||||||
|
if backend == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.MountBackend = backend
|
||||||
|
if !shouldOpenMountExternally(backend, file.MimeType, file.Name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.OpenExternally = true
|
||||||
|
if backend == "google" && isGoogleWorkspaceNativeMime(file.MimeType) {
|
||||||
|
if id := providerIDFromETag(file.ETag); id != "" {
|
||||||
|
file.ExternalURL = GoogleWorkspaceWebURL(file.MimeType, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnrichMountCloudNativeFiles(files []nextcloud.FileInfo, mountBackend string) []nextcloud.FileInfo {
|
||||||
|
for i := range files {
|
||||||
|
EnrichMountCloudNative(&files[i], mountBackend)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
61
internal/driveroot/cloud_native_test.go
Normal file
61
internal/driveroot/cloud_native_test.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package driveroot_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/driveroot"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoogleWorkspaceWebURL(t *testing.T) {
|
||||||
|
url := driveroot.GoogleWorkspaceWebURL("application/vnd.google-apps.document", "abc123")
|
||||||
|
if url != "https://docs.google.com/document/d/abc123/edit" {
|
||||||
|
t.Fatalf("unexpected url: %s", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichMountCloudNativeGoogle(t *testing.T) {
|
||||||
|
file := nextcloud.FileInfo{
|
||||||
|
Type: "file",
|
||||||
|
Name: "Budget",
|
||||||
|
MimeType: "application/vnd.google-apps.spreadsheet",
|
||||||
|
ETag: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
|
||||||
|
}
|
||||||
|
driveroot.EnrichMountCloudNative(&file, "googledrive")
|
||||||
|
if !file.OpenExternally {
|
||||||
|
t.Fatal("expected open_externally")
|
||||||
|
}
|
||||||
|
if file.ExternalURL == "" {
|
||||||
|
t.Fatal("expected external_url")
|
||||||
|
}
|
||||||
|
if file.MountBackend != "google" {
|
||||||
|
t.Fatalf("mount_backend=%s", file.MountBackend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichMountCloudNativePersonalIgnored(t *testing.T) {
|
||||||
|
file := nextcloud.FileInfo{
|
||||||
|
Type: "file",
|
||||||
|
Name: "Report.docx",
|
||||||
|
MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
}
|
||||||
|
driveroot.EnrichMountCloudNative(&file, "googledrive")
|
||||||
|
if file.OpenExternally {
|
||||||
|
t.Fatal("personal ultidrive doc should not open externally from mount enrich without mount context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichMountCloudNativeMicrosoftOffice(t *testing.T) {
|
||||||
|
file := nextcloud.FileInfo{
|
||||||
|
Type: "file",
|
||||||
|
Name: "Plan.xlsx",
|
||||||
|
MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
}
|
||||||
|
driveroot.EnrichMountCloudNative(&file, "onedrive")
|
||||||
|
if !file.OpenExternally {
|
||||||
|
t.Fatal("expected open_externally for office on onedrive mount")
|
||||||
|
}
|
||||||
|
if file.MountBackend != "microsoft" {
|
||||||
|
t.Fatalf("mount_backend=%s", file.MountBackend)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,7 @@ type Mount struct {
|
|||||||
MountPoint string `json:"mount_point"`
|
MountPoint string `json:"mount_point"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
ConfigEnc []byte `json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@ -264,6 +265,34 @@ func (s *Store) CreateMount(ctx context.Context, p CreateMountParams) (Mount, er
|
|||||||
return scanMount(row)
|
return scanMount(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateMountConfig(ctx context.Context, id string, configEnc []byte) error {
|
||||||
|
if s.db == nil {
|
||||||
|
return fmt.Errorf("database not configured")
|
||||||
|
}
|
||||||
|
tag, err := s.db.Exec(ctx, `
|
||||||
|
UPDATE drive_mounts SET config_encrypted = $2, updated_at = NOW() WHERE id = $1
|
||||||
|
`, id, configEnc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrMountNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetMountConfig(ctx context.Context, id string) ([]byte, error) {
|
||||||
|
if s.db == nil {
|
||||||
|
return nil, fmt.Errorf("database not configured")
|
||||||
|
}
|
||||||
|
var config []byte
|
||||||
|
err := s.db.QueryRow(ctx, `SELECT config_encrypted FROM drive_mounts WHERE id = $1`, id).Scan(&config)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrMountNotFound
|
||||||
|
}
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateMountStatus(ctx context.Context, id, status, lastError string, ncMountID *int) error {
|
func (s *Store) UpdateMountStatus(ctx context.Context, id, status, lastError string, ncMountID *int) error {
|
||||||
if s.db == nil {
|
if s.db == nil {
|
||||||
return fmt.Errorf("database not configured")
|
return fmt.Errorf("database not configured")
|
||||||
|
|||||||
@ -36,6 +36,12 @@ type FileInfo struct {
|
|||||||
RootKind string `json:"root_kind,omitempty"`
|
RootKind string `json:"root_kind,omitempty"`
|
||||||
RootID string `json:"root_id,omitempty"`
|
RootID string `json:"root_id,omitempty"`
|
||||||
Capabilities *FileCapabilities `json:"capabilities,omitempty"`
|
Capabilities *FileCapabilities `json:"capabilities,omitempty"`
|
||||||
|
// Mount cloud provider (google, microsoft) when file lives on an external mount.
|
||||||
|
MountBackend string `json:"mount_backend,omitempty"`
|
||||||
|
// ExternalURL opens the file in the provider's web editor (Docs, Office Online, …).
|
||||||
|
ExternalURL string `json:"external_url,omitempty"`
|
||||||
|
// OpenExternally defaults opening to ExternalURL instead of Ultidocs / OnlyOffice.
|
||||||
|
OpenExternally bool `json:"open_externally,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShareInfo struct {
|
type ShareInfo struct {
|
||||||
|
|||||||
194
internal/nextcloud/mount_cloud.go
Normal file
194
internal/nextcloud/mount_cloud.go
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
package nextcloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MountCloudResolver resolves provider web URLs for files on external mounts.
|
||||||
|
type MountCloudResolver struct {
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MountCloudResolver) httpClient() *http.Client {
|
||||||
|
if r != nil && r.Client != nil {
|
||||||
|
return r.Client
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: 45 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseOAuthAccessToken extracts a bearer token from Nextcloud external storage token JSON.
|
||||||
|
func ParseOAuthAccessToken(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var obj map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &obj); err == nil {
|
||||||
|
if at, ok := obj["access_token"].(string); ok && strings.TrimSpace(at) != "" {
|
||||||
|
return strings.TrimSpace(at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MountCloudResolver) GoogleDriveWebViewLink(ctx context.Context, accessToken, fileID string) (string, error) {
|
||||||
|
fileID = strings.TrimSpace(fileID)
|
||||||
|
accessToken = strings.TrimSpace(accessToken)
|
||||||
|
if fileID == "" || accessToken == "" {
|
||||||
|
return "", fmt.Errorf("missing google file id or token")
|
||||||
|
}
|
||||||
|
apiURL := "https://www.googleapis.com/drive/v3/files/" + url.PathEscape(fileID) +
|
||||||
|
"?fields=webViewLink,mimeType&supportsAllDrives=true"
|
||||||
|
body, err := r.providerGET(ctx, accessToken, apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
WebViewLink string `json:"webViewLink"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(parsed.WebViewLink) != "" {
|
||||||
|
return strings.TrimSpace(parsed.WebViewLink), nil
|
||||||
|
}
|
||||||
|
// Fallback when API omits webViewLink but mime is native Google Workspace.
|
||||||
|
return driverootGoogleWorkspaceURL(parsed.MimeType, fileID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func driverootGoogleWorkspaceURL(mimeType, fileID string) string {
|
||||||
|
switch strings.TrimSpace(mimeType) {
|
||||||
|
case "application/vnd.google-apps.document":
|
||||||
|
return "https://docs.google.com/document/d/" + fileID + "/edit"
|
||||||
|
case "application/vnd.google-apps.spreadsheet":
|
||||||
|
return "https://docs.google.com/spreadsheets/d/" + fileID + "/edit"
|
||||||
|
case "application/vnd.google-apps.presentation":
|
||||||
|
return "https://docs.google.com/presentation/d/" + fileID + "/edit"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MountCloudResolver) ResolveGoogleDrivePath(ctx context.Context, accessToken, logicalPath string) (string, error) {
|
||||||
|
segments := splitLogicalPathSegments(logicalPath)
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return "", fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
parentID := "root"
|
||||||
|
for i, name := range segments {
|
||||||
|
isLast := i == len(segments)-1
|
||||||
|
id, mime, err := r.googleFindChild(ctx, accessToken, parentID, name, isLast)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if isLast {
|
||||||
|
link, err := r.GoogleDriveWebViewLink(ctx, accessToken, id)
|
||||||
|
if err != nil {
|
||||||
|
if built := driverootGoogleWorkspaceURL(mime, id); built != "" {
|
||||||
|
return built, nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
parentID = id
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("path not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MountCloudResolver) googleFindChild(ctx context.Context, accessToken, parentID, name string, wantFile bool) (id, mimeType string, err error) {
|
||||||
|
q := fmt.Sprintf("'%s' in parents and name = '%s' and trashed = false", parentID, escapeDriveQuery(name))
|
||||||
|
if wantFile {
|
||||||
|
q += " and mimeType != 'application/vnd.google-apps.folder'"
|
||||||
|
} else {
|
||||||
|
q += " and mimeType = 'application/vnd.google-apps.folder'"
|
||||||
|
}
|
||||||
|
listURL := "https://www.googleapis.com/drive/v3/files?supportsAllDrives=true&includeItemsFromAllDrives=true&fields=files(id,mimeType)&q=" +
|
||||||
|
url.QueryEscape(q)
|
||||||
|
body, err := r.providerGET(ctx, accessToken, listURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Files []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
} `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if len(parsed.Files) == 0 {
|
||||||
|
return "", "", fmt.Errorf("google drive item not found: %s", name)
|
||||||
|
}
|
||||||
|
return parsed.Files[0].ID, parsed.Files[0].MimeType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeDriveQuery(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "'", "\\'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MountCloudResolver) ResolveMicrosoftDrivePath(ctx context.Context, accessToken, logicalPath string) (string, error) {
|
||||||
|
segments := splitLogicalPathSegments(logicalPath)
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return "", fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
encoded := make([]string, len(segments))
|
||||||
|
for i, seg := range segments {
|
||||||
|
encoded[i] = url.PathEscape(seg)
|
||||||
|
}
|
||||||
|
itemPath := "/me/drive/root:/" + strings.Join(encoded, "/")
|
||||||
|
apiURL := "https://graph.microsoft.com/v1.0" + itemPath + "?$select=webUrl"
|
||||||
|
body, err := r.providerGET(ctx, accessToken, apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
WebURL string `json:"webUrl"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
webURL := strings.TrimSpace(parsed.WebURL)
|
||||||
|
if webURL == "" {
|
||||||
|
return "", fmt.Errorf("microsoft drive item has no webUrl")
|
||||||
|
}
|
||||||
|
return webURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLogicalPathSegments(logicalPath string) []string {
|
||||||
|
logicalPath = strings.Trim(logicalPath, "/")
|
||||||
|
if logicalPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(logicalPath, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MountCloudResolver) providerGET(ctx context.Context, accessToken, apiURL string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
resp, err := r.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("provider api %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user