github.com/cloudreve/Cloudreve/v3@v3.0.0-20240224133659-3edb00a6484c/pkg/wopi/wopi.go (about)

     1  package wopi
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	model "github.com/cloudreve/Cloudreve/v3/models"
     7  	"github.com/cloudreve/Cloudreve/v3/pkg/cache"
     8  	"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
     9  	"github.com/cloudreve/Cloudreve/v3/pkg/request"
    10  	"github.com/cloudreve/Cloudreve/v3/pkg/util"
    11  	"github.com/gofrs/uuid"
    12  	"net/url"
    13  	"path"
    14  	"strings"
    15  	"sync"
    16  	"time"
    17  )
    18  
    19  type Client interface {
    20  	// NewSession creates a new document session with access token.
    21  	NewSession(uid uint, file *model.File, action ActonType) (*Session, error)
    22  	// AvailableExts returns a list of file extensions that are supported by WOPI.
    23  	AvailableExts() []string
    24  }
    25  
    26  var (
    27  	ErrActionNotSupported = errors.New("action not supported by current wopi endpoint")
    28  
    29  	Default   Client
    30  	DefaultMu sync.Mutex
    31  
    32  	queryPlaceholders = map[string]string{
    33  		"BUSINESS_USER":           "",
    34  		"DC_LLCC":                 "lng",
    35  		"DISABLE_ASYNC":           "",
    36  		"DISABLE_CHAT":            "",
    37  		"EMBEDDED":                "true",
    38  		"FULLSCREEN":              "true",
    39  		"HOST_SESSION_ID":         "",
    40  		"SESSION_CONTEXT":         "",
    41  		"RECORDING":               "",
    42  		"THEME_ID":                "darkmode",
    43  		"UI_LLCC":                 "lng",
    44  		"VALIDATOR_TEST_CATEGORY": "",
    45  	}
    46  )
    47  
    48  const (
    49  	SessionCachePrefix  = "wopi_session_"
    50  	AccessTokenQuery    = "access_token"
    51  	OverwriteHeader     = wopiHeaderPrefix + "Override"
    52  	ServerErrorHeader   = wopiHeaderPrefix + "ServerError"
    53  	RenameRequestHeader = wopiHeaderPrefix + "RequestedName"
    54  
    55  	MethodLock        = "LOCK"
    56  	MethodUnlock      = "UNLOCK"
    57  	MethodRefreshLock = "REFRESH_LOCK"
    58  	MethodRename      = "RENAME_FILE"
    59  
    60  	wopiSrcPlaceholder    = "WOPI_SOURCE"
    61  	wopiSrcParamDefault   = "WOPISrc"
    62  	languageParamDefault  = "lang"
    63  	sessionExpiresPadding = 10
    64  	wopiHeaderPrefix      = "X-WOPI-"
    65  )
    66  
    67  // Init initializes a new global WOPI client.
    68  func Init() {
    69  	settings := model.GetSettingByNames("wopi_endpoint", "wopi_enabled")
    70  	if !model.IsTrueVal(settings["wopi_enabled"]) {
    71  		DefaultMu.Lock()
    72  		Default = nil
    73  		DefaultMu.Unlock()
    74  		return
    75  	}
    76  
    77  	cache.Deletes([]string{DiscoverResponseCacheKey}, "")
    78  	wopiClient, err := NewClient(settings["wopi_endpoint"], cache.Store, request.NewClient())
    79  	if err != nil {
    80  		util.Log().Error("Failed to initialize WOPI client: %s", err)
    81  		return
    82  	}
    83  
    84  	DefaultMu.Lock()
    85  	Default = wopiClient
    86  	DefaultMu.Unlock()
    87  }
    88  
    89  type client struct {
    90  	cache cache.Driver
    91  	http  request.Client
    92  	mu    sync.RWMutex
    93  
    94  	discovery *WopiDiscovery
    95  	actions   map[string]map[string]Action
    96  
    97  	config
    98  }
    99  
   100  type config struct {
   101  	discoveryEndpoint *url.URL
   102  }
   103  
   104  func NewClient(endpoint string, cache cache.Driver, http request.Client) (Client, error) {
   105  	endpointUrl, err := url.Parse(endpoint)
   106  	if err != nil {
   107  		return nil, fmt.Errorf("failed to parse WOPI endpoint: %s", err)
   108  	}
   109  
   110  	return &client{
   111  		cache: cache,
   112  		http:  http,
   113  		config: config{
   114  			discoveryEndpoint: endpointUrl,
   115  		},
   116  	}, nil
   117  }
   118  
   119  func (c *client) NewSession(uid uint, file *model.File, action ActonType) (*Session, error) {
   120  	if err := c.checkDiscovery(); err != nil {
   121  		return nil, err
   122  	}
   123  
   124  	c.mu.RLock()
   125  	defer c.mu.RUnlock()
   126  
   127  	ext := path.Ext(file.Name)
   128  	availableActions, ok := c.actions[ext]
   129  	if !ok {
   130  		return nil, ErrActionNotSupported
   131  	}
   132  
   133  	var (
   134  		actionConfig Action
   135  	)
   136  	fallbackOrder := []ActonType{action, ActionPreview, ActionPreviewFallback, ActionEdit}
   137  	for _, a := range fallbackOrder {
   138  		if actionConfig, ok = availableActions[string(a)]; ok {
   139  			break
   140  		}
   141  	}
   142  
   143  	if actionConfig.Urlsrc == "" {
   144  		return nil, ErrActionNotSupported
   145  	}
   146  
   147  	// Generate WOPI REST endpoint for given file
   148  	baseURL := model.GetSiteURL()
   149  	linkPath, err := url.Parse(fmt.Sprintf("/api/v3/wopi/files/%s", hashid.HashID(file.ID, hashid.FileID)))
   150  	if err != nil {
   151  		return nil, err
   152  	}
   153  
   154  	actionUrl, err := generateActionUrl(actionConfig.Urlsrc, baseURL.ResolveReference(linkPath).String())
   155  	if err != nil {
   156  		return nil, err
   157  	}
   158  
   159  	// Create document session
   160  	sessionID := uuid.Must(uuid.NewV4())
   161  	token := util.RandStringRunes(64)
   162  	ttl := model.GetIntSetting("wopi_session_timeout", 36000)
   163  	session := &SessionCache{
   164  		AccessToken: fmt.Sprintf("%s.%s", sessionID, token),
   165  		FileID:      file.ID,
   166  		UserID:      uid,
   167  		Action:      action,
   168  	}
   169  	err = c.cache.Set(SessionCachePrefix+sessionID.String(), *session, ttl)
   170  	if err != nil {
   171  		return nil, fmt.Errorf("failed to create document session: %w", err)
   172  	}
   173  
   174  	sessionRes := &Session{
   175  		AccessToken:    session.AccessToken,
   176  		ActionURL:      actionUrl,
   177  		AccessTokenTTL: time.Now().Add(time.Duration(ttl-sessionExpiresPadding) * time.Second).UnixMilli(),
   178  	}
   179  
   180  	return sessionRes, nil
   181  }
   182  
   183  // Replace query parameters in action URL template. Some placeholders need to be replaced
   184  // at the frontend, e.g. `THEME_ID`.
   185  func generateActionUrl(src string, fileSrc string) (*url.URL, error) {
   186  	src = strings.ReplaceAll(src, "<", "")
   187  	src = strings.ReplaceAll(src, ">", "")
   188  	actionUrl, err := url.Parse(src)
   189  	if err != nil {
   190  		return nil, fmt.Errorf("failed to parse action url: %s", err)
   191  	}
   192  
   193  	queries := actionUrl.Query()
   194  	srcReplaced := false
   195  	queryReplaced := url.Values{}
   196  	for k := range queries {
   197  		if placeholder, ok := queryPlaceholders[queries.Get(k)]; ok {
   198  			if placeholder != "" {
   199  				queryReplaced.Set(k, placeholder)
   200  			}
   201  
   202  			continue
   203  		}
   204  
   205  		if queries.Get(k) == wopiSrcPlaceholder {
   206  			queryReplaced.Set(k, fileSrc)
   207  			srcReplaced = true
   208  			continue
   209  		}
   210  
   211  		queryReplaced.Set(k, queries.Get(k))
   212  	}
   213  
   214  	if !srcReplaced {
   215  		queryReplaced.Set(wopiSrcParamDefault, fileSrc)
   216  	}
   217  
   218  	// LibreOffice require this flag to show correct language
   219  	queryReplaced.Set(languageParamDefault, "lng")
   220  
   221  	actionUrl.RawQuery = queryReplaced.Encode()
   222  	return actionUrl, nil
   223  }