Initial QSfera import
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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
|
||||
// errors with status 4xx and 5xx, which will also represent some error
|
||||
// conditions for the FileConnectorService.
|
||||
// Note that the Body is expected to be JSON-encoded outside before sending.
|
||||
type ConnectorResponse struct {
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body any
|
||||
}
|
||||
|
||||
// NewResponse creates a new ConnectorResponse with just the specified status.
|
||||
// Headers and Body will be nil
|
||||
func NewResponse(status int) *ConnectorResponse {
|
||||
return &ConnectorResponse{Status: status}
|
||||
}
|
||||
|
||||
// NewResponse creates a new ConnectorResponse with the specified status
|
||||
// and the "X-WOPI-Lock" header having the value in the lockID parameter.
|
||||
//
|
||||
// This is usually 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)
|
||||
func NewResponseWithLock(status int, lockID string) *ConnectorResponse {
|
||||
return &ConnectorResponse{
|
||||
Status: status,
|
||||
Headers: map[string]string{
|
||||
HeaderWopiLock: lockID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// This is used for the `CheckFileInfo` method in order to return the fileinfo
|
||||
func NewResponseSuccessBody(body any) *ConnectorResponse {
|
||||
return &ConnectorResponse{
|
||||
Status: 200,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
// NewResponseSuccessBodyName creates a new ConnectorResponse with a fixed 200
|
||||
// (success) status and a "map[string]interface{}" body. The body will contain
|
||||
// a "Name" key with the supplied name as value.
|
||||
//
|
||||
// This is used for the `RenameFile` method in order to return the final name
|
||||
// of the renamed file if the operation is successful
|
||||
func NewResponseSuccessBodyName(name string) *ConnectorResponse {
|
||||
return &ConnectorResponse{
|
||||
Status: 200,
|
||||
Body: map[string]any{
|
||||
"Name": name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewResponseSuccessBodyNameUrl creates a new ConnectorResponse with a fixed
|
||||
// 200 (success) status and a "map[string]interface{}" body. The body will
|
||||
// contain "Name" and "Url" keys with their respective suplied values
|
||||
//
|
||||
// This is used in the `PutRelativeFile` methods (both suggested and relative).
|
||||
func NewResponseSuccessBodyNameUrl(name, url string, hostEditURL string, hostViewURL string) *ConnectorResponse {
|
||||
return &ConnectorResponse{
|
||||
Status: 200,
|
||||
Body: map[string]any{
|
||||
"Name": name,
|
||||
"Url": url,
|
||||
"HostEditUrl": hostEditURL,
|
||||
"HostViewUrl": hostViewURL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectorError defines an error in the connector. It contains an error code
|
||||
// and a message.
|
||||
// For convenience, the error code can be used as HTTP error code, although
|
||||
// the connector shouldn't know anything about HTTP.
|
||||
type ConnectorError struct {
|
||||
HttpCodeOut int
|
||||
Msg string
|
||||
}
|
||||
|
||||
// Error gets the error message
|
||||
func (e *ConnectorError) Error() string {
|
||||
return e.Msg
|
||||
}
|
||||
|
||||
// NewConnectorError creates a new connector error using the provided parameters
|
||||
func NewConnectorError(code int, msg string) *ConnectorError {
|
||||
return &ConnectorError{
|
||||
HttpCodeOut: code,
|
||||
Msg: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectorService is the interface to implement the WOPI operations. They're
|
||||
// divided into multiple endpoints.
|
||||
// The IFileConnector will implement the "File" endpoint
|
||||
// The IContentConnector will implement the "File content" endpoint
|
||||
type ConnectorService interface {
|
||||
GetFileConnector() FileConnectorService
|
||||
GetContentConnector() ContentConnectorService
|
||||
}
|
||||
|
||||
// Connector will implement the WOPI operations.
|
||||
// For convenience, the connector splits the operations based on the
|
||||
// WOPI endpoints, so you'll need to get the specific connector first.
|
||||
//
|
||||
// Available endpoints:
|
||||
// * "Files" -> GetFileConnector()
|
||||
// * "File contents" -> GetContentConnector()
|
||||
//
|
||||
// Other endpoints aren't available for now.
|
||||
type Connector struct {
|
||||
fileConnector FileConnectorService
|
||||
contentConnector ContentConnectorService
|
||||
}
|
||||
|
||||
// NewConnector creates a new connector
|
||||
func NewConnector(fc FileConnectorService, cc ContentConnectorService) *Connector {
|
||||
return &Connector{
|
||||
fileConnector: fc,
|
||||
contentConnector: cc,
|
||||
}
|
||||
}
|
||||
|
||||
// GetFileConnector gets the file connector service associated to this connector
|
||||
func (c *Connector) GetFileConnector() FileConnectorService {
|
||||
return c.fileConnector
|
||||
}
|
||||
|
||||
// GetContentConnector gets the content connector service associated to this connector
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package connector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestConnector(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Connector Suite")
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
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/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/middleware"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/rs/zerolog"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
)
|
||||
|
||||
// ContentConnectorService is the interface to implement the "File contents"
|
||||
// endpoint. Basically upload and download contents.
|
||||
// All operations need a context containing a WOPI context and, optionally,
|
||||
// a zerolog logger.
|
||||
// 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, 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
|
||||
// locked with a different lockID)
|
||||
PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (*ConnectorResponse, error)
|
||||
}
|
||||
|
||||
// ContentConnector implements the "File contents" endpoint.
|
||||
// Basically, the ContentConnector handles downloads (GetFile) and
|
||||
// uploads (PutFile)
|
||||
// Note that operations might return any kind of error, not just ConnectorError
|
||||
type ContentConnector struct {
|
||||
gws pool.Selectable[gatewayv1beta1.GatewayAPIClient]
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewContentConnector creates a new content connector
|
||||
func NewContentConnector(gws pool.Selectable[gatewayv1beta1.GatewayAPIClient], cfg *config.Config) *ContentConnector {
|
||||
return &ContentConnector{
|
||||
gws: gws,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func newHttpRequest(ctx context.Context, wopiContext middleware.WopiContext, method, url, transferToken string, body io.Reader) (*http.Request, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if url == "" {
|
||||
return nil, NewConnectorError(500, "url is missing")
|
||||
}
|
||||
if transferToken != "" {
|
||||
httpReq.Header.Add("X-Reva-Transfer", transferToken)
|
||||
}
|
||||
if wopiContext.ViewMode == appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY && wopiContext.ViewOnlyToken != "" {
|
||||
httpReq.Header.Add("X-Access-Token", wopiContext.ViewOnlyToken)
|
||||
} else {
|
||||
httpReq.Header.Add("X-Access-Token", wopiContext.AccessToken)
|
||||
}
|
||||
tracingProp := tracing.GetPropagator()
|
||||
tracingProp.Inject(ctx, propagation.HeaderCarrier(httpReq.Header))
|
||||
return httpReq, nil
|
||||
}
|
||||
|
||||
// GetFile downloads the file from the storage
|
||||
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile
|
||||
//
|
||||
// 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 contents of the file will be written directly into the http Response writer passed as
|
||||
// parameter.
|
||||
// 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
|
||||
}
|
||||
|
||||
logger := zerolog.Ctx(ctx).With().
|
||||
Interface("FileReference", wopiContext.FileReference).
|
||||
Logger()
|
||||
logger.Debug().Msg("GetFile: start")
|
||||
|
||||
gwc, err := c.gws.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sResp, err := gwc.Stat(ctx, &providerv1beta1.StatRequest{
|
||||
Ref: wopiContext.FileReference,
|
||||
})
|
||||
if err := requestFailed(logger, sResp.GetStatus(), false, err, "GetFile: Stat Request failed"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initiate download request
|
||||
req := &providerv1beta1.InitiateFileDownloadRequest{
|
||||
Ref: wopiContext.FileReference,
|
||||
}
|
||||
|
||||
if wopiContext.ViewMode == appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY && wopiContext.ViewOnlyToken != "" {
|
||||
ctx = revactx.ContextSetToken(ctx, wopiContext.ViewOnlyToken)
|
||||
}
|
||||
gwc, err = c.gws.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := gwc.InitiateFileDownload(ctx, req)
|
||||
if err := requestFailed(logger, resp.GetStatus(), false, err, "GetFile: InitiateFileDownload failed"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Figure out the download endpoint and download token
|
||||
downloadEndpoint := ""
|
||||
downloadToken := ""
|
||||
hasDownloadToken := false
|
||||
|
||||
for _, proto := range resp.GetProtocols() {
|
||||
if proto.GetProtocol() == "simple" || proto.GetProtocol() == "spaces" {
|
||||
downloadEndpoint = proto.GetDownloadEndpoint()
|
||||
downloadToken = proto.GetToken()
|
||||
hasDownloadToken = proto.GetToken() != ""
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logger = logger.With().
|
||||
Str("Endpoint", downloadEndpoint).
|
||||
Bool("HasDownloadToken", hasDownloadToken).Logger()
|
||||
|
||||
httpClient := http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: c.cfg.CS3Api.DataGateway.Insecure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Prepare the request to download the file
|
||||
// public link downloads have the token in the download endpoint
|
||||
httpReq, err := newHttpRequest(ctx, wopiContext, http.MethodGet, downloadEndpoint, downloadToken, bytes.NewReader([]byte("")))
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("GetFile: Could not create the request to the endpoint")
|
||||
return err
|
||||
}
|
||||
|
||||
httpResp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("GetFile: Get request to the download endpoint failed")
|
||||
return err
|
||||
}
|
||||
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
logger.Error().
|
||||
Int("HttpCode", httpResp.StatusCode).
|
||||
Msg("GetFile: downloading the file failed")
|
||||
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(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
|
||||
}
|
||||
|
||||
// PutFile uploads the file to the storage
|
||||
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile
|
||||
//
|
||||
// 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 contents of the file will be read from the stream. The full stream
|
||||
// length must be provided in order to upload the file.
|
||||
//
|
||||
// A lock ID must be provided for the upload (which must match the lock in the
|
||||
// file). The only case where an empty lock ID can be used is if the target
|
||||
// file has 0 size.
|
||||
//
|
||||
// This method will return the lock ID that should be returned in case of a
|
||||
// conflict, otherwise it will return an empty string. This means that if the
|
||||
// method returns a ConnectorError with code 409, the returned string is the
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger := zerolog.Ctx(ctx).With().
|
||||
Str("RequestedLockID", lockID).
|
||||
Int64("UploadLength", streamLength).
|
||||
Interface("FileReference", wopiContext.FileReference).
|
||||
Logger()
|
||||
logger.Debug().Msg("PutFile: start")
|
||||
|
||||
gwc, err := c.gws.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 := gwc.Stat(ctx, &providerv1beta1.StatRequest{
|
||||
Ref: wopiContext.FileReference,
|
||||
})
|
||||
// we can ignore a not found error here, as we're going to create the file
|
||||
if err := requestFailed(logger, statRes.GetStatus(), true, err, "PutFile: stat failed"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 NewResponseLockConflict(statRes.GetInfo().GetLock().GetLockId(), "Lock Mismatch"), nil
|
||||
}
|
||||
|
||||
// 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.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 NewResponseLockConflict("", "Cannot PutFile on unlocked file"), nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{
|
||||
IfMatch: statRes.GetInfo().GetEtag(),
|
||||
},
|
||||
}
|
||||
|
||||
gwc, err = c.gws.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Initiate the upload request
|
||||
resp, err := gwc.InitiateFileUpload(ctx, req)
|
||||
if err := requestFailed(logger, resp.GetStatus(), false, err, "PutFile: InitiateFileUpload failed"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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.GetProtocols() {
|
||||
if proto.GetProtocol() == "simple" || proto.GetProtocol() == "spaces" {
|
||||
uploadEndpoint = proto.GetUploadEndpoint()
|
||||
uploadToken = proto.GetToken()
|
||||
hasUploadToken = proto.GetToken() != ""
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logger = logger.With().
|
||||
Str("Endpoint", uploadEndpoint).
|
||||
Bool("HasUploadToken", hasUploadToken).Logger()
|
||||
|
||||
httpClient := http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: c.cfg.CS3Api.DataGateway.Insecure,
|
||||
},
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// prepare the request to upload the contents to the upload endpoint
|
||||
// public link uploads have the token in the upload endpoint
|
||||
httpReq, err := newHttpRequest(ctx, wopiContext, http.MethodPut, uploadEndpoint, uploadToken, stream)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("UploadHelper: Could not create the request to the endpoint")
|
||||
return nil, 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
|
||||
|
||||
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).Msg("UploadHelper: Put request to the upload endpoint failed")
|
||||
return nil, err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
logger.Error().
|
||||
Int("HttpCode", httpResp.StatusCode).
|
||||
Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status")
|
||||
return nil, NewConnectorError(500, fmt.Sprintf("unexpected status code %d from the upload endpoint", httpResp.StatusCode))
|
||||
}
|
||||
gwc, err = c.gws.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// We need a stat call on the target file after the upload to get the
|
||||
// new mtime
|
||||
statResAfter, err := gwc.Stat(ctx, &providerv1beta1.StatRequest{
|
||||
Ref: wopiContext.FileReference,
|
||||
})
|
||||
if err := requestFailed(logger, statResAfter.GetStatus(), false, err, "PutFile: stat after upload failed"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mtime = statResAfter.GetInfo().GetMtime()
|
||||
}
|
||||
|
||||
logger.Debug().Msg("PutFile: success")
|
||||
return NewResponseWithVersion(mtime), nil
|
||||
}
|
||||
|
||||
func requestFailed(logger zerolog.Logger, s *rpcv1beta1.Status, allowNotFound bool, err error, msg string) error {
|
||||
switch {
|
||||
case err != nil: // a connection error
|
||||
logger.Error().Err(err).Msg(msg)
|
||||
return err
|
||||
case s == nil: // we need a status
|
||||
logger.Error().Msg(msg + ": nil status")
|
||||
return NewConnectorError(500, msg+": nil status")
|
||||
case s.GetCode() == rpcv1beta1.Code_CODE_OK: // ok is fine
|
||||
return nil
|
||||
case allowNotFound && s.GetCode() == rpcv1beta1.Code_CODE_NOT_FOUND: // not found might be ok
|
||||
return nil
|
||||
default: // any other status is an error
|
||||
logger.Error().
|
||||
Str("StatusCode", s.GetCode().String()).
|
||||
Str("StatusMsg", s.GetMessage()).
|
||||
Msg(msg)
|
||||
return NewConnectorError(500, s.GetCode().String()+" "+s.GetMessage())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package connector_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/services/collaboration/mocks"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/connector"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/middleware"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
)
|
||||
|
||||
var _ = Describe("ContentConnector", func() {
|
||||
var (
|
||||
cc *connector.ContentConnector
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector *mocks.Selectable[gateway.GatewayAPIClient]
|
||||
cfg *config.Config
|
||||
wopiCtx middleware.WopiContext
|
||||
|
||||
srv *httptest.Server
|
||||
srvReqHeader http.Header
|
||||
randomContent string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// contentConnector only uses "cfg.CS3Api.DataGateway.Insecure", which is irrelevant for the tests
|
||||
cfg = &config.Config{}
|
||||
gatewayClient = cs3mocks.NewGatewayAPIClient(GinkgoT())
|
||||
|
||||
gatewaySelector = mocks.NewSelectable[gateway.GatewayAPIClient](GinkgoT())
|
||||
gatewaySelector.On("Next").Return(gatewayClient, nil)
|
||||
cc = connector.NewContentConnector(gatewaySelector, cfg)
|
||||
|
||||
wopiCtx = middleware.WopiContext{
|
||||
AccessToken: "abcdef123456",
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "abc",
|
||||
OpaqueId: "12345",
|
||||
SpaceId: "zzz",
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
}
|
||||
|
||||
randomContent = "This is the content of the test.txt file"
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
srvReqHeader = req.Header // save the request header to check later
|
||||
switch req.URL.Path {
|
||||
case "/download/failed.png":
|
||||
w.WriteHeader(404)
|
||||
case "/download/test.txt":
|
||||
w.Write([]byte(randomContent))
|
||||
case "/upload/failed.png":
|
||||
w.WriteHeader(404)
|
||||
case "/upload/test.txt":
|
||||
w.WriteHeader(200)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
srv.Close()
|
||||
})
|
||||
|
||||
Describe("GetFile", func() {
|
||||
BeforeEach(func() {
|
||||
gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Id: &providerv1beta1.ResourceId{
|
||||
StorageId: "abc",
|
||||
OpaqueId: "12345",
|
||||
SpaceId: "zzz",
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
It("No valid context", func() {
|
||||
gatewaySelector.EXPECT().Next().Unset()
|
||||
gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything).Unset()
|
||||
sb := httptest.NewRecorder()
|
||||
ctx := context.Background()
|
||||
err := cc.GetFile(ctx, sb)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("Initiate download failed", func() {
|
||||
sb := httptest.NewRecorder()
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
targetErr := errors.New("Something went wrong")
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewInternal(ctx, "Something failed"),
|
||||
}, targetErr)
|
||||
|
||||
err := cc.GetFile(ctx, sb)
|
||||
Expect(err).To(Equal(targetErr))
|
||||
})
|
||||
|
||||
It("Initiate download status not ok", func() {
|
||||
sb := httptest.NewRecorder()
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewInternal(ctx, "Something failed"),
|
||||
}, nil)
|
||||
|
||||
err := cc.GetFile(ctx, sb)
|
||||
targetErr := connector.NewConnectorError(500, "CODE_INTERNAL Something failed")
|
||||
Expect(err).To(Equal(targetErr))
|
||||
})
|
||||
|
||||
It("Missing download endpoint", func() {
|
||||
sb := httptest.NewRecorder()
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
}, nil)
|
||||
|
||||
err := cc.GetFile(ctx, sb)
|
||||
Expect(err).To(HaveOccurred())
|
||||
conErr := err.(*connector.ConnectorError)
|
||||
Expect(conErr.HttpCodeOut).To(Equal(500))
|
||||
})
|
||||
|
||||
It("Download request failed", func() {
|
||||
sb := httptest.NewRecorder()
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Protocols: []*gateway.FileDownloadProtocol{
|
||||
{
|
||||
Protocol: "simple",
|
||||
DownloadEndpoint: srv.URL + "/download/failed.png",
|
||||
Token: "MyDownloadToken",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := cc.GetFile(ctx, sb)
|
||||
Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken))
|
||||
Expect(srvReqHeader.Get("X-Reva-Transfer")).To(Equal("MyDownloadToken"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
conErr := err.(*connector.ConnectorError)
|
||||
Expect(conErr.HttpCodeOut).To(Equal(500))
|
||||
})
|
||||
|
||||
It("Download request success", func() {
|
||||
sb := httptest.NewRecorder()
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Protocols: []*gateway.FileDownloadProtocol{
|
||||
{
|
||||
Protocol: "simple",
|
||||
DownloadEndpoint: srv.URL + "/download/test.txt",
|
||||
Token: "MyDownloadToken",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := cc.GetFile(ctx, sb)
|
||||
Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken))
|
||||
Expect(srvReqHeader.Get("X-Reva-Transfer")).To(Equal("MyDownloadToken"))
|
||||
Expect(err).To(Succeed())
|
||||
Expect(sb.Body.String()).To(Equal(randomContent))
|
||||
})
|
||||
|
||||
It("ViewOnlyMode Download request success", func() {
|
||||
sb := httptest.NewRecorder()
|
||||
|
||||
wopiCtx = middleware.WopiContext{
|
||||
AccessToken: "abcdef123456",
|
||||
ViewOnlyToken: "view.only.123456",
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "abc",
|
||||
OpaqueId: "12345",
|
||||
SpaceId: "zzz",
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY,
|
||||
}
|
||||
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("InitiateFileDownload",
|
||||
mock.MatchedBy(func(ctx context.Context) bool {
|
||||
return revactx.ContextMustGetToken(ctx) == "view.only.123456"
|
||||
}), mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Protocols: []*gateway.FileDownloadProtocol{
|
||||
{
|
||||
Protocol: "simple",
|
||||
DownloadEndpoint: srv.URL + "/download/test.txt",
|
||||
Token: "MyDownloadToken",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := cc.GetFile(ctx, sb)
|
||||
Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.ViewOnlyToken))
|
||||
Expect(srvReqHeader.Get("X-Reva-Transfer")).To(Equal("MyDownloadToken"))
|
||||
Expect(err).To(Succeed())
|
||||
Expect(sb.Body.String()).To(Equal(randomContent))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PutFile", func() {
|
||||
It("No valid context", func() {
|
||||
gatewaySelector.EXPECT().Next().Unset()
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := context.Background()
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("Stat call failed", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
targetErr := errors.New("Something went wrong")
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewInternal(ctx, "Something failed"),
|
||||
}, targetErr)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId")
|
||||
Expect(err).To(Equal(targetErr))
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("Stat call status not ok", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewInternal(ctx, "Something failed"),
|
||||
}, nil)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId")
|
||||
targetErr := connector.NewConnectorError(500, "CODE_INTERNAL Something failed")
|
||||
Expect(err).To(Equal(targetErr))
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("Mismatched lockId", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: &providerv1beta1.Lock{
|
||||
LockId: "goodAndValidLock",
|
||||
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Status).To(Equal(409))
|
||||
Expect(response.Headers).To(HaveLen(2))
|
||||
Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("goodAndValidLock"))
|
||||
Expect(response.Headers[connector.HeaderWopiLockFailureReason]).To(Equal("Lock Mismatch"))
|
||||
})
|
||||
|
||||
It("Upload without lockId but on a non empty file", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: nil,
|
||||
Size: uint64(123456789),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Status).To(Equal(409))
|
||||
Expect(response.Headers[connector.HeaderWopiLock]).To(Equal(""))
|
||||
})
|
||||
|
||||
It("Initiate upload fails", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: &providerv1beta1.Lock{
|
||||
LockId: "goodAndValidLock",
|
||||
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
|
||||
},
|
||||
Size: uint64(123456789),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
targetErr := errors.New("Something went wrong")
|
||||
gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{
|
||||
Status: status.NewInternal(ctx, "Something failed"),
|
||||
}, targetErr)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("Initiate upload status not ok", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: &providerv1beta1.Lock{
|
||||
LockId: "goodAndValidLock",
|
||||
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
|
||||
},
|
||||
Size: uint64(123456789),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{
|
||||
Status: status.NewInternal(ctx, "Something failed"),
|
||||
}, nil)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock")
|
||||
targetErr := connector.NewConnectorError(500, "CODE_INTERNAL Something failed")
|
||||
Expect(err).To(Equal(targetErr))
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("Empty upload successful", func() {
|
||||
reader := strings.NewReader("")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: &providerv1beta1.Lock{
|
||||
LockId: "goodAndValidLock",
|
||||
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
|
||||
},
|
||||
Size: uint64(123456789),
|
||||
Mtime: utils.TimeToTS(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
}, nil)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Status).To(Equal(200))
|
||||
Expect(response.Headers).To(HaveLen(1))
|
||||
Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v16094592000"))
|
||||
})
|
||||
|
||||
It("Missing upload endpoint", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: &providerv1beta1.Lock{
|
||||
LockId: "goodAndValidLock",
|
||||
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
|
||||
},
|
||||
Size: uint64(123456789),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
}, nil)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock")
|
||||
targetErr := connector.NewConnectorError(500, "url is missing")
|
||||
Expect(err).To(Equal(targetErr))
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("upload request failed", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: &providerv1beta1.Lock{
|
||||
LockId: "goodAndValidLock",
|
||||
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
|
||||
},
|
||||
Size: uint64(123456789),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Protocols: []*gateway.FileUploadProtocol{
|
||||
{
|
||||
Protocol: "simple",
|
||||
UploadEndpoint: srv.URL + "/upload/failed.png",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock")
|
||||
Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken))
|
||||
targetErr := connector.NewConnectorError(500, "unexpected status code 404 from the upload endpoint")
|
||||
Expect(err).To(Equal(targetErr))
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("upload request success", func() {
|
||||
reader := strings.NewReader("Content to upload is here!")
|
||||
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
|
||||
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: &providerv1beta1.Lock{
|
||||
LockId: "goodAndValidLock",
|
||||
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
|
||||
},
|
||||
Size: uint64(123456789),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &providerv1beta1.ResourceInfo{
|
||||
Lock: &providerv1beta1.Lock{
|
||||
LockId: "goodAndValidLock",
|
||||
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
|
||||
},
|
||||
Size: uint64(123456789),
|
||||
Id: &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID",
|
||||
SpaceId: "spaceID",
|
||||
},
|
||||
Mtime: utils.TimeToTS(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Protocols: []*gateway.FileUploadProtocol{
|
||||
{
|
||||
Protocol: "simple",
|
||||
UploadEndpoint: srv.URL + "/upload/test.txt",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock")
|
||||
Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Status).To(Equal(200))
|
||||
Expect(response.Headers).To(HaveLen(1))
|
||||
Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v16094592000"))
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
package fileinfo
|
||||
|
||||
// UserExtraInfo contains additional user info shared across collaborative
|
||||
// editing views, such as the user's avatar image and email.
|
||||
// https://sdk.collaboraonline.com/docs/advanced_integration.html#userextrainfo
|
||||
type UserExtraInfo struct {
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Mail string `json:"mail,omitempty"`
|
||||
}
|
||||
|
||||
// Collabora fileInfo properties
|
||||
//
|
||||
// Collabora WOPI check file info specification:
|
||||
// https://sdk.collaboraonline.com/docs/advanced_integration.html
|
||||
type Collabora struct {
|
||||
//
|
||||
// Response properties
|
||||
//
|
||||
|
||||
// Copied from MS WOPI
|
||||
BaseFileName string `json:"BaseFileName,omitempty"`
|
||||
// Copied from MS WOPI
|
||||
DisablePrint bool `json:"DisablePrint"`
|
||||
// Copied from MS WOPI
|
||||
OwnerID string `json:"OwnerId,omitempty"`
|
||||
// A string for the domain the host page sends/receives PostMessages from, we only listen to messages from this domain.
|
||||
PostMessageOrigin string `json:"PostMessageOrigin,omitempty"`
|
||||
// copied from MS WOPI
|
||||
Size int64 `json:"Size"`
|
||||
// The ID of file (like the wopi/files/ID) can be a non-existing file. In that case, the file will be created from a template when the template (eg. an OTT file) is specified as TemplateSource in the CheckFileInfo response. The TemplateSource is supposed to be an URL like https://somewhere/accessible/file.ott that is accessible by the Online. For the actual saving of the content, normal PutFile mechanism will be used.
|
||||
TemplateSource string `json:"TemplateSource,omitempty"`
|
||||
// copied from MS WOPI
|
||||
UserCanWrite bool `json:"UserCanWrite"`
|
||||
// copied from MS WOPI
|
||||
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
|
||||
// copied from MS WOPI
|
||||
UserID string `json:"UserId,omitempty"`
|
||||
// copied from MS WOPI
|
||||
UserFriendlyName string `json:"UserFriendlyName,omitempty"`
|
||||
|
||||
//
|
||||
// Extended response properties
|
||||
//
|
||||
|
||||
// If set to true, this will enable the insertion of images chosen from the WOPI storage. A UI_InsertGraphic postMessage will be send to the WOPI host to request the UI to select the file.
|
||||
EnableInsertRemoteImage bool `json:"EnableInsertRemoteImage,omitempty"`
|
||||
// If set to true, this will enable the insertion of remote files chosen from the WOPI storage. A UI_InsertFile postMessage will be sent to the WOPI host to request the UI to select the file. This enables multimedia insertion and document comparison features.
|
||||
EnableInsertRemoteFile bool `json:"EnableInsertRemoteFile,omitempty"`
|
||||
// If set to true, this will enable picking a link to a remote file from the WOPI storage. A UI_PickLink postMessage will be sent to the WOPI host to request the UI to select the file. The host is expected to reply with an Action_InsertLink message carrying the file URL.
|
||||
EnableRemoteLinkPicker bool `json:"EnableRemoteLinkPicker,omitempty"`
|
||||
// If set to true, this will disable the insertion of image chosen from the local device. If EnableInsertRemoteImage is not set to true, then inserting images files is not possible.
|
||||
DisableInsertLocalImage bool `json:"DisableInsertLocalImage,omitempty"`
|
||||
// If set to true, hides the print option from the file menu bar in the UI.
|
||||
HidePrintOption bool `json:"HidePrintOption,omitempty"`
|
||||
// If set to true, hides the save button from the toolbar and file menubar in the UI.
|
||||
HideSaveOption bool `json:"HideSaveOption,omitempty"`
|
||||
// Hides Download as option in the file menubar.
|
||||
HideExportOption bool `json:"HideExportOption,omitempty"`
|
||||
// Disables export functionality in backend. If set to true, HideExportOption is assumed to be true
|
||||
DisableExport bool `json:"DisableExport,omitempty"`
|
||||
// Disables copying from the document in libreoffice online backend. Pasting into the document would still be possible. However, it is still possible to do an “internal” cut/copy/paste.
|
||||
DisableCopy bool `json:"DisableCopy,omitempty"`
|
||||
// Disables displaying of the explanation text on the overlay when the document becomes inactive or killed. With this, the JS integration must provide the user with appropriate message when it gets Session_Closed or User_Idle postMessages.
|
||||
DisableInactiveMessages bool `json:"DisableInactiveMessages,omitempty"`
|
||||
// Indicate that the integration wants to handle the downloading of pdf for printing or svg for slideshows or exported document, because it cannot rely on browser’s support for downloading.
|
||||
DownloadAsPostMessage bool `json:"DownloadAsPostMessage,omitempty"`
|
||||
// Similar to download as, doctype extensions can be provided for save-as. In this case the new file is loaded in the integration instead of downloaded.
|
||||
SaveAsPostmessage bool `json:"SaveAsPostmessage,omitempty"`
|
||||
// If set to true, it allows the document owner (the one with OwnerId =UserId) to send a closedocument message (see protocol.txt)
|
||||
EnableOwnerTermination bool `json:"EnableOwnerTermination,omitempty"`
|
||||
// If set to true, the user has administrator rights in the integration. Some functionality of Collabora Online, such as update check and server audit are supposed to be shown to administrators only.
|
||||
IsAdminUser bool `json:"IsAdminUser"`
|
||||
// If set to true, some functionality of Collabora which is supposed to be shown to authenticated users only is hidden
|
||||
IsAnonymousUser bool `json:"IsAnonymousUser,omitempty"`
|
||||
|
||||
// JSON object that contains additional info about the user, namely the avatar image.
|
||||
// Shared among all views in collaborative editing sessions.
|
||||
UserExtraInfo *UserExtraInfo `json:"UserExtraInfo,omitempty"`
|
||||
// JSON object that contains additional info about the user, but unlike the UserExtraInfo it is not shared among the views in collaborative editing sessions.
|
||||
//UserPrivateInfo -> requires definition, currently not used
|
||||
|
||||
// If set to a non-empty string, is used for rendering a watermark-like text on each tile of the document.
|
||||
WatermarkText string `json:"WatermarkText,omitempty"`
|
||||
|
||||
//
|
||||
// Undocumented (from source code)
|
||||
//
|
||||
|
||||
EnableShare bool `json:"EnableShare,omitempty"`
|
||||
// If set to "true", user list on the status bar will be hidden
|
||||
// If set to "mobile" | "tablet" | "desktop", will be hidden on a specified device
|
||||
// (may be joint, delimited by commas eg. "mobile,tablet")
|
||||
HideUserList string `json:"HideUserList,omitempty"`
|
||||
SupportsLocks bool `json:"SupportsLocks"`
|
||||
SupportsRename bool `json:"SupportsRename"`
|
||||
UserCanRename bool `json:"UserCanRename"`
|
||||
BreadcrumbDocName string `json:"BreadcrumbDocName,omitempty"`
|
||||
}
|
||||
|
||||
// SetProperties will set the file properties for the Collabora implementation.
|
||||
func (cinfo *Collabora) SetProperties(props map[string]any) {
|
||||
for key, value := range props {
|
||||
switch key {
|
||||
case KeyBaseFileName:
|
||||
cinfo.BaseFileName = value.(string)
|
||||
case KeyDisablePrint:
|
||||
cinfo.DisablePrint = value.(bool)
|
||||
case KeyOwnerID:
|
||||
cinfo.OwnerID = value.(string)
|
||||
case KeyPostMessageOrigin:
|
||||
cinfo.PostMessageOrigin = value.(string)
|
||||
case KeySize:
|
||||
cinfo.Size = value.(int64)
|
||||
case KeyTemplateSource:
|
||||
cinfo.TemplateSource = value.(string)
|
||||
case KeyUserCanWrite:
|
||||
cinfo.UserCanWrite = value.(bool)
|
||||
case KeyUserCanNotWriteRelative:
|
||||
cinfo.UserCanNotWriteRelative = value.(bool)
|
||||
case KeyUserID:
|
||||
cinfo.UserID = value.(string)
|
||||
case KeyUserFriendlyName:
|
||||
cinfo.UserFriendlyName = value.(string)
|
||||
|
||||
case KeyEnableInsertRemoteImage:
|
||||
cinfo.EnableInsertRemoteImage = value.(bool)
|
||||
case KeyEnableInsertRemoteFile:
|
||||
cinfo.EnableInsertRemoteFile = value.(bool)
|
||||
case KeyEnableRemoteLinkPicker:
|
||||
cinfo.EnableRemoteLinkPicker = value.(bool)
|
||||
case KeyDisableInsertLocalImage:
|
||||
cinfo.DisableInsertLocalImage = value.(bool)
|
||||
case KeyHidePrintOption:
|
||||
cinfo.HidePrintOption = value.(bool)
|
||||
case KeyHideSaveOption:
|
||||
cinfo.HideSaveOption = value.(bool)
|
||||
case KeyHideExportOption:
|
||||
cinfo.HideExportOption = value.(bool)
|
||||
case KeyDisableExport:
|
||||
cinfo.DisableExport = value.(bool)
|
||||
case KeyDisableCopy:
|
||||
cinfo.DisableCopy = value.(bool)
|
||||
case KeyDisableInactiveMessages:
|
||||
cinfo.DisableInactiveMessages = value.(bool)
|
||||
case KeyDownloadAsPostMessage:
|
||||
cinfo.DownloadAsPostMessage = value.(bool)
|
||||
case KeySaveAsPostmessage:
|
||||
cinfo.SaveAsPostmessage = value.(bool)
|
||||
case KeyEnableOwnerTermination:
|
||||
cinfo.EnableOwnerTermination = value.(bool)
|
||||
case KeyUserExtraInfo:
|
||||
cinfo.UserExtraInfo = value.(*UserExtraInfo)
|
||||
//UserPrivateInfo -> requires definition, currently not used
|
||||
case KeyWatermarkText:
|
||||
cinfo.WatermarkText = value.(string)
|
||||
case KeyIsAdminUser:
|
||||
cinfo.IsAdminUser = value.(bool)
|
||||
case KeyIsAnonymousUser:
|
||||
cinfo.IsAnonymousUser = value.(bool)
|
||||
|
||||
case KeyEnableShare:
|
||||
cinfo.EnableShare = value.(bool)
|
||||
case KeyHideUserList:
|
||||
cinfo.HideUserList = value.(string)
|
||||
case KeySupportsLocks:
|
||||
cinfo.SupportsLocks = value.(bool)
|
||||
case KeySupportsRename:
|
||||
cinfo.SupportsRename = value.(bool)
|
||||
case KeyUserCanRename:
|
||||
cinfo.UserCanRename = value.(bool)
|
||||
case KeyBreadcrumbDocName:
|
||||
cinfo.BreadcrumbDocName = value.(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetTarget will always return "Collabora"
|
||||
func (cinfo *Collabora) GetTarget() string {
|
||||
return "Collabora"
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package fileinfo
|
||||
|
||||
// FileInfo contains the properties of the file.
|
||||
// Some properties refer to capabilities in the WOPI client, and capabilities
|
||||
// that the WOPI server has.
|
||||
//
|
||||
// Specific implementations must allow json-encoding of their relevant
|
||||
// properties because the object will be marshalled directly
|
||||
type FileInfo interface {
|
||||
// SetProperties will set the properties of this FileInfo.
|
||||
// Keys should match any valid property that the FileInfo implementation
|
||||
// has. If a key doesn't match any property, it must be ignored.
|
||||
// The values must have its matching type for the target property,
|
||||
// otherwise panics might happen.
|
||||
//
|
||||
// This method should help to reduce the friction of using different
|
||||
// implementations with different properties. You can use the same map
|
||||
// for all the implementations knowing that the relevant properties for
|
||||
// each implementation will be set.
|
||||
SetProperties(props map[string]any)
|
||||
|
||||
// GetTarget will return the target implementation (OnlyOffice, Collabora...).
|
||||
// This will help to identify the implementation we're using in an easy way.
|
||||
// Note that the returned value must be unique among all the implementations
|
||||
GetTarget() string
|
||||
}
|
||||
|
||||
// constants that can be used to refer the fileinfo properties for the
|
||||
// SetProperties method of the FileInfo interface
|
||||
const (
|
||||
KeyBaseFileName = "BaseFileName"
|
||||
KeyOwnerID = "OwnerId"
|
||||
KeySize = "Size"
|
||||
KeyUserID = "UserID"
|
||||
KeyVersion = "Version"
|
||||
|
||||
KeySupportedShareURLTypes = "SupportedShareURLTypes"
|
||||
KeySupportsCobalt = "SupportsCobalt"
|
||||
KeySupportsContainers = "SupportsContainers"
|
||||
KeySupportsDeleteFile = "SupportsDeleteFile"
|
||||
KeySupportsEcosystem = "SupportsEcosystem"
|
||||
KeySupportsExtendedLockLength = "SupportsExtendedLockLength"
|
||||
KeySupportsFolders = "SupportsFolders"
|
||||
//KeySupportsGetFileWopiSrc = "SupportsGetFileWopiSrc" // wopivalidator is complaining and the property isn't used for now -> commented
|
||||
KeySupportsGetLock = "SupportsGetLock"
|
||||
KeySupportsLocks = "SupportsLocks"
|
||||
KeySupportsRename = "SupportsRename"
|
||||
KeySupportsUpdate = "SupportsUpdate"
|
||||
KeySupportsUserInfo = "SupportsUserInfo"
|
||||
|
||||
KeyIsAnonymousUser = "IsAnonymousUser"
|
||||
KeyIsEduUser = "IsEduUser"
|
||||
KeyIsAdminUser = "IsAdminUser"
|
||||
KeyLicenseCheckForEditIsEnabled = "LicenseCheckForEditIsEnabled"
|
||||
KeyUserFriendlyName = "UserFriendlyName"
|
||||
KeyUserInfo = "UserInfo"
|
||||
|
||||
KeyReadOnly = "ReadOnly"
|
||||
KeyRestrictedWebViewOnly = "RestrictedWebViewOnly"
|
||||
KeyUserCanAttend = "UserCanAttend"
|
||||
KeyUserCanNotWriteRelative = "UserCanNotWriteRelative"
|
||||
KeyUserCanPresent = "UserCanPresent"
|
||||
KeyUserCanRename = "UserCanRename"
|
||||
KeyUserCanWrite = "UserCanWrite"
|
||||
|
||||
KeyCloseURL = "CloseURL"
|
||||
KeyDownloadURL = "DownloadURL"
|
||||
KeyFileEmbedCommandURL = "FileEmbedCommandURL"
|
||||
KeyFileSharingURL = "FileSharingURL"
|
||||
KeyFileURL = "FileURL"
|
||||
KeyFileVersionURL = "FileVersionURL"
|
||||
KeyHostEditURL = "HostEditURL"
|
||||
KeyHostEmbeddedViewURL = "HostEmbeddedViewURL"
|
||||
KeyHostViewURL = "HostViewURL"
|
||||
KeySignoutURL = "SignoutURL"
|
||||
|
||||
KeyAllowAdditionalMicrosoftServices = "AllowAdditionalMicrosoftServices"
|
||||
KeyAllowErrorReportPrompt = "AllowErrorReportPrompt"
|
||||
KeyAllowExternalMarketplace = "AllowExternalMarketplace"
|
||||
KeyClientThrottlingProtection = "ClientThrottlingProtection"
|
||||
KeyCloseButtonClosesWindow = "CloseButtonClosesWindow"
|
||||
KeyCopyPasteRestrictions = "CopyPasteRestrictions"
|
||||
KeyDisablePrint = "DisablePrint"
|
||||
KeyDisableTranslation = "DisableTranslation"
|
||||
KeyFileExtension = "FileExtension"
|
||||
KeyFileNameMaxLength = "FileNameMaxLength"
|
||||
KeyLastModifiedTime = "LastModifiedTime"
|
||||
KeyRequestedCallThrottling = "RequestedCallThrottling"
|
||||
KeySHA256 = "SHA256"
|
||||
KeySharingStatus = "SharingStatus"
|
||||
KeyTemporarilyNotWritable = "TemporarilyNotWritable"
|
||||
//KeyUniqueContentId = "UniqueContentId" // From microsoft docs: Not supported in CSPP -> commented
|
||||
|
||||
KeyBreadcrumbBrandName = "BreadcrumbBrandName"
|
||||
KeyBreadcrumbBrandURL = "BreadcrumbBrandURL"
|
||||
KeyBreadcrumbDocName = "BreadcrumbDocName"
|
||||
KeyBreadcrumbFolderName = "BreadcrumbFolderName"
|
||||
KeyBreadcrumbFolderURL = "BreadcrumbFolderUrl"
|
||||
|
||||
// Collabora (non-dupped) properties below
|
||||
|
||||
KeyPostMessageOrigin = "PostMessageOrigin"
|
||||
KeyTemplateSource = "TemplateSource"
|
||||
|
||||
KeyEnableInsertRemoteImage = "EnableInsertRemoteImage"
|
||||
KeyEnableInsertRemoteFile = "EnableInsertRemoteFile"
|
||||
KeyEnableRemoteLinkPicker = "EnableRemoteLinkPicker"
|
||||
KeyDisableInsertLocalImage = "DisableInsertLocalImage"
|
||||
KeyHidePrintOption = "HidePrintOption"
|
||||
KeyHideSaveOption = "HideSaveOption"
|
||||
KeyHideExportOption = "HideExportOption"
|
||||
KeyDisableExport = "DisableExport"
|
||||
KeyDisableCopy = "DisableCopy"
|
||||
KeyDisableInactiveMessages = "DisableInactiveMessages"
|
||||
KeyDownloadAsPostMessage = "DownloadAsPostMessage"
|
||||
KeySaveAsPostmessage = "SaveAsPostmessage"
|
||||
KeyEnableOwnerTermination = "EnableOwnerTermination"
|
||||
KeyUserExtraInfo = "UserExtraInfo"
|
||||
//KeyUserPrivateInfo -> requires definition, currently not used
|
||||
KeyWatermarkText = "WatermarkText"
|
||||
|
||||
KeyEnableShare = "EnableShare"
|
||||
KeyHideUserList = "HideUserList"
|
||||
|
||||
// OnlyOffice (non-dupped) properties below
|
||||
|
||||
KeyClosePostMessage = "ClosePostMessage"
|
||||
KeyEditModePostMessage = "EditModePostMessage"
|
||||
KeyEditNotificationPostMessage = "EditNotificationPostMessage"
|
||||
KeyFileSharingPostMessage = "FileSharingPostMessage"
|
||||
KeyFileVersionPostMessage = "FileVersionPostMessage"
|
||||
|
||||
KeyUserCanReview = "UserCanReview"
|
||||
KeySupportsReviewing = "SupportsReviewing"
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
package fileinfo
|
||||
|
||||
// Microsoft fileInfo properties
|
||||
//
|
||||
// Microsoft WOPI check file info specification:
|
||||
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo
|
||||
type Microsoft struct {
|
||||
//
|
||||
// Required response properties
|
||||
//
|
||||
|
||||
// The string name of the file, including extension, without a path. Used for display in user interface (UI), and determining the extension of the file.
|
||||
BaseFileName string `json:"BaseFileName,omitempty"`
|
||||
//A string that uniquely identifies the owner of the file. In most cases, the user who uploaded or created the file should be considered the owner.
|
||||
OwnerID string `json:"OwnerId,omitempty"`
|
||||
// The size of the file in bytes, expressed as a long, a 64-bit signed integer.
|
||||
Size int64 `json:"Size"`
|
||||
// A string value uniquely identifying the user currently accessing the file.
|
||||
UserID string `json:"UserId,omitempty"`
|
||||
// The current version of the file based on the server’s file version schema, as a string. This value must change when the file changes, and version values must never repeat for a given file.
|
||||
Version string `json:"Version,omitempty"`
|
||||
|
||||
//
|
||||
// WOPI host capabilities properties
|
||||
//
|
||||
|
||||
// An array of strings containing the Share URL types supported by the host.
|
||||
SupportedShareURLTypes []string `json:"SupportedShareUrlTypes,omitempty"`
|
||||
// A Boolean value that indicates that the host supports the following WOPI operations: ExecuteCellStorageRequest, ExecuteCellStorageRelativeRequest
|
||||
SupportsCobalt bool `json:"SupportsCobalt"`
|
||||
// A Boolean value that indicates that the host supports the following WOPI operations: CheckContainerInfo, CreateChildContainer, CreateChildFile, DeleteContainer, DeleteFile, EnumerateAncestors (containers), EnumerateAncestors (files), EnumerateChildren (containers), GetEcosystem (containers), RenameContainer
|
||||
SupportsContainers bool `json:"SupportsContainers"`
|
||||
// A Boolean value that indicates that the host supports the DeleteFile operation.
|
||||
SupportsDeleteFile bool `json:"SupportsDeleteFile"`
|
||||
// A Boolean value that indicates that the host supports the following WOPI operations: CheckEcosystem, GetEcosystem (containers), GetEcosystem (files), GetRootContainer (ecosystem)
|
||||
SupportsEcosystem bool `json:"SupportsEcosystem"`
|
||||
// A Boolean value that indicates that the host supports lock IDs up to 1024 ASCII characters long. If not provided, WOPI clients will assume that lock IDs are limited to 256 ASCII characters.
|
||||
SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"`
|
||||
// A Boolean value that indicates that the host supports the following WOPI operations: CheckFolderInfo, EnumerateChildren (folders), DeleteFile
|
||||
SupportsFolders bool `json:"SupportsFolders"`
|
||||
// A Boolean value that indicates that the host supports the GetFileWopiSrc (ecosystem) operation.
|
||||
//SupportsGetFileWopiSrc bool `json:"SupportsGetFileWopiSrc"` // wopivalidator is complaining and the property isn't used for now -> commented
|
||||
// A Boolean value that indicates that the host supports the GetLock operation.
|
||||
SupportsGetLock bool `json:"SupportsGetLock"`
|
||||
// A Boolean value that indicates that the host supports the following WOPI operations: Lock, Unlock, RefreshLock, UnlockAndRelock operations for this file.
|
||||
SupportsLocks bool `json:"SupportsLocks"`
|
||||
// A Boolean value that indicates that the host supports the RenameFile operation.
|
||||
SupportsRename bool `json:"SupportsRename"`
|
||||
// A Boolean value that indicates that the host supports the following WOPI operations: PutFile, PutRelativeFile
|
||||
SupportsUpdate bool `json:"SupportsUpdate"` // whether "Putfile" and "PutRelativeFile" work
|
||||
// A Boolean value that indicates that the host supports the PutUserInfo operation.
|
||||
SupportsUserInfo bool `json:"SupportsUserInfo"`
|
||||
|
||||
//
|
||||
// User metadata properties
|
||||
//
|
||||
|
||||
// A Boolean value indicating whether the user is authenticated with the host or not. Hosts should always set this to true for unauthenticated users, so that clients are aware that the user is anonymous. When setting this to true, hosts can choose to omit the UserId property, but must still set the OwnerId property.
|
||||
IsAnonymousUser bool `json:"IsAnonymousUser,omitempty"`
|
||||
// A Boolean value indicating whether the user is an education user or not.
|
||||
IsEduUser bool `json:"IsEduUser,omitempty"`
|
||||
// A Boolean value indicating whether the user is a business user or not.
|
||||
LicenseCheckForEditIsEnabled bool `json:"LicenseCheckForEditIsEnabled"`
|
||||
// A string that is the name of the user, suitable for displaying in UI.
|
||||
UserFriendlyName string `json:"UserFriendlyName,omitempty"`
|
||||
// A string value containing information about the user. This string can be passed from a WOPI client to the host by means of a PutUserInfo operation. If the host has a UserInfo string for the user, they must include it in this property. See the PutUserInfo documentation for more details.
|
||||
UserInfo string `json:"UserInfo,omitempty"`
|
||||
|
||||
//
|
||||
// User permission properties
|
||||
//
|
||||
|
||||
// A Boolean value that indicates that, for this user, the file cannot be changed.
|
||||
ReadOnly bool `json:"ReadOnly"`
|
||||
// A Boolean value that indicates that the WOPI client should restrict what actions the user can perform on the file. The behavior of this property is dependent on the WOPI client.
|
||||
RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"`
|
||||
// A Boolean value that indicates that the user has permission to view a broadcast of this file.
|
||||
UserCanAttend bool `json:"UserCanAttend"`
|
||||
// A Boolean value that indicates the user does not have sufficient permission to create new files on the WOPI server. Setting this to true tells the WOPI client that calls to PutRelativeFile will fail for this user on the current file.
|
||||
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
|
||||
// A Boolean value that indicates that the user has permission to broadcast this file to a set of users who have permission to broadcast or view a broadcast of the current file.
|
||||
UserCanPresent bool `json:"UserCanPresent"`
|
||||
// A Boolean value that indicates the user has permission to rename the current file.
|
||||
UserCanRename bool `json:"UserCanRename"`
|
||||
// A Boolean value that indicates that the user has permission to alter the file. Setting this to true tells the WOPI client that it can call PutFile on behalf of the user.
|
||||
UserCanWrite bool `json:"UserCanWrite"`
|
||||
|
||||
//
|
||||
// File URL properties
|
||||
//
|
||||
|
||||
// A URI to a web page that the WOPI client should navigate to when the application closes, or in the event of an unrecoverable error.
|
||||
CloseURL string `json:"CloseUrl,omitempty"`
|
||||
// A user-accessible URI to the file intended to allow the user to download a copy of the file.
|
||||
DownloadURL string `json:"DownloadUrl,omitempty"`
|
||||
// A URI to a location that allows the user to create an embeddable URI to the file.
|
||||
FileEmbedCommandURL string `json:"FileEmbedCommandUrl,omitempty"`
|
||||
// A URI to a location that allows the user to share the file.
|
||||
FileSharingURL string `json:"FileSharingUrl,omitempty"`
|
||||
// A URI to the file location that the WOPI client uses to get the file. If this is provided, the WOPI client may use this URI to get the file instead of a GetFile request. A host might set this property if it is easier or provides better performance to serve files from a different domain than the one handling standard WOPI requests. WOPI clients must not add or remove parameters from the URL; no other parameters, including the access token, should be appended to the FileUrl before it is used.
|
||||
FileURL string `json:"FileUrl,omitempty"`
|
||||
// A URI to a location that allows the user to view the version history for the file.
|
||||
FileVersionURL string `json:"FileVersionUrl,omitempty"`
|
||||
// A URI to a host page that loads the edit WOPI action.
|
||||
HostEditURL string `json:"HostEditUrl,omitempty"`
|
||||
// A URI to a web page that provides access to a viewing experience for the file that can be embedded in another HTML page. This is typically a URI to a host page that loads the embedview WOPI action.
|
||||
HostEmbeddedViewURL string `json:"HostEmbeddedViewUrl,omitempty"`
|
||||
// A URI to a host page that loads the view WOPI action. This URL is used by Office Online to navigate between view and edit mode.
|
||||
HostViewURL string `json:"HostViewUrl,omitempty"`
|
||||
// A URI that will sign the current user out of the host’s authentication system.
|
||||
SignoutURL string `json:"SignoutUrl,omitempty"`
|
||||
|
||||
//
|
||||
// Miscellaneous properties
|
||||
//
|
||||
|
||||
// A Boolean value that indicates a WOPI client may connect to Microsoft services to provide end-user functionality.
|
||||
AllowAdditionalMicrosoftServices bool `json:"AllowAdditionalMicrosoftServices"`
|
||||
// A Boolean value that indicates that in the event of an error, the WOPI client is permitted to prompt the user for permission to collect a detailed report about their specific error. The information gathered could include the user’s file and other session-specific state.
|
||||
AllowErrorReportPrompt bool `json:"AllowErrorReportPrompt,omitempty"`
|
||||
// A Boolean value that indicates a WOPI client may allow connections to external services referenced in the file (for example, a marketplace of embeddable JavaScript apps).
|
||||
AllowExternalMarketplace bool `json:"AllowExternalMarketplace"`
|
||||
// A string value offering guidance to the WOPI client as to how to differentiate client throttling behaviors between the user and documents combinations from the WOPI host.
|
||||
ClientThrottlingProtection string `json:"ClientThrottlingProtection,omitempty"`
|
||||
// A Boolean value that indicates the WOPI client should close the window or tab when the user activates any Close UI in the WOPI client.
|
||||
CloseButtonClosesWindow bool `json:"CloseButtonClosesWindow,omitempty"`
|
||||
// A string value indicating whether the WOPI client should disable Copy and Paste functionality within the application. The default is to permit all Copy and Paste functionality, i.e. the setting has no effect.
|
||||
CopyPasteRestrictions string `json:"CopyPasteRestrictions,omitempty"`
|
||||
// A Boolean value that indicates the WOPI client should disable all print functionality.
|
||||
DisablePrint bool `json:"DisablePrint"`
|
||||
// A Boolean value that indicates the WOPI client should disable all machine translation functionality.
|
||||
DisableTranslation bool `json:"DisableTranslation"`
|
||||
// A string value representing the file extension for the file. This value must begin with a .. If provided, WOPI clients will use this value as the file extension. Otherwise the extension will be parsed from the BaseFileName.
|
||||
FileExtension string `json:"FileExtension,omitempty"`
|
||||
// An integer value that indicates the maximum length for file names that the WOPI host supports, excluding the file extension. The default value is 250. Note that WOPI clients will use this default value if the property is omitted or if it is explicitly set to 0.
|
||||
FileNameMaxLength int `json:"FileNameMaxLength,omitempty"`
|
||||
// A string that represents the last time that the file was modified. This time must always be a must be a UTC time, and must be formatted in ISO 8601 round-trip format. For example, "2009-06-15T13:45:30.0000000Z".
|
||||
LastModifiedTime string `json:"LastModifiedTime,omitempty"`
|
||||
// A string value indicating whether the WOPI host is experiencing capacity problems and would like to reduce the frequency at which the WOPI clients make calls to the host
|
||||
RequestedCallThrottling string `json:"RequestedCallThrottling,omitempty"`
|
||||
// A 256 bit SHA-2-encoded [FIPS 180-2] hash of the file contents, as a Base64-encoded string. Used for caching purposes in WOPI clients.
|
||||
SHA256 string `json:"SHA256,omitempty"`
|
||||
// A string value indicating whether the current document is shared with other users. The value can change upon adding or removing permissions to other users. Clients should use this value to help decide when to enable collaboration features as a document must be Shared in order to multi-user collaboration on the document.
|
||||
SharingStatus string `json:"SharingStatus,omitempty"`
|
||||
// A Boolean value that indicates that if host is temporarily unable to process writes on a file
|
||||
TemporarilyNotWritable bool `json:"TemporarilyNotWritable,omitempty"`
|
||||
// In special cases, a host may choose to not provide a SHA256, but still have some mechanism for identifying that two different files contain the same content in the same manner as the SHA256 is used. This string value can be provided rather than a SHA256 value if and only if the host can guarantee that two different files with the same content will have the same UniqueContentId value.
|
||||
//UniqueContentId string `json:"UniqueContentId,omitempty"` // From microsoft docs: Not supported in CSPP -> commented
|
||||
|
||||
//
|
||||
// Breadcrumb properties
|
||||
//
|
||||
|
||||
// A string that indicates the brand name of the host.
|
||||
BreadcrumbBrandName string `json:"BreadcrumbBrandName,omitempty"`
|
||||
// A URI to a web page that the WOPI client should navigate to when the user clicks on UI that displays BreadcrumbBrandName.
|
||||
BreadcrumbBrandURL string `json:"BreadcrumbBrandUrl,omitempty"`
|
||||
// A string that indicates the name of the file. If this is not provided, WOPI clients may use the BaseFileName value.
|
||||
BreadcrumbDocName string `json:"BreadcrumbDocName,omitempty"`
|
||||
// A string that indicates the name of the container that contains the file.
|
||||
BreadcrumbFolderName string `json:"BreadcrumbFolderName,omitempty"`
|
||||
// A URI to a web page that the WOPI client should navigate to when the user clicks on UI that displays BreadcrumbFolderName.
|
||||
BreadcrumbFolderURL string `json:"BreadcrumbFolderUrl,omitempty"`
|
||||
}
|
||||
|
||||
// SetProperties will set the file properties for the Microsoft implementation.
|
||||
func (minfo *Microsoft) SetProperties(props map[string]any) {
|
||||
for key, value := range props {
|
||||
switch key {
|
||||
case KeyBaseFileName:
|
||||
minfo.BaseFileName = value.(string)
|
||||
case KeyOwnerID:
|
||||
minfo.OwnerID = value.(string)
|
||||
case KeySize:
|
||||
minfo.Size = value.(int64)
|
||||
case KeyUserID:
|
||||
minfo.UserID = value.(string)
|
||||
case KeyVersion:
|
||||
minfo.Version = value.(string)
|
||||
|
||||
case KeySupportedShareURLTypes:
|
||||
minfo.SupportedShareURLTypes = value.([]string)
|
||||
case KeySupportsCobalt:
|
||||
minfo.SupportsCobalt = value.(bool)
|
||||
case KeySupportsContainers:
|
||||
minfo.SupportsContainers = value.(bool)
|
||||
case KeySupportsDeleteFile:
|
||||
minfo.SupportsDeleteFile = value.(bool)
|
||||
case KeySupportsEcosystem:
|
||||
minfo.SupportsEcosystem = value.(bool)
|
||||
case KeySupportsExtendedLockLength:
|
||||
minfo.SupportsExtendedLockLength = value.(bool)
|
||||
case KeySupportsFolders:
|
||||
minfo.SupportsFolders = value.(bool)
|
||||
//SupportsGetFileWopiSrc bool `json:"SupportsGetFileWopiSrc"` // wopivalidator is complaining and the property isn't used for now -> commented
|
||||
case KeySupportsGetLock:
|
||||
minfo.SupportsGetLock = value.(bool)
|
||||
case KeySupportsLocks:
|
||||
minfo.SupportsLocks = value.(bool)
|
||||
case KeySupportsRename:
|
||||
minfo.SupportsRename = value.(bool)
|
||||
case KeySupportsUpdate:
|
||||
minfo.SupportsUpdate = value.(bool)
|
||||
case KeySupportsUserInfo:
|
||||
minfo.SupportsUserInfo = value.(bool)
|
||||
|
||||
case KeyIsAnonymousUser:
|
||||
minfo.IsAnonymousUser = value.(bool)
|
||||
case KeyIsEduUser:
|
||||
minfo.IsEduUser = value.(bool)
|
||||
case KeyLicenseCheckForEditIsEnabled:
|
||||
minfo.LicenseCheckForEditIsEnabled = value.(bool)
|
||||
case KeyUserFriendlyName:
|
||||
minfo.UserFriendlyName = value.(string)
|
||||
case KeyUserInfo:
|
||||
minfo.UserInfo = value.(string)
|
||||
|
||||
case KeyReadOnly:
|
||||
minfo.ReadOnly = value.(bool)
|
||||
case KeyRestrictedWebViewOnly:
|
||||
minfo.RestrictedWebViewOnly = value.(bool)
|
||||
case KeyUserCanAttend:
|
||||
minfo.UserCanAttend = value.(bool)
|
||||
case KeyUserCanNotWriteRelative:
|
||||
minfo.UserCanNotWriteRelative = value.(bool)
|
||||
case KeyUserCanPresent:
|
||||
minfo.UserCanPresent = value.(bool)
|
||||
case KeyUserCanRename:
|
||||
minfo.UserCanRename = value.(bool)
|
||||
case KeyUserCanWrite:
|
||||
minfo.UserCanWrite = value.(bool)
|
||||
|
||||
case KeyCloseURL:
|
||||
minfo.CloseURL = value.(string)
|
||||
case KeyDownloadURL:
|
||||
minfo.DownloadURL = value.(string)
|
||||
case KeyFileEmbedCommandURL:
|
||||
minfo.FileEmbedCommandURL = value.(string)
|
||||
case KeyFileSharingURL:
|
||||
minfo.FileSharingURL = value.(string)
|
||||
case KeyFileURL:
|
||||
minfo.FileURL = value.(string)
|
||||
case KeyFileVersionURL:
|
||||
minfo.FileVersionURL = value.(string)
|
||||
case KeyHostEditURL:
|
||||
minfo.HostEditURL = value.(string)
|
||||
case KeyHostEmbeddedViewURL:
|
||||
minfo.HostEmbeddedViewURL = value.(string)
|
||||
case KeyHostViewURL:
|
||||
minfo.HostViewURL = value.(string)
|
||||
case KeySignoutURL:
|
||||
minfo.SignoutURL = value.(string)
|
||||
|
||||
case KeyAllowAdditionalMicrosoftServices:
|
||||
minfo.AllowAdditionalMicrosoftServices = value.(bool)
|
||||
case KeyAllowErrorReportPrompt:
|
||||
minfo.AllowErrorReportPrompt = value.(bool)
|
||||
case KeyAllowExternalMarketplace:
|
||||
minfo.AllowExternalMarketplace = value.(bool)
|
||||
case KeyClientThrottlingProtection:
|
||||
minfo.ClientThrottlingProtection = value.(string)
|
||||
case KeyCloseButtonClosesWindow:
|
||||
minfo.CloseButtonClosesWindow = value.(bool)
|
||||
case KeyCopyPasteRestrictions:
|
||||
minfo.CopyPasteRestrictions = value.(string)
|
||||
case KeyDisablePrint:
|
||||
minfo.DisablePrint = value.(bool)
|
||||
case KeyDisableTranslation:
|
||||
minfo.DisableTranslation = value.(bool)
|
||||
case KeyFileExtension:
|
||||
minfo.FileExtension = value.(string)
|
||||
case KeyFileNameMaxLength:
|
||||
minfo.FileNameMaxLength = value.(int)
|
||||
case KeyLastModifiedTime:
|
||||
minfo.LastModifiedTime = value.(string)
|
||||
case KeyRequestedCallThrottling:
|
||||
minfo.RequestedCallThrottling = value.(string)
|
||||
case KeySHA256:
|
||||
minfo.SHA256 = value.(string)
|
||||
case KeySharingStatus:
|
||||
minfo.SharingStatus = value.(string)
|
||||
case KeyTemporarilyNotWritable:
|
||||
minfo.TemporarilyNotWritable = value.(bool)
|
||||
|
||||
case KeyBreadcrumbBrandName:
|
||||
minfo.BreadcrumbBrandName = value.(string)
|
||||
case KeyBreadcrumbBrandURL:
|
||||
minfo.BreadcrumbBrandURL = value.(string)
|
||||
case KeyBreadcrumbDocName:
|
||||
minfo.BreadcrumbDocName = value.(string)
|
||||
case KeyBreadcrumbFolderName:
|
||||
minfo.BreadcrumbFolderName = value.(string)
|
||||
case KeyBreadcrumbFolderURL:
|
||||
minfo.BreadcrumbFolderURL = value.(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetTarget will always return "Microsoft"
|
||||
func (minfo *Microsoft) GetTarget() string {
|
||||
return "Microsoft"
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package fileinfo
|
||||
|
||||
// OnlyOffice fileInfo properties
|
||||
//
|
||||
// OnlyOffice WOPI check file info specification:
|
||||
// https://api.onlyoffice.com/editors/wopi/restapi/checkfileinfo
|
||||
type OnlyOffice struct {
|
||||
//
|
||||
// Required response properties
|
||||
//
|
||||
|
||||
// copied from MS WOPI
|
||||
BaseFileName string `json:"BaseFileName,omitempty"`
|
||||
// copied from MS WOPI
|
||||
Version string `json:"Version,omitempty"`
|
||||
|
||||
//
|
||||
// Breadcrumb properties
|
||||
//
|
||||
|
||||
// copied from MS WOPI
|
||||
BreadcrumbBrandName string `json:"BreadcrumbBrandName,omitempty"`
|
||||
// copied from MS WOPI
|
||||
BreadcrumbBrandURL string `json:"BreadcrumbBrandUrl,omitempty"`
|
||||
// copied from MS WOPI
|
||||
BreadcrumbDocName string `json:"BreadcrumbDocName,omitempty"`
|
||||
// copied from MS WOPI
|
||||
BreadcrumbFolderName string `json:"BreadcrumbFolderName,omitempty"`
|
||||
// copied from MS WOPI
|
||||
BreadcrumbFolderURL string `json:"BreadcrumbFolderUrl,omitempty"`
|
||||
|
||||
//
|
||||
// PostMessage properties
|
||||
//
|
||||
|
||||
// Specifies if the WOPI client should notify the WOPI server in case the user closes the rendering or editing client currently using this file. The host expects to receive the UI_Close PostMessage when the Close UI in the online office is activated.
|
||||
ClosePostMessage bool `json:"ClosePostMessage,omitempty"`
|
||||
// Specifies if the WOPI client should notify the WOPI server in case the user tries to edit a file. The host expects to receive the UI_Edit PostMessage when the Edit UI in the online office is activated.
|
||||
EditModePostMessage bool `json:"EditModePostMessage,omitempty"`
|
||||
// Specifies if the WOPI client should notify the WOPI server in case the user tries to edit a file. The host expects to receive the Edit_Notification PostMessage.
|
||||
EditNotificationPostMessage bool `json:"EditNotificationPostMessage,omitempty"`
|
||||
// Specifies if the WOPI client should notify the WOPI server in case the user tries to share a file. The host expects to receive the UI_Sharing PostMessage when the Share UI in the online office is activated.
|
||||
FileSharingPostMessage bool `json:"FileSharingPostMessage,omitempty"`
|
||||
// Specifies if the WOPI client will notify the WOPI server in case the user tries to navigate to the previous file version. The host expects to receive the UI_FileVersions PostMessage when the Previous Versions UI in the online office is activated.
|
||||
FileVersionPostMessage bool `json:"FileVersionPostMessage,omitempty"`
|
||||
// A domain that the WOPI client must use as the targetOrigin parameter when sending messages as described in [W3C-HTML5WEBMSG].
|
||||
// copied from collabora WOPI
|
||||
PostMessageOrigin string `json:"PostMessageOrigin,omitempty"`
|
||||
|
||||
//
|
||||
// File URL properties
|
||||
//
|
||||
|
||||
// copied from MS WOPI
|
||||
CloseURL string `json:"CloseUrl,omitempty"`
|
||||
// copied from MS WOPI
|
||||
FileSharingURL string `json:"FileSharingUrl,omitempty"`
|
||||
// copied from MS WOPI
|
||||
FileVersionURL string `json:"FileVersionUrl,omitempty"`
|
||||
// copied from MS WOPI
|
||||
HostEditURL string `json:"HostEditUrl,omitempty"`
|
||||
|
||||
//
|
||||
// Miscellaneous properties
|
||||
//
|
||||
|
||||
// Specifies if the WOPI client must disable the Copy and Paste functionality within the application. By default, all Copy and Paste functionality is enabled, i.e. the setting has no effect. Possible property values:
|
||||
// BlockAll - the Copy and Paste functionality is completely disabled within the application;
|
||||
// CurrentDocumentOnly - the Copy and Paste functionality is enabled but content can only be copied and pasted within the file currently open in the application.
|
||||
// copied from MS WOPI
|
||||
CopyPasteRestrictions string `json:"CopyPasteRestrictions,omitempty"`
|
||||
// copied from MS WOPI
|
||||
DisablePrint bool `json:"DisablePrint"`
|
||||
// copied from MS WOPI
|
||||
FileExtension string `json:"FileExtension,omitempty"`
|
||||
// copied from MS WOPI
|
||||
FileNameMaxLength int `json:"FileNameMaxLength,omitempty"`
|
||||
// copied from MS WOPI
|
||||
LastModifiedTime string `json:"LastModifiedTime,omitempty"`
|
||||
// The ID of file (like the wopi/files/ID) can be a non-existing file. In that case, the file will be created from a template when the template (eg. an OTT file) is specified as TemplateSource in the CheckFileInfo response. The TemplateSource is supposed to be an URL like https://somewhere/accessible/file.ott that is accessible by the Online. For the actual saving of the content, normal PutFile mechanism will be used.
|
||||
TemplateSource string `json:"TemplateSource,omitempty"`
|
||||
|
||||
//
|
||||
// User metadata properties
|
||||
//
|
||||
|
||||
// copied from MS WOPI
|
||||
IsAnonymousUser bool `json:"IsAnonymousUser,omitempty"`
|
||||
// copied from MS WOPI
|
||||
UserFriendlyName string `json:"UserFriendlyName,omitempty"`
|
||||
// copied from MS WOPI
|
||||
UserID string `json:"UserId,omitempty"`
|
||||
|
||||
//
|
||||
// User permissions properties
|
||||
//
|
||||
|
||||
// copied from MS WOPI
|
||||
ReadOnly bool `json:"ReadOnly"`
|
||||
// copied from MS WOPI
|
||||
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
|
||||
// copied from MS WOPI
|
||||
UserCanRename bool `json:"UserCanRename"`
|
||||
// Specifies if the user has permissions to review a file.
|
||||
UserCanReview bool `json:"UserCanReview,omitempty"`
|
||||
// copied from MS WOPI
|
||||
UserCanWrite bool `json:"UserCanWrite"`
|
||||
|
||||
//
|
||||
// Host capabilities properties
|
||||
//
|
||||
|
||||
// copied from MS WOPI
|
||||
SupportsLocks bool `json:"SupportsLocks"`
|
||||
// copied from MS WOPI
|
||||
SupportsRename bool `json:"SupportsRename"`
|
||||
// Specifies if the WOPI server supports the review permission.
|
||||
SupportsReviewing bool `json:"SupportsReviewing,omitempty"`
|
||||
// copied from MS WOPI
|
||||
SupportsUpdate bool `json:"SupportsUpdate"` // whether "Putfile" and "PutRelativeFile" work
|
||||
|
||||
//
|
||||
// Other properties
|
||||
//
|
||||
|
||||
// copied from collabora WOPI
|
||||
EnableInsertRemoteImage bool `json:"EnableInsertRemoteImage,omitempty"`
|
||||
// copied from collabora WOPI
|
||||
HidePrintOption bool `json:"HidePrintOption,omitempty"`
|
||||
}
|
||||
|
||||
// SetProperties will set the file properties for the OnlyOffice implementation.
|
||||
func (oinfo *OnlyOffice) SetProperties(props map[string]any) {
|
||||
for key, value := range props {
|
||||
switch key {
|
||||
case KeyBaseFileName:
|
||||
oinfo.BaseFileName = value.(string)
|
||||
case KeyVersion:
|
||||
oinfo.Version = value.(string)
|
||||
|
||||
case KeyBreadcrumbBrandName:
|
||||
oinfo.BreadcrumbBrandName = value.(string)
|
||||
case KeyBreadcrumbBrandURL:
|
||||
oinfo.BreadcrumbBrandURL = value.(string)
|
||||
case KeyBreadcrumbDocName:
|
||||
oinfo.BreadcrumbDocName = value.(string)
|
||||
case KeyBreadcrumbFolderName:
|
||||
oinfo.BreadcrumbFolderName = value.(string)
|
||||
case KeyBreadcrumbFolderURL:
|
||||
oinfo.BreadcrumbFolderURL = value.(string)
|
||||
|
||||
case KeyClosePostMessage:
|
||||
oinfo.ClosePostMessage = value.(bool)
|
||||
case KeyEditModePostMessage:
|
||||
oinfo.EditModePostMessage = value.(bool)
|
||||
case KeyEditNotificationPostMessage:
|
||||
oinfo.EditNotificationPostMessage = value.(bool)
|
||||
case KeyFileSharingPostMessage:
|
||||
oinfo.FileSharingPostMessage = value.(bool)
|
||||
case KeyFileVersionPostMessage:
|
||||
oinfo.FileVersionPostMessage = value.(bool)
|
||||
case KeyPostMessageOrigin:
|
||||
oinfo.PostMessageOrigin = value.(string)
|
||||
|
||||
case KeyCloseURL:
|
||||
oinfo.CloseURL = value.(string)
|
||||
case KeyFileSharingURL:
|
||||
oinfo.FileSharingURL = value.(string)
|
||||
case KeyFileVersionURL:
|
||||
oinfo.FileVersionURL = value.(string)
|
||||
case KeyHostEditURL:
|
||||
oinfo.HostEditURL = value.(string)
|
||||
|
||||
case KeyCopyPasteRestrictions:
|
||||
oinfo.CopyPasteRestrictions = value.(string)
|
||||
case KeyDisablePrint:
|
||||
oinfo.DisablePrint = value.(bool)
|
||||
case KeyFileExtension:
|
||||
oinfo.FileExtension = value.(string)
|
||||
case KeyFileNameMaxLength:
|
||||
oinfo.FileNameMaxLength = value.(int)
|
||||
case KeyLastModifiedTime:
|
||||
oinfo.LastModifiedTime = value.(string)
|
||||
case KeyTemplateSource:
|
||||
oinfo.TemplateSource = value.(string)
|
||||
case KeyIsAnonymousUser:
|
||||
oinfo.IsAnonymousUser = value.(bool)
|
||||
case KeyUserFriendlyName:
|
||||
oinfo.UserFriendlyName = value.(string)
|
||||
case KeyUserID:
|
||||
oinfo.UserID = value.(string)
|
||||
|
||||
case KeyReadOnly:
|
||||
oinfo.ReadOnly = value.(bool)
|
||||
case KeyUserCanNotWriteRelative:
|
||||
oinfo.UserCanNotWriteRelative = value.(bool)
|
||||
case KeyUserCanRename:
|
||||
oinfo.UserCanRename = value.(bool)
|
||||
case KeyUserCanReview:
|
||||
oinfo.UserCanReview = value.(bool)
|
||||
case KeyUserCanWrite:
|
||||
oinfo.UserCanWrite = value.(bool)
|
||||
|
||||
case KeySupportsLocks:
|
||||
oinfo.SupportsLocks = value.(bool)
|
||||
case KeySupportsRename:
|
||||
oinfo.SupportsRename = value.(bool)
|
||||
case KeySupportsReviewing:
|
||||
oinfo.SupportsReviewing = value.(bool)
|
||||
case KeySupportsUpdate:
|
||||
oinfo.SupportsUpdate = value.(bool)
|
||||
|
||||
case KeyEnableInsertRemoteImage:
|
||||
oinfo.EnableInsertRemoteImage = value.(bool)
|
||||
case KeyHidePrintOption:
|
||||
oinfo.HidePrintOption = value.(bool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetTarget will always return "OnlyOffice"
|
||||
func (oinfo *OnlyOffice) GetTarget() string {
|
||||
return "OnlyOffice"
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/connector/utf7"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/locks"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/rs/zerolog"
|
||||
"go-micro.dev/v4/selector"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderWopiLock string = "X-WOPI-Lock"
|
||||
HeaderWopiOldLock string = "X-WOPI-OldLock"
|
||||
HeaderWopiLockFailureReason string = "X-WOPI-LockFailureReason"
|
||||
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"
|
||||
HeaderWopiRequestedName string = "X-WOPI-RequestedName"
|
||||
HeaderContentLength string = "Content-Length"
|
||||
HeaderContentType string = "Content-Type"
|
||||
HeaderWopiVersion string = "X-WOPI-ItemVersion"
|
||||
)
|
||||
|
||||
// HttpAdapter will adapt the responses from the connector to HTTP.
|
||||
//
|
||||
// The adapter will use the request's context for the connector operations,
|
||||
// this means that the request MUST have a valid WOPI context and a
|
||||
// pre-configured logger. This should have been prepared in the routing.
|
||||
//
|
||||
// All operations are expected to follow the definitions found in
|
||||
// https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/endpoints
|
||||
type HttpAdapter struct {
|
||||
con ConnectorService
|
||||
locks locks.LockParser
|
||||
}
|
||||
|
||||
// NewHttpAdapter will create a new HTTP adapter. A new connector using the
|
||||
// provided gateway API client and configuration will be used in the adapter
|
||||
func NewHttpAdapter(gws pool.Selectable[gatewayv1beta1.GatewayAPIClient], cfg *config.Config, st microstore.Store, graphSelector selector.Selector) *HttpAdapter {
|
||||
httpAdapter := &HttpAdapter{
|
||||
con: NewConnector(
|
||||
NewFileConnector(gws, cfg, st, graphSelector),
|
||||
NewContentConnector(gws, cfg),
|
||||
),
|
||||
}
|
||||
|
||||
// TODO: check if we can get rid of custom log parsing completely
|
||||
httpAdapter.locks = &locks.NoopLockParser{}
|
||||
return httpAdapter
|
||||
}
|
||||
|
||||
// NewHttpAdapterWithConnector will create a new HTTP adapter that will use
|
||||
// the provided connector service
|
||||
func NewHttpAdapterWithConnector(con ConnectorService, l locks.LockParser) *HttpAdapter {
|
||||
return &HttpAdapter{
|
||||
con: con,
|
||||
locks: l,
|
||||
}
|
||||
}
|
||||
|
||||
// GetLock adapts the "GetLock" operation for WOPI.
|
||||
// Only the request's context is needed in order to extract the WOPI context.
|
||||
// The operation's response will be sent through the response writer and
|
||||
// the headers according to the spec
|
||||
func (h *HttpAdapter) GetLock(w http.ResponseWriter, r *http.Request) {
|
||||
fileCon := h.con.GetFileConnector()
|
||||
response, err := fileCon.GetLock(r.Context())
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, response)
|
||||
}
|
||||
|
||||
// Lock adapts the "Lock" and "UnlockAndRelock" operations for WOPI.
|
||||
// The request's context is needed in order to extract the WOPI context. In
|
||||
// addition, the "X-WOPI-Lock" and "X-WOPI-OldLock" headers might be needed"
|
||||
// (check spec)
|
||||
// The operation's response will be sent through the response writer and
|
||||
// the headers according to the spec
|
||||
func (h *HttpAdapter) Lock(w http.ResponseWriter, r *http.Request) {
|
||||
oldLockID := h.locks.ParseLock(r.Header.Get(HeaderWopiOldLock))
|
||||
lockID := h.locks.ParseLock(r.Header.Get(HeaderWopiLock))
|
||||
|
||||
fileCon := h.con.GetFileConnector()
|
||||
response, err := fileCon.Lock(r.Context(), lockID, oldLockID)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, response)
|
||||
}
|
||||
|
||||
// RefreshLock adapts the "RefreshLock" operation for WOPI
|
||||
// The request's context is needed in order to extract the WOPI context. In
|
||||
// addition, the "X-WOPI-Lock" header is needed (check spec).
|
||||
// The lock will be refreshed to last another 30 minutes. The value is
|
||||
// hardcoded
|
||||
// The operation's response will be sent through the response writer and
|
||||
// the headers according to the spec
|
||||
func (h *HttpAdapter) RefreshLock(w http.ResponseWriter, r *http.Request) {
|
||||
lockID := h.locks.ParseLock(r.Header.Get(HeaderWopiLock))
|
||||
|
||||
fileCon := h.con.GetFileConnector()
|
||||
response, err := fileCon.RefreshLock(r.Context(), lockID)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, response)
|
||||
}
|
||||
|
||||
// UnLock adapts the "Unlock" operation for WOPI
|
||||
// The request's context is needed in order to extract the WOPI context. In
|
||||
// addition, the "X-WOPI-Lock" header is needed (check spec).
|
||||
// The operation's response will be sent through the response writer and
|
||||
// the headers according to the spec
|
||||
func (h *HttpAdapter) UnLock(w http.ResponseWriter, r *http.Request) {
|
||||
lockID := h.locks.ParseLock(r.Header.Get(HeaderWopiLock))
|
||||
|
||||
fileCon := h.con.GetFileConnector()
|
||||
response, err := fileCon.UnLock(r.Context(), lockID)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, response)
|
||||
}
|
||||
|
||||
// CheckFileInfo will retrieve the information of the file in json format
|
||||
// Only the request's context is needed in order to extract the WOPI context.
|
||||
// The operation's response will be sent through the response writer and
|
||||
// the headers according to the spec
|
||||
func (h *HttpAdapter) CheckFileInfo(w http.ResponseWriter, r *http.Request) {
|
||||
fileCon := h.con.GetFileConnector()
|
||||
response, err := fileCon.CheckFileInfo(r.Context())
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, response)
|
||||
}
|
||||
|
||||
// GetFile will download the file
|
||||
// Only the request's context is needed in order to extract the WOPI context.
|
||||
// The file's content will be written in the response writer
|
||||
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)
|
||||
}
|
||||
}
|
||||
// status might have been already sent if the file is big enough, but for
|
||||
// small files, the content might still be buffered.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// PutFile will upload the file
|
||||
// The request's context and its body are needed (content length is also
|
||||
// needed)
|
||||
// The operation's response will be sent through the response writer and
|
||||
// the headers according to the spec
|
||||
func (h *HttpAdapter) PutFile(w http.ResponseWriter, r *http.Request) {
|
||||
lockID := h.locks.ParseLock(r.Header.Get(HeaderWopiLock))
|
||||
|
||||
contentCon := h.con.GetContentConnector()
|
||||
response, err := contentCon.PutFile(r.Context(), r.Body, r.ContentLength, lockID)
|
||||
|
||||
if err != nil {
|
||||
var connErr *ConnectorError
|
||||
if errors.As(err, &connErr) && connErr.HttpCodeOut != 0 {
|
||||
response = NewResponse(connErr.HttpCodeOut)
|
||||
} else {
|
||||
response = NewResponse(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, response)
|
||||
}
|
||||
|
||||
// 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).
|
||||
//
|
||||
// The file name must be encoded in utf7. This method will decode the utf7 name
|
||||
// into utf8. The utf8 (not utf7) name must have less than 512 bytes, otherwise
|
||||
// the request will fail.
|
||||
func (h *HttpAdapter) PutRelativeFile(w http.ResponseWriter, r *http.Request) {
|
||||
relativeTarget := r.Header.Get(HeaderWopiRT)
|
||||
suggestedTarget := r.Header.Get(HeaderWopiST)
|
||||
|
||||
if relativeTarget != "" && suggestedTarget != "" {
|
||||
// headers are mutually exclusive
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var response *ConnectorResponse
|
||||
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, putErr = fileCon.PutRelativeFileRelative(r.Context(), h.con.GetContentConnector(), r.Body, r.ContentLength, utf8Target)
|
||||
}
|
||||
|
||||
if putErr != nil {
|
||||
var connErr *ConnectorError
|
||||
if errors.As(putErr, &connErr) && connErr.HttpCodeOut != 0 {
|
||||
response = NewResponse(connErr.HttpCodeOut)
|
||||
} else {
|
||||
response = NewResponse(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, 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()
|
||||
response, err := fileCon.DeleteFile(r.Context(), lockID)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, response)
|
||||
}
|
||||
|
||||
// RenameFile will rename the file. The name might be automatically adjusted.
|
||||
// Note that this method will also send a json body in the response. The
|
||||
// adjusted file name will be returned in the body.
|
||||
//
|
||||
// The file name must be encoded in utf7. This method will decode the utf7 name
|
||||
// into utf8. The utf8 (not utf7) name must have less than 495 bytes, otherwise
|
||||
// the request will fail.
|
||||
func (h *HttpAdapter) RenameFile(w http.ResponseWriter, r *http.Request) {
|
||||
lockID := r.Header.Get(HeaderWopiLock)
|
||||
requestedName := r.Header.Get(HeaderWopiRequestedName)
|
||||
|
||||
utf8Target, decErr := utf7.DecodeString(requestedName)
|
||||
if decErr != nil || len(utf8Target) > 495 { // need space for the possible prefix and the extension
|
||||
w.Header().Set("X-WOPI-InvalidFileNameError", "Filename too long")
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fileCon := h.con.GetFileConnector()
|
||||
response, err := fileCon.RenameFile(r.Context(), lockID, utf8Target)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeConnectorResponse(w, r, response)
|
||||
}
|
||||
|
||||
// GetAvatar proxies the user's avatar from the Graph API.
|
||||
// The WOPI token in the query string provides authentication (validated by
|
||||
// the WopiContextAuthMiddleware). Collabora loads avatars via img.src which
|
||||
// is a plain browser GET — it cannot send auth headers.
|
||||
func (h *HttpAdapter) GetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
logger := zerolog.Ctx(r.Context())
|
||||
userID := chi.URLParam(r, "userID")
|
||||
if userID == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fileCon := h.con.GetFileConnector()
|
||||
response, err := fileCon.GetAvatar(r.Context(), userID)
|
||||
if err != nil {
|
||||
var connErr *ConnectorError
|
||||
if errors.As(err, &connErr) {
|
||||
http.Error(w, http.StatusText(connErr.HttpCodeOut), connErr.HttpCodeOut)
|
||||
} else {
|
||||
logger.Error().Err(err).Msg("GetAvatar: failed to fetch avatar")
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
h.writeConnectorAvatarResponse(w, r, response)
|
||||
}
|
||||
|
||||
func (h *HttpAdapter) writeConnectorAvatarResponse(w http.ResponseWriter, r *http.Request, response *ConnectorResponse) {
|
||||
data, _ := response.Body.([]byte)
|
||||
for key, value := range response.Headers {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
w.WriteHeader(response.Status)
|
||||
written, err := w.Write(data)
|
||||
if err != nil {
|
||||
logger := zerolog.Ctx(r.Context())
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Int("TotalBytes", len(data)).
|
||||
Int("WrittenBytes", written).
|
||||
Msg("failed to write avatar contents in the HTTP response")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpAdapter) writeConnectorResponse(w http.ResponseWriter, r *http.Request, response *ConnectorResponse) {
|
||||
jsonBody := []byte{}
|
||||
if response.Body != nil {
|
||||
var err error
|
||||
jsonBody, err = json.Marshal(response.Body)
|
||||
if err != nil {
|
||||
logger := zerolog.Ctx(r.Context())
|
||||
logger.Error().Err(err).Msg("failed to marshal response")
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set(HeaderContentType, "application/json")
|
||||
w.Header().Set(HeaderContentLength, strconv.Itoa(len(jsonBody)))
|
||||
}
|
||||
|
||||
for key, value := range response.Headers {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
w.WriteHeader(response.Status)
|
||||
|
||||
bytes, err := w.Write(jsonBody)
|
||||
if err != nil {
|
||||
logger := zerolog.Ctx(r.Context())
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Int("TotalBytes", len(jsonBody)).
|
||||
Int("WrittenBytes", bytes).
|
||||
Msg("failed to write contents in the HTTP response")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
package connector_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/services/collaboration/mocks"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/connector"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/connector/fileinfo"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("HttpAdapter", func() {
|
||||
var (
|
||||
fc *mocks.FileConnectorService
|
||||
cc *mocks.ContentConnectorService
|
||||
con *mocks.ConnectorService
|
||||
locks *mocks.LockParser
|
||||
httpAdapter *connector.HttpAdapter
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
fc = &mocks.FileConnectorService{}
|
||||
cc = &mocks.ContentConnectorService{}
|
||||
|
||||
con = &mocks.ConnectorService{}
|
||||
con.On("GetContentConnector").Return(cc)
|
||||
con.On("GetFileConnector").Return(fc)
|
||||
|
||||
locks = &mocks.LockParser{}
|
||||
locks.EXPECT().ParseLock(mock.Anything).RunAndReturn(func(id string) string {
|
||||
return id
|
||||
})
|
||||
|
||||
httpAdapter = connector.NewHttpAdapterWithConnector(con, locks)
|
||||
})
|
||||
|
||||
Describe("GetLock", func() {
|
||||
It("General error", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "POST_LOCK")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("GetLock", mock.Anything).Times(1).Return(nil, errors.New("Something happened"))
|
||||
|
||||
httpAdapter.GetLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
})
|
||||
|
||||
It("File not found", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "POST_LOCK")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("GetLock", mock.Anything).Times(1).Return(connector.NewResponse(404), nil)
|
||||
|
||||
httpAdapter.GetLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(404))
|
||||
})
|
||||
|
||||
It("LockId", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "POST_LOCK")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("GetLock", mock.Anything).Times(1).Return(connector.NewResponseWithLock(200, "zzz111"), nil)
|
||||
|
||||
httpAdapter.GetLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111"))
|
||||
})
|
||||
|
||||
It("Empty LockId", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "POST_LOCK")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("GetLock", mock.Anything).Times(1).Return(connector.NewResponseWithLock(200, ""), nil)
|
||||
|
||||
httpAdapter.GetLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Lock", func() {
|
||||
Describe("Just lock", func() {
|
||||
It("General error", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return(nil, errors.New("Something happened"))
|
||||
|
||||
httpAdapter.Lock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
})
|
||||
|
||||
It("No LockId provided", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("Lock", mock.Anything, "", "").Times(1).Return(connector.NewResponse(400), nil)
|
||||
|
||||
httpAdapter.Lock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(400))
|
||||
})
|
||||
|
||||
It("Conflict", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return(connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil)
|
||||
|
||||
httpAdapter.Lock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(409))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict"))
|
||||
})
|
||||
|
||||
It("Success", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return(
|
||||
connector.NewResponseWithVersionAndLock(
|
||||
200,
|
||||
&typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)},
|
||||
"abc123",
|
||||
), nil)
|
||||
|
||||
httpAdapter.Lock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Unlock and relock", func() {
|
||||
It("General error", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
req.Header.Set(connector.HeaderWopiOldLock, "qwerty")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return(nil, errors.New("Something happened"))
|
||||
|
||||
httpAdapter.Lock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
})
|
||||
|
||||
It("No LockId provided", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "")
|
||||
req.Header.Set(connector.HeaderWopiOldLock, "")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("Lock", mock.Anything, "", "").Times(1).Return(connector.NewResponse(400), nil)
|
||||
|
||||
httpAdapter.Lock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(400))
|
||||
})
|
||||
|
||||
It("Conflict", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
req.Header.Set(connector.HeaderWopiOldLock, "qwerty")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return(connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil)
|
||||
|
||||
httpAdapter.Lock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(409))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict"))
|
||||
})
|
||||
|
||||
It("Success", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
req.Header.Set(connector.HeaderWopiOldLock, "qwerty")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return(
|
||||
connector.NewResponseWithVersionAndLock(
|
||||
200,
|
||||
&typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)},
|
||||
"abc123",
|
||||
), nil)
|
||||
|
||||
httpAdapter.Lock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RefreshLock", func() {
|
||||
It("General error", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "REFRESH_LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return(nil, errors.New("Something happened"))
|
||||
|
||||
httpAdapter.RefreshLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
})
|
||||
|
||||
It("No LockId provided", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "REFRESH_LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("RefreshLock", mock.Anything, "").Times(1).Return(connector.NewResponse(400), nil)
|
||||
|
||||
httpAdapter.RefreshLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(400))
|
||||
})
|
||||
|
||||
It("Conflict", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "REFRESH_LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return(connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil)
|
||||
|
||||
httpAdapter.RefreshLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(409))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict"))
|
||||
})
|
||||
|
||||
It("Success", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "REFRESH_LOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return(
|
||||
connector.NewResponseWithVersionAndLock(
|
||||
200,
|
||||
&typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(5678)},
|
||||
"abc123",
|
||||
), nil)
|
||||
|
||||
httpAdapter.RefreshLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v12345678"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Unlock", func() {
|
||||
It("General error", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "UNLOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("UnLock", mock.Anything, "abc123").Times(1).Return(nil, errors.New("Something happened"))
|
||||
|
||||
httpAdapter.UnLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
})
|
||||
|
||||
It("No LockId provided", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "UNLOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("UnLock", mock.Anything, "").Times(1).Return(connector.NewResponse(400), nil)
|
||||
|
||||
httpAdapter.UnLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(400))
|
||||
})
|
||||
|
||||
It("Conflict", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "UNLOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("UnLock", mock.Anything, "abc123").Times(1).Return(connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil)
|
||||
|
||||
httpAdapter.UnLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(409))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict"))
|
||||
})
|
||||
|
||||
It("Success", func() {
|
||||
req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil)
|
||||
req.Header.Set("X-WOPI-Override", "UNLOCK")
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("UnLock", mock.Anything, "abc123").Times(1).Return(
|
||||
connector.NewResponseWithVersion(&typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)}), nil)
|
||||
|
||||
httpAdapter.UnLock(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CheckFileInfo", func() {
|
||||
It("General error", func() {
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("CheckFileInfo", mock.Anything).Times(1).Return(nil, errors.New("Something happened"))
|
||||
|
||||
httpAdapter.CheckFileInfo(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
})
|
||||
|
||||
It("Not found", func() {
|
||||
// 404 isn't thrown at the moment. Test is here to prove it's possible to
|
||||
// throw any error code
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("CheckFileInfo", mock.Anything).Times(1).Return(connector.NewResponse(404), nil)
|
||||
|
||||
httpAdapter.CheckFileInfo(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(404))
|
||||
})
|
||||
|
||||
It("Success", func() {
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// might need more info, but should be enough for the test
|
||||
finfo := &fileinfo.Microsoft{
|
||||
Size: 123456789,
|
||||
BreadcrumbDocName: "testy.docx",
|
||||
}
|
||||
fc.On("CheckFileInfo", mock.Anything).Times(1).Return(connector.NewResponseSuccessBody(finfo), nil)
|
||||
|
||||
httpAdapter.CheckFileInfo(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
|
||||
jsonInfo, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var responseInfo *fileinfo.Microsoft
|
||||
json.Unmarshal(jsonInfo, &responseInfo)
|
||||
Expect(responseInfo).To(Equal(finfo))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetFile", func() {
|
||||
It("General error", func() {
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
cc.On("GetFile", mock.Anything, mock.Anything).Times(1).Return(errors.New("Something happened"))
|
||||
|
||||
httpAdapter.GetFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
})
|
||||
|
||||
It("Not found", func() {
|
||||
// 404 isn't thrown at the moment. Test is here to prove it's possible to
|
||||
// throw any error code
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
cc.On("GetFile", mock.Anything, mock.Anything).Times(1).Return(connector.NewConnectorError(404, "Not found"))
|
||||
|
||||
httpAdapter.GetFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(404))
|
||||
})
|
||||
|
||||
It("Success", func() {
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
expectedContent := []byte("This is a fake content for a test file")
|
||||
cc.On("GetFile", mock.Anything, mock.Anything).Times(1).Run(func(args mock.Arguments) {
|
||||
w := args.Get(1).(io.Writer)
|
||||
w.Write(expectedContent)
|
||||
}).Return(nil)
|
||||
|
||||
httpAdapter.GetFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
|
||||
content, _ := io.ReadAll(resp.Body)
|
||||
Expect(content).To(Equal(expectedContent))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PutFile", func() {
|
||||
It("General error", func() {
|
||||
contentBody := "this is the new fake content"
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody))
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return(nil, errors.New("Something happened"))
|
||||
|
||||
httpAdapter.PutFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
|
||||
content, _ := io.ReadAll(resp.Body)
|
||||
Expect(content).To(Equal([]byte("")))
|
||||
})
|
||||
|
||||
It("Connector error", func() {
|
||||
contentBody := "this is the new fake content"
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody))
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return(nil, connector.NewConnectorError(500, "Something happened"))
|
||||
|
||||
httpAdapter.PutFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
|
||||
content, _ := io.ReadAll(resp.Body)
|
||||
Expect(content).To(Equal([]byte("")))
|
||||
})
|
||||
|
||||
It("Conflict", func() {
|
||||
contentBody := "this is the new fake content"
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody))
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return(
|
||||
connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil)
|
||||
|
||||
httpAdapter.PutFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(409))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict"))
|
||||
})
|
||||
|
||||
It("Success", func() {
|
||||
contentBody := "this is the new fake content"
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody))
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return(
|
||||
connector.NewResponseWithVersionAndLock(
|
||||
200,
|
||||
&typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)},
|
||||
"abc123",
|
||||
), nil)
|
||||
|
||||
httpAdapter.PutFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAvatar", func() {
|
||||
It("Missing userID returns 400", func() {
|
||||
// No chi route context means chi.URLParam returns ""
|
||||
req := httptest.NewRequest("GET", "/wopi/avatars/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
httpAdapter.GetAvatar(w, req)
|
||||
Expect(w.Result().StatusCode).To(Equal(400))
|
||||
})
|
||||
|
||||
It("ConnectorError propagates the status code", func() {
|
||||
req := httptest.NewRequest("GET", "/wopi/avatars/user-123", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", "user-123")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("GetAvatar", mock.Anything, "user-123").Times(1).
|
||||
Return(nil, connector.NewConnectorError(502, "Bad Gateway"))
|
||||
|
||||
httpAdapter.GetAvatar(w, req)
|
||||
Expect(w.Result().StatusCode).To(Equal(502))
|
||||
})
|
||||
|
||||
It("General error returns 500", func() {
|
||||
req := httptest.NewRequest("GET", "/wopi/avatars/user-123", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", "user-123")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("GetAvatar", mock.Anything, "user-123").Times(1).
|
||||
Return(nil, errors.New("unexpected failure"))
|
||||
|
||||
httpAdapter.GetAvatar(w, req)
|
||||
Expect(w.Result().StatusCode).To(Equal(500))
|
||||
})
|
||||
|
||||
It("Success writes Content-Type, Cache-Control and body", func() {
|
||||
req := httptest.NewRequest("GET", "/wopi/avatars/user-123", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", "user-123")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
avatarData := []byte{0xFF, 0xD8, 0xFF, 0xE0} // fake JPEG bytes
|
||||
fc.On("GetAvatar", mock.Anything, "user-123").Times(1).Return(
|
||||
&connector.ConnectorResponse{
|
||||
Status: 200,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "image/jpeg",
|
||||
"Cache-Control": "public, max-age=300",
|
||||
},
|
||||
Body: avatarData,
|
||||
}, nil)
|
||||
|
||||
httpAdapter.GetAvatar(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get("Content-Type")).To(Equal("image/jpeg"))
|
||||
Expect(resp.Header.Get("Cache-Control")).To(Equal("public, max-age=300"))
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
Expect(body).To(Equal(avatarData))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PutRelativeFile", func() {
|
||||
It("Connector error", func() {
|
||||
contentBody := "this is the new fake content"
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody))
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
req.Header.Set(connector.HeaderWopiRT, "relativetarget.docx")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("PutRelativeFileRelative", mock.Anything, mock.Anything, mock.Anything, int64(len(contentBody)), "relativetarget.docx").Times(1).Return(nil, connector.NewConnectorError(500, "Something happened"))
|
||||
|
||||
httpAdapter.PutRelativeFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(500))
|
||||
|
||||
content, _ := io.ReadAll(resp.Body)
|
||||
Expect(content).To(Equal([]byte("")))
|
||||
})
|
||||
|
||||
It("Conflict", func() {
|
||||
contentBody := "this is the new fake content"
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody))
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
req.Header.Set(connector.HeaderWopiRT, "relativetarget.docx")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("PutRelativeFileRelative", mock.Anything, mock.Anything, mock.Anything, int64(len(contentBody)), "relativetarget.docx").Times(1).Return(
|
||||
connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil)
|
||||
|
||||
httpAdapter.PutRelativeFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(409))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict"))
|
||||
})
|
||||
|
||||
It("Success", func() {
|
||||
contentBody := "this is the new fake content"
|
||||
req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody))
|
||||
req.Header.Set(connector.HeaderWopiLock, "abc123")
|
||||
req.Header.Set(connector.HeaderWopiRT, "relativetarget.docx")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
fc.On("PutRelativeFileRelative", mock.Anything, mock.Anything, mock.Anything, int64(len(contentBody)), "relativetarget.docx").Times(1).Return(
|
||||
connector.NewResponseWithVersionAndLock(
|
||||
200,
|
||||
&typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)},
|
||||
"abc123",
|
||||
), nil)
|
||||
|
||||
httpAdapter.PutRelativeFile(w, req)
|
||||
resp := w.Result()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123"))
|
||||
Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
package utf7
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const (
|
||||
rangeASCII = "ascii"
|
||||
rangeUTF7 = "utf7"
|
||||
)
|
||||
|
||||
// Range represents a range with a lower and upper bounds. The range has a
|
||||
// name for easier identification
|
||||
type Range struct {
|
||||
Name string
|
||||
Low int
|
||||
High int
|
||||
}
|
||||
|
||||
// Range table for ASCII chars belonging to the "direct character" group
|
||||
// for UTF-7
|
||||
var utf7AsciiRT = &unicode.RangeTable{
|
||||
R16: []unicode.Range16{
|
||||
{0x27, 0x29, 1}, // '()
|
||||
{0x2c, 0x2f, 1}, // ,-./
|
||||
{0x30, 0x39, 1}, // 0-9
|
||||
{0x3a, 0x3f, 5}, // :?
|
||||
{0x41, 0x5a, 1}, // A-Z
|
||||
{0x61, 0x7a, 1}, // a-z
|
||||
},
|
||||
}
|
||||
|
||||
// EncodeString will encode the provided UTF-8 string into UTF-7 format
|
||||
//
|
||||
// The encoding process will have the following peculiarities
|
||||
// * Any char outside the "direct characters" will be encoded. This means that
|
||||
// only "a-z", "A-Z", "0-9" and "'(),-.:?" chars will remain intact while the
|
||||
// rest will be encoded. "Optional direct chars" (such as the space) will
|
||||
// be encoded.
|
||||
// * The "+" char will be encoded as any other character, so the result will
|
||||
// be "+ACs-", not "+-"
|
||||
// * Sequences of chars will be encoded as a single group. For example,
|
||||
// "こんにちは" will be encoded as "+MFMwkzBrMGEwbw-"
|
||||
// * All encoded sequences will be enclosed between "+" and "-"
|
||||
func EncodeString(s string) string {
|
||||
runes := []rune(s)
|
||||
|
||||
ranges := analyzeRunes(runes)
|
||||
|
||||
var sb strings.Builder
|
||||
// doubling the number of bytes of the string is usually enough
|
||||
sb.Grow(len(s) * 2)
|
||||
|
||||
for _, v := range ranges {
|
||||
if v.Name == rangeASCII {
|
||||
for _, v := range runes[v.Low:v.High] {
|
||||
sb.WriteRune(v)
|
||||
}
|
||||
} else {
|
||||
utf7Bytes := convertToUtf7(runes[v.Low:v.High])
|
||||
sb.Write(utf7Bytes)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// DecodeString will decode the provided UTF-7 string into UTF-8.
|
||||
//
|
||||
// Any valid UTF-7 string can be decoded, not just the ones returned by
|
||||
// the EncodeString function.
|
||||
// In particular, UTF-7 strings such as "a+-b" or "a+AD0.b" can be decoded
|
||||
// even if the EncodeString function won't generate the corresponding
|
||||
// strings that way.
|
||||
//
|
||||
// Note that this function requires the string to contain only ASCII chars
|
||||
// (as per UTF-7), otherwise an error will be returned.
|
||||
// Illegal char sequences in the encoded parts of the string will also trigger
|
||||
// errors.
|
||||
func DecodeString(s string) (string, error) {
|
||||
byteArray := []byte(s)
|
||||
|
||||
ranges, err := analyzeUtf7(byteArray)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(byteArray))
|
||||
|
||||
for _, v := range ranges {
|
||||
if v.Name == rangeASCII {
|
||||
// if it's an ascii range, just copy it
|
||||
sb.Write(byteArray[v.Low:v.High])
|
||||
} else {
|
||||
// utf7 range
|
||||
utf7ByteRange := byteArray[v.Low:v.High]
|
||||
if err := convertRangeFromUtf7(utf7ByteRange, &sb); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// analyzeRunes will analyze the array of runes and provide a list of ranges.
|
||||
// Each range will be defined by a name and a low and high index. For example,
|
||||
// an "ascii" range could go from index 0 to 12 and "utf7" range from 12 to 25.
|
||||
// The range includes the low index but not the high "[0,12)". This means it
|
||||
// be easily extracted with something like "runes[r.Low:r.High]".
|
||||
//
|
||||
// The list of ranges will only include the following names:
|
||||
// * "ascii" for runes belonging to the "direct characters" group of UTF-7
|
||||
// (those that can be used directly without encoding them). Note that
|
||||
// it won't consider every ASCII character.
|
||||
// * "utf7" for runes that should be encoded for UTF-7.
|
||||
//
|
||||
// As said, runes in the ranges marked as "utf7" should be encoded for UTF-7,
|
||||
// while the others can be used without changes.
|
||||
//
|
||||
// This method is intended to be used to detect which ranges need to be
|
||||
// encoded to UTF-7
|
||||
func analyzeRunes(runes []rune) []Range {
|
||||
ranges := make([]Range, 0)
|
||||
|
||||
var currentRange Range
|
||||
for k, v := range runes {
|
||||
if unicode.Is(utf7AsciiRT, v) {
|
||||
if currentRange.Name == "" {
|
||||
// take control of the current range
|
||||
currentRange.Name = rangeASCII
|
||||
currentRange.Low = k
|
||||
} else if currentRange.Name != rangeASCII {
|
||||
// close current range and open a new one
|
||||
currentRange.High = k
|
||||
ranges = append(ranges, currentRange)
|
||||
currentRange = Range{
|
||||
Name: rangeASCII,
|
||||
Low: k,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if currentRange.Name == "" {
|
||||
// take control of the current range
|
||||
currentRange.Name = rangeUTF7
|
||||
currentRange.Low = k
|
||||
} else if currentRange.Name != rangeUTF7 {
|
||||
// close current range and open a new one
|
||||
currentRange.High = k
|
||||
ranges = append(ranges, currentRange)
|
||||
currentRange = Range{
|
||||
Name: rangeUTF7,
|
||||
Low: k,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// close the last range
|
||||
currentRange.High = len(runes)
|
||||
ranges = append(ranges, currentRange)
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
// analyzeUtf7 will analyze the provided byte sequence and return a list of
|
||||
// ranges.
|
||||
// The byte sequence is considered as UTF-7, so if there is a non-ASCII char
|
||||
// in the sequence, an error will be returned (it isn't a valid UTF-7 string).
|
||||
//
|
||||
// Each returned range will have either "ascii" or "utf7" as name for the range.
|
||||
// "ascii" ranges won't require any change and can be used directly. "utf7"
|
||||
// ranges are encoded in UTF-7 and will require decoding.
|
||||
//
|
||||
// This method is intended to be used to detect which ranges need to be
|
||||
// decoded from UTF-7
|
||||
func analyzeUtf7(byteArray []byte) ([]Range, error) {
|
||||
base64chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
base64ByteArray := []byte(base64chars)
|
||||
|
||||
ranges := make([]Range, 0)
|
||||
|
||||
currentRange := Range{
|
||||
Name: rangeASCII,
|
||||
Low: 0,
|
||||
}
|
||||
|
||||
for k, v := range byteArray {
|
||||
if v > unicode.MaxASCII {
|
||||
return nil, errors.New("Byte sequence contains a non-ASCII char")
|
||||
}
|
||||
|
||||
if v == '+' && currentRange.Name != rangeUTF7 {
|
||||
// start utf7-encoded range
|
||||
currentRange.High = k
|
||||
ranges = append(ranges, currentRange)
|
||||
currentRange = Range{
|
||||
Name: rangeUTF7,
|
||||
Low: k,
|
||||
}
|
||||
} else if v == '-' {
|
||||
// close utf7-encoded range
|
||||
currentRange.High = k + 1 // the '-' char is part of the range
|
||||
ranges = append(ranges, currentRange)
|
||||
currentRange = Range{
|
||||
Name: rangeASCII,
|
||||
Low: k + 1,
|
||||
}
|
||||
} else if bytes.IndexByte(base64ByteArray, v) == -1 && currentRange.Name == rangeUTF7 {
|
||||
// found invalid base64 char, so need to close the utf7 range
|
||||
currentRange.High = k
|
||||
ranges = append(ranges, currentRange)
|
||||
currentRange = Range{
|
||||
Name: rangeASCII,
|
||||
Low: k,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// close the last range
|
||||
currentRange.High = len(byteArray)
|
||||
ranges = append(ranges, currentRange)
|
||||
|
||||
// there might be empty ranges we need to clear
|
||||
// empty ranges have Low = High
|
||||
realRanges := make([]Range, 0, len(ranges))
|
||||
for _, v := range ranges {
|
||||
if v.Low != v.High {
|
||||
realRanges = append(realRanges, v)
|
||||
}
|
||||
}
|
||||
|
||||
return realRanges, nil
|
||||
}
|
||||
|
||||
// convertToUtf7 will convert the provided runes to a UTF-7 sequence of bytes.
|
||||
// The function assumes that all the provided runes must be converted to UTF-7
|
||||
func convertToUtf7(runes []rune) []byte {
|
||||
byteArray := make([]byte, 0, len(runes)*2)
|
||||
|
||||
u16 := utf16.Encode(runes)
|
||||
for _, v := range u16 {
|
||||
byteArray = binary.BigEndian.AppendUint16(byteArray, v)
|
||||
}
|
||||
|
||||
dst := make([]byte, base64.RawStdEncoding.EncodedLen(len(byteArray))+2)
|
||||
dst[0] = '+'
|
||||
base64.RawStdEncoding.Encode(dst[1:len(dst)-1], byteArray)
|
||||
dst[len(dst)-1] = '-'
|
||||
return dst
|
||||
}
|
||||
|
||||
// convertRangeFromUtf7 will convert an utf7 byte range (enclosed in
|
||||
// the "+" and "-" chars) and write the result in the provided string builder.
|
||||
// The string builder won't be modified other than to append the result.
|
||||
// An error might be returned if there is any problem with the conversion.
|
||||
func convertRangeFromUtf7(utf7ByteRange []byte, sb *strings.Builder) error {
|
||||
if len(utf7ByteRange) == 2 && utf7ByteRange[0] == '+' && utf7ByteRange[1] == '-' {
|
||||
// special case for the "+-" sequence -> just write "+" as replacement
|
||||
sb.WriteByte('+')
|
||||
} else {
|
||||
// utf7 range must start with "+" and should (but might not) end with "-"
|
||||
// we need to remove those chars before decoding
|
||||
toDecode := utf7ByteRange[1 : len(utf7ByteRange)-1]
|
||||
if utf7ByteRange[len(utf7ByteRange)-1] != '-' {
|
||||
toDecode = utf7ByteRange[1:]
|
||||
}
|
||||
runeArray, err := convertFromUtf7(toDecode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range runeArray {
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertFromUtf7 will convert the sequence of bytes to runes. The sequence
|
||||
// of bytes is assumed to be an UTF-7 encoded sequence (without the "+" and
|
||||
// "-" limiters)
|
||||
// The returned runes should be UTF-8 encoded and can be converted to a
|
||||
// regular string easily.
|
||||
// Note that errors can be returned if the decoding process fails
|
||||
func convertFromUtf7(byteArray []byte) ([]rune, error) {
|
||||
dst := make([]byte, base64.RawStdEncoding.DecodedLen(len(byteArray)))
|
||||
|
||||
_, err := base64.RawStdEncoding.Decode(dst, byteArray)
|
||||
if err != nil {
|
||||
return []rune{}, err
|
||||
}
|
||||
|
||||
if len(dst)%2 != 0 {
|
||||
// some data can't be represented as utf16, and can't be decoded
|
||||
return []rune{}, errors.New("some utf7 data can't be represented as utf16")
|
||||
}
|
||||
|
||||
u16array := make([]uint16, 0, len(dst)/2)
|
||||
for i := 0; i < len(dst); i++ {
|
||||
u16array = append(u16array, binary.BigEndian.Uint16(dst[i:i+2]))
|
||||
i = i + 1
|
||||
}
|
||||
return utf16.Decode(u16array), nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package utf7_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestUtf7(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Utf7 Suite")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package utf7_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/connector/utf7"
|
||||
)
|
||||
|
||||
var _ = Describe("Utf7", func() {
|
||||
DescribeTable(
|
||||
"Encode",
|
||||
func(input, output string) {
|
||||
Expect(utf7.EncodeString(input)).To(Equal(output))
|
||||
},
|
||||
Entry("regular filename", "private.txt", "private.txt"),
|
||||
Entry("direct chars", "is-better?yes:no(3).pdf", "is-better?yes:no(3).pdf"),
|
||||
Entry("with spaces", "a big file with spaces.docx", "a+ACA-big+ACA-file+ACA-with+ACA-spaces.docx"),
|
||||
Entry("with symbols", "a+b=c", "a+ACs-b+AD0-c"),
|
||||
Entry("unicode filenames", "超極秘文書.doc", "+jYVpdXnYZYdm+A-.doc"),
|
||||
Entry("emoji and symbols", "💰🔜™️.pdf", "+2D3csNg93RwhIv4P-.pdf"),
|
||||
)
|
||||
|
||||
DescribeTable(
|
||||
"Decode",
|
||||
func(input, output string) {
|
||||
Expect(utf7.DecodeString(input)).To(Equal(output))
|
||||
},
|
||||
Entry("regular filename", "private.txt", "private.txt"),
|
||||
Entry("direct chars", "is-better?yes:no(3).pdf", "is-better?yes:no(3).pdf"),
|
||||
Entry("with spaces", "a+ACA-big+ACA-file+ACA-with+ACA-spaces.docx", "a big file with spaces.docx"),
|
||||
Entry("with symbols", "a+ACs-b+AD0-c", "a+b=c"),
|
||||
Entry("unicode filenames", "+jYVpdXnYZYdm+A-.doc", "超極秘文書.doc"),
|
||||
Entry("emoji and symbols", "+2D3csNg93RwhIv4P-.pdf", "💰🔜™️.pdf"),
|
||||
Entry("special case +- chars", "a+-b+AD0-c", "a+b=c"),
|
||||
Entry("optional direct chars", "1 +- 1 +AD0- 2", "1 + 1 = 2"),
|
||||
Entry("missing - char", "+jYVpdXnYZYdm+A.doc", "超極秘文書.doc"),
|
||||
Entry("missing - char2", "a+AD0.b", "a=.b"),
|
||||
Entry("missing - char end", "88+xUixVdVYwTjGlA", "88안녕하세요"),
|
||||
)
|
||||
|
||||
DescribeTable(
|
||||
"EncodeDecode",
|
||||
func(input string) {
|
||||
output, err := utf7.DecodeString(utf7.EncodeString(input))
|
||||
Expect(err).To(Succeed())
|
||||
Expect(output).To(Equal(input))
|
||||
},
|
||||
Entry("regular filename", "private.txt"),
|
||||
Entry("direct chars", "is-better?yes:no(3).pdf"),
|
||||
Entry("with spaces", "a big file with spaces.docx"),
|
||||
Entry("with symbols", "a+b=c"),
|
||||
Entry("unicode filenames", "超極秘文書.doc"),
|
||||
Entry("emoji and symbols", "💰🔜™️.pdf"),
|
||||
)
|
||||
|
||||
DescribeTable(
|
||||
"DecodeFailures",
|
||||
func(input string) {
|
||||
out, err := utf7.DecodeString(input)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(out).To(Equal(""))
|
||||
},
|
||||
Entry("Non-utf16 sequence in base64", "a+-b+AD-c"),
|
||||
Entry("Illegal base64 char", "a+-b+A.D-c"),
|
||||
Entry("Non-ascii string", "⇉a+-b"),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user