Skip to content

Commit

Permalink
Merge pull request #10276 from owncloud/templates
Browse files Browse the repository at this point in the history
Template Files for WebOffice
  • Loading branch information
micbar authored Oct 17, 2024
2 parents 485dab8 + e40b724 commit 8b27e42
Show file tree
Hide file tree
Showing 9 changed files with 417 additions and 11 deletions.
48 changes: 48 additions & 0 deletions changelog/unreleased/template-conversions-for-apps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
Enhancement: WebOffice Templates

We are now able to use templates for WebOffice and use them as a starting point for new documents.

We are supporting the following mime types:

## OnlyOffice

- **MimeType:** `application/vnd.ms-word.template.macroenabled.12`
**TargetExtension:** `docx`

- **MimeType:** `application/vnd.oasis.opendocument.text-template`
**TargetExtension:** `docx`

- **MimeType:** `application/vnd.openxmlformats-officedocument.wordprocessingml.template`
**TargetExtension:** `docx`

- **MimeType:** `application/vnd.oasis.opendocument.spreadsheet-template`
**TargetExtension:** `xlsx`

- **MimeType:** `application/vnd.ms-excel.template.macroenabled.12`
**TargetExtension:** `xlsx`

- **MimeType:** `application/vnd.openxmlformats-officedocument.spreadsheetml.template`
**TargetExtension:** `xlsx`

- **MimeType:** `application/vnd.oasis.opendocument.presentation-template`
**TargetExtension:** `pptx`

- **MimeType:** `application/vnd.ms-powerpoint.template.macroenabled.12`
**TargetExtension:** `pptx`

- **MimeType:** `application/vnd.openxmlformats-officedocument.presentationml.template`
**TargetExtension:** `pptx`

## Collabora

- **MimeType:** `application/vnd.oasis.opendocument.spreadsheet-template`
**TargetExtension:** `ods`

- **MimeType:** `application/vnd.oasis.opendocument.text-template`
**TargetExtension:** `odt`

- **MimeType:** `application/vnd.oasis.opendocument.presentation-template`
**TargetExtension:** `odp`

https://github.com/owncloud/ocis/pull/10276
https://github.com/owncloud/ocis/issues/9785
35 changes: 35 additions & 0 deletions services/collaboration/pkg/connector/fileconnector.go
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,10 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (*ConnectorResponse,
if err != nil {
return nil, err
}
collaborationURL, err := url.Parse(f.cfg.Wopi.WopiSrc)
if err != nil {
return nil, err
}
privateLinkURL := &url.URL{}
*privateLinkURL = *ocisURL
privateLinkURL.Path = path.Join(ocisURL.Path, "f", storagespace.FormatResourceID(statRes.GetInfo().GetId()))
Expand Down Expand Up @@ -1240,12 +1244,43 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (*ConnectorResponse,
}
}

// if the file content is empty and a template reference is set, add the template source URL
if wopiContext.TemplateReference != nil && statRes.GetInfo().GetSize() == 0 {
if tu, err := f.createDownloadURL(wopiContext, collaborationURL); err == nil {
infoMap[fileinfo.KeyTemplateSource] = tu
}
}

info.SetProperties(infoMap)

logger.Debug().Interface("FileInfo", info).Msg("CheckFileInfo: success")
return NewResponseSuccessBody(info), nil
}

// createDownloadURL will create a download URL for the template file.
// It uses a new wopi context with the template reference set as the file reference
// and a reva access token to download the file.
func (f *FileConnector) createDownloadURL(wopiContext middleware.WopiContext, collaborationURL *url.URL) (string, error) {
templateContext := wopiContext
templateContext.FileReference = wopiContext.TemplateReference
templateContext.TemplateReference = nil

token, _, err := middleware.GenerateWopiToken(templateContext, f.cfg)
if err != nil {
return "", err
}
downloadURL := *collaborationURL
downloadURL.Path = path.Join(
collaborationURL.Path,
"wopi/templates/",
helpers.HashResourceId(templateContext.FileReference.GetResourceId()),
)
q := downloadURL.Query()
q.Add("access_token", token)
downloadURL.RawQuery = q.Encode()
return downloadURL.String(), nil
}

func createHostUrl(mode string, ocisUrl *url.URL, appName string, info *providerv1beta1.ResourceInfo) string {
webUrl := createAppExternalURL(ocisUrl, appName, info)
addURLParams(webUrl, map[string]string{"view_mode": mode})
Expand Down
79 changes: 79 additions & 0 deletions services/collaboration/pkg/connector/fileconnector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ var _ = Describe("FileConnector", func() {
WopiSrc: "https://ocis.server.prv",
Secret: "topsecret",
},
TokenManager: &config.TokenManager{JWTSecret: "secret"},
}
ccs = &collabmocks.ContentConnectorService{}

Expand Down Expand Up @@ -1843,5 +1844,83 @@ var _ = Describe("FileConnector", func() {
Expect(response.Status).To(Equal(200))
Expect(response.Body.(*fileinfo.Collabora)).To(Equal(expectedFileInfo))
})
It("Stat success with template", func() {
wopiCtx.TemplateReference = &providerv1beta1.Reference{
ResourceId: &providerv1beta1.ResourceId{
StorageId: "storageid",
OpaqueId: "opaqueid2",
SpaceId: "spaceid",
},
}
ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx)
u := &userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: "customIdp",
OpaqueId: "admin",
},
DisplayName: "Pet Shaft",
}
ctx = ctxpkg.ContextSetUser(ctx, u)

gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{
Status: status.NewOK(ctx),
Info: &providerv1beta1.ResourceInfo{
Owner: &userv1beta1.UserId{
Idp: "customIdp",
OpaqueId: "aabbcc",
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
},
Size: uint64(0),
Mtime: &typesv1beta1.Timestamp{
Seconds: uint64(16273849),
},
Path: "/path/to/test.txt",
Id: &providerv1beta1.ResourceId{
StorageId: "storageid",
OpaqueId: "opaqueid",
SpaceId: "spaceid",
},
},
}, nil)

expectedFileInfo := &fileinfo.OnlyOffice{
Version: "v162738490",
BaseFileName: "test.txt",
BreadcrumbDocName: "test.txt",
BreadcrumbFolderName: "/path/to",
BreadcrumbFolderURL: "https://ocis.example.prv/f/storageid$spaceid%21opaqueid",
UserCanNotWriteRelative: false,
SupportsLocks: true,
SupportsUpdate: true,
SupportsRename: true,
UserCanWrite: true,
UserCanRename: true,
UserID: "61646d696e40637573746f6d496470", // hex of admin@customIdp
UserFriendlyName: "Pet Shaft",
FileSharingURL: "https://ocis.example.prv/f/storageid$spaceid%21opaqueid?details=sharing",
FileVersionURL: "https://ocis.example.prv/f/storageid$spaceid%21opaqueid?details=versions",
HostEditURL: "https://ocis.example.prv/external-onlyoffice/path/to/test.txt?fileId=storageid%24spaceid%21opaqueid&view_mode=write",
PostMessageOrigin: "https://ocis.example.prv",
}

// change wopi app provider
cfg.App.Name = "OnlyOffice"

