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 }