github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/sharing/open_office.go (about)

     1  package sharing
     2  
     3  import (
     4  	"bytes"
     5  	"net/url"
     6  
     7  	"github.com/cozy/cozy-stack/client/request"
     8  	"github.com/cozy/cozy-stack/model/instance"
     9  	"github.com/cozy/cozy-stack/model/office"
    10  	"github.com/cozy/cozy-stack/model/settings"
    11  	"github.com/cozy/cozy-stack/model/vfs"
    12  	"github.com/cozy/cozy-stack/pkg/config/config"
    13  	"github.com/cozy/cozy-stack/pkg/consts"
    14  	"github.com/cozy/cozy-stack/pkg/couchdb"
    15  	"github.com/cozy/cozy-stack/pkg/jsonapi"
    16  	jwt "github.com/golang-jwt/jwt/v5"
    17  )
    18  
    19  type apiOfficeURL struct {
    20  	FileID     string      `json:"_id,omitempty"`
    21  	DocID      string      `json:"document_id"`
    22  	Subdomain  string      `json:"subdomain"`
    23  	Protocol   string      `json:"protocol"`
    24  	Instance   string      `json:"instance"`
    25  	Sharecode  string      `json:"sharecode,omitempty"`
    26  	PublicName string      `json:"public_name,omitempty"`
    27  	OO         *onlyOffice `json:"onlyoffice,omitempty"`
    28  }
    29  
    30  type onlyOffice struct {
    31  	URL   string `json:"url,omitempty"`
    32  	Token string `json:"token,omitempty"`
    33  	Type  string `json:"documentType"`
    34  	Doc   struct {
    35  		Filetype string `json:"filetype,omitempty"`
    36  		Key      string `json:"key"`
    37  		Title    string `json:"title,omitempty"`
    38  		URL      string `json:"url"`
    39  		Info     struct {
    40  			Owner    string `json:"owner,omitempty"`
    41  			Uploaded string `json:"uploaded,omitempty"`
    42  		} `json:"info"`
    43  	} `json:"document"`
    44  	Editor struct {
    45  		Callback string `json:"callbackUrl"`
    46  		Lang     string `json:"lang,omitempty"`
    47  		Mode     string `json:"mode"`
    48  		Custom   struct {
    49  			CompactHeader bool `json:"compactHeader"`
    50  			Customer      struct {
    51  				Address string `json:"address"`
    52  				Logo    string `json:"logo"`
    53  				Mail    string `json:"mail"`
    54  				Name    string `json:"name"`
    55  				WWW     string `json:"www"`
    56  			} `json:"customer"`
    57  			Feedback  bool `json:"feedback"`
    58  			ForceSave bool `json:"forcesave"`
    59  			GoBack    bool `json:"goback"`
    60  		} `json:"customization"`
    61  	} `json:"editor"`
    62  }
    63  
    64  func (o *apiOfficeURL) ID() string                             { return o.FileID }
    65  func (o *apiOfficeURL) Rev() string                            { return "" }
    66  func (o *apiOfficeURL) DocType() string                        { return consts.OfficeURL }
    67  func (o *apiOfficeURL) Clone() couchdb.Doc                     { cloned := *o; return &cloned }
    68  func (o *apiOfficeURL) SetID(id string)                        { o.FileID = id }
    69  func (o *apiOfficeURL) SetRev(rev string)                      {}
    70  func (o *apiOfficeURL) Relationships() jsonapi.RelationshipMap { return nil }
    71  func (o *apiOfficeURL) Included() []jsonapi.Object             { return nil }
    72  func (o *apiOfficeURL) Links() *jsonapi.LinksList              { return nil }
    73  func (o *apiOfficeURL) Fetch(field string) []string            { return nil }
    74  
    75  func (o *apiOfficeURL) sign(cfg *config.Office) (string, error) {
    76  	if cfg == nil || cfg.InboxSecret == "" {
    77  		return "", nil
    78  	}
    79  
    80  	claims := *o.OO
    81  	claims.URL = ""
    82  	claims.Doc.Filetype = ""
    83  	claims.Doc.Title = ""
    84  	claims.Doc.Info.Owner = ""
    85  	claims.Doc.Info.Uploaded = ""
    86  	claims.Editor.Lang = ""
    87  	claims.Editor.Custom.Customer.Address = ""
    88  	claims.Editor.Custom.Customer.Logo = ""
    89  	claims.Editor.Custom.Customer.Mail = ""
    90  	claims.Editor.Custom.Customer.Name = ""
    91  	claims.Editor.Custom.Customer.WWW = ""
    92  	token := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims)
    93  	return token.SignedString([]byte(cfg.InboxSecret))
    94  }
    95  
    96  func (o *onlyOffice) GetExpirationTime() (*jwt.NumericDate, error) { return nil, nil }
    97  func (o *onlyOffice) GetIssuedAt() (*jwt.NumericDate, error)       { return nil, nil }
    98  func (o *onlyOffice) GetNotBefore() (*jwt.NumericDate, error)      { return nil, nil }
    99  func (o *onlyOffice) GetIssuer() (string, error)                   { return "", nil }
   100  func (o *onlyOffice) GetSubject() (string, error)                  { return "", nil }
   101  func (o *onlyOffice) GetAudience() (jwt.ClaimStrings, error)       { return nil, nil }
   102  
   103  // OfficeOpener can be used to find the parameters for opening an office document.
   104  type OfficeOpener struct {
   105  	*FileOpener
   106  }
   107  
   108  // Open will return an OfficeOpener for the given file.
   109  func OpenOffice(inst *instance.Instance, fileID string) (*OfficeOpener, error) {
   110  	file, err := inst.VFS().FileByID(fileID)
   111  	if err != nil {
   112  		return nil, err
   113  	}
   114  
   115  	// Check that the file is an office document
   116  	if !isOfficeDocument(file) {
   117  		return nil, office.ErrInvalidFile
   118  	}
   119  
   120  	opener, err := NewFileOpener(inst, file)
   121  	if err != nil {
   122  		return nil, err
   123  	}
   124  	return &OfficeOpener{opener}, nil
   125  }
   126  
   127  // GetResult looks if the file can be opened locally or not, which code can be
   128  // used in case of a shared office document, and other parameters.. and returns
   129  // the information.
   130  func (o *OfficeOpener) GetResult(memberIndex int, readOnly bool) (jsonapi.Object, error) {
   131  	var result *apiOfficeURL
   132  	var err error
   133  	if o.ShouldOpenLocally() {
   134  		result, err = o.openLocalDocument(memberIndex, readOnly)
   135  	} else {
   136  		result, err = o.openSharedDocument()
   137  	}
   138  	if err != nil {
   139  		return nil, err
   140  	}
   141  
   142  	result.FileID = o.File.ID()
   143  	return result, nil
   144  }
   145  
   146  func (o *OfficeOpener) openLocalDocument(memberIndex int, readOnly bool) (*apiOfficeURL, error) {
   147  	cfg := office.GetConfig(o.Inst.ContextName)
   148  	if cfg == nil || cfg.OnlyOfficeURL == "" {
   149  		return nil, office.ErrNoServer
   150  	}
   151  
   152  	// Create a local result
   153  	code, err := o.GetSharecode(memberIndex, readOnly)
   154  	if err != nil {
   155  		return nil, err
   156  	}
   157  	params := o.OpenLocalFile(code)
   158  	doc := apiOfficeURL{
   159  		DocID:     params.FileID,
   160  		Protocol:  params.Protocol,
   161  		Subdomain: params.Subdomain,
   162  		Instance:  params.Instance,
   163  		Sharecode: params.Sharecode,
   164  	}
   165  
   166  	// Fill the parameters for the Document Server
   167  	mode := "edit"
   168  	if readOnly || o.File.Trashed {
   169  		mode = "view"
   170  	}
   171  	download, err := o.downloadURL()
   172  	if err != nil {
   173  		o.Inst.Logger().WithNamespace("office").
   174  			Infof("Cannot build download URL: %s", err)
   175  		return nil, ErrInternalServerError
   176  	}
   177  	key, err := office.GetStore().GetSecretByID(o.Inst, o.File.ID())
   178  	if err != nil {
   179  		o.Inst.Logger().WithNamespace("office").
   180  			Infof("Cannot get secret from store: %s", err)
   181  		return nil, ErrInternalServerError
   182  	}
   183  	if key != "" {
   184  		doc, err := office.GetStore().GetDoc(o.Inst, key)
   185  		if err != nil {
   186  			o.Inst.Logger().WithNamespace("office").
   187  				Infof("Cannot get doc from store: %s", err)
   188  			return nil, ErrInternalServerError
   189  		}
   190  		if shouldOpenANewVersion(o.File, doc) {
   191  			key = ""
   192  		}
   193  	}
   194  	if key == "" {
   195  		detector := office.ConflictDetector{ID: o.File.ID(), Rev: o.File.Rev(), MD5Sum: o.File.MD5Sum}
   196  		key, err = office.GetStore().AddDoc(o.Inst, detector)
   197  	}
   198  	if err != nil {
   199  		o.Inst.Logger().WithNamespace("office").
   200  			Infof("Cannot add doc to store: %s", err)
   201  		return nil, ErrInternalServerError
   202  	}
   203  	publicName, _ := settings.PublicName(o.Inst)
   204  	doc.PublicName = publicName
   205  	doc.OO = &onlyOffice{
   206  		URL:  cfg.OnlyOfficeURL,
   207  		Type: documentType(o.File),
   208  	}
   209  	doc.OO.Doc.Filetype = o.File.Mime
   210  	doc.OO.Doc.Key = key
   211  	doc.OO.Doc.Title = o.File.DocName
   212  	doc.OO.Doc.URL = download
   213  	doc.OO.Doc.Info.Owner = publicName
   214  	doc.OO.Doc.Info.Uploaded = uploadedDate(o.File)
   215  	doc.OO.Editor.Callback = o.Inst.PageURL("/office/callback", nil)
   216  	doc.OO.Editor.Lang = o.Inst.Locale
   217  	doc.OO.Editor.Mode = mode
   218  	doc.OO.Editor.Custom.CompactHeader = true
   219  	doc.OO.Editor.Custom.Customer.Address = "\"Le Surena\" Face au 5 Quai Marcel Dassault 92150 Suresnes"
   220  	doc.OO.Editor.Custom.Customer.Logo = o.Inst.FromURL(&url.URL{Path: "/assets/icon-192.png"})
   221  	doc.OO.Editor.Custom.Customer.Mail = o.Inst.SupportEmailAddress()
   222  	doc.OO.Editor.Custom.Customer.Name = "Cozy Cloud"
   223  	doc.OO.Editor.Custom.Customer.WWW = "cozy.io"
   224  	doc.OO.Editor.Custom.Feedback = false
   225  	doc.OO.Editor.Custom.ForceSave = true
   226  	doc.OO.Editor.Custom.GoBack = false
   227  
   228  	token, err := doc.sign(cfg)
   229  	if err != nil {
   230  		return nil, err
   231  	}
   232  	doc.OO.Token = token
   233  	return &doc, nil
   234  }
   235  
   236  func (o *OfficeOpener) openSharedDocument() (*apiOfficeURL, error) {
   237  	prepared, err := o.PrepareRequestForSharedFile()
   238  	if err != nil {
   239  		return nil, err
   240  	}
   241  	if prepared.Opts == nil {
   242  		return o.openLocalDocument(prepared.MemberIndex, prepared.ReadOnly)
   243  	}
   244  
   245  	prepared.Opts.Path = "/office/" + prepared.XoredID + "/open"
   246  	res, err := request.Req(prepared.Opts)
   247  	if res != nil && res.StatusCode/100 == 4 {
   248  		res, err = RefreshToken(o.Inst, err, o.Sharing, prepared.Creator,
   249  			prepared.Creds, prepared.Opts, nil)
   250  	}
   251  	if res != nil && res.StatusCode == 404 {
   252  		return o.openLocalDocument(prepared.MemberIndex, prepared.ReadOnly)
   253  	}
   254  	if err != nil {
   255  		o.Inst.Logger().WithNamespace("office").Infof("openSharedDocument error: %s", err)
   256  		return nil, ErrInternalServerError
   257  	}
   258  	defer res.Body.Close()
   259  	var doc apiOfficeURL
   260  	if _, err := jsonapi.Bind(res.Body, &doc); err != nil {
   261  		return nil, err
   262  	}
   263  	publicName, _ := settings.PublicName(o.Inst)
   264  	doc.PublicName = publicName
   265  	doc.OO = nil
   266  	return &doc, nil
   267  }
   268  
   269  // downloadURL returns an URL where the Document Server can download the file.
   270  func (o *OfficeOpener) downloadURL() (string, error) {
   271  	path, err := o.File.Path(o.Inst.VFS())
   272  	if err != nil {
   273  		return "", err
   274  	}
   275  	secret, err := vfs.GetStore().AddFile(o.Inst, path)
   276  	if err != nil {
   277  		return "", err
   278  	}
   279  	return o.Inst.PageURL("/files/downloads/"+secret+"/"+o.File.DocName, nil), nil
   280  }
   281  
   282  // uploadedDate returns the uploaded date for a file in the date format used by
   283  // OnlyOffice
   284  func uploadedDate(f *vfs.FileDoc) string {
   285  	date := f.CreatedAt
   286  	if f.CozyMetadata != nil && f.CozyMetadata.UploadedAt != nil {
   287  		date = *f.CozyMetadata.UploadedAt
   288  	}
   289  	return date.Format("2006-01-02 3:04 PM")
   290  }
   291  
   292  // documentType returns the document type parameter for Only Office
   293  // Cf https://api.onlyoffice.com/editors/config/#documentType
   294  func documentType(f *vfs.FileDoc) string {
   295  	switch f.Class {
   296  	case "spreadsheet":
   297  		return "cell"
   298  	case "slide":
   299  		return "slide"
   300  	default:
   301  		return "word"
   302  	}
   303  }
   304  
   305  func isOfficeDocument(f *vfs.FileDoc) bool {
   306  	switch f.Class {
   307  	case "spreadsheet", "slide", "text":
   308  		return true
   309  	default:
   310  		return false
   311  	}
   312  }
   313  
   314  func shouldOpenANewVersion(file *vfs.FileDoc, detector *office.ConflictDetector) bool {
   315  	if detector == nil {
   316  		return true
   317  	}
   318  	cm := file.CozyMetadata
   319  	if cm != nil && cm.UploadedBy != nil && cm.UploadedBy.Slug == office.OOSlug {
   320  		return false
   321  	}
   322  	if bytes.Equal(file.MD5Sum, detector.MD5Sum) {
   323  		return false
   324  	}
   325  	return true
   326  }