diff --git a/.gitignore b/.gitignore index 5ed50f2891..119d753fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,7 @@ tests/ocis/tests/acceptance/work_tmp # drone .drone.yml +# air config file +.air.toml + toolchain/ diff --git a/changelog/unreleased/overleaf.md b/changelog/unreleased/overleaf.md new file mode 100644 index 0000000000..84f43ee6cc --- /dev/null +++ b/changelog/unreleased/overleaf.md @@ -0,0 +1,10 @@ +Enhancement: implementation of an app provider for Overleaf + +This PR adds an app provider for Overleaf as a standalone http service. + +The app provider currently consists of support for the export to Overleaf +feature, which when called returns a URL to Overleaf that prompts Overleaf +to download the appropriate resource making use of the Archiver service, +and upload the files to a user's Overleaf account. + +https://github.com/cs3org/reva/pull/4084 diff --git a/docs/content/en/docs/config/packages/app/provider/overleaf/_index.md b/docs/content/en/docs/config/packages/app/provider/overleaf/_index.md new file mode 100644 index 0000000000..280e4bdef8 --- /dev/null +++ b/docs/content/en/docs/config/packages/app/provider/overleaf/_index.md @@ -0,0 +1,90 @@ +--- +title: "overleaf" +linkTitle: "overleaf" +weight: 10 +description: > + Configuration for the overleaf service +--- + +# _struct: config_ + +{{% dir name="mime_types" type="[]string" default=nil %}} +Inherited from the appprovider. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L69) +{{< highlight toml >}} +[app.provider.overleaf] +mime_types = nil +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="iop_secret" type="string" default="" %}} +The IOP secret used to connect to the wopiserver. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L70) +{{< highlight toml >}} +[app.provider.overleaf] +iop_secret = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="app_name" type="string" default="" %}} +The App user-friendly name. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L71) +{{< highlight toml >}} +[app.provider.overleaf] +app_name = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="app_icon_uri" type="string" default="" %}} +A URI to a static asset which represents the app icon. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L72) +{{< highlight toml >}} +[app.provider.overleaf] +app_icon_uri = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="folder_base_url" type="string" default="" %}} +The base URL to generate links to navigate back to the containing folder. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L73) +{{< highlight toml >}} +[app.provider.overleaf] +folder_base_url = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="app_url" type="string" default="" %}} +The App URL. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L74) +{{< highlight toml >}} +[app.provider.overleaf] +app_url = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="app_int_url" type="string" default="" %}} +The internal app URL in case of dockerized deployments. Defaults to AppURL [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L75) +{{< highlight toml >}} +[app.provider.overleaf] +app_int_url = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="app_api_key" type="string" default="" %}} +The API key used by the app, if applicable. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L76) +{{< highlight toml >}} +[app.provider.overleaf] +app_api_key = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="jwt_secret" type="string" default="" %}} +The JWT secret to be used to retrieve the token TTL. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L77) +{{< highlight toml >}} +[app.provider.overleaf] +jwt_secret = "" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="app_desktop_only" type="bool" default=false %}} +Specifies if the app can be opened only on desktop. [[Ref]](https://github.com/cs3org/reva/tree/master/pkg/app/provider/overleaf/overleaf.go#L78) +{{< highlight toml >}} +[app.provider.overleaf] +app_desktop_only = false +{{< /highlight >}} +{{% /dir %}} + diff --git a/internal/http/services/appprovider/appprovider.go b/internal/http/services/appprovider/appprovider.go index 24fb6d3dc4..14289f9006 100644 --- a/internal/http/services/appprovider/appprovider.go +++ b/internal/http/services/appprovider/appprovider.go @@ -371,8 +371,8 @@ func (s *svc) handleOpen(w http.ResponseWriter, r *http.Request) { return } - if statRes.Info.Type != storagepb.ResourceType_RESOURCE_TYPE_FILE { - writeError(w, r, appErrorInvalidParameter, "the given file id does not point to a file", nil) + if statRes.Info.Type != storagepb.ResourceType_RESOURCE_TYPE_FILE && statRes.Info.Type != storagepb.ResourceType_RESOURCE_TYPE_CONTAINER { + writeError(w, r, appErrorInvalidParameter, "the given file id does not point to a file or a container", nil) return } diff --git a/internal/http/services/loader/loader.go b/internal/http/services/loader/loader.go index 3fc02ce9bb..c15d96b68a 100644 --- a/internal/http/services/loader/loader.go +++ b/internal/http/services/loader/loader.go @@ -29,6 +29,7 @@ import ( _ "github.com/cs3org/reva/internal/http/services/metrics" _ "github.com/cs3org/reva/internal/http/services/ocmd" _ "github.com/cs3org/reva/internal/http/services/ocmprovider" + _ "github.com/cs3org/reva/internal/http/services/overleaf" _ "github.com/cs3org/reva/internal/http/services/owncloud/ocdav" _ "github.com/cs3org/reva/internal/http/services/owncloud/ocs" _ "github.com/cs3org/reva/internal/http/services/plugins" diff --git a/internal/http/services/overleaf/overleaf.go b/internal/http/services/overleaf/overleaf.go new file mode 100644 index 0000000000..ab0bf42312 --- /dev/null +++ b/internal/http/services/overleaf/overleaf.go @@ -0,0 +1,319 @@ +// Copyright 2018-2023 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package overleaf + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + authpb "github.com/cs3org/go-cs3apis/cs3/auth/provider/v1beta1" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + storagepb "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/internal/http/services/reqres" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/auth/scope" + ctxpkg "github.com/cs3org/reva/pkg/ctx" + "github.com/cs3org/reva/pkg/rgrpc/todo/pool" + "github.com/cs3org/reva/pkg/rhttp" + "github.com/cs3org/reva/pkg/rhttp/global" + "github.com/cs3org/reva/pkg/sharedconf" + "github.com/cs3org/reva/pkg/token/manager/jwt" + "github.com/cs3org/reva/pkg/utils/cfg" + "github.com/cs3org/reva/pkg/utils/resourceid" + "github.com/go-chi/chi/v5" +) + +type svc struct { + conf *config + gtwClient gateway.GatewayAPIClient + router *chi.Mux +} + +type config struct { + Prefix string `mapstructure:"prefix"` + GatewaySvc string `mapstructure:"gatewaysvc" validate:"required"` + AppName string `mapstructure:"app_name" docs:";The App user-friendly name." validate:"required"` + ArchiverURL string `mapstructure:"archiver_url" docs:";Internet-facing URL of the archiver service, used to serve the files to Overleaf." validate:"required"` + appURL string `mapstructure:"app_url" docs:";The App URL." validate:"required"` + Insecure bool `mapstructure:"insecure" docs:"false;Whether to skip certificate checks when sending requests."` + JWTSecret string `mapstructure:"jwt_secret"` +} + +func init() { + global.Register("overleaf", New) +} + +func New(ctx context.Context, m map[string]interface{}) (global.Service, error) { + var conf config + if err := cfg.Decode(m, &conf); err != nil { + return nil, err + } + + gtw, err := pool.GetGatewayServiceClient(pool.Endpoint(conf.GatewaySvc)) + if err != nil { + return nil, err + } + + r := chi.NewRouter() + + s := &svc{ + conf: &conf, + gtwClient: gtw, + router: r, + } + + if err := s.routerInit(); err != nil { + return nil, err + } + + return s, nil +} + +func (s *svc) routerInit() error { + s.router.Get("/import", s.handleImport) + s.router.Post("/export", s.handleExport) + return nil +} + +func (c *config) ApplyDefaults() { + if c.Prefix == "" { + c.Prefix = "overleaf" + } + + c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc) +} + +// Close performs cleanup. +func (s *svc) Close() error { + return nil +} + +func (s *svc) Prefix() string { + return s.conf.Prefix +} + +func (s *svc) Unprotected() []string { + return nil +} + +func (s *svc) Handler() http.Handler { + return s.router +} + +func (s *svc) handleImport(w http.ResponseWriter, r *http.Request) { + reqres.WriteError(w, r, reqres.APIErrorUnimplemented, "Overleaf import not yet supported", nil) +} + +func (s *svc) handleExport(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + exportRequest, err := getExportRequest(w, r) + + if err != nil { + return + } + + statRes, err := s.gtwClient.Stat(ctx, &storagepb.StatRequest{Ref: &exportRequest.ResourceRef}) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "Internal error accessing the resource, please try again later", err) + return + } + + if statRes.Status.Code == rpc.Code_CODE_NOT_FOUND { + reqres.WriteError(w, r, reqres.APIErrorNotFound, "resource does not exist", nil) + return + } else if statRes.Status.Code != rpc.Code_CODE_OK { + reqres.WriteError(w, r, reqres.APIErrorServerError, "failed to stat the resource", nil) + return + } + + resource := statRes.Info + + // User needs to have download rights to export to Overleaf + if !resource.PermissionSet.InitiateFileDownload { + reqres.WriteError(w, r, reqres.APIErrorUnauthenticated, "permission denied when accessing the file", err) + return + } + + if resource.Type != storagepb.ResourceType_RESOURCE_TYPE_FILE && resource.Type != storagepb.ResourceType_RESOURCE_TYPE_CONTAINER { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "invalid resource type, resource should be a file or a folder", nil) + return + } + + token, ok := ctxpkg.ContextGetToken(ctx) + if !ok || token == "" { + reqres.WriteError(w, r, reqres.APIErrorUnauthenticated, "Access token is invalid or empty", err) + return + } + + if !exportRequest.Override { + creationTime, alreadySet := resource.GetArbitraryMetadata().Metadata["reva.overleaf.exporttime"] + if alreadySet { + w.WriteHeader(http.StatusConflict) + if err := json.NewEncoder(w).Encode(map[string]any{ + "code": "ALREADY_EXISTS", + "message": "Project was already exported", + "export_time": creationTime, + }); err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error marshalling JSON response", err) + return + } + w.Header().Set("Content-Type", "application/json") + return + } + } + + tokenManager, err := jwt.New(map[string]interface{}{ + "secret": sharedconf.GetJWTSecret(s.conf.JWTSecret), + }) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error fetching secret", err) + return + } + + u := ctxpkg.ContextMustGetUser(ctx) + + scope, err := scope.AddResourceInfoScope(resource, authpb.Role_ROLE_VIEWER, nil) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error getting restricted token", err) + return + } + + restrictedToken, err := tokenManager.MintToken(context.Background(), u, scope) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error getting restricted token", err) + return + } + + // Setting up archiver request + archHTTPReq, err := rhttp.NewRequest(ctx, http.MethodGet, s.conf.ArchiverURL, nil) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "overleaf: error setting up http request", nil) + return + } + + archQuery := archHTTPReq.URL.Query() + archQuery.Add("id", resourceid.OwnCloudResourceIDWrap(resource.Id)) + archQuery.Add("access_token", restrictedToken) + archQuery.Add("arch_type", "zip") + + archHTTPReq.URL.RawQuery = archQuery.Encode() + log.Debug().Str("Archiver url", archHTTPReq.URL.String()).Msg("URL for downloading zipped resource from archiver") + + // Setting up Overleaf request + appURL := s.conf.appURL + "/docs" + httpReq, err := rhttp.NewRequest(ctx, http.MethodGet, appURL, nil) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "overleaf: error setting up http request", nil) + return + } + + q := httpReq.URL.Query() + + // snip_uri is link to archiver request + q.Add("snip_uri", archHTTPReq.URL.String()) + + // getting file/folder name so as not to expose authentication token in project name + name := strings.TrimSuffix(filepath.Base(resource.Path), filepath.Ext(resource.Path)) + q.Add("snip_name", name) + + httpReq.URL.RawQuery = q.Encode() + url := httpReq.URL.String() + + req := &storagepb.SetArbitraryMetadataRequest{ + Ref: &storagepb.Reference{ + ResourceId: resource.Id, + }, + ArbitraryMetadata: &storagepb.ArbitraryMetadata{ + Metadata: map[string]string{ + "reva.overleaf.exporttime": strconv.Itoa(int(time.Now().Unix())), + "reva.overleaf.name": base64.StdEncoding.EncodeToString([]byte(name)), + }, + }, + } + + res, err := s.gtwClient.SetArbitraryMetadata(ctx, req) + + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "overleaf: error setting arbitrary metadata", nil) + return + } + + if res.Status.Code != rpc.Code_CODE_OK { + reqres.WriteError(w, r, reqres.APIErrorServerError, "overleaf: error statting", nil) + return + } + + if err := json.NewEncoder(w).Encode(map[string]any{ + "app_url": url, + }); err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error marshalling JSON response", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} + +func getExportRequest(w http.ResponseWriter, r *http.Request) (*exportRequest, error) { + if err := r.ParseForm(); err != nil { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "parameters could not be parsed", nil) + return nil, err + } + + resourceID := r.Form.Get("resource_id") + + var resourceRef storagepb.Reference + if resourceID == "" { + path := r.Form.Get("path") + if path == "" { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "missing resource ID or path", nil) + return nil, errors.New("missing resource ID or path") + } + resourceRef.Path = path + } else { + resourceID := resourceid.OwnCloudResourceIDUnwrap(resourceID) + if resourceID == nil { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "invalid resource ID", nil) + return nil, errors.New("invalid resource ID") + } + resourceRef.ResourceId = resourceID + } + + // Override is true if field is set + override := r.Form.Get("override") != "" + return &exportRequest{ + ResourceRef: resourceRef, + Override: override, + }, nil +} + +type exportRequest struct { + ResourceRef storagepb.Reference `json:"resourceId"` + Override bool `json:"override"` +} diff --git a/pkg/app/provider/loader/loader.go b/pkg/app/provider/loader/loader.go index 8809c109ad..35280916a8 100644 --- a/pkg/app/provider/loader/loader.go +++ b/pkg/app/provider/loader/loader.go @@ -19,7 +19,7 @@ package loader import ( - // Load core application providers. + // Importing app providers. _ "github.com/cs3org/reva/pkg/app/provider/demo" _ "github.com/cs3org/reva/pkg/app/provider/wopi" // Add your own here.