From aa58caef63d0953c5d87b6dd5a2f1505e0564d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 20 Mar 2024 10:14:32 +0100 Subject: [PATCH] refactor: move wopi operation into connector and change logging --- services/collaboration/pkg/command/server.go | 13 +- .../collaboration/pkg/connector/connector.go | 37 ++ .../pkg/connector/contentconnector.go | 312 +++++++++++ .../pkg/connector/fileconnector.go | 480 ++++++++++++++++ .../pkg/connector/httpadapter.go | 178 ++++++ .../collaboration/pkg/internal/app/app.go | 4 + .../collaboration/pkg/internal/app/headers.go | 6 - .../pkg/internal/app/wopicontext.go | 18 +- .../pkg/internal/app/wopifilecontents.go | 169 ------ .../pkg/internal/app/wopiinfo.go | 150 ----- .../pkg/internal/app/wopilocking.go | 524 ------------------ .../pkg/internal/helpers/download.go | 109 ---- .../pkg/internal/helpers/upload.go | 169 ------ .../pkg/internal/logging/logging.go | 17 - .../collaboration/pkg/server/http/option.go | 8 +- .../collaboration/pkg/server/http/server.go | 37 +- 16 files changed, 1066 insertions(+), 1165 deletions(-) create mode 100644 services/collaboration/pkg/connector/connector.go create mode 100644 services/collaboration/pkg/connector/contentconnector.go create mode 100644 services/collaboration/pkg/connector/fileconnector.go create mode 100644 services/collaboration/pkg/connector/httpadapter.go delete mode 100644 services/collaboration/pkg/internal/app/headers.go delete mode 100644 services/collaboration/pkg/internal/app/wopifilecontents.go delete mode 100644 services/collaboration/pkg/internal/app/wopiinfo.go delete mode 100644 services/collaboration/pkg/internal/app/wopilocking.go delete mode 100644 services/collaboration/pkg/internal/helpers/download.go delete mode 100644 services/collaboration/pkg/internal/helpers/upload.go delete mode 100644 services/collaboration/pkg/internal/logging/logging.go diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index d49183b5bb..a7eba82730 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -7,11 +7,12 @@ import ( "github.com/oklog/run" "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/tracing" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" "github.com/owncloud/ocis/v2/services/collaboration/pkg/cs3wopiserver" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/logging" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/grpc" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/http" "github.com/urfave/cli/v2" @@ -27,7 +28,13 @@ func Server(cfg *config.Config) *cli.Command { return configlog.ReturnFatal(parser.ParseConfig(cfg)) }, Action: func(c *cli.Context) error { - logger := logging.Configure(cfg.Service.Name, cfg.Log) + logger := log.NewLogger( + log.Name(cfg.Service.Name), + log.Level(cfg.Log.Level), + log.Pretty(cfg.Log.Pretty), + log.Color(cfg.Log.Color), + log.File(cfg.Log.File), + ) traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name) if err != nil { return err @@ -92,7 +99,7 @@ func Server(cfg *config.Config) *cli.Command { }) */ server, err := http.Server( - http.App(app), + http.Adapter(connector.NewHttpAdapter(app.GetGwc(), cfg)), http.Logger(logger), http.Config(cfg), http.Context(ctx), diff --git a/services/collaboration/pkg/connector/connector.go b/services/collaboration/pkg/connector/connector.go new file mode 100644 index 0000000000..c23c2a4f77 --- /dev/null +++ b/services/collaboration/pkg/connector/connector.go @@ -0,0 +1,37 @@ +package connector + +type ConnectorError struct { + HttpCodeOut int + Msg string +} + +func (e *ConnectorError) Error() string { + return e.Msg +} + +func NewConnectorError(code int, msg string) *ConnectorError { + return &ConnectorError{ + HttpCodeOut: code, + Msg: msg, + } +} + +type Connector struct { + fileConnector *FileConnector + contentConnector *ContentConnector +} + +func NewConnector(fc *FileConnector, cc *ContentConnector) *Connector { + return &Connector{ + fileConnector: fc, + contentConnector: cc, + } +} + +func (c *Connector) GetFileConnector() *FileConnector { + return c.fileConnector +} + +func (c *Connector) GetContentConnector() *ContentConnector { + return c.contentConnector +} diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go new file mode 100644 index 0000000000..014cdac46a --- /dev/null +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -0,0 +1,312 @@ +package connector + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "net/http" + "strconv" + "time" + + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/rs/zerolog" +) + +type ContentConnector struct { + gwc gatewayv1beta1.GatewayAPIClient + cfg *config.Config +} + +func NewContentConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *ContentConnector { + return &ContentConnector{ + gwc: gwc, + cfg: cfg, + } +} + +// GetFile downloads the file from the storage +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile +func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return err + } + + logger := zerolog.Ctx(ctx) + + // Initiate download request + req := &providerv1beta1.InitiateFileDownloadRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := c.gwc.InitiateFileDownload(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("GetFile: InitiateFileDownload failed") + return err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("GetFile: InitiateFileDownload failed with wrong status") + return NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + // Figure out the download endpoint and download token + downloadEndpoint := "" + downloadToken := "" + hasDownloadToken := false + + for _, proto := range resp.Protocols { + if proto.Protocol == "simple" || proto.Protocol == "spaces" { + downloadEndpoint = proto.DownloadEndpoint + downloadToken = proto.Token + hasDownloadToken = proto.Token != "" + break + } + } + + if downloadEndpoint == "" { + logger.Error(). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("GetFile: Download endpoint or token is missing") + return NewConnectorError(500, "GetFile: Download endpoint is missing") + } + + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.cfg.CS3Api.DataGateway.Insecure, + }, + }, + } + + // Prepare the request to download the file + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadEndpoint, bytes.NewReader([]byte(""))) + if err != nil { + logger.Error(). + Err(err). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("GetFile: Could not create the request to the endpoint") + return err + } + if downloadToken != "" { + // public link downloads have the token in the download endpoint + httpReq.Header.Add("X-Reva-Transfer", downloadToken) + } + // TODO: the access token shouldn't be needed + httpReq.Header.Add("X-Access-Token", wopiContext.AccessToken) + + httpResp, err := httpClient.Do(httpReq) + if err != nil { + logger.Error(). + Err(err). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("GetFile: Get request to the download endpoint failed") + return err + } + + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + logger.Error(). + Err(err). + Int("HttpCode", httpResp.StatusCode). + Msg("GetFile: downloading the file failed") + return NewConnectorError(500, "GetFile: Downloading the file failed") + } + + // Copy the download into the writer + _, err = io.Copy(writer, httpResp.Body) + if err != nil { + logger.Error().Msg("GetFile: copying the file content to the response body failed") + return err + } + + logger.Debug().Msg("GetFile: success") + return nil +} + +// PutFile uploads the file to the storage +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile +func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx).With(). + Str("RequestedLockID", lockID). + Int64("UploadLength", streamLength). + Logger() + + // We need a stat call on the target file in order to get both the lock + // (if any) and the current size of the file + statRes, err := c.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("PutFile: stat failed") + return "", err + } + + if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statRes.Status.Code.String()). + Str("StatusMsg", statRes.Status.Message). + Msg("PutFile: stat failed with unexpected status") + return "", NewConnectorError(500, statRes.Status.GetCode().String()+" "+statRes.Status.GetMessage()) + } + + // If there is a lock and it mismatches, return 409 + if statRes.Info.Lock != nil && statRes.Info.Lock.LockId != lockID { + logger.Error(). + Str("LockID", statRes.Info.Lock.LockId). + Msg("PutFile: wrong lock") + // onlyoffice says it's required to send the current lockId, MS doesn't say anything + return statRes.Info.Lock.LockId, NewConnectorError(409, "Wrong lock") + } + + // only unlocked uploads can go through if the target file is empty, + // otherwise the X-WOPI-Lock header is required even if there is no lock on the file + // This is part of the onlyoffice documentation (https://api.onlyoffice.com/editors/wopi/restapi/putfile) + // Wopivalidator fails some tests if we don't also check for the X-WOPI-Lock header. + if lockID == "" && statRes.Info.Lock == nil && statRes.Info.Size > 0 { + logger.Error().Msg("PutFile: file must be locked first") + // onlyoffice says to send an empty string if the file is unlocked, MS doesn't say anything + return "", NewConnectorError(409, "File must be locked first") + } + + // Prepare the data to initiate the upload + opaque := &types.Opaque{ + Map: make(map[string]*types.OpaqueEntry), + } + + if streamLength >= 0 { + opaque.Map["Upload-Length"] = &types.OpaqueEntry{ + Decoder: "plain", + Value: []byte(strconv.FormatInt(streamLength, 10)), + } + } + + req := &providerv1beta1.InitiateFileUploadRequest{ + Opaque: opaque, + Ref: &wopiContext.FileReference, + LockId: lockID, + // TODO: if-match + //Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ + // IfMatch: "", + //}, + } + + // Initiate the upload request + resp, err := c.gwc.InitiateFileUpload(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("UploadHelper: InitiateFileUpload failed") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("UploadHelper: InitiateFileUpload failed with wrong status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + // if the content length is greater than 0, we need to upload the content to the + // target endpoint, otherwise we're done + if streamLength > 0 { + + uploadEndpoint := "" + uploadToken := "" + hasUploadToken := false + + for _, proto := range resp.Protocols { + if proto.Protocol == "simple" || proto.Protocol == "spaces" { + uploadEndpoint = proto.UploadEndpoint + uploadToken = proto.Token + hasUploadToken = proto.Token != "" + break + } + } + + if uploadEndpoint == "" { + logger.Error(). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Upload endpoint or token is missing") + return "", NewConnectorError(500, "upload endpoint or token is missing") + } + + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.cfg.CS3Api.DataGateway.Insecure, + }, + }, + Timeout: 10 * time.Second, + } + + // prepare the request to upload the contents to the upload endpoint + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadEndpoint, stream) + if err != nil { + logger.Error(). + Err(err). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Could not create the request to the endpoint") + return "", err + } + // "stream" is an *http.body and doesn't fill the httpReq.ContentLength automatically + // we need to fill the ContentLength ourselves, and must match the stream length in order + // to prevent issues + httpReq.ContentLength = streamLength + + if uploadToken != "" { + // public link uploads have the token in the upload endpoint + httpReq.Header.Add("X-Reva-Transfer", uploadToken) + } + // TODO: the access token shouldn't be needed + httpReq.Header.Add("X-Access-Token", wopiContext.AccessToken) + + httpReq.Header.Add("X-Lock-Id", lockID) + // TODO: better mechanism for the upload while locked, relies on patch in REVA + //if lockID, ok := ctxpkg.ContextGetLockID(ctx); ok { + // httpReq.Header.Add("X-Lock-Id", lockID) + //} + + httpResp, err := httpClient.Do(httpReq) + if err != nil { + logger.Error(). + Err(err). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Put request to the upload endpoint failed") + return "", err + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + logger.Error(). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Int("HttpCode", httpResp.StatusCode). + Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status") + return "", NewConnectorError(500, "PutFile: Uploading the file failed") + } + } + + logger.Debug().Msg("PutFile: success") + return "", nil +} diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go new file mode 100644 index 0000000000..94fea49175 --- /dev/null +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -0,0 +1,480 @@ +package connector + +import ( + "context" + "encoding/hex" + "path" + "time" + + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/google/uuid" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/rs/zerolog" +) + +const ( + // WOPI Locks generally have a lock duration of 30 minutes and will be refreshed before expiration if needed + // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#lock + lockDuration time.Duration = 30 * time.Minute +) + +type FileConnector struct { + gwc gatewayv1beta1.GatewayAPIClient + cfg *config.Config +} + +func NewFileConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *FileConnector { + return &FileConnector{ + gwc: gwc, + cfg: cfg, + } +} + +// GetLock returns a lock or an empty string if no lock exists +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock +func (f *FileConnector) GetLock(ctx context.Context) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx) + + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := f.gwc.GetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("GetLock failed") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.GetCode().String()). + Str("StatusMsg", resp.Status.GetMessage()). + Msg("GetLock failed with unexpected status") + return "", NewConnectorError(404, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + lockID := "" + if resp.Lock != nil { + lockID = resp.Lock.LockId + } + + // log the success at debug level + logger.Debug(). + Str("LockID", lockID). + Msg("GetLock success") + + return lockID, nil +} + +// Lock returns a WOPI lock or performs an unlock and relock +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock +func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx).With(). + Str("RequestedLockID", lockID). + Str("RequestedOldLockID", oldLockID). + Logger() + + if lockID == "" { + logger.Error().Msg("Lock failed due to empty lockID") + return "", NewConnectorError(400, "Requested lockID is empty") + } + + var setOrRefreshStatus *rpcv1beta1.Status + if oldLockID == "" { + // If the oldLockID is empty, this is a "LOCK" request + req := &providerv1beta1.SetLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: f.cfg.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + } + + resp, err := f.gwc.SetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("SetLock failed") + return "", err + } + setOrRefreshStatus = resp.Status + } else { + // If the oldLockID isn't empty, this is a "UnlockAndRelock" request. We'll + // do a "RefreshLock" in reva and provide the old lock + req := &providerv1beta1.RefreshLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: f.cfg.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + ExistingLockId: oldLockID, + } + + resp, err := f.gwc.RefreshLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("UnlockAndRefresh failed") + return "", err + } + setOrRefreshStatus = resp.Status + } + + // we're checking the status of either the "SetLock" or "RefreshLock" operations + switch setOrRefreshStatus.Code { + case rpcv1beta1.Code_CODE_OK: + logger.Debug().Msg("SetLock successful") + return "", nil + + case rpcv1beta1.Code_CODE_FAILED_PRECONDITION, rpcv1beta1.Code_CODE_ABORTED: + // Code_CODE_FAILED_PRECONDITION -> Lock operation mismatched lock + // Code_CODE_ABORTED -> UnlockAndRelock operation mismatched lock + // In both cases, we need to get the current lock to return it in a + // 409 response if needed + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := f.gwc.GetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("SetLock failed, fallback to GetLock failed too") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("SetLock failed, fallback to GetLock failed with unexpected status") + } + + if resp.Lock != nil { + if resp.Lock.LockId != lockID { + // lockId is different -> return 409 with the current lockId + logger.Warn(). + Str("LockID", resp.Lock.LockId). + Msg("SetLock conflict") + return resp.Lock.LockId, NewConnectorError(409, "Lock conflict") + } + + // TODO: according to the spec we need to treat this as a RefreshLock + // There was a problem with the lock, but the file has the same lockId now. + // This should never happen unless there are race conditions. + // Since the lockId matches now, we'll assume success for now. + // As said in the todo, we probably should send a "RefreshLock" request here. + logger.Warn(). + Str("LockID", resp.Lock.LockId). + Msg("SetLock lock refreshed instead") + return resp.Lock.LockId, nil + } + + // TODO: Is this the right error code? + logger.Error().Msg("SetLock failed and could not refresh") + return "", NewConnectorError(404, "Could not refresh the lock") + + default: + logger.Error(). + Str("StatusCode", setOrRefreshStatus.Code.String()). + Str("StatusMsg", setOrRefreshStatus.Message). + Msg("SetLock failed with unexpected status") + return "", NewConnectorError(500, setOrRefreshStatus.GetCode().String()+" "+setOrRefreshStatus.GetMessage()) + } +} + +// RefreshLock refreshes a provided lock for 30 minutes +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock +func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx).With(). + Str("RequestedLockID", lockID). + Logger() + + if lockID == "" { + logger.Error().Msg("RefreshLock failed due to empty lockID") + return "", NewConnectorError(400, "Requested lockID is empty") + } + + req := &providerv1beta1.RefreshLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: f.cfg.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + } + + resp, err := f.gwc.RefreshLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("RefreshLock failed") + return "", err + } + + switch resp.Status.Code { + case rpcv1beta1.Code_CODE_OK: + logger.Debug().Msg("RefreshLock successful") + return "", nil + + case rpcv1beta1.Code_CODE_NOT_FOUND: + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, file reference not found") + return "", NewConnectorError(404, "File reference not found") + + case rpcv1beta1.Code_CODE_ABORTED: + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, lock mismatch") + + // Either the file is unlocked or there is no lock + // We need to return 409 with the current lock + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := f.gwc.GetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("RefreshLock failed trying to get the current lock") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, tried to get the current lock failed with unexpected status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + if resp.Lock == nil { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, no lock on file") + return "", NewConnectorError(409, "No lock on file") + } else { + // lock is different than the one requested, otherwise we wouldn't reached this point + logger.Error(). + Str("LockID", resp.Lock.LockId). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, lock mismatch") + return resp.Lock.LockId, NewConnectorError(409, "Lock mismatch") + } + default: + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed with unexpected status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } +} + +// UnLock removes a given lock from a file +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock +func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx).With(). + Str("RequestedLockID", lockID). + Logger() + + if lockID == "" { + logger.Error().Msg("Unlock failed due to empty lockID") + return "", NewConnectorError(400, "Requested lockID is empty") + } + + req := &providerv1beta1.UnlockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: f.cfg.App.LockName, + }, + } + + resp, err := f.gwc.Unlock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("Unlock failed") + return "", err + } + + switch resp.Status.Code { + case rpcv1beta1.Code_CODE_OK: + logger.Debug().Msg("Unlock successful") + return "", nil + case rpcv1beta1.Code_CODE_ABORTED: + // File isn't locked. Need to return 409 with empty lock + logger.Error().Err(err).Msg("Unlock failed, file isn't locked") + return "", NewConnectorError(409, "File is not locked") + case rpcv1beta1.Code_CODE_LOCKED: + // We need to return 409 with the current lock + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := f.gwc.GetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("Unlock failed trying to get the current lock") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, tried to get the current lock failed with unexpected status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + var outLockId string + if resp.Lock == nil { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, no lock on file") + outLockId = "" + } else { + // lock is different than the one requested, otherwise we wouldn't reached this point + logger.Error(). + Str("LockID", resp.Lock.LockId). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, lock mismatch") + outLockId = resp.Lock.LockId + } + return outLockId, NewConnectorError(409, "Lock mismatch") + default: + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed with unexpected status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } +} + +// 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 +func (f *FileConnector) CheckFileInfo(ctx context.Context) (app.FileInfo, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return app.FileInfo{}, err + } + + logger := zerolog.Ctx(ctx) + + statRes, err := f.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("CheckFileInfo: stat failed") + return app.FileInfo{}, err + } + + if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statRes.Status.Code.String()). + Str("StatusMsg", statRes.Status.Message). + Msg("CheckFileInfo: stat failed with unexpected status") + return app.FileInfo{}, NewConnectorError(500, statRes.Status.GetCode().String()+" "+statRes.Status.GetMessage()) + } + + fileInfo := app.FileInfo{ + // OwnerID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) + OwnerID: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), + Size: int64(statRes.Info.Size), + Version: statRes.Info.Mtime.String(), + BaseFileName: path.Base(statRes.Info.Path), + BreadcrumbDocName: path.Base(statRes.Info.Path), + // to get the folder we actually need to do a GetPath() request + //BreadcrumbFolderName: path.Dir(statRes.Info.Path), + + UserCanNotWriteRelative: true, + + HostViewUrl: wopiContext.ViewAppUrl, + HostEditUrl: wopiContext.EditAppUrl, + + //EnableOwnerTermination: true, // enable only for collabora? wopivalidator is complaining + EnableOwnerTermination: false, + + SupportsExtendedLockLength: true, + + SupportsGetLock: true, + SupportsLocks: true, + } + + switch wopiContext.ViewMode { + case appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE: + fileInfo.SupportsUpdate = true + fileInfo.UserCanWrite = true + + case appproviderv1beta1.ViewMode_VIEW_MODE_READ_ONLY: + // nothing special to do here for now + + case appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY: + fileInfo.DisableExport = true + fileInfo.DisableCopy = true + fileInfo.DisablePrint = true + } + + // user logic from reva wopi driver #TODO: refactor + var isPublicShare bool = false + if wopiContext.User != nil { + // UserID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) + if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { + fileInfo.UserID = hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)) + } else { + fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) + } + + if wopiContext.User.Opaque != nil { + if _, ok := wopiContext.User.Opaque.Map["public-share-role"]; ok { + isPublicShare = true + } + } + if !isPublicShare { + fileInfo.UserFriendlyName = wopiContext.User.Username + fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) + } + } + if wopiContext.User == nil || isPublicShare { + randomID, _ := uuid.NewUUID() + fileInfo.UserID = hex.EncodeToString([]byte("guest-" + randomID.String())) + fileInfo.UserFriendlyName = "Guest " + randomID.String() + fileInfo.IsAnonymousUser = true + } + + logger.Debug().Msg("CheckFileInfo: success") + return fileInfo, nil +} diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go new file mode 100644 index 0000000000..ff94a6d479 --- /dev/null +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -0,0 +1,178 @@ +package connector + +import ( + "encoding/json" + "errors" + "net/http" + + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/rs/zerolog" +) + +const ( + HeaderWopiLock string = "X-WOPI-Lock" + HeaderWopiOldLock string = "X-WOPI-OldLock" +) + +type HttpAdapter struct { + con *Connector +} + +func NewHttpAdapter(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *HttpAdapter { + return &HttpAdapter{ + con: NewConnector( + NewFileConnector(gwc, cfg), + NewContentConnector(gwc, cfg), + ), + } +} + +func (h *HttpAdapter) GetLock(w http.ResponseWriter, r *http.Request) { + fileCon := h.con.GetFileConnector() + + lockID, err := fileCon.GetLock(r.Context()) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + w.Header().Set(HeaderWopiLock, lockID) +} + +func (h *HttpAdapter) Lock(w http.ResponseWriter, r *http.Request) { + oldLockID := r.Header.Get(HeaderWopiOldLock) + lockID := r.Header.Get(HeaderWopiLock) + + fileCon := h.con.GetFileConnector() + newLockID, err := fileCon.Lock(r.Context(), lockID, oldLockID) + 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 +} + +func (h *HttpAdapter) RefreshLock(w http.ResponseWriter, r *http.Request) { + lockID := r.Header.Get(HeaderWopiLock) + + fileCon := h.con.GetFileConnector() + newLockID, err := fileCon.RefreshLock(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 +} + +func (h *HttpAdapter) UnLock(w http.ResponseWriter, r *http.Request) { + lockID := r.Header.Get(HeaderWopiLock) + + fileCon := h.con.GetFileConnector() + newLockID, err := fileCon.UnLock(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 +} + +func (h *HttpAdapter) CheckFileInfo(w http.ResponseWriter, r *http.Request) { + fileCon := h.con.GetFileConnector() + + fileInfo, err := fileCon.CheckFileInfo(r.Context()) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + logger := zerolog.Ctx(r.Context()) + + jsonFileInfo, err := json.Marshal(fileInfo) + if err != nil { + logger.Error().Err(err).Msg("CheckFileInfo: failed to marshal fileinfo") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + bytes, err := w.Write(jsonFileInfo) + + if err != nil { + logger.Error(). + Err(err). + Int("TotalBytes", len(jsonFileInfo)). + Int("WrittenBytes", bytes). + Msg("CheckFileInfo: failed to write contents in the HTTP response") + } +} + +func (h *HttpAdapter) GetFile(w http.ResponseWriter, r *http.Request) { + contentCon := h.con.GetContentConnector() + err := contentCon.GetFile(r.Context(), w) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } +} + +func (h *HttpAdapter) PutFile(w http.ResponseWriter, r *http.Request) { + lockID := r.Header.Get(HeaderWopiLock) + + contentCon := h.con.GetContentConnector() + newLockID, err := contentCon.PutFile(r.Context(), r.Body, r.ContentLength, 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/internal/app/app.go b/services/collaboration/pkg/internal/app/app.go index 53e4b288d9..ab85a6d67e 100644 --- a/services/collaboration/pkg/internal/app/app.go +++ b/services/collaboration/pkg/internal/app/app.go @@ -44,6 +44,10 @@ func New(cfg *config.Config, logger log.Logger) (*DemoApp, error) { return app, nil } +func (app *DemoApp) GetGwc() gatewayv1beta1.GatewayAPIClient { + return app.gwc +} + func (app *DemoApp) GetCS3apiClient() error { // establish a connection to the cs3 api endpoint // in this case a REVA gateway, started by oCIS diff --git a/services/collaboration/pkg/internal/app/headers.go b/services/collaboration/pkg/internal/app/headers.go deleted file mode 100644 index 698ad3a637..0000000000 --- a/services/collaboration/pkg/internal/app/headers.go +++ /dev/null @@ -1,6 +0,0 @@ -package app - -const ( - HeaderWopiLock string = "X-WOPI-Lock" - HeaderWopiOldLock string = "X-WOPI-OldLock" -) diff --git a/services/collaboration/pkg/internal/app/wopicontext.go b/services/collaboration/pkg/internal/app/wopicontext.go index fea0b60421..806e3cee99 100644 --- a/services/collaboration/pkg/internal/app/wopicontext.go +++ b/services/collaboration/pkg/internal/app/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/rs/zerolog" "google.golang.org/grpc/metadata" ) @@ -29,7 +30,7 @@ type WopiContext struct { ViewAppUrl string } -func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { +func WopiContextAuthMiddleware(jwtSecret string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { accessToken := r.URL.Query().Get("access_token") if accessToken == "" { @@ -44,7 +45,7 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return []byte(app.Config.JWTSecret), nil + return []byte(jwtSecret), nil }) if err != nil { @@ -59,7 +60,7 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { ctx := r.Context() - wopiContextAccessToken, err := DecryptAES([]byte(app.Config.JWTSecret), claims.WopiContext.AccessToken) + wopiContextAccessToken, err := DecryptAES([]byte(jwtSecret), claims.WopiContext.AccessToken) if err != nil { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return @@ -70,6 +71,17 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { // authentication for the CS3 api ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, claims.WopiContext.AccessToken) + // include additional info in the context's logger + // we might need to check https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/common-headers + // although some headers might not be sent depending on the client. + logger := zerolog.Ctx(ctx) + ctx = logger.With(). + Str("WopiOverride", r.Header.Get("X-WOPI-Override")). + Str("FileReference", claims.WopiContext.FileReference.String()). + Str("ViewMode", claims.WopiContext.ViewMode.String()). + Str("Requester", claims.WopiContext.User.GetId().String()). + Logger().WithContext(ctx) + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/services/collaboration/pkg/internal/app/wopifilecontents.go b/services/collaboration/pkg/internal/app/wopifilecontents.go deleted file mode 100644 index d45a975609..0000000000 --- a/services/collaboration/pkg/internal/app/wopifilecontents.go +++ /dev/null @@ -1,169 +0,0 @@ -package app - -import ( - "io" - "net/http" - "strconv" - - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/helpers" -) - -// GetFile downloads the file from the storage -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile -func GetFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - // download the file - resp, err := helpers.DownloadFile( - ctx, - &wopiContext.FileReference, - app.gwc, - wopiContext.AccessToken, - app.Config.CS3Api.DataGateway.Insecure, - app.Logger, - ) - - if err != nil || resp.StatusCode != http.StatusOK { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Int("HttpCode", resp.StatusCode). - Msg("GetFile: downloading the file failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - // read the file from the body - defer resp.Body.Close() - _, err = io.Copy(w, resp.Body) - if err != nil { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("GetFile: copying the file content to the response body failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("GetFile: success") -} - -// PutFile uploads the file to the storage -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile -func PutFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - // read the file from the body - defer r.Body.Close() - - // We need a stat call on the target file in order to get both the lock - // (if any) and the current size of the file - statRes, err := app.gwc.Stat(ctx, &providerv1beta1.StatRequest{ - Ref: &wopiContext.FileReference, - }) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("PutFile: stat failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", statRes.Status.Code.String()). - Str("StatusMsg", statRes.Status.Message). - Msg("PutFile: stat failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - // If there is a lock and it mismatches, return 409 - if statRes.Info.Lock != nil && statRes.Info.Lock.LockId != r.Header.Get(HeaderWopiLock) { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", statRes.Info.Lock.LockId). - Msg("PutFile: wrong lock") - // onlyoffice says it's required to send the current lockId, MS doesn't say anything - w.Header().Add(HeaderWopiLock, statRes.Info.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - - // only unlocked uploads can go through if the target file is empty, - // otherwise the X-WOPI-Lock header is required even if there is no lock on the file - // This is part of the onlyoffice documentation (https://api.onlyoffice.com/editors/wopi/restapi/putfile) - // Wopivalidator fails some tests if we don't also check for the X-WOPI-Lock header. - if r.Header.Get(HeaderWopiLock) == "" && statRes.Info.Lock == nil && statRes.Info.Size > 0 { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("PutFile: file must be locked first") - // onlyoffice says to send an empty string if the file is unlocked, MS doesn't say anything - w.Header().Add(HeaderWopiLock, "") - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - - // upload the file - err = helpers.UploadFile( - ctx, - r.Body, - r.ContentLength, - &wopiContext.FileReference, - app.gwc, - wopiContext.AccessToken, - r.Header.Get(HeaderWopiLock), - app.Config.CS3Api.DataGateway.Insecure, - app.Logger, - ) - - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("PutFile: uploading the file failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("PutFile: success") -} diff --git a/services/collaboration/pkg/internal/app/wopiinfo.go b/services/collaboration/pkg/internal/app/wopiinfo.go deleted file mode 100644 index 0a5bc44ad3..0000000000 --- a/services/collaboration/pkg/internal/app/wopiinfo.go +++ /dev/null @@ -1,150 +0,0 @@ -package app - -import ( - "encoding/hex" - "encoding/json" - "net/http" - "path" - - appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" - userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - "github.com/google/uuid" -) - -func WopiInfoHandler(app *DemoApp, w http.ResponseWriter, r *http.Request) { - // Logs for this endpoint will be covered by the access log. We can't extract - // more info - http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot) -} - -// 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 -func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - statRes, err := app.gwc.Stat(ctx, &providerv1beta1.StatRequest{ - Ref: &wopiContext.FileReference, - }) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("CheckFileInfo: stat failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", statRes.Status.Code.String()). - Str("StatusMsg", statRes.Status.Message). - Msg("CheckFileInfo: stat failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - fileInfo := FileInfo{ - // OwnerID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) - OwnerID: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), - Size: int64(statRes.Info.Size), - Version: statRes.Info.Mtime.String(), - BaseFileName: path.Base(statRes.Info.Path), - BreadcrumbDocName: path.Base(statRes.Info.Path), - // to get the folder we actually need to do a GetPath() request - //BreadcrumbFolderName: path.Dir(statRes.Info.Path), - - UserCanNotWriteRelative: true, - - HostViewUrl: wopiContext.ViewAppUrl, - HostEditUrl: wopiContext.EditAppUrl, - - //EnableOwnerTermination: true, // enable only for collabora? wopivalidator is complaining - EnableOwnerTermination: false, - - SupportsExtendedLockLength: true, - - SupportsGetLock: true, - SupportsLocks: true, - } - - switch wopiContext.ViewMode { - case appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE: - fileInfo.SupportsUpdate = true - fileInfo.UserCanWrite = true - - case appproviderv1beta1.ViewMode_VIEW_MODE_READ_ONLY: - // nothing special to do here for now - - case appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY: - fileInfo.DisableExport = true - fileInfo.DisableCopy = true - fileInfo.DisablePrint = true - } - - // user logic from reva wopi driver #TODO: refactor - var isPublicShare bool = false - if wopiContext.User != nil { - // UserID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) - if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { - fileInfo.UserID = hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)) - } else { - fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) - } - - if wopiContext.User.Opaque != nil { - if _, ok := wopiContext.User.Opaque.Map["public-share-role"]; ok { - isPublicShare = true - } - } - if !isPublicShare { - fileInfo.UserFriendlyName = wopiContext.User.Username - fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) - } - } - if wopiContext.User == nil || isPublicShare { - randomID, _ := uuid.NewUUID() - fileInfo.UserID = hex.EncodeToString([]byte("guest-" + randomID.String())) - fileInfo.UserFriendlyName = "Guest " + randomID.String() - fileInfo.IsAnonymousUser = true - } - - jsonFileInfo, err := json.Marshal(fileInfo) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("CheckFileInfo: failed to marshal fileinfo") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("CheckFileInfo: success") - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - bytes, err := w.Write(jsonFileInfo) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Int("TotalBytes", len(jsonFileInfo)). - Int("WrittenBytes", bytes). - Msg("CheckFileInfo: failed to write contents in the HTTP response") - } -} diff --git a/services/collaboration/pkg/internal/app/wopilocking.go b/services/collaboration/pkg/internal/app/wopilocking.go deleted file mode 100644 index 4e8bf17a59..0000000000 --- a/services/collaboration/pkg/internal/app/wopilocking.go +++ /dev/null @@ -1,524 +0,0 @@ -package app - -import ( - "net/http" - "time" - - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" -) - -const ( - // WOPI Locks generally have a lock duration of 30 minutes and will be refreshed before expiration if needed - // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#lock - lockDuration time.Duration = 30 * time.Minute -) - -// GetLock returns a lock or an empty string if no lock exists -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock -func GetLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - req := &providerv1beta1.GetLockRequest{ - Ref: &wopiContext.FileReference, - } - - resp, err := app.gwc.GetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("GetLock failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("GetLock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - - lockID := "" - if resp.Lock != nil { - lockID = resp.Lock.LockId - } - - // log the success at debug level - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("LockID", lockID). - Msg("GetLock success") - - w.Header().Set(HeaderWopiLock, lockID) -} - -// Lock returns a WOPI lock or performs an unlock and relock -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock -func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - oldLockID := r.Header.Get(HeaderWopiOldLock) - lockID := r.Header.Get(HeaderWopiLock) - if lockID == "" { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Lock failed due to empty lockID") - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - var setOrRefreshStatus *rpcv1beta1.Status - if oldLockID == "" { - // If the oldLockID is empty, this is a "LOCK" request - req := &providerv1beta1.SetLockRequest{ - Ref: &wopiContext.FileReference, - Lock: &providerv1beta1.Lock{ - LockId: lockID, - AppName: app.Config.App.LockName, - Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, - Expiration: &typesv1beta1.Timestamp{ - Seconds: uint64(time.Now().Add(lockDuration).Unix()), - }, - }, - } - - resp, err := app.gwc.SetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("SetLock failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - setOrRefreshStatus = resp.Status - } else { - // If the oldLockID isn't empty, this is a "UnlockAndRelock" request. We'll - // do a "RefreshLock" in reva and provide the old lock - req := &providerv1beta1.RefreshLockRequest{ - Ref: &wopiContext.FileReference, - Lock: &providerv1beta1.Lock{ - LockId: lockID, - AppName: app.Config.App.LockName, - Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, - Expiration: &typesv1beta1.Timestamp{ - Seconds: uint64(time.Now().Add(lockDuration).Unix()), - }, - }, - ExistingLockId: oldLockID, - } - - resp, err := app.gwc.RefreshLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("RequestedOldLockID", oldLockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("UnlockAndRefresh failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - setOrRefreshStatus = resp.Status - } - - // we're checking the status of either the "SetLock" or "RefreshLock" operations - switch setOrRefreshStatus.Code { - case rpcv1beta1.Code_CODE_OK: - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("SetLock successful") - return - - case rpcv1beta1.Code_CODE_FAILED_PRECONDITION, rpcv1beta1.Code_CODE_ABORTED: - // Code_CODE_FAILED_PRECONDITION -> Lock operation mismatched lock - // Code_CODE_ABORTED -> UnlockAndRelock operation mismatched lock - // In both cases, we need to get the current lock to return it in a - // 409 response if needed - req := &providerv1beta1.GetLockRequest{ - Ref: &wopiContext.FileReference, - } - - resp, err := app.gwc.GetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("SetLock failed, fallback to GetLock failed too") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("SetLock failed, fallback to GetLock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - if resp.Lock != nil { - if resp.Lock.LockId != lockID { - // lockId is different -> return 409 with the current lockId - app.Logger.Warn(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", resp.Lock.LockId). - Msg("SetLock conflict") - w.Header().Set(HeaderWopiLock, resp.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - - // TODO: according to the spec we need to treat this as a RefreshLock - // There was a problem with the lock, but the file has the same lockId now. - // This should never happen unless there are race conditions. - // Since the lockId matches now, we'll assume success for now. - // As said in the todo, we probably should send a "RefreshLock" request here. - app.Logger.Warn(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", resp.Lock.LockId). - Msg("SetLock lock refreshed instead") - return - } - - // TODO: Is this the right error code? - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("SetLock failed and could not refresh") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - - default: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", setOrRefreshStatus.Code.String()). - Str("StatusMsg", setOrRefreshStatus.Message). - Msg("SetLock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - -} - -// RefreshLock refreshes a provided lock for 30 minutes -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock -func RefreshLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - lockID := r.Header.Get(HeaderWopiLock) - if lockID == "" { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("RefreshLock failed due to empty lockID") - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - req := &providerv1beta1.RefreshLockRequest{ - Ref: &wopiContext.FileReference, - Lock: &providerv1beta1.Lock{ - LockId: lockID, - AppName: app.Config.App.LockName, - Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, - Expiration: &typesv1beta1.Timestamp{ - Seconds: uint64(time.Now().Add(lockDuration).Unix()), - }, - }, - } - - resp, err := app.gwc.RefreshLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("RefreshLock failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - switch resp.Status.Code { - case rpcv1beta1.Code_CODE_OK: - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("RefreshLock successful") - return - - case rpcv1beta1.Code_CODE_NOT_FOUND: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, file reference not found") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - - case rpcv1beta1.Code_CODE_ABORTED: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, lock mismatch") - - // Either the file is unlocked or there is no lock - // We need to return 409 with the current lock - req := &providerv1beta1.GetLockRequest{ - Ref: &wopiContext.FileReference, - } - - resp, err := app.gwc.GetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("RefreshLock failed trying to get the current lock") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, tried to get the current lock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Lock == nil { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, no lock on file") - w.Header().Set(HeaderWopiLock, "") - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } else { - // lock is different than the one requested, otherwise we wouldn't reached this point - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", resp.Lock.LockId). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, lock mismatch") - w.Header().Set(HeaderWopiLock, resp.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - default: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } -} - -// UnLock removes a given lock from a file -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock -func UnLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - lockID := r.Header.Get(HeaderWopiLock) - if lockID == "" { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock failed due to empty lockID") - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - req := &providerv1beta1.UnlockRequest{ - Ref: &wopiContext.FileReference, - Lock: &providerv1beta1.Lock{ - LockId: lockID, - AppName: app.Config.App.LockName, - }, - } - - resp, err := app.gwc.Unlock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - switch resp.Status.Code { - case rpcv1beta1.Code_CODE_OK: - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock successful") - return - case rpcv1beta1.Code_CODE_ABORTED: - // File isn't locked. Need to return 409 with empty lock - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock failed, file isn't locked") - w.Header().Set(HeaderWopiLock, "") - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - case rpcv1beta1.Code_CODE_LOCKED: - // We need to return 409 with the current lock - req := &providerv1beta1.GetLockRequest{ - Ref: &wopiContext.FileReference, - } - - resp, err := app.gwc.GetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock failed trying to get the current lock") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("Unlock failed, tried to get the current lock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Lock == nil { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("Unlock failed, no lock on file") - w.Header().Set(HeaderWopiLock, "") - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - } else { - // lock is different than the one requested, otherwise we wouldn't reached this point - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", resp.Lock.LockId). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("Unlock failed, lock mismatch") - w.Header().Set(HeaderWopiLock, resp.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - } - return - default: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("Unlock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } -} diff --git a/services/collaboration/pkg/internal/helpers/download.go b/services/collaboration/pkg/internal/helpers/download.go deleted file mode 100644 index acba7a3753..0000000000 --- a/services/collaboration/pkg/internal/helpers/download.go +++ /dev/null @@ -1,109 +0,0 @@ -package helpers - -import ( - "bytes" - "context" - "crypto/tls" - "errors" - "net/http" - - "github.com/owncloud/ocis/v2/ocis-pkg/log" - - gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" -) - -func DownloadFile( - ctx context.Context, - ref *providerv1beta1.Reference, - gwc gatewayv1beta1.GatewayAPIClient, - token string, - insecure bool, - logger log.Logger, -) (http.Response, error) { - - req := &providerv1beta1.InitiateFileDownloadRequest{ - Ref: ref, - } - - resp, err := gwc.InitiateFileDownload(ctx, req) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Msg("DownloadHelper: InitiateFileDownload failed") - return http.Response{}, err - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error(). - Str("FileReference", ref.String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("DownloadHelper: InitiateFileDownload failed with wrong status") - return http.Response{}, errors.New("InitiateFileDownload failed with status " + resp.Status.Code.String()) - } - - downloadEndpoint := "" - downloadToken := "" - hasDownloadToken := false - - for _, proto := range resp.Protocols { - if proto.Protocol == "simple" || proto.Protocol == "spaces" { - downloadEndpoint = proto.DownloadEndpoint - downloadToken = proto.Token - hasDownloadToken = proto.Token != "" - break - } - } - - if downloadEndpoint == "" { - logger.Error(). - Str("FileReference", ref.String()). - Str("Endpoint", downloadEndpoint). - Bool("HasDownloadToken", hasDownloadToken). - Msg("DownloadHelper: Download endpoint or token is missing") - return http.Response{}, errors.New("download endpoint is missing") - } - - httpClient := http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: insecure, - }, - }, - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadEndpoint, bytes.NewReader([]byte(""))) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("Endpoint", downloadEndpoint). - Bool("HasDownloadToken", hasDownloadToken). - Msg("DownloadHelper: Could not create the request to the endpoint") - return http.Response{}, err - } - if downloadToken != "" { - // public link downloads have the token in the download endpoint - httpReq.Header.Add("X-Reva-Transfer", downloadToken) - } - // TODO: the access token shouldn't be needed - httpReq.Header.Add("X-Access-Token", token) - - // TODO: this needs a refactor to comply with the "bodyclose" linter - // response body is closed in the caller method for now - httpResp, err := httpClient.Do(httpReq) //nolint:bodyclose - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("Endpoint", downloadEndpoint). - Bool("HasDownloadToken", hasDownloadToken). - Msg("DownloadHelper: Get request to the download endpoint failed") - return http.Response{}, err - } - - return *httpResp, nil -} diff --git a/services/collaboration/pkg/internal/helpers/upload.go b/services/collaboration/pkg/internal/helpers/upload.go deleted file mode 100644 index 0a40220a71..0000000000 --- a/services/collaboration/pkg/internal/helpers/upload.go +++ /dev/null @@ -1,169 +0,0 @@ -package helpers - -import ( - "context" - "crypto/tls" - "errors" - "io" - "net/http" - "strconv" - "time" - - gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" - "github.com/owncloud/ocis/v2/ocis-pkg/log" -) - -func UploadFile( - ctx context.Context, - content io.Reader, // content won't be closed inside the method - contentLength int64, - ref *providerv1beta1.Reference, - gwc gatewayv1beta1.GatewayAPIClient, - token string, - lockID string, - insecure bool, - logger log.Logger, -) error { - opaque := &types.Opaque{ - Map: make(map[string]*types.OpaqueEntry), - } - - strContentLength := strconv.FormatInt(contentLength, 10) - if contentLength >= 0 { - opaque.Map["Upload-Length"] = &types.OpaqueEntry{ - Decoder: "plain", - Value: []byte(strContentLength), - } - } - - req := &providerv1beta1.InitiateFileUploadRequest{ - Opaque: opaque, - Ref: ref, - LockId: lockID, - // TODO: if-match - //Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ - // IfMatch: "", - //}, - } - - resp, err := gwc.InitiateFileUpload(ctx, req) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Msg("UploadHelper: InitiateFileUpload failed") - return err - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error(). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("UploadHelper: InitiateFileUpload failed with wrong status") - return errors.New("InitiateFileUpload failed with status " + resp.Status.Code.String()) - } - - // if content length is 0, we're done. We don't upload anything to the target endpoint - if contentLength == 0 { - return nil - } - - uploadEndpoint := "" - uploadToken := "" - hasUploadToken := false - - for _, proto := range resp.Protocols { - if proto.Protocol == "simple" || proto.Protocol == "spaces" { - uploadEndpoint = proto.UploadEndpoint - uploadToken = proto.Token - hasUploadToken = proto.Token != "" - break - } - } - - if uploadEndpoint == "" { - logger.Error(). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("Endpoint", uploadEndpoint). - Bool("HasUploadToken", hasUploadToken). - Msg("UploadHelper: Upload endpoint or token is missing") - return errors.New("upload endpoint or token is missing") - } - - httpClient := http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: insecure, - }, - }, - Timeout: 10 * time.Second, - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadEndpoint, content) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("Endpoint", uploadEndpoint). - Bool("HasUploadToken", hasUploadToken). - Msg("UploadHelper: Could not create the request to the endpoint") - return err - } - // "content" is an *http.body and doesn't fill the httpReq.ContentLength automatically - // we need to fill the ContentLength ourselves, and must match the stream length in order - // to prevent issues - httpReq.ContentLength = contentLength - - if uploadToken != "" { - // public link uploads have the token in the upload endpoint - httpReq.Header.Add("X-Reva-Transfer", uploadToken) - } - // TODO: the access token shouldn't be needed - httpReq.Header.Add("X-Access-Token", token) - - httpReq.Header.Add("X-Lock-Id", lockID) - // TODO: better mechanism for the upload while locked, relies on patch in REVA - //if lockID, ok := ctxpkg.ContextGetLockID(ctx); ok { - // httpReq.Header.Add("X-Lock-Id", lockID) - //} - - httpResp, err := httpClient.Do(httpReq) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("Endpoint", uploadEndpoint). - Bool("HasUploadToken", hasUploadToken). - Msg("UploadHelper: Put request to the upload endpoint failed") - return err - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK { - logger.Error(). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("Endpoint", uploadEndpoint). - Bool("HasUploadToken", hasUploadToken). - Int("HttpCode", httpResp.StatusCode). - Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status") - return errors.New("Put request failed with status " + strconv.Itoa(httpResp.StatusCode)) - } - - return nil -} diff --git a/services/collaboration/pkg/internal/logging/logging.go b/services/collaboration/pkg/internal/logging/logging.go deleted file mode 100644 index 91d896e349..0000000000 --- a/services/collaboration/pkg/internal/logging/logging.go +++ /dev/null @@ -1,17 +0,0 @@ -package logging - -import ( - "github.com/owncloud/ocis/v2/ocis-pkg/log" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" -) - -// LoggerFromConfig initializes a service-specific logger instance. -func Configure(name string, cfg *config.Log) log.Logger { - return log.NewLogger( - log.Name(name), - log.Level(cfg.Level), - log.Pretty(cfg.Pretty), - log.Color(cfg.Color), - log.File(cfg.File), - ) -} diff --git a/services/collaboration/pkg/server/http/option.go b/services/collaboration/pkg/server/http/option.go index acffb503bb..10628a0fc2 100644 --- a/services/collaboration/pkg/server/http/option.go +++ b/services/collaboration/pkg/server/http/option.go @@ -5,7 +5,7 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" "go.opentelemetry.io/otel/trace" ) @@ -14,7 +14,7 @@ type Option func(o *Options) // Options defines the available options for this package. type Options struct { - App *app.DemoApp + Adapter *connector.HttpAdapter Logger log.Logger Context context.Context Config *config.Config @@ -33,9 +33,9 @@ func newOptions(opts ...Option) Options { } // App provides a function to set the logger option. -func App(val *app.DemoApp) Option { +func Adapter(val *connector.HttpAdapter) Option { return func(o *Options) { - o.App = val + o.Adapter = val } } diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index c93f3ac1ae..bdd2f2c2b8 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/owncloud/ocis/v2/ocis-pkg/account" + "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/middleware" "github.com/owncloud/ocis/v2/ocis-pkg/service/http" "github.com/owncloud/ocis/v2/ocis-pkg/tracing" @@ -76,7 +77,7 @@ func Server(opts ...Option) (http.Service, error) { ), ) - prepareRoutes(mux, options.App) + prepareRoutes(mux, options) if err := micro.RegisterHandler(service.Server(), mux); err != nil { return http.Service{}, err @@ -85,23 +86,37 @@ func Server(opts ...Option) (http.Service, error) { return service, nil } -func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { +func prepareRoutes(r *chi.Mux, options Options) { + adapter := options.Adapter + logger := options.Logger + // prepare basic logger for the request + r.Use(func(h stdhttp.Handler) stdhttp.Handler { + return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + ctx := logger.With(). + Str(log.RequestIDString, r.Header.Get("X-Request-ID")). + Str("proto", r.Proto). + Str("method", r.Method). + Str("path", r.URL.Path). + Logger().WithContext(r.Context()) + h.ServeHTTP(w, r.WithContext(ctx)) + }) + }) r.Route("/wopi", func(r chi.Router) { r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { - app.WopiInfoHandler(demoapp, w, r) + stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusTeapot), stdhttp.StatusTeapot) }) r.Route("/files/{fileid}", func(r chi.Router) { r.Use(func(h stdhttp.Handler) stdhttp.Handler { // authentication and wopi context - return app.WopiContextAuthMiddleware(demoapp, h) + return app.WopiContextAuthMiddleware(options.Config.JWTSecret, h) }, ) r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { - app.CheckFileInfo(demoapp, w, r) + adapter.CheckFileInfo(w, r) }) r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -110,13 +125,13 @@ func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { case "LOCK": // "UnlockAndRelock" operation goes through here - app.Lock(demoapp, w, r) + adapter.Lock(w, r) case "GET_LOCK": - app.GetLock(demoapp, w, r) + adapter.GetLock(w, r) case "REFRESH_LOCK": - app.RefreshLock(demoapp, w, r) + adapter.RefreshLock(w, r) case "UNLOCK": - app.UnLock(demoapp, w, r) + adapter.UnLock(w, r) case "PUT_USER_INFO": // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putuserinfo @@ -138,7 +153,7 @@ func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { r.Route("/contents", func(r chi.Router) { r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { - app.GetFile(demoapp, w, r) + adapter.GetFile(w, r) }) r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -146,7 +161,7 @@ func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { switch action { case "PUT": - app.PutFile(demoapp, w, r) + adapter.PutFile(w, r) default: stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusInternalServerError), stdhttp.StatusInternalServerError)