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  }