github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/office/callback.go (about) 1 // Package office is for interactions with an OnlyOffice server to allow users 2 // to view/edit their office documents online. 3 package office 4 5 import ( 6 "bytes" 7 "errors" 8 "fmt" 9 "io" 10 "net/http" 11 "os" 12 "path" 13 "strings" 14 "time" 15 16 "github.com/cozy/cozy-stack/model/instance" 17 "github.com/cozy/cozy-stack/model/permission" 18 "github.com/cozy/cozy-stack/model/vfs" 19 "github.com/cozy/cozy-stack/pkg/config/config" 20 "github.com/cozy/cozy-stack/pkg/crypto" 21 "github.com/cozy/cozy-stack/pkg/metadata" 22 23 jwt "github.com/golang-jwt/jwt/v5" 24 ) 25 26 // Status list is described on https://api.onlyoffice.com/editors/callback#status 27 const ( 28 // StatusReadyForSaving is used when the file should be saved after being 29 // edited. 30 StatusReadyForSaving = 2 31 // StatusForceSaveRequested is used when the file has been modified and 32 // should be saved, even if the document is still opened and can be edited 33 // by users. 34 StatusForceSaveRequested = 6 35 ) 36 37 // OOSlug is the slug for uploadedBy field of the CozyMetadata when a file has 38 // been modified in the online OnlyOffice. 39 const OOSlug = "onlyoffice-server" 40 41 // CallbackParameters is a struct for the parameters sent by the document 42 // server to the stack. 43 // Cf https://api.onlyoffice.com/editors/callback 44 type CallbackParameters struct { 45 Key string `json:"key"` 46 Status int `json:"status"` 47 URL string `json:"url"` 48 Token string `json:"-"` // From the Authorization header 49 } 50 51 var docserverClient = &http.Client{ 52 Timeout: 60 * time.Second, 53 } 54 55 type callbackClaims struct { 56 Payload struct { 57 Key string `json:"key"` 58 Status int `json:"status"` 59 URL string `json:"url"` 60 } `json:"payload"` 61 } 62 63 func (c *callbackClaims) GetExpirationTime() (*jwt.NumericDate, error) { return nil, nil } 64 func (c *callbackClaims) GetIssuedAt() (*jwt.NumericDate, error) { return nil, nil } 65 func (c *callbackClaims) GetNotBefore() (*jwt.NumericDate, error) { return nil, nil } 66 func (c *callbackClaims) GetIssuer() (string, error) { return "", nil } 67 func (c *callbackClaims) GetSubject() (string, error) { return "", nil } 68 func (c *callbackClaims) GetAudience() (jwt.ClaimStrings, error) { return nil, nil } 69 70 // Callback will manage the callback from the document server. 71 func Callback(inst *instance.Instance, params CallbackParameters) error { 72 cfg := GetConfig(inst.ContextName) 73 if err := checkToken(cfg, params); err != nil { 74 return err 75 } 76 77 switch params.Status { 78 case StatusReadyForSaving: 79 return finalSaveFile(inst, params.Key, params.URL) 80 case StatusForceSaveRequested: 81 return forceSaveFile(inst, params.Key, params.URL) 82 default: 83 return nil 84 } 85 } 86 87 func checkToken(cfg *config.Office, params CallbackParameters) error { 88 if cfg == nil || cfg.OutboxSecret == "" { 89 return nil 90 } 91 92 var claims callbackClaims 93 err := crypto.ParseJWT(params.Token, func(token *jwt.Token) (interface{}, error) { 94 if token.Method != jwt.SigningMethodHS256 { 95 return nil, permission.ErrInvalidToken 96 } 97 return []byte(cfg.OutboxSecret), nil 98 }, &claims) 99 if err != nil { 100 return permission.ErrInvalidToken 101 } 102 if params.URL != claims.Payload.URL || params.Key != claims.Payload.Key || params.Status != claims.Payload.Status { 103 return permission.ErrInvalidToken 104 } 105 return nil 106 } 107 108 func finalSaveFile(inst *instance.Instance, key, downloadURL string) error { 109 detector, err := GetStore().GetDoc(inst, key) 110 if err != nil || detector == nil || detector.ID == "" || detector.Rev == "" { 111 return ErrInvalidKey 112 } 113 114 _, err = saveFile(inst, *detector, downloadURL) 115 if err == nil { 116 _ = GetStore().RemoveDoc(inst, key) 117 } 118 return err 119 } 120 121 func forceSaveFile(inst *instance.Instance, key, downloadURL string) error { 122 detector, err := GetStore().GetDoc(inst, key) 123 if err != nil || detector == nil || detector.ID == "" || detector.Rev == "" { 124 return ErrInvalidKey 125 } 126 127 updated, err := saveFile(inst, *detector, downloadURL) 128 if err == nil { 129 _ = GetStore().UpdateDoc(inst, key, *updated) 130 } 131 return err 132 } 133 134 // saveFile saves the file with content from the given URL and returns the new revision. 135 func saveFile(inst *instance.Instance, detector ConflictDetector, downloadURL string) (*ConflictDetector, error) { 136 fs := inst.VFS() 137 file, err := fs.FileByID(detector.ID) 138 if err != nil { 139 return nil, err 140 } 141 142 res, err := docserverClient.Get(downloadURL) 143 if err != nil { 144 return nil, err 145 } 146 defer func() { 147 // Flush the body in case of error to allow reusing the connection with 148 // Keep-Alive 149 _, _ = io.Copy(io.Discard, res.Body) 150 _ = res.Body.Close() 151 }() 152 153 instanceURL := inst.PageURL("/", nil) 154 newfile := file.Clone().(*vfs.FileDoc) 155 newfile.MD5Sum = nil // Let the VFS compute the new md5sum 156 newfile.ByteSize = res.ContentLength 157 if newfile.CozyMetadata == nil { 158 newfile.CozyMetadata = vfs.NewCozyMetadata(instanceURL) 159 } 160 newfile.UpdatedAt = time.Now() 161 newfile.CozyMetadata.UpdatedByApp(&metadata.UpdatedByAppEntry{ 162 Slug: OOSlug, 163 Date: newfile.UpdatedAt, 164 Instance: instanceURL, 165 }) 166 newfile.CozyMetadata.UpdatedAt = newfile.UpdatedAt 167 newfile.CozyMetadata.UploadedAt = &newfile.UpdatedAt 168 newfile.CozyMetadata.UploadedBy = &vfs.UploadedByEntry{Slug: OOSlug} 169 170 // If the file was renamed while OO editor was opened, the revision has 171 // been changed, but we still should avoid creating a conflict if the 172 // content is the same (md5sum has not changed). 173 if file.Rev() != detector.Rev && !bytes.Equal(file.MD5Sum, detector.MD5Sum) { 174 // Conflict: save it in a new file 175 file = nil 176 newfile.SetID("") 177 newfile.SetRev("") 178 } 179 180 basename := newfile.DocName 181 var f vfs.File 182 for i := 2; i < 100; i++ { 183 f, err = fs.CreateFile(newfile, file) 184 if err == nil { 185 break 186 } else if !errors.Is(err, os.ErrExist) { 187 return nil, err 188 } 189 ext := path.Ext(basename) 190 filename := strings.TrimSuffix(path.Base(basename), ext) 191 newfile.DocName = fmt.Sprintf("%s (%d)%s", filename, i, ext) 192 newfile.ResetFullpath() 193 _, _ = newfile.Path(inst.VFS()) // Prefill the fullpath 194 } 195 196 _, err = io.Copy(f, res.Body) 197 if cerr := f.Close(); cerr != nil && err == nil { 198 err = cerr 199 } 200 updated := ConflictDetector{ID: newfile.ID(), Rev: newfile.Rev(), MD5Sum: newfile.MD5Sum} 201 return &updated, err 202 }