Files
QSfera/services/collaboration/pkg/connector/fileconnector.go
T

592 lines
22 KiB
Go

package connector
import (
"context"
"encoding/hex"
"path"
"strconv"
"strings"
"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/cs3org/reva/v2/pkg/utils"
"github.com/google/uuid"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/connector/fileinfo"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware"
"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
)
// FileConnectorService is the interface to implement the "Files"
// endpoint. Basically lock operations on the file plus the CheckFileInfo.
// All operations need a context containing a WOPI context and, optionally,
// a zerolog logger.
// Target file is within the WOPI context
type FileConnectorService interface {
// GetLock will return the lockID present in the target file.
GetLock(ctx context.Context) (string, error)
// Lock will lock the target file with the provided lockID. If the oldLockID
// is provided (not empty), the method will perform an unlockAndRelock
// operation (unlock the file with the oldLockID and immediately relock
// the file with the new lockID).
// The current lockID will be returned if a conflict happens
Lock(ctx context.Context, lockID, oldLockID string) (string, error)
// RefreshLock will extend the lock time 30 minutes. The current lockID
// needs to be provided.
// The current lockID will be returned if a conflict happens
RefreshLock(ctx context.Context, lockID string) (string, error)
// Unlock will unlock the target file. The current lockID needs to be
// provided.
// The current lockID will be returned if a conflict happens
UnLock(ctx context.Context, lockID string) (string, error)
// CheckFileInfo will return the file information of the target file
CheckFileInfo(ctx context.Context) (fileinfo.FileInfo, error)
}
// FileConnector implements the "File" endpoint.
// Currently, it handles file locks and getting the file info.
// Note that operations might return any kind of error, not just ConnectorError
type FileConnector struct {
gwc gatewayv1beta1.GatewayAPIClient
cfg *config.Config
}
// NewFileConnector creates a new file connector
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
//
// The context MUST have a WOPI context, otherwise an error will be returned.
// You can pass a pre-configured zerologger instance through the context that
// will be used to log messages.
//
// The lock ID applied to the file reference in the context will be returned
// (if any). An error will be returned if something goes wrong. The error
// could be a ConnectorError
func (f *FileConnector) GetLock(ctx context.Context) (string, error) {
wopiContext, err := middleware.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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("GetLock failed with unexpected status")
// TODO: Should we be more strict? There could be more causes for the failure
return "", NewConnectorError(404, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage())
}
lockID := ""
if resp.GetLock() != nil {
lockID = resp.GetLock().GetLockId()
}
// 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
//
// The context MUST have a WOPI context, otherwise an error will be returned.
// You can pass a pre-configured zerologger instance through the context that
// will be used to log messages.
//
// Lock the file reference contained in the context with the provided lockID.
// The oldLockID is only used for the "unlock and relock" operation. The "lock"
// operation doesn't use the oldLockID and needs to be empty in this case.
//
// For the "lock" operation, if the operation is successful, an empty lock id
// will be returned without any error. In case of conflict, the current lock
// id will be returned along with a 409 ConnectorError. For any other error,
// the method will return an empty lock id.
//
// For the "unlock and relock" operation, the behavior will be the same.
func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (string, error) {
wopiContext, err := middleware.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 + "." + f.cfg.App.Name,
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.GetStatus()
} 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 + "." + f.cfg.App.Name,
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.GetStatus()
}
// we're checking the status of either the "SetLock" or "RefreshLock" operations
switch setOrRefreshStatus.GetCode() {
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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("SetLock failed, fallback to GetLock failed with unexpected status")
}
if resp.GetLock() != nil {
if resp.GetLock().GetLockId() != lockID {
// lockId is different -> return 409 with the current lockId
logger.Warn().
Str("LockID", resp.GetLock().GetLockId()).
Msg("SetLock conflict")
return resp.GetLock().GetLockId(), 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.GetLock().GetLockId()).
Msg("SetLock lock refreshed instead")
return resp.GetLock().GetLockId(), nil
}
logger.Error().Msg("SetLock failed and could not refresh")
return "", NewConnectorError(500, "Could not refresh the lock")
case rpcv1beta1.Code_CODE_NOT_FOUND:
logger.Error().Msg("SetLock failed, file not found")
return "", NewConnectorError(404, "File not found")
default:
logger.Error().
Str("StatusCode", setOrRefreshStatus.GetCode().String()).
Str("StatusMsg", setOrRefreshStatus.GetMessage()).
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
//
// The context MUST have a WOPI context, otherwise an error will be returned.
// You can pass a pre-configured zerologger instance through the context that
// will be used to log messages.
//
// If the operation is successful, an empty lock id will be returned without
// any error. In case of conflict, the current lock id will be returned
// along with a 409 ConnectorError. For any other error, the method will
// return an empty lock id.
// The conflict happens if the provided lockID doesn't match the one actually
// applied in the target file.
func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, error) {
wopiContext, err := middleware.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 + "." + f.cfg.App.Name,
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.GetStatus().GetCode() {
case rpcv1beta1.Code_CODE_OK:
logger.Debug().Msg("RefreshLock successful")
return "", nil
case rpcv1beta1.Code_CODE_NOT_FOUND:
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("RefreshLock failed, file reference not found")
return "", NewConnectorError(404, "File reference not found")
case rpcv1beta1.Code_CODE_ABORTED:
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("RefreshLock failed, tried to get the current lock failed with unexpected status")
return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage())
}
if resp.GetLock() == nil {
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
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.GetLock().GetLockId()).
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("RefreshLock failed, lock mismatch")
return resp.GetLock().GetLockId(), NewConnectorError(409, "Lock mismatch")
}
default:
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("RefreshLock failed with unexpected status")
return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage())
}
}
// UnLock removes a given lock from a file
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock
//
// The context MUST have a WOPI context, otherwise an error will be returned.
// You can pass a pre-configured zerologger instance through the context that
// will be used to log messages.
//
// If the operation is successful, an empty lock id will be returned without
// any error. In case of conflict, the current lock id will be returned
// along with a 409 ConnectorError. For any other error, the method will
// return an empty lock id.
// The conflict happens if the provided lockID doesn't match the one actually
// applied in the target file.
func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, error) {
wopiContext, err := middleware.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 + "." + f.cfg.App.Name,
},
}
resp, err := f.gwc.Unlock(ctx, req)
if err != nil {
logger.Error().Err(err).Msg("Unlock failed")
return "", err
}
switch resp.GetStatus().GetCode() {
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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("Unlock failed, tried to get the current lock failed with unexpected status")
return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage())
}
var outLockId string
if resp.GetLock() == nil {
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
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.GetLock().GetLockId()).
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("Unlock failed, lock mismatch")
outLockId = resp.GetLock().GetLockId()
}
return outLockId, NewConnectorError(409, "Lock mismatch")
default:
logger.Error().
Str("StatusCode", resp.GetStatus().GetCode().String()).
Str("StatusMsg", resp.GetStatus().GetMessage()).
Msg("Unlock failed with unexpected status")
return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().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
//
// The context MUST have a WOPI context, otherwise an error will be returned.
// You can pass a pre-configured zerologger instance through the context that
// will be used to log messages.
//
// If the operation is successful, a "FileInfo" instance will be returned,
// otherwise the "FileInfo" will be empty and an error will be returned.
func (f *FileConnector) CheckFileInfo(ctx context.Context) (fileinfo.FileInfo, error) {
wopiContext, err := middleware.WopiContextFromCtx(ctx)
if err != nil {
return nil, 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 nil, err
}
if statRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
logger.Error().
Str("StatusCode", statRes.GetStatus().GetCode().String()).
Str("StatusMsg", statRes.GetStatus().GetMessage()).
Msg("CheckFileInfo: stat failed with unexpected status")
return nil, NewConnectorError(500, statRes.GetStatus().GetCode().String()+" "+statRes.GetStatus().GetMessage())
}
// If a not known app name is used, consider "Microsoft" as default.
// This will help with the CI because we're using a "FakeOffice" app
// for the wopi validator, which requires a Microsoft fileinfo
var info fileinfo.FileInfo
switch strings.ToLower(f.cfg.App.Name) {
case "collabora":
info = &fileinfo.Collabora{}
case "onlyoffice":
info = &fileinfo.OnlyOffice{}
default:
info = &fileinfo.Microsoft{}
}
hexEncodedOwnerId := hex.EncodeToString([]byte(statRes.GetInfo().GetOwner().GetOpaqueId() + "@" + statRes.GetInfo().GetOwner().GetIdp()))
version := strconv.FormatUint(statRes.GetInfo().GetMtime().GetSeconds(), 10) + "." + strconv.FormatUint(uint64(statRes.GetInfo().GetMtime().GetNanos()), 10)
// 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)
// assign userId, userFriendlyName and isAnonymousUser
// assume we don't have a wopiContext.User
randomID, _ := uuid.NewUUID()
userId := hex.EncodeToString([]byte("guest-" + randomID.String()))
userFriendlyName := "Guest " + randomID.String()
isAnonymousUser := true
isPublicShare := false
if wopiContext.User != nil {
// if we have a wopiContext.User
isPublicShare = utils.ExistsInOpaque(wopiContext.User.GetOpaque(), "public-share-role")
if !isPublicShare {
hexEncodedWopiUserId := hex.EncodeToString([]byte(wopiContext.User.GetId().GetOpaqueId() + "@" + wopiContext.User.GetId().GetIdp()))
isAnonymousUser = false
userFriendlyName = wopiContext.User.GetDisplayName()
userId = hexEncodedWopiUserId
}
}
// fileinfo map
infoMap := map[string]interface{}{
fileinfo.KeyOwnerID: hexEncodedOwnerId,
fileinfo.KeySize: int64(statRes.GetInfo().GetSize()),
fileinfo.KeyVersion: version,
fileinfo.KeyBaseFileName: path.Base(statRes.GetInfo().GetPath()),
fileinfo.KeyBreadcrumbDocName: path.Base(statRes.GetInfo().GetPath()),
// to get the folder we actually need to do a GetPath() request
//BreadcrumbFolderName: path.Dir(statRes.Info.Path),
fileinfo.KeyHostViewURL: wopiContext.ViewAppUrl,
fileinfo.KeyHostEditURL: wopiContext.EditAppUrl,
fileinfo.KeyEnableOwnerTermination: true, // only for collabora
fileinfo.KeySupportsExtendedLockLength: true,
fileinfo.KeySupportsGetLock: true,
fileinfo.KeySupportsLocks: true,
fileinfo.KeySupportsUpdate: true,
fileinfo.KeyUserCanNotWriteRelative: true,
fileinfo.KeyIsAnonymousUser: isAnonymousUser,
fileinfo.KeyUserFriendlyName: userFriendlyName,
fileinfo.KeyUserID: userId,
}
switch wopiContext.ViewMode {
case appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE:
infoMap[fileinfo.KeyUserCanWrite] = true
case appproviderv1beta1.ViewMode_VIEW_MODE_READ_ONLY:
// nothing special to do here for now
case appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY:
infoMap[fileinfo.KeyDisableExport] = true // only for collabora
infoMap[fileinfo.KeyDisableCopy] = true // only for collabora
infoMap[fileinfo.KeyDisablePrint] = true
if !isPublicShare {
infoMap[fileinfo.KeyWatermarkText] = f.watermarkText(wopiContext.User) // only for collabora
}
}
info.SetProperties(infoMap)
logger.Debug().Msg("CheckFileInfo: success")
return info, nil
}
func (f *FileConnector) watermarkText(user *userv1beta1.User) string {
if user != nil {
return strings.TrimSpace(user.GetDisplayName() + " " + user.GetMail())
}
return "Watermark"
}