Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Microsoft office 365 Cloud and Office Online Server support #9686

Merged
merged 3 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog/unreleased/microsoft-cloud-on-prem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: Microsoft Office365 and Office Online support

Add support for Microsoft Office365 Cloud and Microsoft Office Online on premises. You can use the cloud feature either within a Microsoft [CSP](https://learn.microsoft.com/en-us/partner-center/enroll/csp-overview) partnership or via the ownCloud office365 proxy subscription.
Please contact sales@owncloud.com to get more information about the ownCloud office365 proxy subscription.

https://github.com/owncloud/ocis/pull/9686
5 changes: 5 additions & 0 deletions services/collaboration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The collaboration service connects ocis with document servers such as Collabora

Since this service requires an external document server, it won't start by default when using `ocis server`. You must start it manually with the `ocis collaboration server` command.

This service needs to be part of the ocis service mesh. It is not intended to be used as a standalone service. You must share the common config variables like OCIS_URL, OCIS_JWT_SECRET and OCIS_REVA_GATEWAY betweed this service and the other ocis services. In addition to that, MICRO_REGISTRY_ADDRESS should point to the main ocis service registry.

## Requirements

The collaboration service requires the target document server (ONLYOFFICE, Collabora, etc.) to be up and running. Additionally, some Infinite Scale services are also required to be running in order to register the GRPC service for the `open in app` action in the webUI. The following internal and external services need to be available:
Expand All @@ -18,6 +20,9 @@ If any of the named services above have not been started or are not reachable, t

There are a few variables that you need to set:

* `COLLABORATION_APP_NAME`:\
The name of the connected WebOffice app, either `Collabora`, `OnlyOffice`, `Microsoft365` or `MicrosoftOfficeOnline`.

* `COLLABORATION_APP_ADDR`:\
The URL of the collaborative editing app (onlyoffice, collabora, etc).\
For example: `https://office.example.com`.
Expand Down
24 changes: 13 additions & 11 deletions services/collaboration/mocks/content_connector_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion services/collaboration/pkg/config/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ type App struct {
Addr string `yaml:"addr" env:"COLLABORATION_APP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080." introductionVersion:"6.0.0"`
Insecure bool `yaml:"insecure" env:"COLLABORATION_APP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app" introductionVersion:"6.0.0"`

ProofKeys ProofKeys `yaml:"proofkeys"`
ProofKeys ProofKeys `yaml:"proofkeys"`
LicenseCheckEnable bool `yaml:"licensecheckenable" env:"COLLABORATION_APP_LICENSE_CHECK_ENABLE" desc:"Enable license check for edit. Needs to be enabled when using Microsoft365 with the business flow." introductionVersion:"%%NEXT%%"`
}

type ProofKeys struct {
Expand Down
4 changes: 3 additions & 1 deletion services/collaboration/pkg/config/wopi.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ package config
type Wopi struct {
WopiSrc string `yaml:"wopisrc" env:"COLLABORATION_WOPI_SRC" desc:"The WOPISrc base URL containing schema, host and port. Set this to the schema and domain where the collaboration service is reachable for the wopi app, such as https://office.owncloud.test." introductionVersion:"6.0.0"`
Secret string `yaml:"secret" env:"COLLABORATION_WOPI_SECRET" desc:"Used to mint and verify WOPI JWT tokens and encrypt and decrypt the REVA JWT token embedded in the WOPI JWT token." introductionVersion:"6.0.0"`
DisableChat bool `yaml:"disable_chat" env:"COLLABORATION_WOPI_DISABLE_CHAT;OCIS_WOPI_DISABLE_CHAT" desc:"Disable chat in the frontend." introductionVersion:"%%NEXT%%"`
DisableChat bool `yaml:"disable_chat" env:"COLLABORATION_WOPI_DISABLE_CHAT;OCIS_WOPI_DISABLE_CHAT" desc:"Disable chat in the frontend. This feature is available in OnlyOffice and Microsoft." introductionVersion:"%%NEXT%%"`
ProxyURL string `yaml:"proxy_url" env:"COLLABORATION_WOPI_PROXY_URL" desc:"The URL to the ownCloud Office365 WOPI proxy. Optional. To use this feature, you need an office365 proxy subscription. If you become part of the Microsoft CSP program (https://learn.microsoft.com/en-us/partner-center/enroll/csp-overview), you can use the WebOffice without a proxy." introductionVersion:"%%NEXT%%"`
ProxySecret string `yaml:"proxy_secret" env:"COLLABORATION_WOPI_PROXY_SECRET" desc:"The secret to authenticate against the ownCloud Office365 WOPI proxy. Optional. This secret can be obtained from ownCloud via the office365 proxy subscription." introductionVersion:"%%NEXT%%"`
}
54 changes: 54 additions & 0 deletions services/collaboration/pkg/connector/connector.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package connector

import (
"strconv"

types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
)

// ConnectorResponse represent a response from the FileConnectorService.
// The ConnectorResponse is oriented to HTTP, so it has the Status, Headers
// and Body that the actual HTTP response should have. This includes HTTP
Expand Down Expand Up @@ -33,6 +39,48 @@ func NewResponseWithLock(status int, lockID string) *ConnectorResponse {
}
}

// NewResponseLockConflict creates a new ConnectorResponse with the status 409
// and the "X-WOPI-Lock" header having the value in the lockID parameter.
//
// This is used for conflict responses where the current lock id needs
// to be returned, although the `GetLock` method also uses this method for a
// successful response (with the lock id included)
// The lockFailureReason parameter will be included in the "X-WOPI-LockFailureReason".
func NewResponseLockConflict(lockID string, lockFailureReason string) *ConnectorResponse {
return &ConnectorResponse{
Status: 409,
Headers: map[string]string{
HeaderWopiLock: lockID,
HeaderWopiLockFailureReason: lockFailureReason,
},
}
}

// NewResponseWithVersion creates a new ConnectorResponse with the specified status
// and the "X-WOPI-ItemVersion" header having the value in the mtime parameter.
func NewResponseWithVersion(mtime *types.Timestamp) *ConnectorResponse {
return &ConnectorResponse{
Status: 200,
Headers: map[string]string{
HeaderWopiVersion: getVersion(mtime),
},
}
}

// NewResponseWithVersionAndLock creates a new ConnectorResponse with the specified status
// and the "X-WOPI-ItemVersion" header and the "X-WOPI-Lock" header
// having the values in the mtime and lockID parameters.
func NewResponseWithVersionAndLock(status int, mtime *types.Timestamp, lockID string) *ConnectorResponse {
r := &ConnectorResponse{
Status: status,
Headers: map[string]string{
HeaderWopiVersion: getVersion(mtime),
HeaderWopiLock: lockID,
},
}
return r
}

// NewResponseSuccessBody creates a new ConnectorResponse with a fixed 200
// (success) status and the specified body. The headers will be nil.
//
Expand Down Expand Up @@ -136,3 +184,9 @@ func (c *Connector) GetFileConnector() FileConnectorService {
func (c *Connector) GetContentConnector() ContentConnectorService {
return c.contentConnector
}

// getVersion returns a string representation of the timestamp
func getVersion(timestamp *types.Timestamp) string {
return "v" + strconv.FormatUint(timestamp.GetSeconds(), 10) +
strconv.FormatUint(uint64(timestamp.GetNanos()), 10)
}
49 changes: 41 additions & 8 deletions services/collaboration/pkg/connector/contentconnector.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import (
// Target file is within the WOPI context
type ContentConnectorService interface {
// GetFile downloads the file and write its contents in the provider writer
GetFile(ctx context.Context, writer io.Writer) error
GetFile(ctx context.Context, w http.ResponseWriter) error
// PutFile uploads the stream up to the stream length. The file should be
// locked beforehand, so the lockID needs to be provided.
// The current lockID will be returned ONLY if a conflict happens (the file is
Expand Down Expand Up @@ -61,9 +61,11 @@ func NewContentConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config
// You can pass a pre-configured zerologger instance through the context that
// will be used to log messages.
//
// The contents of the file will be written directly into the writer passed as
// The contents of the file will be written directly into the http Response writer passed as
// parameter.
func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error {
// Be aware that the body of the response will be written during the execution of this method.
// Any further modifications to the response headers or body will be ignored.
func (c *ContentConnector) GetFile(ctx context.Context, w http.ResponseWriter) error {
wopiContext, err := middleware.WopiContextFromCtx(ctx)
if err != nil {
return err
Expand All @@ -74,6 +76,16 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error
Logger()
logger.Debug().Msg("GetFile: start")

sResp, err := c.gwc.Stat(ctx, &providerv1beta1.StatRequest{
Ref: wopiContext.FileReference,
})
if err != nil {
logger.Error().Err(err).Msg("GetFile: Stat Request failed")
return err
butonic marked this conversation as resolved.
Show resolved Hide resolved
}
if sResp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
return NewConnectorError(500, sResp.GetStatus().GetCode().String()+" "+sResp.GetStatus().GetMessage())
}
// Initiate download request
req := &providerv1beta1.InitiateFileDownloadRequest{
Ref: wopiContext.FileReference,
Expand Down Expand Up @@ -168,13 +180,14 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error
return NewConnectorError(500, "GetFile: Downloading the file failed")
}

w.Header().Set(HeaderWopiVersion, getVersion(sResp.GetInfo().GetMtime()))

// Copy the download into the writer
_, err = io.Copy(writer, httpResp.Body)
_, err = io.Copy(w, 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
}
Expand All @@ -199,6 +212,8 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error
// lock ID that should be used in the X-WOPI-Lock header. In other error
// cases or if the method is successful, an empty string will be returned
// (check for err != nil to know if something went wrong)
//
// On success, the method will return the new mtime of the file
func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (*ConnectorResponse, error) {
wopiContext, err := middleware.WopiContextFromCtx(ctx)
if err != nil {
Expand Down Expand Up @@ -230,13 +245,14 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream
return NewResponse(500), nil
}

mtime := statRes.GetInfo().GetMtime()
// If there is a lock and it mismatches, return 409
if statRes.GetInfo().GetLock() != nil && statRes.GetInfo().GetLock().GetLockId() != lockID {
logger.Error().
Str("LockID", statRes.GetInfo().GetLock().GetLockId()).
Msg("PutFile: wrong lock")
// onlyoffice says it's required to send the current lockId, MS doesn't say anything
return NewResponseWithLock(409, statRes.GetInfo().GetLock().GetLockId()), nil
return NewResponseLockConflict(statRes.GetInfo().GetLock().GetLockId(), "Lock Mismatch"), nil
}

// only unlocked uploads can go through if the target file is empty,
Expand All @@ -246,7 +262,7 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream
if lockID == "" && statRes.GetInfo().GetLock() == nil && statRes.GetInfo().GetSize() > 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 NewResponseWithLock(409, ""), nil
return NewResponseLockConflict("", "Cannot PutFile on unlocked file"), nil
}

// Prepare the data to initiate the upload
Expand Down Expand Up @@ -367,8 +383,25 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream
Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status")
return NewResponse(500), nil
}
// We need a stat call on the target file after the upload to get the
// new mtime
statResAfter, err := c.gwc.Stat(ctx, &providerv1beta1.StatRequest{
Ref: wopiContext.FileReference,
})
if err != nil {
logger.Error().Err(err).Msg("PutFile: stat after upload failed")
return nil, err
}
if statResAfter.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
logger.Error().
Str("StatusCode", statRes.GetStatus().GetCode().String()).
Str("StatusMsg", statRes.GetStatus().GetMessage()).
Msg("PutFile: stat after upload failed with unexpected status")
return NewResponse(500), nil
}
mtime = statResAfter.GetInfo().GetMtime()
}

logger.Debug().Msg("PutFile: success")
return NewResponse(200), nil
return NewResponseWithVersion(mtime), nil
}
Loading