Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Template Files for WebOffice #10276

Merged
merged 1 commit into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
micbar marked this conversation as resolved.
Show resolved Hide resolved
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