github.com/cs3org/reva/v2@v2.27.7/pkg/app/provider/wopi/wopi.go (about) 1 // Copyright 2018-2021 CERN 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 // 15 // In applying this license, CERN does not waive the privileges and immunities 16 // granted to it by virtue of its status as an Intergovernmental Organization 17 // or submit itself to any jurisdiction. 18 19 package wopi 20 21 import ( 22 "bytes" 23 "context" 24 "encoding/json" 25 "fmt" 26 "io" 27 "net/http" 28 "net/url" 29 "os" 30 "path" 31 "strconv" 32 "strings" 33 "time" 34 35 "github.com/beevik/etree" 36 appprovider "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" 37 appregistry "github.com/cs3org/go-cs3apis/cs3/app/registry/v1beta1" 38 userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" 39 provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" 40 "github.com/cs3org/reva/v2/pkg/app" 41 "github.com/cs3org/reva/v2/pkg/app/provider/registry" 42 "github.com/cs3org/reva/v2/pkg/appctx" 43 ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" 44 "github.com/cs3org/reva/v2/pkg/errtypes" 45 "github.com/cs3org/reva/v2/pkg/mime" 46 "github.com/cs3org/reva/v2/pkg/rhttp" 47 "github.com/cs3org/reva/v2/pkg/sharedconf" 48 "github.com/cs3org/reva/v2/pkg/storage/utils/templates" 49 "github.com/cs3org/reva/v2/pkg/storagespace" 50 "github.com/golang-jwt/jwt/v5" 51 "github.com/mitchellh/mapstructure" 52 "github.com/pkg/errors" 53 ) 54 55 func init() { 56 registry.Register("wopi", New) 57 } 58 59 type config struct { 60 IOPSecret string `mapstructure:"iop_secret" docs:";The IOP secret used to connect to the wopiserver."` 61 WopiURL string `mapstructure:"wopi_url" docs:";The wopiserver's URL."` 62 WopiFolderURLBaseURL string `mapstructure:"wopi_folder_url_base_url" docs:";The base URL to generate links to navigate back to the containing folder."` 63 WopiFolderURLPathTemplate string `mapstructure:"wopi_folder_url_path_template" docs:";The template to generate the folderurl path segments."` 64 AppName string `mapstructure:"app_name" docs:";The App user-friendly name."` 65 AppIconURI string `mapstructure:"app_icon_uri" docs:";A URI to a static asset which represents the app icon."` 66 AppURL string `mapstructure:"app_url" docs:";The App URL."` 67 AppIntURL string `mapstructure:"app_int_url" docs:";The internal app URL in case of dockerized deployments. Defaults to AppURL"` 68 AppAPIKey string `mapstructure:"app_api_key" docs:";The API key used by the app, if applicable."` 69 JWTSecret string `mapstructure:"jwt_secret" docs:";The JWT secret to be used to retrieve the token TTL."` 70 AppDesktopOnly bool `mapstructure:"app_desktop_only" docs:"false;Specifies if the app can be opened only on desktop."` 71 InsecureConnections bool `mapstructure:"insecure_connections"` 72 AppDisableChat bool `mapstructure:"app_disable_chat"` 73 } 74 75 func parseConfig(m map[string]interface{}) (*config, error) { 76 c := &config{} 77 if err := mapstructure.Decode(m, c); err != nil { 78 return nil, err 79 } 80 return c, nil 81 } 82 83 type wopiProvider struct { 84 conf *config 85 wopiClient *http.Client 86 appURLs map[string]map[string]string // map[viewMode]map[extension]appURL 87 } 88 89 // New returns an implementation of the app.Provider interface that 90 // connects to an application in the backend. 91 func New(m map[string]interface{}) (app.Provider, error) { 92 c, err := parseConfig(m) 93 if err != nil { 94 return nil, err 95 } 96 97 if c.AppIntURL == "" { 98 c.AppIntURL = c.AppURL 99 } 100 if c.IOPSecret == "" { 101 c.IOPSecret = os.Getenv("REVA_APPPROVIDER_IOPSECRET") 102 } 103 c.JWTSecret = sharedconf.GetJWTSecret(c.JWTSecret) 104 105 appURLs, err := getAppURLs(c) 106 if err != nil { 107 return nil, err 108 } 109 110 wopiClient := rhttp.GetHTTPClient( 111 rhttp.Timeout(time.Duration(5*int64(time.Second))), 112 rhttp.Insecure(c.InsecureConnections), 113 ) 114 wopiClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { 115 return http.ErrUseLastResponse 116 } 117 118 return &wopiProvider{ 119 conf: c, 120 wopiClient: wopiClient, 121 appURLs: appURLs, 122 }, nil 123 } 124 125 func (p *wopiProvider) GetAppURL(ctx context.Context, resource *provider.ResourceInfo, viewMode appprovider.ViewMode, token, language string) (*appprovider.OpenInAppURL, error) { 126 log := appctx.GetLogger(ctx) 127 128 ext := path.Ext(resource.Path) 129 wopiurl, err := url.Parse(p.conf.WopiURL) 130 if err != nil { 131 return nil, err 132 } 133 wopiurl.Path = path.Join(wopiurl.Path, "/wopi/iop/openinapp") 134 135 httpReq, err := rhttp.NewRequest(ctx, "GET", wopiurl.String(), nil) 136 if err != nil { 137 return nil, err 138 } 139 140 q := httpReq.URL.Query() 141 142 q.Add("endpoint", storagespace.FormatStorageID(resource.GetId().GetStorageId(), resource.GetId().GetSpaceId())) 143 q.Add("fileid", resource.GetId().OpaqueId) 144 q.Add("viewmode", viewMode.String()) 145 146 folderURLPath := templates.WithResourceInfo(resource, p.conf.WopiFolderURLPathTemplate) 147 folderURLBaseURL, err := url.Parse(p.conf.WopiFolderURLBaseURL) 148 if err != nil { 149 return nil, err 150 } 151 if folderURLPath != "" { 152 folderURLBaseURL.Path = path.Join(folderURLBaseURL.Path, folderURLPath) 153 q.Add("folderurl", folderURLBaseURL.String()) 154 } 155 156 u, ok := ctxpkg.ContextGetUser(ctx) 157 if ok { // else defaults to "Guest xyz" 158 if u.Id.Type == userpb.UserType_USER_TYPE_LIGHTWEIGHT || u.Id.Type == userpb.UserType_USER_TYPE_FEDERATED { 159 q.Add("userid", resource.Owner.OpaqueId+"@"+resource.Owner.Idp) 160 } else { 161 q.Add("userid", u.Id.OpaqueId+"@"+u.Id.Idp) 162 } 163 var isPublicShare bool 164 if u.Opaque != nil { 165 if _, ok := u.Opaque.Map["public-share-role"]; ok { 166 isPublicShare = true 167 } 168 } 169 170 if !isPublicShare { 171 q.Add("username", u.DisplayName) 172 } 173 } 174 175 q.Add("appname", p.conf.AppName) 176 177 var viewAppURL string 178 if viewAppURLs, ok := p.appURLs["view"]; ok { 179 if viewAppURL, ok = viewAppURLs[ext]; ok { 180 q.Add("appviewurl", viewAppURL) 181 } 182 } 183 access := "edit" 184 if resource.GetSize() == 0 { 185 if _, ok := p.appURLs["editnew"]; ok { 186 access = "editnew" 187 } 188 } 189 if editAppURLs, ok := p.appURLs[access]; ok { 190 if editAppURL, ok := editAppURLs[ext]; ok { 191 q.Add("appurl", editAppURL) 192 } 193 } 194 if q.Get("appurl") == "" { 195 // assuming that a view action is always available in the /hosting/discovery manifest 196 // eg. Collabora does support viewing jpgs but no editing 197 // eg. OnlyOffice does support viewing pdfs but no editing 198 // there is no known case of supporting edit only without view 199 q.Add("appurl", viewAppURL) 200 } 201 if q.Get("appurl") == "" && q.Get("appviewurl") == "" { 202 return nil, errors.New("wopi: neither edit nor view app url found") 203 } 204 205 if p.conf.AppIntURL != "" { 206 q.Add("appinturl", p.conf.AppIntURL) 207 } 208 209 httpReq.URL.RawQuery = q.Encode() 210 211 if p.conf.AppAPIKey != "" { 212 httpReq.Header.Set("ApiKey", p.conf.AppAPIKey) 213 } 214 215 httpReq.Header.Set("Authorization", "Bearer "+p.conf.IOPSecret) 216 httpReq.Header.Set("TokenHeader", token) 217 218 // Call the WOPI server and parse the response (body will always contain a payload) 219 openRes, err := p.wopiClient.Do(httpReq) 220 if err != nil { 221 return nil, errors.Wrap(err, "wopi: error performing open request to WOPI server") 222 } 223 defer openRes.Body.Close() 224 225 body, err := io.ReadAll(openRes.Body) 226 if err != nil { 227 return nil, err 228 } 229 if openRes.StatusCode != http.StatusOK { 230 // WOPI returned failure: body contains a user-friendly error message (yet perform a sanity check) 231 sbody := "" 232 if body != nil { 233 sbody = string(body) 234 } 235 log.Warn().Msg(fmt.Sprintf("wopi: WOPI server returned HTTP %s to request %s, error was: %s", openRes.Status, httpReq.URL.String(), sbody)) 236 return nil, errors.New(sbody) 237 } 238 239 var result map[string]interface{} 240 err = json.Unmarshal(body, &result) 241 if err != nil { 242 return nil, err 243 } 244 245 tokenTTL, err := p.getAccessTokenTTL(ctx) 246 if err != nil { 247 return nil, err 248 } 249 250 url, err := url.Parse(result["app-url"].(string)) 251 if err != nil { 252 return nil, err 253 } 254 255 urlQuery := url.Query() 256 if language != "" { 257 urlQuery.Set("ui", language) // OnlyOffice 258 urlQuery.Set("lang", covertLangTag(language)) // Collabora, Impact on the default document language of OnlyOffice 259 urlQuery.Set("UI_LLCC", language) // Office365 260 } 261 if p.conf.AppDisableChat { 262 urlQuery.Set("dchat", "1") // OnlyOffice disable chat 263 } 264 265 url.RawQuery = urlQuery.Encode() 266 appFullURL := url.String() 267 268 // Depending on whether wopi server returned any form parameters or not, 269 // we decide whether the request method is POST or GET 270 var formParams map[string]string 271 method := "GET" 272 if form, ok := result["form-parameters"].(map[string]interface{}); ok { 273 if tkn, ok := form["access_token"].(string); ok { 274 formParams = map[string]string{ 275 "access_token": tkn, 276 "access_token_ttl": tokenTTL, 277 } 278 method = "POST" 279 } 280 } 281 282 log.Info().Msg(fmt.Sprintf("wopi: returning app URL %s", appFullURL)) 283 return &appprovider.OpenInAppURL{ 284 AppUrl: appFullURL, 285 Method: method, 286 FormParameters: formParams, 287 }, nil 288 } 289 290 func (p *wopiProvider) GetAppProviderInfo(ctx context.Context) (*appregistry.ProviderInfo, error) { 291 // Initially we store the mime types in a map to avoid duplicates 292 mimeTypesMap := make(map[string]bool) 293 for _, extensions := range p.appURLs { 294 for ext := range extensions { 295 m := mime.Detect(false, ext) 296 mimeTypesMap[m] = true 297 } 298 } 299 300 mimeTypes := make([]string, 0, len(mimeTypesMap)) 301 for m := range mimeTypesMap { 302 mimeTypes = append(mimeTypes, m) 303 } 304 305 return &appregistry.ProviderInfo{ 306 Name: p.conf.AppName, 307 Icon: p.conf.AppIconURI, 308 DesktopOnly: p.conf.AppDesktopOnly, 309 MimeTypes: mimeTypes, 310 }, nil 311 } 312 313 func getAppURLs(c *config) (map[string]map[string]string, error) { 314 // Initialize WOPI URLs by discovery 315 httpcl := rhttp.GetHTTPClient( 316 rhttp.Timeout(time.Duration(5*int64(time.Second))), 317 rhttp.Insecure(c.InsecureConnections), 318 ) 319 320 appurl, err := url.Parse(c.AppIntURL) 321 if err != nil { 322 return nil, err 323 } 324 appurl.Path = path.Join(appurl.Path, "/hosting/discovery") 325 326 discReq, err := http.NewRequest("GET", appurl.String(), nil) 327 if err != nil { 328 return nil, err 329 } 330 discRes, err := httpcl.Do(discReq) 331 if err != nil { 332 return nil, err 333 } 334 defer discRes.Body.Close() 335 336 var appURLs map[string]map[string]string 337 338 if discRes.StatusCode == http.StatusOK { 339 appURLs, err = parseWopiDiscovery(discRes.Body) 340 if err != nil { 341 return nil, errors.Wrap(err, "error parsing wopi discovery response") 342 } 343 } else if discRes.StatusCode == http.StatusNotFound { 344 // this may be a bridge-supported app 345 discReq, err = http.NewRequest("GET", c.AppIntURL, nil) 346 if err != nil { 347 return nil, err 348 } 349 discRes, err = httpcl.Do(discReq) 350 if err != nil { 351 return nil, err 352 } 353 defer discRes.Body.Close() 354 355 buf := new(bytes.Buffer) 356 _, err = buf.ReadFrom(discRes.Body) 357 if err != nil { 358 return nil, err 359 } 360 361 // scrape app's home page to find the appname 362 if !strings.Contains(buf.String(), c.AppName) { 363 return nil, errors.New("Application server at " + c.AppURL + " does not match this AppProvider for " + c.AppName) 364 } 365 366 // register the supported mimetypes in the AppRegistry: this is hardcoded for the time being 367 // TODO(lopresti) move to config 368 switch c.AppName { 369 case "CodiMD": 370 appURLs = getCodimdExtensions(c.AppURL) 371 case "Etherpad": 372 appURLs = getEtherpadExtensions(c.AppURL) 373 default: 374 return nil, errors.New("Application server " + c.AppName + " running at " + c.AppURL + " is unsupported") 375 } 376 } 377 return appURLs, nil 378 } 379 380 func (p *wopiProvider) getAccessTokenTTL(ctx context.Context) (string, error) { 381 tkn := ctxpkg.ContextMustGetToken(ctx) 382 token, err := jwt.ParseWithClaims(tkn, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { 383 return []byte(p.conf.JWTSecret), nil 384 }) 385 if err != nil { 386 return "", err 387 } 388 389 if claims, ok := token.Claims.(*jwt.RegisteredClaims); ok && token.Valid { 390 // milliseconds since Jan 1, 1970 UTC as required in https://wopi.readthedocs.io/projects/wopirest/en/latest/concepts.html?highlight=access_token_ttl#term-access-token-ttl 391 return strconv.FormatInt(claims.ExpiresAt.Unix()*1000, 10), nil 392 } 393 394 return "", errtypes.InvalidCredentials("wopi: invalid token present in ctx") 395 } 396 397 func parseWopiDiscovery(body io.Reader) (map[string]map[string]string, error) { 398 appURLs := make(map[string]map[string]string) 399 400 doc := etree.NewDocument() 401 if _, err := doc.ReadFrom(body); err != nil { 402 return nil, err 403 } 404 root := doc.SelectElement("wopi-discovery") 405 if root == nil { 406 return nil, errors.New("wopi-discovery response malformed") 407 } 408 409 for _, netzone := range root.SelectElements("net-zone") { 410 411 if strings.Contains(netzone.SelectAttrValue("name", ""), "external") { 412 for _, app := range netzone.SelectElements("app") { 413 for _, action := range app.SelectElements("action") { 414 access := action.SelectAttrValue("name", "") 415 if access == "view" || access == "edit" || access == "editnew" { 416 ext := action.SelectAttrValue("ext", "") 417 urlString := action.SelectAttrValue("urlsrc", "") 418 419 if ext == "" || urlString == "" { 420 continue 421 } 422 423 u, err := url.Parse(urlString) 424 if err != nil { 425 // it sucks we cannot log here because this function is run 426 // on init without any context. 427 // TODO(labkode): add logging when we'll have static logging in boot phase. 428 continue 429 } 430 431 // remove any malformed query parameter from discovery urls 432 q := u.Query() 433 for k := range q { 434 if strings.Contains(k, "<") || strings.Contains(k, ">") { 435 q.Del(k) 436 } 437 } 438 439 u.RawQuery = q.Encode() 440 441 if _, ok := appURLs[access]; !ok { 442 appURLs[access] = make(map[string]string) 443 } 444 appURLs[access]["."+ext] = u.String() 445 } 446 } 447 } 448 } 449 } 450 return appURLs, nil 451 } 452 453 func getCodimdExtensions(appURL string) map[string]map[string]string { 454 // Register custom mime types 455 mime.RegisterMime(".zmd", "application/compressed-markdown") 456 457 appURLs := make(map[string]map[string]string) 458 appURLs["edit"] = map[string]string{ 459 ".txt": appURL, 460 ".md": appURL, 461 ".zmd": appURL, 462 } 463 return appURLs 464 } 465 466 func getEtherpadExtensions(appURL string) map[string]map[string]string { 467 appURLs := make(map[string]map[string]string) 468 appURLs["edit"] = map[string]string{ 469 ".epd": appURL, 470 } 471 return appURLs 472 } 473 474 // TODO Find better solution 475 // This conversion was made because no other way to set the default document language to OnlyOffice was found. 476 func covertLangTag(lang string) string { 477 switch lang { 478 case "cs": 479 return "cs-CZ" 480 case "de": 481 return "de-DE" 482 case "es": 483 return "es-ES" 484 case "fr": 485 return "fr-FR" 486 case "it": 487 return "it-IT" 488 default: 489 return "en" 490 } 491 }