response, err := fc.CheckFileInfo(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(response.Status).To(Equal(200))

returnedFileInfo := response.Body.(*fileinfo.OnlyOffice)
templateSource := returnedFileInfo.TemplateSource
expectedTemplateSource := "https://ocis.server.prv/wopi/templates/a340d017568d0d579ee061a9ac02109e32fb07082d35c40aa175864303bd9107?access_token="

// take TemplateSource out of the response for easier comparison
returnedFileInfo.TemplateSource = ""
Expect(returnedFileInfo).To(Equal(expectedFileInfo))
// check if the template source is correct
// the url is using a generated access token which always has a new ttl
// so we can't compare the whole url
Expect(templateSource).To(HavePrefix(expectedTemplateSource))
})
})
})
5 changes: 4 additions & 1 deletion services/collaboration/pkg/connector/fileinfo/onlyoffice.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ type OnlyOffice struct {
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
Expand Down Expand Up @@ -179,7 +181,8 @@ func (oinfo *OnlyOffice) SetProperties(props map[string]interface{}) {
oinfo.FileNameMaxLength = value.(int)
case KeyLastModifiedTime:
oinfo.LastModifiedTime = value.(string)

case KeyTemplateSource:
oinfo.TemplateSource = value.(string)
case KeyIsAnonymousUser:
oinfo.IsAnonymousUser = value.(bool)
case KeyUserFriendlyName:
Expand Down
28 changes: 20 additions & 8 deletions services/collaboration/pkg/middleware/wopicontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ const (

// WopiContext wraps all the information we need for WOPI
type WopiContext struct {
AccessToken string
ViewOnlyToken string
FileReference *providerv1beta1.Reference
ViewMode appproviderv1beta1.ViewMode
AccessToken string
ViewOnlyToken string
FileReference *providerv1beta1.Reference
TemplateReference *providerv1beta1.Reference
ViewMode appproviderv1beta1.ViewMode
}

// WopiContextAuthMiddleware will prepare an HTTP handler to be used as
Expand Down Expand Up @@ -120,10 +121,21 @@ func WopiContextAuthMiddleware(cfg *config.Config, next http.Handler) http.Handl

hashedRef := helpers.HashResourceId(claims.WopiContext.FileReference.GetResourceId())
fileID := parseWopiFileID(cfg, r.URL.Path)
if fileID != hashedRef {
wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
if claims.WopiContext.TemplateReference != nil {
hashedTemplateRef := helpers.HashResourceId(claims.WopiContext.TemplateReference.GetResourceId())
// the fileID could be one of the references within the access token if both are set
// because we can use the access token to get the contents of the template file
if fileID != hashedTemplateRef && fileID != hashedRef {
wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
} else {
if fileID != hashedRef {
wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
}

next.ServeHTTP(w, r.WithContext(ctx))
Expand Down
64 changes: 64 additions & 0 deletions services/collaboration/pkg/middleware/wopicontext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,70 @@ var _ = Describe("Wopi Context Middleware", func() {
mw.ServeHTTP(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
})
It("Should authorize successful with template reference", func() {
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
token, err := tknMngr.MintToken(ctx, user, nil)
Expect(err).ToNot(HaveOccurred())

wopiContext := middleware.WopiContext{
AccessToken: token,
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
TemplateReference: &providerv1beta1.Reference{
ResourceId: rid,
Path: ".",
},
FileReference: &providerv1beta1.Reference{
ResourceId: &providerv1beta1.ResourceId{
StorageId: "storageID",
OpaqueId: "opaqueID2",
SpaceId: "spaceID",
},
},
}
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg)
q := req.URL.Query()
q.Add("access_token", wopiToken)
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
req.URL.RawQuery = q.Encode()
resp := httptest.NewRecorder()

mw.ServeHTTP(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
})
It("Should not authorize when no reference matches", func() {
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
token, err := tknMngr.MintToken(ctx, user, nil)
Expect(err).ToNot(HaveOccurred())

wopiContext := middleware.WopiContext{
AccessToken: token,
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
TemplateReference: &providerv1beta1.Reference{
ResourceId: &providerv1beta1.ResourceId{
StorageId: "storageID",
OpaqueId: "opaqueID3",
SpaceId: "spaceID",
},
Path: ".",
},
FileReference: &providerv1beta1.Reference{
ResourceId: &providerv1beta1.ResourceId{
StorageId: "storageID",
OpaqueId: "opaqueID2",
SpaceId: "spaceID",
},
},
}
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg)
q := req.URL.Query()
q.Add("access_token", wopiToken)
q.Add("access_token_ttl", strconv.FormatInt(ttl, 10))
req.URL.RawQuery = q.Encode()
resp := httptest.NewRecorder()

mw.ServeHTTP(resp, req)
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
})
It("Should not authorize with proxy when fileID mismatches", func() {
cfg.Wopi.ProxySecret = "proxySecret"
cfg.Wopi.ProxyURL = "https://proxy"
Expand Down
12 changes: 12 additions & 0 deletions services/collaboration/pkg/server/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,17 @@ func prepareRoutes(r *chi.Mux, options Options) {
})
})
})
r.Route("/templates/{templateID}", func(r chi.Router) {
r.Use(
func(h stdhttp.Handler) stdhttp.Handler {
// authentication and wopi context
return colabmiddleware.WopiContextAuthMiddleware(options.Config, h)
},
colabmiddleware.CollaborationTracingMiddleware,
)
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
adapter.GetFile(w, r)
})
})
})
}
27 changes: 26 additions & 1 deletion services/collaboration/pkg/service/grpc/v0/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
"github.com/cs3org/reva/v2/pkg/storagespace"
"github.com/cs3org/reva/v2/pkg/utils"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/wopisrc"

Expand Down Expand Up @@ -102,7 +103,12 @@ func (s *Service) OpenInApp(
appURL, err = s.addQueryToURL(appURL, req)
if err != nil {
logger.Error().Err(err).Msg("OpenInApp: error parsing appUrl")
return nil, err
return &appproviderv1beta1.OpenInAppResponse{
Status: &rpcv1beta1.Status{
Code: rpcv1beta1.Code_CODE_INVALID_ARGUMENT,
Message: "OpenInApp: error parsing appUrl",
},
}, nil
}

// create the wopiContext and generate the token
Expand All @@ -113,6 +119,25 @@ func (s *Service) OpenInApp(
ViewMode: req.GetViewMode(),
}

if templateID := utils.ReadPlainFromOpaque(req.GetOpaque(), "template"); templateID != "" {
// we can ignore the error here, as we are sure that the templateID is not empty
templateRes, _ := storagespace.ParseID(templateID)
// we need to have at least both opaqueID and spaceID set
if templateRes.GetOpaqueId() == "" || templateRes.GetSpaceId() == "" {
logger.Error().Err(err).Msg("OpenInApp: error parsing templateID")
return &appproviderv1beta1.OpenInAppResponse{
Status: &rpcv1beta1.Status{
Code: rpcv1beta1.Code_CODE_INVALID_ARGUMENT,
Message: "OpenInApp: error parsing templateID",
},
}, nil
}
wopiContext.TemplateReference = &providerv1beta1.Reference{
ResourceId: &templateRes,
Path: ".",
}
}

accessToken, accessExpiration, err := middleware.GenerateWopiToken(wopiContext, s.config)
if err != nil {
logger.Error().Err(err).Msg("OpenInApp: error generating the token")
Expand Down
Loading

0 comments on commit 8b27e42

Please sign in to comment.