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