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  }