From b5b9f8d1896c2a514a072ae4b77d406096130828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 25 Jun 2024 18:20:03 +0200 Subject: [PATCH] feat: implement putRelativeFile and deleteFile endpoints --- .../pkg/connector/contentconnector.go | 2 +- .../pkg/connector/fileconnector.go | 444 +++++++++++++++++- .../pkg/connector/httpadapter.go | 115 ++++- .../pkg/middleware/wopicontext.go | 37 +- .../collaboration/pkg/server/http/server.go | 8 +- .../pkg/service/grpc/v0/service.go | 45 +- 6 files changed, 596 insertions(+), 55 deletions(-) diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index a292442141..ee7cad2d0c 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -218,7 +218,7 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream return "", err } - if statRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + if statRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK && statRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_NOT_FOUND { logger.Error(). Str("StatusCode", statRes.GetStatus().GetCode().String()). Str("StatusMsg", statRes.GetStatus().GetMessage()). diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 39993a2efe..44a84296d1 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -1,8 +1,15 @@ package connector import ( + "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/binary" "encoding/hex" + "errors" + "io" + "net/url" "path" "strconv" "strings" @@ -28,6 +35,20 @@ const ( lockDuration time.Duration = 30 * time.Minute ) +type PutRelativeHeaders struct { + ValidTarget string + LockID string +} + +type PutRelativeResponse struct { + Name string + Url string + + // These are optional and not used for now + HostView string + HostEdit string +} + // FileConnectorService is the interface to implement the "Files" // endpoint. Basically lock operations on the file plus the CheckFileInfo. // All operations need a context containing a WOPI context and, optionally, @@ -52,6 +73,27 @@ type FileConnectorService interface { UnLock(ctx context.Context, lockID string) (string, error) // CheckFileInfo will return the file information of the target file CheckFileInfo(ctx context.Context) (fileinfo.FileInfo, error) + // PutRelativeFileSuggested will create a new file based on the contents of the + // current file. Target is the filename that will be used for this + // new file. + // This implements the "suggested" code flow for the PutRelativeFile endpoint. + // Since we need to upload contents, it will be done through the provided + // ContentConnectorService + PutRelativeFileSuggested(ctx context.Context, ccs ContentConnectorService, stream io.Reader, streamLength int64, target string) (*PutRelativeResponse, error) + // PutRelativeFileRelative will create a new file based on the contents of the + // current file. Target is the filename that will be used for this + // new file. + // This implements the "relative" code flow for the PutRelativeFile endpoint. + // The required headers that could need to be sent through HTTP will also + // be returned if needed. + // Since we need to upload contents, it will be done through the provided + // ContentConnectorService + PutRelativeFileRelative(ctx context.Context, ccs ContentConnectorService, stream io.Reader, streamLength int64, target string) (*PutRelativeResponse, *PutRelativeHeaders, error) + // DeleteFile will delete the provided file in the context. Although + // not documented, a lockID can be used to try to delete a locked file + // assuming the lock matches. + // The current lockID will be returned if the file is locked. + DeleteFile(ctx context.Context, lockID string) (string, error) } // FileConnector implements the "File" endpoint. @@ -472,6 +514,295 @@ func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, erro } } +// PutRelativeFileSuggested upload a file using the suggested target name +// https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putrelativefile +// +// The PutRelativeFile have 2 variants based on the "X-WOPI-SuggestedTarget" +// and "X-WOPI-RelativeTarget" headers. This method only implements the first, +// so this method must be used only if the "X-WOPI-SuggestedTarget" is present. +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// Since the method involves uploading a file to a location, it will use the +// provided ContentConnectorService to upload the stream. Note that the +// associated wopicontext is modified in order to point to the right location +// before the upload (it shouldn't matter because we'll work on a copy). +// +// As per documentation, this method will try to upload the provided stream +// using the suggested name. If the upload fails, we'll try using a different +// name. This new name will be generated by prefixing a random string to the +// suggested name. +// Since the upload won't use any lock, the upload will fail if the target file +// already exists and it isn't empty. This means that, this method can only +// generate new files. +func (f *FileConnector) PutRelativeFileSuggested(ctx context.Context, ccs ContentConnectorService, stream io.Reader, streamLength int64, target string) (*PutRelativeResponse, error) { + // assume the target is a full name + wopiContext, err := middleware.WopiContextFromCtx(ctx) + if err != nil { + return nil, err + } + + logger := zerolog.Ctx(ctx) + + // stat the current file in order to get the reference of the parent folder + oldStatRes, err := f.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("PutRelativeFileSuggested: stat failed") + return nil, err + } + + if oldStatRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", oldStatRes.GetStatus().GetCode().String()). + Str("StatusMsg", oldStatRes.GetStatus().GetMessage()). + Msg("PutRelativeFileSuggested: stat failed with unexpected status") + return nil, NewConnectorError(500, oldStatRes.GetStatus().GetCode().String()+" "+oldStatRes.GetStatus().GetMessage()) + } + + if strings.HasPrefix(target, ".") { + // the target is an extension, so we need to use the original + // name with the modified extension + oldStatPath := oldStatRes.GetInfo().GetPath() + ext := path.Ext(oldStatPath) + target = strings.TrimSuffix(oldStatPath, ext) + target + } + + finalTarget := target + newLogger := *logger + for isDone := false; !isDone; { + var conError *ConnectorError + + targetPath := utils.MakeRelativePath(finalTarget) + // need to change the file reference of the wopicontext to point to the new path + wopiContext.FileReference = providerv1beta1.Reference{ + ResourceId: oldStatRes.GetInfo().GetParentId(), + Path: targetPath, + } + + // create a new context for the modified wopicontext + newLogger := logger.With().Str("NewFileReference", wopiContext.FileReference.String()).Logger() + newCtx := middleware.WopiContextToCtx(newLogger.WithContext(ctx), wopiContext) + + // try to put the file. It mustn't return a 400 or 409 + _, err := ccs.PutFile(newCtx, stream, streamLength, "") + if err != nil { + // if the error isn't a connectorError, fail the request + if !errors.As(err, &conError) { + return nil, err + } + + if conError.HttpCodeOut == 409 { + // if conflict generate a different name and retry. + // this should happen only once + actualFilename, _ := f.extractFilenameAndPrefix(target) + finalTarget = f.generatePrefix() + " " + actualFilename + } else { + // TODO: code 400 might happen, what to do? + // in other cases, just return the error + return nil, err + } + } else { + // if the put is successful, exit the loop and move on + isDone = true + logger = &newLogger + } + } + + wopiSrcURL, err := f.generateWOPISrc(ctx, wopiContext, newLogger) + if err != nil { + return nil, err + } + + // send the info + result := &PutRelativeResponse{ + Name: finalTarget, + Url: wopiSrcURL.String(), + } + + logger.Debug().Msg("PutRelativeFileSuggested: success") + return result, nil +} + +// PutRelativeFileRelative upload a file using the provided target name +// https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putrelativefile +// +// The PutRelativeFile have 2 variants based on the "X-WOPI-SuggestedTarget" +// and "X-WOPI-RelativeTarget" headers. This method only implements the second, +// so this method must be used only if the "X-WOPI-RelativeTarget" is present. +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// Since the method involves uploading a file to a location, it will use the +// provided ContentConnectorService to upload the stream. Note that the +// associated wopicontext is modified in order to point to the right location +// before the upload (it shouldn't matter because we'll work on a copy). +// +// As per documentation, this method will try to upload the provided stream +// using the provided name. The filename won't be changed. +// Since the upload won't use any lock, the upload will fail if the target file +// already exists and it isn't empty. This means that, this method can only +// generate new files. +func (f *FileConnector) PutRelativeFileRelative(ctx context.Context, ccs ContentConnectorService, stream io.Reader, streamLength int64, target string) (*PutRelativeResponse, *PutRelativeHeaders, error) { + // assume the target is a full name + wopiContext, err := middleware.WopiContextFromCtx(ctx) + if err != nil { + return nil, nil, err + } + + logger := zerolog.Ctx(ctx) + + // stat the current file in order to get the reference of the parent folder + oldStatRes, err := f.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("PutRelativeFileRelative: stat failed") + return nil, nil, err + } + + if oldStatRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", oldStatRes.GetStatus().GetCode().String()). + Str("StatusMsg", oldStatRes.GetStatus().GetMessage()). + Msg("PutRelativeFileRelative: stat failed with unexpected status") + return nil, nil, NewConnectorError(500, oldStatRes.GetStatus().GetCode().String()+" "+oldStatRes.GetStatus().GetMessage()) + } + + targetPath := utils.MakeRelativePath(target) + // need to change the file reference of the wopicontext to point to the new path + wopiContext.FileReference = providerv1beta1.Reference{ + ResourceId: oldStatRes.GetInfo().GetParentId(), + Path: targetPath, + } + + // create a new context for the modified wopicontext + newLogger := logger.With().Str("NewFileReference", wopiContext.FileReference.String()).Logger() + newCtx := middleware.WopiContextToCtx(newLogger.WithContext(ctx), wopiContext) + + var conError *ConnectorError + // try to put the file. It mustn't return a 400 or 409 + lockID, err := ccs.PutFile(newCtx, stream, streamLength, "") + if err != nil { + // if the error isn't a connectorError, fail the request + if !errors.As(err, &conError) { + return nil, nil, err + } + + if conError.HttpCodeOut == 409 { + // if conflict generate a different name and retry. + // this should happen only once + wopiSrcURL, err2 := f.generateWOPISrc(ctx, wopiContext, newLogger) + if err2 != nil { + return nil, nil, err + } + + actualFilename, _ := f.extractFilenameAndPrefix(target) + finalTarget := f.generatePrefix() + " " + actualFilename + headers := &PutRelativeHeaders{ + ValidTarget: finalTarget, + LockID: lockID, + } + response := &PutRelativeResponse{ + Name: target, + Url: wopiSrcURL.String(), + } + return response, headers, err + } else { + // TODO: code 400 might happen, what to do? + // in other cases, just return the error + return nil, nil, err + } + } + + wopiSrcURL, err := f.generateWOPISrc(ctx, wopiContext, newLogger) + if err != nil { + return nil, nil, err + } + // send the info + result := &PutRelativeResponse{ + Name: target, + Url: wopiSrcURL.String(), + } + + logger.Debug().Msg("PutRelativeFileRelative: success") + return result, nil, nil +} + +// DeleteFile will delete the requested file +// https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/deletefile +// +// The lock isn't part of the documentation, but it might be possible to +// delete a file as long as you have the lock. In addition, we'll need to +// return the lock if there is a conflict. +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// Note that this method isn't required and it's likely used just for the +// WOPI validator +func (f *FileConnector) DeleteFile(ctx context.Context, lockID string) (string, error) { + wopiContext, err := middleware.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx) + + deleteRes, err := f.gwc.Delete(ctx, &providerv1beta1.DeleteRequest{ + Ref: &wopiContext.FileReference, + LockId: lockID, + }) + if err != nil { + logger.Error().Err(err).Msg("DeleteFile: stat failed") + return "", err + } + + if deleteRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", deleteRes.GetStatus().GetCode().String()). + Str("StatusMsg", deleteRes.GetStatus().GetMessage()). + Msg("DeleteFile: delete failed with unexpected status") + + if deleteRes.GetStatus().GetCode() == rpcv1beta1.Code_CODE_NOT_FOUND { + // don't bother to check for locks of a missing file + return "", NewConnectorError(404, deleteRes.GetStatus().GetCode().String()+" "+deleteRes.GetStatus().GetMessage()) + } + + // check if the file is locked to return a proper lockID + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err2 := f.gwc.GetLock(ctx, req) + if err2 != nil { + logger.Error().Err(err2).Msg("DeleteFile: GetLock failed") + return "", err2 + } + + if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + Msg("DeleteFile: GetLock failed with unexpected status") + return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) + } + + if resp.GetLock() != nil { + return resp.GetLock().GetLockId(), NewConnectorError(409, "file is locked") + } else { + return "", err // return the original error since the file isn't locked + } + } + return "", nil +} + // CheckFileInfo returns information about the requested file and capabilities of the wopi server // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo // @@ -559,11 +890,14 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (fileinfo.FileInfo, e fileinfo.KeySupportsGetLock: true, fileinfo.KeySupportsLocks: true, fileinfo.KeySupportsUpdate: true, + fileinfo.KeySupportsDeleteFile: true, - fileinfo.KeyUserCanNotWriteRelative: true, - fileinfo.KeyIsAnonymousUser: isAnonymousUser, - fileinfo.KeyUserFriendlyName: userFriendlyName, - fileinfo.KeyUserID: userId, + //fileinfo.KeyUserCanNotWriteRelative: true, + fileinfo.KeyIsAnonymousUser: isAnonymousUser, + fileinfo.KeyUserFriendlyName: userFriendlyName, + fileinfo.KeyUserID: userId, + + fileinfo.KeyPostMessageOrigin: f.cfg.Commons.OcisURL, } switch wopiContext.ViewMode { @@ -594,3 +928,105 @@ func (f *FileConnector) watermarkText(user *userv1beta1.User) string { } return "Watermark" } + +// extractFilenameAndPrefix will extract the filename and the prefix from the +// provided filename. The prefix in the filename must have been generated +// using the generatePrefix() method below and there must be a space between +// the prefix and the actual filename. For example "AZBVUm5F Document99.docx". +// +// In order to prevent false positives, all prefixes must have been generated +// after Jan 1th, 2020 (so any generated prefix should be correctly detected). +// +// This method will return the expected filename as first value, and the prefix +// as second value. If the provided filename doesn't have a valid prefix, the +// whole filename will be returned as first parameter, and the second will be +// the empty string. +func (f *FileConnector) extractFilenameAndPrefix(filename string) (string, string) { + before, after, found := strings.Cut(filename, " ") + if !found { + return filename, "" + } + + // try to decode the prefix + byteArray, err := base64.RawURLEncoding.DecodeString(before) + if err != nil { + // filename not prefixed + return filename, "" + } + + if len(byteArray) > 8 { + // weird prefix, likely part of a regular filename, probably a false positive + // return the whole filename + return filename, "" + } + + if len(byteArray) < 8 { + newArray := make([]byte, 8) + for i := 0; i < len(byteArray); i++ { + // first bytes should be 0 + newArray[8-len(byteArray)+i] = byteArray[i] + } + byteArray = newArray + } + + millis := binary.BigEndian.Uint64(byteArray) + t := time.UnixMilli(int64(millis)) // the uint64 should fit + + baseT := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC) + if t.Before(baseT) { + // decoded integer isn't recent and is too low, likely a false positive + // return the whole filename + return filename, "" + } + return after, before +} + +// generatePrefix will generate a short unique prefix based on the current +// time. This prefix can be used as part of a filename +func (f *FileConnector) generatePrefix() string { + byteArray := binary.BigEndian.AppendUint64([]byte{}, uint64(time.Now().UnixMilli())) + return base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(byteArray, "\x00")) +} + +func (f *FileConnector) generateWOPISrc(ctx context.Context, wopiContext middleware.WopiContext, logger zerolog.Logger) (*url.URL, error) { + statRes, err := f.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("generateWOPISrc: stat failed") + return nil, err + } + + if statRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statRes.GetStatus().GetCode().String()). + Str("StatusMsg", statRes.GetStatus().GetMessage()). + Msg("generateWOPISrc: stat failed with unexpected status") + return nil, NewConnectorError(500, statRes.GetStatus().GetCode().String()+" "+statRes.GetStatus().GetMessage()) + } + + // get the WOPI token for the new file + accessToken, _, err := middleware.GenerateWopiToken(wopiContext, f.cfg) + if err != nil { + logger.Error().Err(err).Msg("generateWOPISrc: failed to generate access token for the new file") + return nil, err + } + + // get the reference + c := sha256.New() + c.Write([]byte(statRes.GetInfo().GetId().GetStorageId() + "$" + statRes.GetInfo().GetId().GetSpaceId() + "!" + statRes.GetInfo().GetId().GetOpaqueId())) + fileRef := hex.EncodeToString(c.Sum(nil)) + + // generate the URL for the WOPI app to access the new created file + wopiSrcURL, err := url.Parse(f.cfg.Wopi.WopiSrc) + if err != nil { + logger.Error().Err(err).Msg("generateWOPISrc: failed to generate WOPISrc URL for the new file") + return nil, err + } + wopiSrcURL.Path = path.Join("wopi", "files", fileRef) + q := wopiSrcURL.Query() + q.Add("access_token", accessToken) + wopiSrcURL.RawQuery = q.Encode() + + return wopiSrcURL, nil +} diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go index ea014482a4..5b64a4d00d 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -9,13 +9,19 @@ import ( gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector/utf7" "github.com/owncloud/ocis/v2/services/collaboration/pkg/locks" "github.com/rs/zerolog" ) const ( - HeaderWopiLock string = "X-WOPI-Lock" - HeaderWopiOldLock string = "X-WOPI-OldLock" + HeaderWopiLock string = "X-WOPI-Lock" + HeaderWopiOldLock string = "X-WOPI-OldLock" + HeaderWopiST string = "X-WOPI-SuggestedTarget" + HeaderWopiRT string = "X-WOPI-RelativeTarget" + HeaderWopiOverwriteRT string = "X-WOPI-OverwriteRelativeTarget" + HeaderWopiSize string = "X-WOPI-Size" + HeaderWopiValidRT string = "X-WOPI-ValidRelativeTarget" ) // HttpAdapter will adapt the responses from the connector to HTTP. @@ -239,3 +245,108 @@ func (h *HttpAdapter) PutFile(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) } + +// PutRelativeFile will upload the file with a specific name. The name might be +// automatically adjusted depending on the request headers. +// Note that this method will also send a json body in the response. +// It has 2 mutually exclusive operation methods that are used based on the +// provided headers in the request. +// Note that this method won't used locks (not documented). +func (h *HttpAdapter) PutRelativeFile(w http.ResponseWriter, r *http.Request) { + relativeTarget := r.Header.Get(HeaderWopiRT) + suggestedTarget := r.Header.Get(HeaderWopiST) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", "0") + + if relativeTarget != "" && suggestedTarget != "" { + // headers are mutually exclusive + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + var response *PutRelativeResponse + var headers *PutRelativeHeaders + var putErr error + fileCon := h.con.GetFileConnector() + + if suggestedTarget != "" { + utf8Target, decErr := utf7.DecodeString(suggestedTarget) + if decErr != nil || len(utf8Target) > 512 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + response, putErr = fileCon.PutRelativeFileSuggested(r.Context(), h.con.GetContentConnector(), r.Body, r.ContentLength, utf8Target) + } + + if relativeTarget != "" { + utf8Target, decErr := utf7.DecodeString(relativeTarget) + if decErr != nil || len(utf8Target) > 512 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + response, headers, putErr = fileCon.PutRelativeFileRelative(r.Context(), h.con.GetContentConnector(), r.Body, r.ContentLength, utf8Target) + } + + var conError *ConnectorError + if putErr != nil { + if errors.As(putErr, &conError) { + if headers != nil { + w.Header().Set(HeaderWopiValidRT, utf7.EncodeString(headers.ValidTarget)) + w.Header().Set(HeaderWopiLock, headers.LockID) + } + // we might still need to send a body, so we'll hold the write for now + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } + + logger := zerolog.Ctx(r.Context()) + + jsonFileInfo, err := json.Marshal(response) + if err != nil { + logger.Error().Err(err).Msg("PutRelativeFile: failed to marshal response") + return + } + + w.Header().Set("Content-Length", strconv.Itoa(len(jsonFileInfo))) + if conError != nil { + w.WriteHeader(conError.HttpCodeOut) + } else { + w.WriteHeader(http.StatusOK) + } + bytes, err := w.Write(jsonFileInfo) + + if err != nil { + logger.Error(). + Err(err). + Int("TotalBytes", len(jsonFileInfo)). + Int("WrittenBytes", bytes). + Msg("PutRelativeFile: failed to write contents in the HTTP response") + } +} + +// DeleteFile will delete the provided file. If the file is locked and can't +// be deleted, a 409 conflict error will be returned with its corresponding +// lock. +func (h *HttpAdapter) DeleteFile(w http.ResponseWriter, r *http.Request) { + lockID := r.Header.Get(HeaderWopiLock) + + fileCon := h.con.GetFileConnector() + newLockID, err := fileCon.DeleteFile(r.Context(), lockID) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + if conError.HttpCodeOut == 409 { + w.Header().Set(HeaderWopiLock, newLockID) + } + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + // If no error, a HTTP 200 should be sent automatically. + // X-WOPI-Lock header isn't needed on HTTP 200 +} diff --git a/services/collaboration/pkg/middleware/wopicontext.go b/services/collaboration/pkg/middleware/wopicontext.go index d1b73e4371..7a02d8e6c3 100644 --- a/services/collaboration/pkg/middleware/wopicontext.go +++ b/services/collaboration/pkg/middleware/wopicontext.go @@ -11,6 +11,7 @@ import ( providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" "github.com/golang-jwt/jwt/v4" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/rs/zerolog" "google.golang.org/grpc/metadata" ) @@ -43,7 +44,7 @@ type WopiContext struct { // * The created WopiContext for the request // * A contextual zerologger containing information about the request // and the WopiContext -func WopiContextAuthMiddleware(jwtSecret string, next http.Handler) http.Handler { +func WopiContextAuthMiddleware(cfg *config.Config, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { accessToken := r.URL.Query().Get("access_token") if accessToken == "" { @@ -58,7 +59,7 @@ func WopiContextAuthMiddleware(jwtSecret string, next http.Handler) http.Handler return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return []byte(jwtSecret), nil + return []byte(cfg.Wopi.Secret), nil }) if err != nil { @@ -73,7 +74,7 @@ func WopiContextAuthMiddleware(jwtSecret string, next http.Handler) http.Handler ctx := r.Context() - wopiContextAccessToken, err := DecryptAES([]byte(jwtSecret), claims.WopiContext.AccessToken) + wopiContextAccessToken, err := DecryptAES([]byte(cfg.Wopi.Secret), claims.WopiContext.AccessToken) if err != nil { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return @@ -119,3 +120,33 @@ func WopiContextFromCtx(ctx context.Context) (WopiContext, error) { func WopiContextToCtx(ctx context.Context, wopiContext WopiContext) context.Context { return context.WithValue(ctx, wopiContextKey, wopiContext) } + +// The access token inside the wopiContext is expected to be decrypted. +// In order to generate the access token for WOPI, the reva token inside the +// wopiContext will be encrypted +func GenerateWopiToken(wopiContext WopiContext, cfg *config.Config) (string, int64, error) { + cryptedReqAccessToken, err := EncryptAES([]byte(cfg.Wopi.Secret), wopiContext.AccessToken) + if err != nil { + return "", 0, err + } + + cs3Claims := &jwt.RegisteredClaims{} + cs3JWTparser := jwt.Parser{} + _, _, err = cs3JWTparser.ParseUnverified(wopiContext.AccessToken, cs3Claims) + if err != nil { + return "", 0, err + } + + wopiContext.AccessToken = cryptedReqAccessToken + claims := &Claims{ + WopiContext: wopiContext, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: cs3Claims.ExpiresAt, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessToken, err := token.SignedString([]byte(cfg.Wopi.Secret)) + + return accessToken, claims.ExpiresAt.UnixMilli(), err +} diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index c331d6b671..e8a8ef80ed 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -117,7 +117,7 @@ func prepareRoutes(r *chi.Mux, options Options) { r.Use(func(h stdhttp.Handler) stdhttp.Handler { // authentication and wopi context - return colabmiddleware.WopiContextAuthMiddleware(options.Config.Wopi.Secret, h) + return colabmiddleware.WopiContextAuthMiddleware(options.Config, h) }) // check whether we should check for proof keys @@ -149,14 +149,12 @@ func prepareRoutes(r *chi.Mux, options Options) { // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putuserinfo stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented) case "PUT_RELATIVE": - // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putrelativefile - stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented) + adapter.PutRelativeFile(w, r) case "RENAME_FILE": // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/renamefile stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented) case "DELETE": - // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/deletefile - stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented) + adapter.DeleteFile(w, r) default: stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusInternalServerError), stdhttp.StatusInternalServerError) diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 4dfa90a280..94f76e2ce4 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -16,7 +16,6 @@ import ( providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/v2/pkg/utils" - "github.com/golang-jwt/jwt/v4" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" @@ -198,21 +197,8 @@ func (s *Service) OpenInApp( appURL = editAppURL } - cryptedReqAccessToken, err := middleware.EncryptAES([]byte(s.config.Wopi.Secret), req.GetAccessToken()) - if err != nil { - s.logger.Error(). - Err(err). - Str("FileReference", providerFileRef.String()). - Str("ViewMode", req.GetViewMode().String()). - Str("Requester", user.GetId().String()). - Msg("OpenInApp: error encrypting access token") - return &appproviderv1beta1.OpenInAppResponse{ - Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_INTERNAL}, - }, err - } - wopiContext := middleware.WopiContext{ - AccessToken: cryptedReqAccessToken, + AccessToken: req.GetAccessToken(), // it will be encrypted ViewOnlyToken: utils.ReadPlainFromOpaque(req.GetOpaque(), "viewOnlyToken"), FileReference: &providerFileRef, User: user, @@ -221,36 +207,14 @@ func (s *Service) OpenInApp( ViewAppUrl: viewAppURL, } - cs3Claims := &jwt.RegisteredClaims{} - cs3JWTparser := jwt.Parser{} - _, _, err = cs3JWTparser.ParseUnverified(req.GetAccessToken(), cs3Claims) + accessToken, accessExpiration, err := middleware.GenerateWopiToken(wopiContext, s.config) if err != nil { s.logger.Error(). Err(err). Str("FileReference", providerFileRef.String()). Str("ViewMode", req.GetViewMode().String()). Str("Requester", user.GetId().String()). - Msg("OpenInApp: error parsing JWT token") - return nil, err - } - - claims := &middleware.Claims{ - WopiContext: wopiContext, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: cs3Claims.ExpiresAt, - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - accessToken, err := token.SignedString([]byte(s.config.Wopi.Secret)) - - if err != nil { - s.logger.Error(). - Err(err). - Str("FileReference", providerFileRef.String()). - Str("ViewMode", req.GetViewMode().String()). - Str("Requester", user.GetId().String()). - Msg("OpenInApp: error signing access token") + Msg("OpenInApp: error generating the token") return &appproviderv1beta1.OpenInAppResponse{ Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_INTERNAL}, }, err @@ -271,7 +235,8 @@ func (s *Service) OpenInApp( // these parameters will be passed to the web server by the app provider application "access_token": accessToken, // milliseconds since Jan 1, 1970 UTC as required in https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#access_token_ttl - "access_token_ttl": strconv.FormatInt(claims.ExpiresAt.UnixMilli(), 10), + //"access_token_ttl": strconv.FormatInt(claims.ExpiresAt.UnixMilli(), 10), + "access_token_ttl": strconv.FormatInt(accessExpiration, 10), }, }, }, nil