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 }