Initial QSfera import
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config/parser"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Health is the entrypoint for the health command.
|
||||
func Health(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "check health status",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnError(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
|
||||
resp, err := http.Get(
|
||||
fmt.Sprintf(
|
||||
"http://%s/healthz",
|
||||
cfg.Debug.Addr,
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to request health check")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Fatal().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health seems to be in bad state")
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health got a good state")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/clihelper"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GetCommands provides all commands for this service
|
||||
func GetCommands(cfg *config.Config) []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
Server(cfg),
|
||||
Health(cfg),
|
||||
Version(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point for the antivirus command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cobra.Command{
|
||||
Use: "collaboration",
|
||||
Short: "Serve WOPI for КуСфера",
|
||||
})
|
||||
app.AddCommand(GetCommands(cfg)...)
|
||||
app.SetArgs(os.Args[1:])
|
||||
|
||||
return app.ExecuteContext(cfg.Context)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/runner"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config/parser"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/connector"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/server/debug"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/server/grpc"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/server/http"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go-micro.dev/v4/selector"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
)
|
||||
|
||||
// Server is the entrypoint for the server command.
|
||||
func Server(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "server",
|
||||
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnFatal(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if cfg.Context == nil {
|
||||
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
|
||||
defer cancel()
|
||||
}
|
||||
ctx := cfg.Context
|
||||
|
||||
// prepare components
|
||||
if err := helpers.RegisterQsferaService(ctx, cfg, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tm, err := pool.StringToTLSMode(cfg.CS3Api.GRPCClientTLS.Mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gatewaySelector, err := pool.GatewaySelector(
|
||||
cfg.CS3Api.Gateway.Name,
|
||||
pool.WithTLSCACert(cfg.CS3Api.GRPCClientTLS.CACert),
|
||||
pool.WithTLSMode(tm),
|
||||
pool.WithRegistry(registry.GetRegistry()),
|
||||
pool.WithTracerProvider(traceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// use the AppURLs helper (an atomic pointer) to fetch and store the app URLs
|
||||
// this is required as the app URLs are fetched periodically in the background
|
||||
// and read when handling requests
|
||||
appURLs := helpers.NewAppURLs()
|
||||
|
||||
ticker := time.NewTicker(cfg.CS3Api.APPRegistrationInterval)
|
||||
defer ticker.Stop()
|
||||
go func() {
|
||||
for ; true; <-ticker.C {
|
||||
// fetch and store the app URLs
|
||||
v, err := helpers.GetAppURLs(cfg, logger)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to get app URLs")
|
||||
// empty map to clear previous URLs
|
||||
v = make(map[string]map[string]string)
|
||||
}
|
||||
appURLs.Store(v)
|
||||
|
||||
// register the app provider
|
||||
if err := helpers.RegisterAppProvider(ctx, cfg, logger, gatewaySelector, appURLs); err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to register app provider")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
st := store.Create(
|
||||
store.Store(cfg.Store.Store),
|
||||
store.TTL(cfg.Store.TTL),
|
||||
microstore.Nodes(cfg.Store.Nodes...),
|
||||
microstore.Database(cfg.Store.Database),
|
||||
microstore.Table(cfg.Store.Table),
|
||||
store.Authentication(cfg.Store.AuthUsername, cfg.Store.AuthPassword),
|
||||
)
|
||||
|
||||
gr := runner.NewGroup()
|
||||
|
||||
// start GRPC server
|
||||
grpcServer, teardown, err := grpc.Server(
|
||||
grpc.AppURLs(appURLs),
|
||||
grpc.Config(cfg),
|
||||
grpc.Logger(logger),
|
||||
grpc.TraceProvider(traceProvider),
|
||||
grpc.Store(st),
|
||||
)
|
||||
defer teardown()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "grpc").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
l, err := net.Listen("tcp", cfg.GRPC.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gr.Add(runner.NewGolangGrpcServerRunner(cfg.Service.Name+".grpc", grpcServer, l))
|
||||
|
||||
// start debug server
|
||||
debugServer, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Context(ctx),
|
||||
debug.Config(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
|
||||
|
||||
// start HTTP server
|
||||
httpServer, err := http.Server(
|
||||
http.Adapter(connector.NewHttpAdapter(gatewaySelector, cfg, st, selector.NewSelector(selector.Registry(registry.GetRegistry())))),
|
||||
http.Logger(logger),
|
||||
http.Config(cfg),
|
||||
http.Context(ctx),
|
||||
http.TracerProvider(traceProvider),
|
||||
http.Store(st),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Info().Err(err).Str("transport", "http").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
gr.Add(runner.NewGoMicroHttpServerRunner("collaboration_http", httpServer))
|
||||
|
||||
grResults := gr.Run(ctx)
|
||||
|
||||
// return the first non-nil error found in the results
|
||||
for _, grResult := range grResults {
|
||||
if grResult.RunnerError != nil {
|
||||
return grResult.RunnerError
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version prints the service versions of all running instances.
|
||||
func Version(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "print the version of this binary and the running service instances",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Version: " + version.GetString())
|
||||
fmt.Printf("Compiled: %s\n", version.Compiled())
|
||||
fmt.Println("")
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No running " + cfg.Service.Name + " service found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
|
||||
table.Header([]string{"Version", "Address", "Id"})
|
||||
for _, s := range services {
|
||||
for _, n := range s.Nodes {
|
||||
table.Append([]string{s.Version, n.Address, n.Id})
|
||||
}
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
// App defines the available app configuration.
|
||||
type App struct {
|
||||
Name string `yaml:"name" env:"COLLABORATION_APP_NAME" desc:"The name of the app which is shown to the user. You can chose freely but you are limited to a single word without special characters or whitespaces. We recommend to use pascalCase like 'CollaboraOnline'." introductionVersion:"1.0.0"`
|
||||
Product string `yaml:"product" env:"COLLABORATION_APP_PRODUCT" desc:"The WebOffice app, either Collabora, OnlyOffice, Microsoft365 or MicrosoftOfficeOnline." introductionVersion:"1.0.0"`
|
||||
Description string `yaml:"description" env:"COLLABORATION_APP_DESCRIPTION" desc:"App description" introductionVersion:"1.0.0"`
|
||||
Icon string `yaml:"icon" env:"COLLABORATION_APP_ICON" desc:"Icon for the app" introductionVersion:"1.0.0"`
|
||||
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_APP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080." introductionVersion:"1.0.0"`
|
||||
Insecure bool `yaml:"insecure" env:"COLLABORATION_APP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app" introductionVersion:"1.0.0"`
|
||||
|
||||
ProofKeys ProofKeys `yaml:"proofkeys"`
|
||||
LicenseCheckEnable bool `yaml:"licensecheckenable" env:"COLLABORATION_APP_LICENSE_CHECK_ENABLE" desc:"Enable license checking to edit files. Needs to be enabled when using Microsoft365 with the business flow." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
type ProofKeys struct {
|
||||
Disable bool `yaml:"disable" env:"COLLABORATION_APP_PROOF_DISABLE" desc:"Disable the proof keys verification" introductionVersion:"1.0.0"`
|
||||
Duration string `yaml:"duration" env:"COLLABORATION_APP_PROOF_DURATION" desc:"Duration for the proof keys to be cached in memory, using time.ParseDuration format. If the duration can't be parsed, we'll use the default 12h as duration" introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
type Config struct {
|
||||
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
|
||||
|
||||
Service Service `yaml:"-"`
|
||||
App App `yaml:"app"`
|
||||
Store Store `yaml:"store"`
|
||||
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
GRPC GRPC `yaml:"grpc"`
|
||||
HTTP HTTP `yaml:"http"`
|
||||
|
||||
Wopi Wopi `yaml:"wopi"`
|
||||
CS3Api CS3Api `yaml:"cs3api"`
|
||||
|
||||
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;COLLABORATION_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
|
||||
Debug Debug `yaml:"debug"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
)
|
||||
|
||||
// CS3Api defines the available configuration in order to access to the CS3 gateway.
|
||||
type CS3Api struct {
|
||||
Gateway Gateway `yaml:"gateway"`
|
||||
DataGateway DataGateway `yaml:"datagateway"`
|
||||
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
|
||||
APPRegistrationInterval time.Duration `yaml:"app_registration_interval" env:"COLLABORATION_CS3API_APP_REGISTRATION_INTERVAL" desc:"The interval at which the app provider registers itself." introductionVersion:"4.0.0"`
|
||||
}
|
||||
|
||||
// Gateway defines the available configuration for the CS3 API gateway
|
||||
type Gateway struct {
|
||||
Name string `yaml:"name" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// DataGateway defines the available configuration for the CS3 API data gateway
|
||||
type DataGateway struct {
|
||||
Insecure bool `yaml:"insecure" env:"COLLABORATION_CS3API_DATAGATEWAY_INSECURE" desc:"Connect to the CS3API data gateway insecurely." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration. Not used at the moment
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
|
||||
Token string `yaml:"token" env:"COLLABORATION_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
|
||||
Pprof bool `yaml:"pprof" env:"COLLABORATION_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
|
||||
Zpages bool `yaml:"zpages" env:"COLLABORATION_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/pkg/structs"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
)
|
||||
|
||||
// FullDefaultConfig returns a fully initialized default configuration
|
||||
func FullDefaultConfig() *config.Config {
|
||||
cfg := DefaultConfig()
|
||||
EnsureDefaults(cfg)
|
||||
Sanitize(cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DefaultConfig returns a basic default configuration
|
||||
func DefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Service: config.Service{
|
||||
Name: "collaboration",
|
||||
},
|
||||
App: config.App{
|
||||
Name: "Collabora",
|
||||
Description: "Open office documents with Collabora",
|
||||
Icon: "image-edit",
|
||||
Addr: "https://127.0.0.1:9980",
|
||||
Insecure: false,
|
||||
ProofKeys: config.ProofKeys{
|
||||
// they'll be enabled by default
|
||||
Duration: "12h",
|
||||
},
|
||||
},
|
||||
Store: config.Store{
|
||||
Store: "nats-js-kv",
|
||||
Nodes: []string{"127.0.0.1:9233"},
|
||||
Database: "collaboration",
|
||||
Table: "",
|
||||
TTL: 30 * time.Minute,
|
||||
},
|
||||
GRPC: config.GRPC{
|
||||
Addr: "127.0.0.1:9301",
|
||||
Protocol: "tcp",
|
||||
Namespace: "qsfera.api",
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "127.0.0.1:9300",
|
||||
Namespace: "qsfera.web",
|
||||
},
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9304",
|
||||
Token: "",
|
||||
Pprof: false,
|
||||
Zpages: false,
|
||||
},
|
||||
Wopi: config.Wopi{
|
||||
WopiSrc: "https://localhost:9300",
|
||||
},
|
||||
CS3Api: config.CS3Api{
|
||||
Gateway: config.Gateway{
|
||||
Name: shared.DefaultRevaConfig().Address,
|
||||
},
|
||||
DataGateway: config.DataGateway{
|
||||
Insecure: false,
|
||||
},
|
||||
APPRegistrationInterval: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDefaults adds default values to the configuration if they are not set yet
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "error"
|
||||
}
|
||||
|
||||
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
|
||||
cfg.TokenManager = &config.TokenManager{
|
||||
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
|
||||
}
|
||||
} else if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
if cfg.CS3Api.GRPCClientTLS == nil && cfg.Commons != nil {
|
||||
cfg.CS3Api.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize sanitized the configuration
|
||||
func Sanitize(cfg *config.Config) {
|
||||
// sanitize config
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// GRPC defines the available grpc configuration.
|
||||
type GRPC struct {
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"`
|
||||
Protocol string `yaml:"protocol" env:"OC_GRPC_PROTOCOL;COLLABORATION_GRPC_PROTOCOL" desc:"The transport protocol of the GRPC service." introductionVersion:"1.0.0"`
|
||||
|
||||
Namespace string `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
)
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
|
||||
Namespace string `yaml:"-"`
|
||||
TLS shared.HTTPServiceTLS `yaml:"tls"`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
occfg "github.com/qsfera/server/pkg/config"
|
||||
ocdefaults "github.com/qsfera/server/pkg/config/defaults"
|
||||
"github.com/qsfera/server/pkg/config/envdecode"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config/defaults"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
func ParseConfig(cfg *config.Config) error {
|
||||
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.EnsureDefaults(cfg)
|
||||
|
||||
// load all env variables relevant to the config in the current context.
|
||||
if err := envdecode.Decode(cfg); err != nil {
|
||||
// no environment variable set for this config is an expected "error"
|
||||
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
}
|
||||
|
||||
// Validate validates the configuration
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
if cfg.Wopi.Secret == "" {
|
||||
return shared.MissingWOPISecretError(cfg.Service.Name)
|
||||
}
|
||||
url, err := url.Parse(cfg.Wopi.WopiSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("The WOPI Src has not been set properly in your config for %s. "+
|
||||
"Make sure your %s config contains the proper values "+
|
||||
"(e.g. by running qsfera init or setting it manually in "+
|
||||
"the config/corresponding environment variable): %s",
|
||||
cfg.Service.Name, ocdefaults.BaseConfigPath(), err.Error())
|
||||
}
|
||||
if url.Path != "" {
|
||||
return fmt.Errorf("The WOPI Src must not contain a path in your config for %s. "+
|
||||
"Make sure your %s config contains the proper values "+
|
||||
"(e.g. by running qsfera init or setting it manually in "+
|
||||
"the config/corresponding environment variable)",
|
||||
cfg.Service.Name, ocdefaults.BaseConfigPath())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;COLLABORATION_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"name" env:"COLLABORATION_SERVICE_NAME" desc:"The name of the service which is registered. You only need to change this when more than one collaboration service is needed." introductionVersion:"3.6.0"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Store configures the store to use
|
||||
type Store struct {
|
||||
Store string `yaml:"store" env:"OC_PERSISTENT_STORE;COLLABORATION_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"1.0.0"`
|
||||
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;COLLABORATION_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
Database string `yaml:"database" env:"COLLABORATION_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"1.0.0"`
|
||||
Table string `yaml:"table" env:"COLLABORATION_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"1.0.0"`
|
||||
TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;COLLABORATION_STORE_TTL" desc:"Time to live for events in the store. Defaults to '30m' (30 minutes). See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;COLLABORATION_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;COLLABORATION_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
// Wopi defines the available configuration for the WOPI endpoint.
|
||||
type Wopi struct {
|
||||
WopiSrc string `yaml:"wopisrc" env:"COLLABORATION_WOPI_SRC" desc:"The WOPI source base URL containing schema, host and port. Set this to the schema and domain where the collaboration service is reachable for the wopi app, such as https://office.example.test." introductionVersion:"1.0.0"`
|
||||
Secret string `yaml:"secret" env:"COLLABORATION_WOPI_SECRET" desc:"Used to mint and verify WOPI JWT tokens and encrypt and decrypt the REVA JWT token embedded in the WOPI JWT token." introductionVersion:"1.0.0"`
|
||||
DisableChat bool `yaml:"disable_chat" env:"COLLABORATION_WOPI_DISABLE_CHAT;OC_WOPI_DISABLE_CHAT" desc:"Disable chat in the office web frontend. This feature applies to OnlyOffice and Microsoft." introductionVersion:"1.0.0"`
|
||||
ProxyURL string `yaml:"proxy_url" env:"COLLABORATION_WOPI_PROXY_URL" desc:"The URL to the КуСфера WOPI proxy. Optional. To use this feature, you need an office365 proxy subscription. If you become part of the Microsoft CSP program (https://learn.microsoft.com/en-us/partner-center/enroll/csp-overview), you can use WebOffice without a proxy." introductionVersion:"1.0.0"`
|
||||
ProxySecret string `yaml:"proxy_secret" env:"COLLABORATION_WOPI_PROXY_SECRET" desc:"Optional, the secret to authenticate against the КуСфера WOPI proxy. This secret can be obtained from КуСфера via the office365 proxy subscription." introductionVersion:"1.0.0"`
|
||||
ShortTokens bool `yaml:"short_tokens" env:"COLLABORATION_WOPI_SHORTTOKENS" desc:"Use short access tokens for WOPI access. This is useful for office packages, like Microsoft Office Online, which have URL length restrictions. If enabled, a persistent store must be configured." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
)
|
||||
|
||||
// HashResourceId builds a urlsafe and stable file reference that can be used for proxy routing,
|
||||
// so that all sessions on one file end on the same office server
|
||||
func HashResourceId(resourceId *providerv1beta1.ResourceId) string {
|
||||
c := sha256.New()
|
||||
c.Write([]byte(storagespace.FormatResourceID(resourceId)))
|
||||
return hex.EncodeToString(c.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/mime"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// AppURLs holds the app urls fetched from the WOPI app discovery endpoint
|
||||
// It is a type safe wrapper around an atomic pointer to a map
|
||||
type AppURLs struct {
|
||||
urls atomic.Pointer[map[string]map[string]string]
|
||||
}
|
||||
|
||||
func NewAppURLs() *AppURLs {
|
||||
a := &AppURLs{}
|
||||
a.urls.Store(&map[string]map[string]string{})
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AppURLs) Store(urls map[string]map[string]string) {
|
||||
a.urls.Store(&urls)
|
||||
}
|
||||
|
||||
func (a *AppURLs) GetMimeTypes() []string {
|
||||
currentURLs := a.urls.Load()
|
||||
if currentURLs == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
mimeTypesMap := make(map[string]bool)
|
||||
for _, extensions := range *currentURLs {
|
||||
for ext := range extensions {
|
||||
m := mime.Detect(false, ext)
|
||||
// skip the default
|
||||
if m == "application/octet-stream" {
|
||||
continue
|
||||
}
|
||||
mimeTypesMap[m] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to slice
|
||||
mimeTypes := make([]string, 0, len(mimeTypesMap))
|
||||
for mimeType := range mimeTypesMap {
|
||||
mimeTypes = append(mimeTypes, mimeType)
|
||||
}
|
||||
|
||||
return mimeTypes
|
||||
}
|
||||
|
||||
// GetAppURLFor gets the appURL from the list of appURLs based on the
|
||||
// action and file extension provided. If there is no match, an empty
|
||||
// string will be returned.
|
||||
func (a *AppURLs) GetAppURLFor(action, fileExt string) string {
|
||||
currentURLs := a.urls.Load()
|
||||
if currentURLs == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if actionURL, ok := (*currentURLs)[action]; ok {
|
||||
if actionExtensionURL, ok := actionURL[fileExt]; ok {
|
||||
return actionExtensionURL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetAppURLs gets the edit and view urls for different file types from the
|
||||
// target WOPI app (onlyoffice, collabora, etc) via their "/hosting/discovery"
|
||||
// endpoint.
|
||||
func GetAppURLs(cfg *config.Config, logger log.Logger) (map[string]map[string]string, error) {
|
||||
wopiAppUrl := cfg.App.Addr + "/hosting/discovery"
|
||||
|
||||
httpClient := http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: cfg.App.Insecure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
httpResp, err := httpClient.Get(wopiAppUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
logger.Error().
|
||||
Str("WopiAppUrl", wopiAppUrl).
|
||||
Int("HttpCode", httpResp.StatusCode).
|
||||
Msg("WopiDiscovery: wopi app url failed with unexpected code")
|
||||
return nil, errors.New("status code was not 200")
|
||||
}
|
||||
|
||||
var appURLs map[string]map[string]string
|
||||
|
||||
appURLs, err = parseWopiDiscovery(httpResp.Body)
|
||||
if err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("WopiAppUrl", wopiAppUrl).
|
||||
Msg("WopiDiscovery: failed to parse wopi discovery response")
|
||||
return nil, errors.Wrap(err, "error parsing wopi discovery response")
|
||||
}
|
||||
|
||||
// We won't log anything if successful
|
||||
return appURLs, nil
|
||||
}
|
||||
|
||||
// parseWopiDiscovery parses the response of the "/hosting/discovery" endpoint
|
||||
func parseWopiDiscovery(body io.Reader) (map[string]map[string]string, error) {
|
||||
appURLs := make(map[string]map[string]string)
|
||||
|
||||
doc := etree.NewDocument()
|
||||
if _, err := doc.ReadFrom(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := doc.SelectElement("wopi-discovery")
|
||||
|
||||
for _, netzone := range root.SelectElements("net-zone") {
|
||||
|
||||
if strings.Contains(netzone.SelectAttrValue("name", ""), "external") {
|
||||
for _, app := range netzone.SelectElements("app") {
|
||||
for _, action := range app.SelectElements("action") {
|
||||
access := action.SelectAttrValue("name", "")
|
||||
if access == "view" || access == "edit" || access == "view_comment" {
|
||||
ext := action.SelectAttrValue("ext", "")
|
||||
urlString := action.SelectAttrValue("urlsrc", "")
|
||||
|
||||
if ext == "" || urlString == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
u, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// remove any malformed query parameter from discovery urls
|
||||
q := u.Query()
|
||||
for k := range q {
|
||||
if strings.Contains(k, "<") || strings.Contains(k, ">") {
|
||||
q.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
if _, ok := appURLs[access]; !ok {
|
||||
appURLs[access] = make(map[string]string)
|
||||
}
|
||||
appURLs[access]["."+ext] = u.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return appURLs, nil
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package helpers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
)
|
||||
|
||||
var _ = Describe("AppURLs", func() {
|
||||
var appURLs *helpers.AppURLs
|
||||
|
||||
BeforeEach(func() {
|
||||
appURLs = helpers.NewAppURLs()
|
||||
})
|
||||
|
||||
Describe("NewAppURLs", func() {
|
||||
It("should create a new AppURLs instance with empty map", func() {
|
||||
Expect(appURLs).NotTo(BeNil())
|
||||
Expect(appURLs.GetMimeTypes()).To(BeEmpty())
|
||||
Expect(appURLs.GetAppURLFor("view", ".pdf")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Store and GetAppURLFor", func() {
|
||||
It("should store and retrieve app URLs correctly", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": {
|
||||
".pdf": "https://example.com/view/pdf",
|
||||
".docx": "https://example.com/view/docx",
|
||||
".xlsx": "https://example.com/view/xlsx",
|
||||
},
|
||||
"edit": {
|
||||
".docx": "https://example.com/edit/docx",
|
||||
".xlsx": "https://example.com/edit/xlsx",
|
||||
},
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
// Test successful lookups
|
||||
Expect(appURLs.GetAppURLFor("view", ".pdf")).To(Equal("https://example.com/view/pdf"))
|
||||
Expect(appURLs.GetAppURLFor("view", ".docx")).To(Equal("https://example.com/view/docx"))
|
||||
Expect(appURLs.GetAppURLFor("edit", ".docx")).To(Equal("https://example.com/edit/docx"))
|
||||
})
|
||||
|
||||
It("should return empty string for non-existent action", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": {".pdf": "https://example.com/view/pdf"},
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
Expect(appURLs.GetAppURLFor("nonexistent", ".pdf")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return empty string for non-existent extension", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": {".pdf": "https://example.com/view/pdf"},
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
Expect(appURLs.GetAppURLFor("view", ".nonexistent")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should handle empty maps gracefully", func() {
|
||||
emptyURLs := map[string]map[string]string{}
|
||||
appURLs.Store(emptyURLs)
|
||||
|
||||
Expect(appURLs.GetAppURLFor("view", ".pdf")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should handle nil action maps gracefully", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": nil,
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
Expect(appURLs.GetAppURLFor("view", ".pdf")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetMimeTypes", func() {
|
||||
It("should return empty slice for empty AppURLs", func() {
|
||||
mimeTypes := appURLs.GetMimeTypes()
|
||||
Expect(mimeTypes).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return correct mime types for known extensions", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": {
|
||||
".pdf": "https://example.com/view/pdf",
|
||||
".docx": "https://example.com/view/docx",
|
||||
".xlsx": "https://example.com/view/xlsx",
|
||||
".pptx": "https://example.com/view/pptx",
|
||||
},
|
||||
"edit": {
|
||||
".txt": "https://example.com/edit/txt",
|
||||
".html": "https://example.com/edit/html",
|
||||
},
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
mimeTypes := appURLs.GetMimeTypes()
|
||||
|
||||
// Should contain expected mime types (order doesn't matter)
|
||||
Expect(mimeTypes).To(ContainElements(
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"text/plain",
|
||||
"text/html",
|
||||
))
|
||||
|
||||
// Should not contain application/octet-stream (filtered out)
|
||||
Expect(mimeTypes).NotTo(ContainElement("application/octet-stream"))
|
||||
})
|
||||
|
||||
It("should deduplicate mime types across actions", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": {
|
||||
".pdf": "https://example.com/view/pdf",
|
||||
".txt": "https://example.com/view/txt",
|
||||
},
|
||||
"edit": {
|
||||
".pdf": "https://example.com/edit/pdf", // Same extension as view
|
||||
".txt": "https://example.com/edit/txt", // Same extension as view
|
||||
},
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
mimeTypes := appURLs.GetMimeTypes()
|
||||
|
||||
// Should only have unique mime types
|
||||
Expect(mimeTypes).To(ContainElements("application/pdf", "text/plain"))
|
||||
Expect(len(mimeTypes)).To(Equal(2)) // No duplicates
|
||||
})
|
||||
|
||||
It("should filter out application/octet-stream mime types", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": {
|
||||
".pdf": "https://example.com/view/pdf",
|
||||
".unknown": "https://example.com/view/unknown", // This might return application/octet-stream
|
||||
".fake-ext": "https://example.com/view/fake", // This might return application/octet-stream
|
||||
},
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
mimeTypes := appURLs.GetMimeTypes()
|
||||
|
||||
// Should contain PDF but not octet-stream
|
||||
Expect(mimeTypes).To(ContainElement("application/pdf"))
|
||||
Expect(mimeTypes).NotTo(ContainElement("application/octet-stream"))
|
||||
})
|
||||
|
||||
It("should handle empty extension maps", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": {},
|
||||
"edit": {},
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
mimeTypes := appURLs.GetMimeTypes()
|
||||
Expect(mimeTypes).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should handle nil extension maps", func() {
|
||||
testURLs := map[string]map[string]string{
|
||||
"view": nil,
|
||||
"edit": nil,
|
||||
}
|
||||
|
||||
appURLs.Store(testURLs)
|
||||
|
||||
mimeTypes := appURLs.GetMimeTypes()
|
||||
Expect(mimeTypes).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent Access", func() {
|
||||
It("should handle concurrent reads and writes safely", func() {
|
||||
// This is a basic smoke test for concurrent access
|
||||
// In practice, you'd want more sophisticated race testing
|
||||
|
||||
initialURLs := map[string]map[string]string{
|
||||
"view": {".pdf": "https://example.com/view/pdf"},
|
||||
}
|
||||
appURLs.Store(initialURLs)
|
||||
|
||||
done := make(chan bool, 10)
|
||||
|
||||
// Start multiple readers
|
||||
for i := 0; i < 5; i++ {
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
for j := 0; j < 100; j++ {
|
||||
_ = appURLs.GetAppURLFor("view", ".pdf")
|
||||
_ = appURLs.GetMimeTypes()
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Start multiple writers
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(id int) {
|
||||
defer GinkgoRecover()
|
||||
for j := 0; j < 100; j++ {
|
||||
newURLs := map[string]map[string]string{
|
||||
"view": {".pdf": "https://example.com/updated/pdf"},
|
||||
}
|
||||
appURLs.Store(newURLs)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Should still be functional after concurrent access
|
||||
Expect(appURLs.GetAppURLFor("view", ".pdf")).NotTo(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Real-world scenarios", func() {
|
||||
It("should handle realistic WOPI discovery data", func() {
|
||||
// Based on the test data from the discovery tests
|
||||
realisticURLs := map[string]map[string]string{
|
||||
"view": {
|
||||
".pdf": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".djvu": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".docx": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".xls": "https://cloud.qsfera.test/hosting/wopi/cell/view",
|
||||
".xlsb": "https://cloud.qsfera.test/hosting/wopi/cell/view",
|
||||
},
|
||||
"edit": {
|
||||
".docx": "https://cloud.qsfera.test/hosting/wopi/word/edit",
|
||||
},
|
||||
}
|
||||
|
||||
appURLs.Store(realisticURLs)
|
||||
|
||||
// Test specific lookups
|
||||
Expect(appURLs.GetAppURLFor("view", ".pdf")).To(Equal("https://cloud.qsfera.test/hosting/wopi/word/view"))
|
||||
Expect(appURLs.GetAppURLFor("edit", ".docx")).To(Equal("https://cloud.qsfera.test/hosting/wopi/word/edit"))
|
||||
Expect(appURLs.GetAppURLFor("edit", ".pdf")).To(BeEmpty()) // No edit for PDF
|
||||
|
||||
// Test mime types
|
||||
mimeTypes := appURLs.GetMimeTypes()
|
||||
Expect(mimeTypes).To(ContainElements(
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Discovery", func() {
|
||||
var (
|
||||
discoveryContent1 string
|
||||
srv *httptest.Server
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
discoveryContent1 = `
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<wopi-discovery>
|
||||
<net-zone name="external-http">
|
||||
<app name="Word" favIconUrl="https://cloud.qsfera.test/web-apps/apps/documenteditor/main/resources/img/favicon.ico">
|
||||
<action name="view" ext="pdf" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="embedview" ext="pdf" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?embed=1&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="view" ext="djvu" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="embedview" ext="djvu" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?embed=1&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="view" ext="docx" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="embedview" ext="docx" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?embed=1&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="editnew" ext="docx" requires="locks,update" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/edit?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="edit" ext="docx" default="true" requires="locks,update" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/edit?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
</app>
|
||||
<app name="Excel" favIconUrl="https://cloud.qsfera.test/web-apps/apps/spreadsheeteditor/main/resources/img/favicon.ico">
|
||||
<action name="view" ext="xls" urlsrc="https://cloud.qsfera.test/hosting/wopi/cell/view?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="embedview" ext="xls" urlsrc="https://cloud.qsfera.test/hosting/wopi/cell/view?embed=1&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="convert" ext="xls" targetext="xlsx" requires="update" urlsrc="https://cloud.qsfera.test/hosting/wopi/convert-and-edit/xls/xlsx?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="view" ext="xlsb" urlsrc="https://cloud.qsfera.test/hosting/wopi/cell/view?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="embedview" ext="xlsb" urlsrc="https://cloud.qsfera.test/hosting/wopi/cell/view?embed=1&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
<action name="convert" ext="xlsb" targetext="xlsx" requires="update" urlsrc="https://cloud.qsfera.test/hosting/wopi/convert-and-edit/xlsb/xlsx?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
</app>
|
||||
<app name="application/vnd.oasis.opendocument.presentation">
|
||||
<action name="edit" ext="" default="true" requires="locks,update" urlsrc="https://cloud.qsfera.test/hosting/wopi/slide/edit?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
|
||||
</app>
|
||||
</net-zone>
|
||||
<proof-key oldvalue="BgIAAACkAABSU0ExAAgAAAEAAQD/NVqekFNi8X3p6Bvdlaxm0GGuggW5kKfVEQzPGuOkGVrz6DrOMNR+k7Pq8tONY+1NHgS6Z+v3959em78qclVDuQX77Tkml0xMHAQHN4sAHF9iQJS8gOBUKSVKaHD7Z8YXch6F212YSUSc8QphpDSHWVShU7rcUeLQsd/0pkflh5+um4YKEZhm4Mou3vstp5p12NeffyK1WFZF7q4jB7jclAslYKQsP82YY3DcRwu5Tl/+W0ifVcXze0mI7v1reJ12pKn8ifRiq+0q5oJST3TRSrvmjLg9Gt3ozhVIt2HUi3La7Qh40YOAUXm0g/hUq2BepeOp1C7WSvaOFHXe6Hqq" oldmodulus="qnro3nUUjvZK1i7UqeOlXmCrVPiDtHlRgIPReAjt2nKL1GG3SBXO6N0aPbiM5rtK0XRPUoLmKu2rYvSJ/Kmkdp14a/3uiEl788VVn0hb/l9OuQtH3HBjmM0/LKRgJQuU3LgHI67uRVZYtSJ/n9fYdZqnLfveLsrgZpgRCoabrp+H5Uem9N+x0OJR3LpToVRZhzSkYQrxnERJmF3bhR5yF8Zn+3BoSiUpVOCAvJRAYl8cAIs3BwQcTEyXJjnt+wW5Q1VyKr+bXp/39+tnugQeTe1jjdPy6rOTftQwzjro81oZpOMazwwR1aeQuQWCrmHQZqyV3Rvo6X3xYlOQnlo1/w==" oldexponent="AQAB" value="BgIAAACkAABSU0ExAAgAAAEAAQD/NVqekFNi8X3p6Bvdlaxm0GGuggW5kKfVEQzPGuOkGVrz6DrOMNR+k7Pq8tONY+1NHgS6Z+v3959em78qclVDuQX77Tkml0xMHAQHN4sAHF9iQJS8gOBUKSVKaHD7Z8YXch6F212YSUSc8QphpDSHWVShU7rcUeLQsd/0pkflh5+um4YKEZhm4Mou3vstp5p12NeffyK1WFZF7q4jB7jclAslYKQsP82YY3DcRwu5Tl/+W0ifVcXze0mI7v1reJ12pKn8ifRiq+0q5oJST3TRSrvmjLg9Gt3ozhVIt2HUi3La7Qh40YOAUXm0g/hUq2BepeOp1C7WSvaOFHXe6Hqq" modulus="qnro3nUUjvZK1i7UqeOlXmCrVPiDtHlRgIPReAjt2nKL1GG3SBXO6N0aPbiM5rtK0XRPUoLmKu2rYvSJ/Kmkdp14a/3uiEl788VVn0hb/l9OuQtH3HBjmM0/LKRgJQuU3LgHI67uRVZYtSJ/n9fYdZqnLfveLsrgZpgRCoabrp+H5Uem9N+x0OJR3LpToVRZhzSkYQrxnERJmF3bhR5yF8Zn+3BoSiUpVOCAvJRAYl8cAIs3BwQcTEyXJjnt+wW5Q1VyKr+bXp/39+tnugQeTe1jjdPy6rOTftQwzjro81oZpOMazwwR1aeQuQWCrmHQZqyV3Rvo6X3xYlOQnlo1/w==" exponent="AQAB"/>
|
||||
</wopi-discovery>
|
||||
`
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/bad/hosting/discovery":
|
||||
w.WriteHeader(500)
|
||||
case "/good/hosting/discovery":
|
||||
w.Write([]byte(discoveryContent1))
|
||||
case "/wrongformat/hosting/discovery":
|
||||
w.Write([]byte("Text that <can't> be XML /form<atted/"))
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
srv.Close()
|
||||
})
|
||||
|
||||
Describe("GetAppURLs", func() {
|
||||
It("Good discovery URL", func() {
|
||||
cfg := &config.Config{
|
||||
App: config.App{
|
||||
Addr: srv.URL + "/good",
|
||||
Insecure: true,
|
||||
},
|
||||
}
|
||||
logger := log.NopLogger()
|
||||
|
||||
appUrls, err := helpers.GetAppURLs(cfg, logger)
|
||||
|
||||
expectedAppUrls := map[string]map[string]string{
|
||||
"view": map[string]string{
|
||||
".pdf": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".djvu": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".docx": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".xls": "https://cloud.qsfera.test/hosting/wopi/cell/view",
|
||||
".xlsb": "https://cloud.qsfera.test/hosting/wopi/cell/view",
|
||||
},
|
||||
"edit": map[string]string{
|
||||
".docx": "https://cloud.qsfera.test/hosting/wopi/word/edit",
|
||||
},
|
||||
}
|
||||
|
||||
Expect(err).To(Succeed())
|
||||
Expect(appUrls).To(Equal(expectedAppUrls))
|
||||
})
|
||||
|
||||
It("Wrong discovery URL", func() {
|
||||
cfg := &config.Config{
|
||||
App: config.App{
|
||||
Addr: srv.URL + "/bad",
|
||||
Insecure: true,
|
||||
},
|
||||
}
|
||||
logger := log.NopLogger()
|
||||
|
||||
appUrls, err := helpers.GetAppURLs(cfg, logger)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(appUrls).To(BeNil())
|
||||
})
|
||||
|
||||
It("Not XML formatted", func() {
|
||||
cfg := &config.Config{
|
||||
App: config.App{
|
||||
Addr: srv.URL + "/wrongformat",
|
||||
Insecure: true,
|
||||
},
|
||||
}
|
||||
logger := log.NopLogger()
|
||||
|
||||
appUrls, err := helpers.GetAppURLs(cfg, logger)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(appUrls).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
package helpers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestHelpers(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Helpers Suite")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
registryv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/registry/v1beta1"
|
||||
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
)
|
||||
|
||||
// RegisterQsferaService will register this service.
|
||||
// There are no explicit requirements for the context, and it will be passed
|
||||
// without changes to the underlying RegisterService method.
|
||||
func RegisterQsferaService(ctx context.Context, cfg *config.Config, logger log.Logger) error {
|
||||
svc := registry.BuildGRPCService(cfg.GRPC.Namespace+"."+cfg.Service.Name, cfg.GRPC.Protocol, cfg.GRPC.Addr, version.GetString())
|
||||
return registry.RegisterService(ctx, logger, svc, cfg.Debug.Addr)
|
||||
}
|
||||
|
||||
// RegisterAppProvider will register this service as app provider in REVA.
|
||||
// The GatewayAPIClient is expected to be provided via `helpers.GetCS3apiClient`.
|
||||
// The appUrls are expected to be provided via `helpers.GetAppURLs`
|
||||
//
|
||||
// Note that this method doesn't provide a re-registration mechanism, so it
|
||||
// will register the service once
|
||||
func RegisterAppProvider(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
logger log.Logger,
|
||||
gws pool.Selectable[gatewayv1beta1.GatewayAPIClient],
|
||||
appUrls *AppURLs,
|
||||
) error {
|
||||
mimeTypes := appUrls.GetMimeTypes()
|
||||
|
||||
logger.Debug().
|
||||
Str("AppName", cfg.App.Name).
|
||||
Strs("Mimetypes", mimeTypes).
|
||||
Msg("Registering mimetypes in the app provider")
|
||||
|
||||
// TODO: an added app provider shouldn't last forever. Instead the registry should use a TTL
|
||||
// and delete providers that didn't register again. If an app provider dies or get's disconnected,
|
||||
// the users will be no longer available to choose to open a file with it (currently, opening a file just fails)
|
||||
req := ®istryv1beta1.AddAppProviderRequest{
|
||||
Provider: ®istryv1beta1.ProviderInfo{
|
||||
Name: cfg.App.Name,
|
||||
Description: cfg.App.Description,
|
||||
Icon: cfg.App.Icon,
|
||||
Address: cfg.GRPC.Namespace + "." + cfg.Service.Name,
|
||||
MimeTypes: mimeTypes,
|
||||
ProductName: cfg.App.Product,
|
||||
},
|
||||
}
|
||||
gwc, err := gws.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := gwc.AddAppProvider(ctx, req)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("AddAppProvider failed")
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
|
||||
logger.Error().Str("status_code", resp.GetStatus().GetCode().String()).Msg("AddAppProvider failed")
|
||||
return errors.New("status code != CODE_OK")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package locks provides functionality to parse lockIDs.
|
||||
//
|
||||
// It can be used to bridge requests from different clients that send lockIDs in different formats.
|
||||
// For example, Microsoft Office Online sends the lockID in a JSON string,
|
||||
// while other clients send the lockID as a plain string.
|
||||
package locks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// LockParser is the interface that wraps the ParseLock method
|
||||
type LockParser interface {
|
||||
ParseLock(id string) string
|
||||
}
|
||||
|
||||
// LegacyLockParser is a lock parser that can extract the lockID from a JSON string
|
||||
type LegacyLockParser struct{}
|
||||
|
||||
// NoopLockParser is a lock parser that does not change the lockID
|
||||
type NoopLockParser struct{}
|
||||
|
||||
// ParseLock will return the lockID as is
|
||||
func (*NoopLockParser) ParseLock(id string) string {
|
||||
return id
|
||||
}
|
||||
|
||||
// ParseLock extracts the lockID from a JSON string.
|
||||
// For Microsoft Office Online we need to extract the lockID from the JSON string
|
||||
// that is sent by the WOPI client.
|
||||
// The JSON string is expected to have the following format:
|
||||
//
|
||||
// {
|
||||
// "L": "12345678",
|
||||
// "F": 4,
|
||||
// "E": 2,
|
||||
// "C": "",
|
||||
// "P": "3453345345346",
|
||||
// "M": "12345678"
|
||||
// }
|
||||
//
|
||||
// or
|
||||
//
|
||||
// {
|
||||
// "S": "12345678",
|
||||
// "F": 4,
|
||||
// "E": 2,
|
||||
// "C": "",
|
||||
// "P": "3453345345346",
|
||||
// "M": "12345678"
|
||||
// }
|
||||
//
|
||||
// If the JSON string is not in the expected format, the original lockID will be returned.
|
||||
func (*LegacyLockParser) ParseLock(id string) string {
|
||||
var decodedValues map[string]any
|
||||
err := json.Unmarshal([]byte(id), &decodedValues)
|
||||
if err != nil || len(decodedValues) == 0 {
|
||||
return id
|
||||
}
|
||||
if v, ok := decodedValues["L"]; ok {
|
||||
if idString, ok := v.(string); ok {
|
||||
return idString
|
||||
}
|
||||
}
|
||||
if v, ok := decodedValues["S"]; ok {
|
||||
if idString, ok := v.(string); ok {
|
||||
return idString
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package locks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLegacyLockParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lock string
|
||||
cleanLock string
|
||||
}{
|
||||
{
|
||||
name: "JsonStringWithLKey",
|
||||
lock: createJsonString(map[string]any{"L": "12345678", "F": 4, "E": 2, "C": "", "P": "3453345345346", "M": "12345678"}),
|
||||
cleanLock: "12345678",
|
||||
},
|
||||
{
|
||||
name: "JsonStringWithSKey",
|
||||
lock: createJsonString(map[string]any{"S": "12345678", "F": 4, "E": 2, "C": "", "P": "3453345345346", "M": "12345678"}),
|
||||
cleanLock: "12345678",
|
||||
},
|
||||
{
|
||||
name: "PlainString",
|
||||
lock: "12345678",
|
||||
cleanLock: "12345678",
|
||||
},
|
||||
{
|
||||
name: "JsonStringUnknownFormat",
|
||||
lock: createJsonString(map[string]any{"A": "12345678", "F": 4, "E": 2, "C": "", "P": "3453345345346", "X": "12345678"}),
|
||||
cleanLock: `{"A":"12345678","C":"","E":2,"F":4,"P":"3453345345346","X":"12345678"}`,
|
||||
},
|
||||
{
|
||||
name: "InvalidJsonString",
|
||||
lock: `"A":"12345678","C":"","E":2,"F":4,"P":"3453345345346","X":"12345678"}`,
|
||||
cleanLock: `"A":"12345678","C":"","E":2,"F":4,"P":"3453345345346","X":"12345678"}`,
|
||||
},
|
||||
{
|
||||
name: "EmptyString",
|
||||
lock: "",
|
||||
cleanLock: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
lockParser := &LegacyLockParser{}
|
||||
lock := lockParser.ParseLock(test.lock)
|
||||
assert.Equal(t, test.cleanLock, lock)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopLockParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lock string
|
||||
}{
|
||||
{
|
||||
name: "PlainString",
|
||||
lock: "123",
|
||||
},
|
||||
{
|
||||
name: "EmptyString",
|
||||
lock: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
lockParser := &NoopLockParser{}
|
||||
lock := lockParser.ParseLock(test.lock)
|
||||
assert.Equal(t, test.lock, lock)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createJsonString(input map[string]any) string {
|
||||
rawData, err := json.Marshal(&input)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(rawData)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// AccessLog is a middleware to log http requests at info level logging.
|
||||
func AccessLog(logger log.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
requestID := middleware.GetReqID(r.Context())
|
||||
// add Request Id to all responses
|
||||
w.Header().Set(middleware.RequestIDHeader, requestID)
|
||||
wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(wrap, r)
|
||||
|
||||
spanContext := trace.SpanContextFromContext(r.Context())
|
||||
logger.Info().
|
||||
Str("proto", r.Proto).
|
||||
Str(log.RequestIDString, requestID).
|
||||
Str("traceid", spanContext.TraceID().String()).
|
||||
Str("remote-addr", r.RemoteAddr).
|
||||
Str("method", r.Method).
|
||||
Str("wopi-action", r.Header.Get("X-WOPI-Override")).
|
||||
Int("status", wrap.Status()).
|
||||
Str("path", r.URL.Path).
|
||||
Dur("duration", time.Since(start)).
|
||||
Int("bytes", wrap.BytesWritten()).
|
||||
Msg("access-log")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
// Claims contains the jwt registered claims plus the used WOPI context
|
||||
type Claims struct {
|
||||
WopiContext WopiContext `json:"WopiContext"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// keyPadding will add the required zero padding to the provided key.
|
||||
// The resulting key will have a length of either 16, 24 or 32 bytes.
|
||||
// If the key has more than 32 bytes, only the first 32 bytes will be returned.
|
||||
func keyPadding(key []byte) []byte {
|
||||
switch length := len(key); {
|
||||
case length < 16:
|
||||
return append(key, make([]byte, 16-length)...)
|
||||
case length == 16:
|
||||
return key
|
||||
case length < 24:
|
||||
return append(key, make([]byte, 24-length)...)
|
||||
case length == 24:
|
||||
return key
|
||||
case length < 32:
|
||||
return append(key, make([]byte, 32-length)...)
|
||||
case length == 32:
|
||||
return key
|
||||
case length > 32:
|
||||
return key[:32]
|
||||
}
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
// EncryptAES encrypts the provided plainText using the provided key.
|
||||
// AES CFB will be used as cryptographic method.
|
||||
// Use DecryptAES to decrypt the resulting string
|
||||
func EncryptAES(key []byte, plainText string) (string, error) {
|
||||
src := []byte(plainText)
|
||||
|
||||
block, err := aes.NewCipher(keyPadding(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cipherText := make([]byte, aes.BlockSize+len(src))
|
||||
iv := cipherText[:aes.BlockSize]
|
||||
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stream := cipher.NewCFBEncrypter(block, iv)
|
||||
stream.XORKeyStream(cipherText[aes.BlockSize:], src)
|
||||
|
||||
return base64.URLEncoding.EncodeToString(cipherText), nil
|
||||
}
|
||||
|
||||
// DecryptAES decrypts the provided string using the provided key.
|
||||
// The provided string must have been encrypted with AES CFB.
|
||||
// This method will decrypt the result from the EncryptAES method
|
||||
func DecryptAES(key []byte, securemess string) (string, error) {
|
||||
cipherText, err := base64.URLEncoding.DecodeString(securemess)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(keyPadding(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(cipherText) < aes.BlockSize {
|
||||
return "", errors.New("ciphertext block size is too short")
|
||||
}
|
||||
|
||||
iv := cipherText[:aes.BlockSize]
|
||||
cipherText = cipherText[aes.BlockSize:]
|
||||
|
||||
stream := cipher.NewCFBDecrypter(block, iv)
|
||||
stream.XORKeyStream(cipherText, cipherText)
|
||||
|
||||
return string(cipherText), nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Middleware Suite")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/proofkeys"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// ProofKeysMiddleware will verify the proof keys of the requests.
|
||||
// This is a middleware that could be disabled / not set.
|
||||
//
|
||||
// Requests will fail with a 500 HTTP status if the verification fails.
|
||||
// As said, this can be disabled (via configuration) if you want to skip
|
||||
// the verification.
|
||||
// The middleware requires hitting the "/hosting/discovery" endpoint of the
|
||||
// WOPI app in order to get the keys. The keys will be cached in memory for
|
||||
// 12 hours (or the configured value) before hitting the endpoint again to
|
||||
// request new / updated keys.
|
||||
func ProofKeysMiddleware(cfg *config.Config, next http.Handler) http.Handler {
|
||||
wopiDiscovery := cfg.App.Addr + "/hosting/discovery"
|
||||
insecure := cfg.App.Insecure
|
||||
cacheDuration, err := time.ParseDuration(cfg.App.ProofKeys.Duration)
|
||||
if err != nil {
|
||||
cacheDuration = 12 * time.Hour
|
||||
}
|
||||
|
||||
pkHandler := proofkeys.NewVerifyHandler(wopiDiscovery, insecure, cacheDuration)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger := zerolog.Ctx(r.Context())
|
||||
|
||||
// the url we need is the one being requested, but we need the
|
||||
// scheme and host, so we'll get those from the configured WOPISrc
|
||||
wopiSrcURL, _ := url.Parse(cfg.Wopi.WopiSrc)
|
||||
if cfg.Wopi.ProxyURL != "" {
|
||||
wopiSrcURL, _ = url.Parse(cfg.Wopi.ProxyURL)
|
||||
}
|
||||
currentURL, _ := url.Parse(r.URL.String())
|
||||
currentURL.Scheme = wopiSrcURL.Scheme
|
||||
currentURL.Host = wopiSrcURL.Host
|
||||
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
stamp := r.Header.Get("X-WOPI-TimeStamp")
|
||||
|
||||
err := pkHandler.Verify(
|
||||
accessToken,
|
||||
currentURL.String(),
|
||||
stamp,
|
||||
r.Header.Get("X-WOPI-Proof"),
|
||||
r.Header.Get("X-WOPI-ProofOld"),
|
||||
proofkeys.VerifyWithLogger(logger),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("ProofKeys verification failed")
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Msg("ProofKeys verified")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// CollaborationTracingMiddleware adds a new middleware in order to include
|
||||
// more attributes in the traced span.
|
||||
//
|
||||
// In order not to mess with the expected responses, this middleware won't do
|
||||
// anything if there is no available WOPI context set in the request (there is
|
||||
// nothing to report). This means that the WopiContextAuthMiddleware should be
|
||||
// set before this middleware.
|
||||
func CollaborationTracingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wopiContext, err := WopiContextFromCtx(r.Context())
|
||||
if err != nil {
|
||||
// if we can't get the context, skip this middleware
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
span := trace.SpanFromContext(r.Context())
|
||||
|
||||
wopiMethod := r.Header.Get("X-WOPI-Override")
|
||||
|
||||
wopiFile := wopiContext.FileReference
|
||||
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("wopi.session.id", r.Header.Get("X-WOPI-SessionId")),
|
||||
attribute.String("wopi.method", wopiMethod),
|
||||
attribute.String("cs3.resource.id.storage", wopiFile.GetResourceId().GetStorageId()),
|
||||
attribute.String("cs3.resource.id.opaque", wopiFile.GetResourceId().GetOpaqueId()),
|
||||
attribute.String("cs3.resource.id.space", wopiFile.GetResourceId().GetSpaceId()),
|
||||
attribute.String("cs3.resource.path", wopiFile.GetPath()),
|
||||
}
|
||||
|
||||
if wopiUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
|
||||
attrs = append(attrs, []attribute.KeyValue{
|
||||
attribute.String("enduser.id", wopiUser.GetId().GetOpaqueId()),
|
||||
attribute.String("cs3.user.idp", wopiUser.GetId().GetIdp()),
|
||||
attribute.String("cs3.user.opaque", wopiUser.GetId().GetOpaqueId()),
|
||||
attribute.String("cs3.user.type", wopiUser.GetId().GetType().String()),
|
||||
}...)
|
||||
}
|
||||
span.SetAttributes(attrs...)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
rjwt "github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
||||
"github.com/rs/zerolog"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type key int
|
||||
|
||||
const (
|
||||
wopiContextKey key = iota
|
||||
)
|
||||
|
||||
// WopiContext wraps all the information we need for WOPI
|
||||
type WopiContext struct {
|
||||
AccessToken string
|
||||
ViewOnlyToken string
|
||||
FileReference *providerv1beta1.Reference
|
||||
TemplateReference *providerv1beta1.Reference
|
||||
ViewMode appproviderv1beta1.ViewMode
|
||||
}
|
||||
|
||||
// WopiContextAuthMiddleware will prepare an HTTP handler to be used as
|
||||
// middleware. The handler will create a WopiContext by parsing the
|
||||
// access_token (which must be provided as part of the URL query).
|
||||
// The access_token is required.
|
||||
//
|
||||
// This middleware will add the following to the request's context:
|
||||
// * The access token as metadata for outgoing requests (for the
|
||||
// authentication against the CS3 API, the "x-access-token" header).
|
||||
// * The created WopiContext for the request
|
||||
// * A contextual zerologger containing information about the request
|
||||
// and the WopiContext
|
||||
func WopiContextAuthMiddleware(cfg *config.Config, st microstore.Store, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// include additional info in the context's logger
|
||||
// we might need to check https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/common-headers
|
||||
// although some headers might not be sent depending on the client.
|
||||
logger := zerolog.Ctx(ctx)
|
||||
wopiLogger := logger.With().
|
||||
Str("WopiSessionId", r.Header.Get("X-WOPI-SessionId")).
|
||||
Str("WopiOverride", r.Header.Get("X-WOPI-Override")).
|
||||
Str("WopiProof", r.Header.Get("X-WOPI-Proof")).
|
||||
Str("WopiProofOld", r.Header.Get("X-WOPI-ProofOld")).
|
||||
Str("WopiStamp", r.Header.Get("X-WOPI-TimeStamp")).
|
||||
Logger()
|
||||
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
if accessToken == "" {
|
||||
wopiLogger.Error().Msg("missing access token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.Wopi.ShortTokens {
|
||||
records, err := st.Read(accessToken)
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("cannot retrieve access token from store")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if len(records) != 1 {
|
||||
wopiLogger.Error().Int("records", len(records)).Msg("no record found for the token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken = string(records[0].Value)
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (any, error) {
|
||||
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(cfg.Wopi.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("failed to parse jwt token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
wopiContextAccessToken, err := DecryptAES([]byte(cfg.Wopi.Secret), claims.WopiContext.AccessToken)
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("failed to decrypt reva access token")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
tokenManager, err := rjwt.New(map[string]any{
|
||||
"secret": cfg.TokenManager.JWTSecret,
|
||||
"expires": int64(24 * 60 * 60),
|
||||
})
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("failed to get a reva token manager")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user, scopes, err := tokenManager.DismantleToken(ctx, wopiContextAccessToken)
|
||||
if err != nil {
|
||||
wopiLogger.Error().Err(err).Msg("failed to dismantle reva token manager")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims.WopiContext.AccessToken = wopiContextAccessToken
|
||||
|
||||
ctx = context.WithValue(ctx, wopiContextKey, claims.WopiContext)
|
||||
// authentication for the CS3 api
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, claims.WopiContext.AccessToken)
|
||||
ctx = ctxpkg.ContextSetUser(ctx, user)
|
||||
ctx = ctxpkg.ContextSetScopes(ctx, scopes)
|
||||
|
||||
// include additional info in the context's logger
|
||||
wopiLogger = wopiLogger.With().
|
||||
Str("FileReference", claims.WopiContext.FileReference.String()).
|
||||
Str("ViewMode", claims.WopiContext.ViewMode.String()).
|
||||
Str("Requester", user.GetId().String()).
|
||||
Logger()
|
||||
ctx = wopiLogger.WithContext(ctx)
|
||||
|
||||
// Validate that the file ID in the URL matches the WOPI token's file
|
||||
// reference. This check only applies to /wopi/files/ and /wopi/templates/
|
||||
// paths. Other WOPI-authenticated endpoints (e.g. /wopi/avatars/) don't
|
||||
// carry a file ID in the URL — they only need a valid WOPI token.
|
||||
if strings.Contains(r.URL.Path, "/files/") || strings.Contains(r.URL.Path, "/templates/") {
|
||||
hashedRef := helpers.HashResourceId(claims.WopiContext.FileReference.GetResourceId())
|
||||
fileID := parseWopiFileID(cfg, r.URL.Path)
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
// Extract a WopiContext from the context if possible. An error will be
|
||||
// returned if there is no WopiContext
|
||||
func WopiContextFromCtx(ctx context.Context) (WopiContext, error) {
|
||||
if wopiContext, ok := ctx.Value(wopiContextKey).(WopiContext); ok {
|
||||
return wopiContext, nil
|
||||
}
|
||||
return WopiContext{}, errors.New("no wopi context found")
|
||||
}
|
||||
|
||||
// Set a WopiContext in the context. A new context will be returned with the
|
||||
// add WopiContext inside. Note that the old one won't have the WopiContext set.
|
||||
//
|
||||
// This method is used for testing. The WopiContextAuthMiddleware should be
|
||||
// used instead in order to provide a valid WopiContext
|
||||
func WopiContextToCtx(ctx context.Context, wopiContext WopiContext) context.Context {
|
||||
return context.WithValue(ctx, wopiContextKey, wopiContext)
|
||||
}
|
||||
|
||||
// The access token inside the wopiContext is expected to be decrypted.
|
||||
// In order to generate the access token for WOPI, the reva token inside the
|
||||
// wopiContext will be encrypted
|
||||
func GenerateWopiToken(wopiContext WopiContext, cfg *config.Config, st microstore.Store) (string, int64, error) {
|
||||
if cfg.Wopi.ShortTokens && st == nil {
|
||||
return "", 0, errors.New("Cannot generate a short token without microstore")
|
||||
}
|
||||
|
||||
cryptedReqAccessToken, err := EncryptAES([]byte(cfg.Wopi.Secret), wopiContext.AccessToken)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
cs3Claims := &jwt.RegisteredClaims{}
|
||||
cs3JWTparser := jwt.Parser{}
|
||||
_, _, err = cs3JWTparser.ParseUnverified(wopiContext.AccessToken, cs3Claims)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
wopiContext.AccessToken = cryptedReqAccessToken
|
||||
claims := &Claims{
|
||||
WopiContext: wopiContext,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: cs3Claims.ExpiresAt,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessToken, err := token.SignedString([]byte(cfg.Wopi.Secret))
|
||||
|
||||
if cfg.Wopi.ShortTokens {
|
||||
c := md5.New()
|
||||
c.Write([]byte(accessToken))
|
||||
shortAccessToken := hex.EncodeToString(c.Sum(nil)) + strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
|
||||
errWrite := st.Write(µstore.Record{
|
||||
Key: shortAccessToken,
|
||||
Value: []byte(accessToken),
|
||||
Expiry: time.Until(claims.ExpiresAt.Time),
|
||||
})
|
||||
|
||||
return shortAccessToken, claims.ExpiresAt.UnixMilli(), errWrite
|
||||
}
|
||||
|
||||
return accessToken, claims.ExpiresAt.UnixMilli(), err
|
||||
}
|
||||
|
||||
// parseWopiFileID extracts the file id from a wopi path
|
||||
//
|
||||
// If the file id is a jwt, it will be decoded and the file id will be extracted from the jwt claims.
|
||||
// If the file id is not a jwt, it will be returned as is.
|
||||
func parseWopiFileID(cfg *config.Config, path string) string {
|
||||
s := strings.Split(path, "/")
|
||||
if len(s) < 4 || (s[1] != "wopi" && s[2] != "files") {
|
||||
return path
|
||||
}
|
||||
// check if the fileid is a jwt
|
||||
if strings.Contains(s[3], ".") {
|
||||
token, err := jwt.Parse(s[3], func(_ *jwt.Token) (any, error) {
|
||||
return []byte(cfg.Wopi.ProxySecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return s[3]
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return s[3]
|
||||
}
|
||||
|
||||
f, ok := claims["f"].(string)
|
||||
if !ok {
|
||||
return s[3]
|
||||
}
|
||||
return f
|
||||
}
|
||||
// fileid is not a jwt
|
||||
return s[3]
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
appprovider "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/middleware"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/wopisrc"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/token"
|
||||
rjwt "github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
||||
)
|
||||
|
||||
var _ = Describe("Wopi Context Middleware", func() {
|
||||
var (
|
||||
cfg *config.Config
|
||||
ctx context.Context
|
||||
mw http.Handler
|
||||
rid *providerv1beta1.ResourceId
|
||||
tknMngr token.Manager
|
||||
user *userv1beta1.User
|
||||
src *url.URL
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
cfg = &config.Config{
|
||||
TokenManager: &config.TokenManager{JWTSecret: "jwtSecret"},
|
||||
Wopi: config.Wopi{
|
||||
Secret: "wopiSecret",
|
||||
WopiSrc: "https://localhost:9300",
|
||||
},
|
||||
}
|
||||
|
||||
ctx = context.Background()
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mw = middleware.WopiContextAuthMiddleware(cfg, nil, next)
|
||||
|
||||
tknMngr, err = rjwt.New(map[string]any{
|
||||
"secret": cfg.TokenManager.JWTSecret,
|
||||
"expires": int64(24 * 60 * 60),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
user = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "example.com",
|
||||
OpaqueId: "12345",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "admin",
|
||||
Mail: "admin@example.com",
|
||||
}
|
||||
|
||||
rid = &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID",
|
||||
SpaceId: "spaceID",
|
||||
}
|
||||
|
||||
src, err = url.Parse(cfg.Wopi.WopiSrc)
|
||||
src.Path = path.Join("wopi", "files", helpers.HashResourceId(rid))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
It("Should not authorize with empty access token", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
It("Should not authorize with malformed access token", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
q := req.URL.Query()
|
||||
q.Add("access_token", "token")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
mw.ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
It("Should not authorize when fileID mismatches", func() {
|
||||
req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx)
|
||||
// create request with different fileID in the wopi context
|
||||
token, err := tknMngr.MintToken(ctx, user, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: token,
|
||||
ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID2",
|
||||
SpaceId: "spaceID",
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
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 wrong wopi secret", func() {
|
||||
src.Path = path.Join("wopi", "files", helpers.HashResourceId(rid))
|
||||
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,
|
||||
}
|
||||
// use wrong wopi secret when generating the wopi token
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, &config.Config{Wopi: config.Wopi{
|
||||
Secret: "wrongSecret",
|
||||
}}, nil)
|
||||
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 authorize successful", 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,
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: rid,
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
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 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, nil)
|
||||
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, nil)
|
||||
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"
|
||||
src, err := wopisrc.GenerateWopiSrc(helpers.HashResourceId(rid), cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
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,
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: &providerv1beta1.ResourceId{
|
||||
StorageId: "storageID",
|
||||
OpaqueId: "opaqueID3",
|
||||
SpaceId: "spaceID",
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
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 authorize successful with proxy", func() {
|
||||
cfg.Wopi.ProxySecret = "proxySecret"
|
||||
cfg.Wopi.ProxyURL = "https://proxy"
|
||||
src, err := wopisrc.GenerateWopiSrc(helpers.HashResourceId(rid), cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
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,
|
||||
FileReference: &providerv1beta1.Reference{
|
||||
ResourceId: rid,
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg, nil)
|
||||
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))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
package proofkeys
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type PubKeys struct {
|
||||
Key *rsa.PublicKey
|
||||
OldKey *rsa.PublicKey
|
||||
ExpireTime time.Time
|
||||
}
|
||||
|
||||
type Verifier interface {
|
||||
Verify(accessToken, url, timestamp, sig64, oldSig64 string, opts ...VerifyOption) error
|
||||
}
|
||||
|
||||
type VerifyHandler struct {
|
||||
discoveryURL string
|
||||
insecure bool
|
||||
cachedKeys *PubKeys
|
||||
cachedDur time.Duration
|
||||
}
|
||||
|
||||
// NewVerifyHandler will return a new Verifier with the provided parameters
|
||||
// The discoveryURL must point to the https://office.wopi/hosting/discovery
|
||||
// address, which contains the xml with the proof keys (and more information)
|
||||
// The insecure parameter can be used to disable certificate verification when
|
||||
// conecting to the provided discoveryURL
|
||||
// CachedDur is the duration the keys will be cached in memory. The cached keys
|
||||
// will be used for the duration provided, after that new keys will be fetched
|
||||
// from the discoveryURL.
|
||||
//
|
||||
// For WOPI apps whose proof keys rotate after a while, you must ensure that
|
||||
// the provided duration is shorter than the rotation time. This should
|
||||
// guarantee that we can't fail to verify a request due to obsolete keys.
|
||||
func NewVerifyHandler(discoveryURL string, insecure bool, cachedDur time.Duration) Verifier {
|
||||
return &VerifyHandler{
|
||||
discoveryURL: discoveryURL,
|
||||
insecure: insecure,
|
||||
cachedDur: cachedDur,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the request comes from a trusted source
|
||||
// All the provided parameters are strings:
|
||||
// * accessToken: The access token used for this request (targeting this collaboration service)
|
||||
// * url: The full url for this request, including scheme, host and all query parameters,
|
||||
// something like "https://wopi.qsfera.test/wopi/file/abcbcbd?access_token=oiuiu" or
|
||||
// "http://wopiserver:8888/wopi/file/abcdef?access_token=zzxxyy"
|
||||
// * timestamp: The timestamp provided by the WOPI app in the "X-WOPI-TimeStamp" header, as string
|
||||
// * sig64: The base64-encoded signature, which should come directly from the "X-WOPI-Proof" header
|
||||
// * oldSig64: The base64-encoded previous signature, coming from the "X-WOPI-ProofOld" header
|
||||
//
|
||||
// The public keys will be obtained from the /hosting/discovery path of the target WOPI app.
|
||||
// Note that the method will perform the following checks in that order:
|
||||
// * current signature with the current key
|
||||
// * old signature with the current key
|
||||
// * current signature with the old key
|
||||
// If all of those checks are wrong, the method will fail, and the request should be rejected.
|
||||
//
|
||||
// The method will return an error if something fails, or nil if everything is ok
|
||||
func (vh *VerifyHandler) Verify(accessToken, url, timestamp, sig64, oldSig64 string, opts ...VerifyOption) error {
|
||||
verifyOptions := newOptions(opts...)
|
||||
|
||||
// check timestamp
|
||||
if err := vh.checkTimestamp(timestamp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// need to decode the signatures
|
||||
signature, err := base64.StdEncoding.DecodeString(sig64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var oldSignature []byte
|
||||
if oldSig64 != "" {
|
||||
if oldSig, err := base64.StdEncoding.DecodeString(oldSig64); err != nil {
|
||||
return err
|
||||
} else {
|
||||
oldSignature = oldSig
|
||||
}
|
||||
}
|
||||
|
||||
pubkeys := vh.cachedKeys
|
||||
if pubkeys == nil || pubkeys.ExpireTime.Before(time.Now()) {
|
||||
// fetch the public keys
|
||||
newpubkeys, err := vh.fetchPublicKeys(verifyOptions.Logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pubkeys = newpubkeys
|
||||
vh.cachedKeys = newpubkeys
|
||||
}
|
||||
|
||||
// build and hash the expected proof
|
||||
expectedProof := vh.generateProof(accessToken, url, timestamp)
|
||||
hashedProof := sha256.Sum256(expectedProof)
|
||||
|
||||
// verify
|
||||
if err := rsa.VerifyPKCS1v15(pubkeys.Key, crypto.SHA256, hashedProof[:], signature); err != nil {
|
||||
if err := rsa.VerifyPKCS1v15(pubkeys.Key, crypto.SHA256, hashedProof[:], oldSignature); err != nil {
|
||||
if pubkeys.OldKey != nil {
|
||||
return rsa.VerifyPKCS1v15(pubkeys.OldKey, crypto.SHA256, hashedProof[:], signature)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTimestamp will check if the provided timestamp is valid.
|
||||
// The timestamp is valid if it isn't older than 20 minutes (info from
|
||||
// MS WOPI docs).
|
||||
//
|
||||
// Note: the timestamp is based on C# DateTime.UtcNow.Ticks
|
||||
// "One tick equals 100 nanoseconds. The value of this property represents
|
||||
// the number of ticks that have elapsed since 12:00:00 midnight, January 1, 0001."
|
||||
// It is NOT a unix timestamp (current unix timestamp ~1718123417 secs ;
|
||||
// expected timestamp ~638537195321890000 100-nanosecs)
|
||||
func (vh *VerifyHandler) checkTimestamp(timestamp string) error {
|
||||
// set the stamp
|
||||
stamp, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New("Invalid timestamp")
|
||||
}
|
||||
|
||||
// 62135596800 seconds from "January 1, 1 AD" to "January 1, 1970 12:00:00 AM"
|
||||
// need to convert those secs into 100-nanosecs in order to compare the stamp
|
||||
unixBaseStamp := int64(62135596800 * 1000 * 1000 * 10)
|
||||
|
||||
// stamp - unixBaseStamp gives us the unix-based timestamp we can use
|
||||
unixStamp := stamp - unixBaseStamp
|
||||
|
||||
// divide between 1000*1000*10 to get the seconds and 100-nanoseconds
|
||||
unixStampSec := unixStamp / (1000 * 1000 * 10)
|
||||
unixStampNanoSec := (unixStamp % (1000 * 1000 * 10)) * 100
|
||||
|
||||
// both seconds and nanoseconds should be within int64 range
|
||||
convertedUnixTimestamp := time.Unix(unixStampSec, unixStampNanoSec)
|
||||
|
||||
if time.Now().After(convertedUnixTimestamp.Add(20 * time.Minute)) {
|
||||
return errors.New("Timestamp expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateProof will generated a expected proof to be verified later.
|
||||
// The method will return a slice of bytes with the proof (consider it binary
|
||||
// data).
|
||||
// The bytes will need to be hashed later in order to perform the verification
|
||||
func (vh *VerifyHandler) generateProof(accessToken, url, timestamp string) []byte {
|
||||
tokenBytes := []byte(accessToken)
|
||||
tokenLen := len(tokenBytes)
|
||||
tokenLenBytes := big.NewInt(int64(tokenLen)).FillBytes(make([]byte, 4))
|
||||
|
||||
// url needs to be uppercase
|
||||
urlBytes := []byte(strings.ToUpper(url))
|
||||
urlLen := len(urlBytes)
|
||||
urlLenBytes := big.NewInt(int64(urlLen)).FillBytes(make([]byte, 4))
|
||||
|
||||
stampBigInt, _ := new(big.Int).SetString(timestamp, 10)
|
||||
stampBytes := stampBigInt.FillBytes(make([]byte, 8))
|
||||
stampLen := len(stampBytes)
|
||||
stampLenBytes := big.NewInt(int64(stampLen)).FillBytes(make([]byte, 4))
|
||||
|
||||
proof := new(bytes.Buffer)
|
||||
proof.Write(tokenLenBytes)
|
||||
proof.Write(tokenBytes)
|
||||
proof.Write(urlLenBytes)
|
||||
proof.Write(urlBytes)
|
||||
proof.Write(stampLenBytes)
|
||||
proof.Write(stampBytes)
|
||||
return proof.Bytes()
|
||||
}
|
||||
|
||||
// fetchPublicKeys will fetch the public keys from the /hosting/discovery URL
|
||||
// of the provided WOPI app.
|
||||
// It will return a PubKeys struct to hold the public keys based on the modulus
|
||||
// and exponent found.
|
||||
// The PubKeys returned might be either nil (with the non-nil error), or might
|
||||
// contain only a PubKeys.Key field (the PubKeys.OldKey might be nil)
|
||||
func (vh *VerifyHandler) fetchPublicKeys(logger *zerolog.Logger) (*PubKeys, error) {
|
||||
logger.Debug().Str("WopiAppUrl", vh.discoveryURL).Msg("WopiDiscovery: requesting new public keys")
|
||||
|
||||
httpClient := http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: vh.insecure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
httpResp, err := httpClient.Get(vh.discoveryURL)
|
||||
if err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("WopiAppUrl", vh.discoveryURL).
|
||||
Msg("WopiDiscovery: failed to access wopi app url")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
logger.Error().
|
||||
Str("WopiAppUrl", vh.discoveryURL).
|
||||
Int("HttpCode", httpResp.StatusCode).
|
||||
Msg("WopiDiscovery: wopi app url failed with unexpected code")
|
||||
return nil, errors.New("wopi app url failed with unexpected code")
|
||||
}
|
||||
|
||||
doc := etree.NewDocument()
|
||||
if _, err := doc.ReadFrom(httpResp.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
root := doc.SelectElement("wopi-discovery")
|
||||
if root == nil {
|
||||
return nil, errors.New("wopi-discovery element not found in the XML body")
|
||||
}
|
||||
|
||||
proofKey := root.SelectElement("proof-key")
|
||||
if proofKey == nil {
|
||||
return nil, errors.New("proof-key element not found in the XML body")
|
||||
}
|
||||
|
||||
mod64 := proofKey.SelectAttrValue("modulus", "")
|
||||
exp64 := proofKey.SelectAttrValue("exponent", "")
|
||||
oldMod64 := proofKey.SelectAttrValue("oldmodulus", "")
|
||||
oldExp64 := proofKey.SelectAttrValue("oldexponent", "")
|
||||
|
||||
if mod64 == "" || exp64 == "" {
|
||||
return nil, errors.New("modulus or exponent not found in the proof-key element")
|
||||
}
|
||||
|
||||
keys := &PubKeys{
|
||||
Key: vh.keyFromBase64(mod64, exp64),
|
||||
ExpireTime: time.Now().Add(vh.cachedDur),
|
||||
}
|
||||
|
||||
if oldMod64 != "" && oldExp64 != "" {
|
||||
keys.OldKey = vh.keyFromBase64(oldMod64, oldExp64)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// keyFromBase64 will create a rsa public key from the provided modulus and
|
||||
// exponent, both encoded with base64.
|
||||
// If any of the provided strings can't be decoded, nil will be returned.
|
||||
func (vh *VerifyHandler) keyFromBase64(mod64, exp64 string) *rsa.PublicKey {
|
||||
dataMod, err := base64.StdEncoding.DecodeString(mod64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dataE, err := base64.StdEncoding.DecodeString(exp64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pub := &rsa.PublicKey{
|
||||
N: new(big.Int).SetBytes(dataMod),
|
||||
E: int(new(big.Int).SetBytes(dataE).Int64()),
|
||||
}
|
||||
return pub
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package proofkeys
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// VerifyOption defines a single option function.
|
||||
type VerifyOption func(o *VerifyOptions)
|
||||
|
||||
// VerifyOptions defines the available options for the Verify function.
|
||||
type VerifyOptions struct {
|
||||
Logger *zerolog.Logger
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...VerifyOption) VerifyOptions {
|
||||
defaultLog := zerolog.Nop()
|
||||
opt := VerifyOptions{
|
||||
//Logger: log.NopLogger(), // use a NopLogger by default
|
||||
Logger: &defaultLog, // use a NopLogger by default
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// VerifyWithLogger provides a function to set the Logger option.
|
||||
func VerifyWithLogger(val *zerolog.Logger) VerifyOption {
|
||||
return func(o *VerifyOptions) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/checks"
|
||||
"github.com/qsfera/server/pkg/handlers"
|
||||
"github.com/qsfera/server/pkg/service/debug"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
checkHandler := handlers.NewCheckHandler(
|
||||
handlers.NewCheckHandlerConfiguration().
|
||||
WithLogger(options.Logger).
|
||||
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr)).
|
||||
WithCheck("grpc reachability", checks.NewGRPCCheck(options.Config.GRPC.Addr)),
|
||||
)
|
||||
|
||||
return debug.NewService(
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Health(checkHandler),
|
||||
debug.Ready(checkHandler),
|
||||
//debug.CorsAllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
|
||||
//debug.CorsAllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
|
||||
//debug.CorsAllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
|
||||
//debug.CorsAllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
AppURLs *helpers.AppURLs
|
||||
Name string
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
TraceProvider trace.TracerProvider
|
||||
Store microstore.Store
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// AppURLs provides app urls based on mimetypes.
|
||||
func AppURLs(val *helpers.AppURLs) Option {
|
||||
return func(o *Options) {
|
||||
o.AppURLs = val
|
||||
}
|
||||
}
|
||||
|
||||
// Name provides a name for the service.
|
||||
func Name(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.Name = val
|
||||
}
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to set the trace provider option.
|
||||
func TraceProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// Store provides a function to set the Store option
|
||||
func Store(val microstore.Store) Option {
|
||||
return func(o *Options) {
|
||||
o.Store = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
svc "github.com/qsfera/server/services/collaboration/pkg/service/grpc/v0"
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Server initializes a new grpc service ready to run
|
||||
// THIS SERVICE IS REGISTERED AGAINST REVA, NOT GO-MICRO
|
||||
func Server(opts ...Option) (*grpc.Server, func(), error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
grpcOpts := []grpc.ServerOption{
|
||||
grpc.StatsHandler(
|
||||
otelgrpc.NewServerHandler(
|
||||
otelgrpc.WithTracerProvider(options.TraceProvider),
|
||||
otelgrpc.WithPropagators(tracing.GetPropagator()),
|
||||
),
|
||||
),
|
||||
}
|
||||
grpcServer := grpc.NewServer(grpcOpts...)
|
||||
|
||||
handle, teardown, err := svc.NewHandler(
|
||||
svc.Config(options.Config),
|
||||
svc.Logger(options.Logger),
|
||||
svc.AppURLs(options.AppURLs),
|
||||
svc.Store(options.Store),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing collaboration service")
|
||||
return grpcServer, teardown, err
|
||||
}
|
||||
|
||||
// register the app provider interface / OpenInApp call
|
||||
appproviderv1beta1.RegisterProviderAPIServer(grpcServer, handle)
|
||||
|
||||
return grpcServer, teardown, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/connector"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Adapter *connector.HttpAdapter
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
TracerProvider trace.TracerProvider
|
||||
Store microstore.Store
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// App provides a function to set the logger option.
|
||||
func Adapter(val *connector.HttpAdapter) Option {
|
||||
return func(o *Options) {
|
||||
o.Adapter = val
|
||||
}
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// TracerProvider provides a function to set the TracerProvider option
|
||||
func TracerProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TracerProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// Store provides a function to set the Store option
|
||||
func Store(val microstore.Store) Option {
|
||||
return func(o *Options) {
|
||||
o.Store = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
stdhttp "net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/qsfera/server/pkg/account"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/middleware"
|
||||
"github.com/qsfera/server/pkg/service/http"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
colabmiddleware "github.com/qsfera/server/services/collaboration/pkg/middleware"
|
||||
"github.com/riandyrn/otelchi"
|
||||
"go-micro.dev/v4"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (http.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
service, err := http.NewService(
|
||||
http.TLSConfig(options.Config.HTTP.TLS),
|
||||
http.Logger(options.Logger),
|
||||
http.Namespace(options.Config.HTTP.Namespace),
|
||||
http.Name(options.Config.Service.Name),
|
||||
http.Version(version.GetString()),
|
||||
http.Address(options.Config.HTTP.Addr),
|
||||
http.Context(options.Context),
|
||||
http.TraceProvider(options.TracerProvider),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing http service")
|
||||
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
|
||||
}
|
||||
|
||||
middlewares := []func(stdhttp.Handler) stdhttp.Handler{
|
||||
chimiddleware.RequestID,
|
||||
middleware.Version(
|
||||
options.Config.Service.Name,
|
||||
version.GetString(),
|
||||
),
|
||||
colabmiddleware.AccessLog(
|
||||
options.Logger,
|
||||
),
|
||||
middleware.ExtractAccountUUID(
|
||||
account.Logger(options.Logger),
|
||||
account.JWTSecret(options.Config.TokenManager.JWTSecret),
|
||||
),
|
||||
/*
|
||||
// Need CORS? not in the original server
|
||||
// Also, CORS isn't part of the config right now
|
||||
middleware.Cors(
|
||||
cors.Logger(options.Logger),
|
||||
cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
|
||||
cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
|
||||
cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
|
||||
cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
|
||||
),
|
||||
*/
|
||||
}
|
||||
|
||||
mux := chi.NewMux()
|
||||
mux.Use(middlewares...)
|
||||
|
||||
mux.Use(
|
||||
otelchi.Middleware(
|
||||
options.Config.Service.Name,
|
||||
otelchi.WithChiRoutes(mux),
|
||||
otelchi.WithTracerProvider(options.TracerProvider),
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
otelchi.WithRequestMethodInSpanName(true),
|
||||
),
|
||||
)
|
||||
|
||||
prepareRoutes(mux, options)
|
||||
|
||||
// in debug mode print out the actual routes
|
||||
_ = chi.Walk(mux, func(method string, route string, handler stdhttp.Handler, middlewares ...func(stdhttp.Handler) stdhttp.Handler) error {
|
||||
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), mux); err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// prepareRoutes will prepare all the implemented routes
|
||||
func prepareRoutes(r *chi.Mux, options Options) {
|
||||
adapter := options.Adapter
|
||||
logger := options.Logger
|
||||
// prepare basic logger for the request
|
||||
r.Use(func(h stdhttp.Handler) stdhttp.Handler {
|
||||
return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
ctx := logger.With().
|
||||
Str(log.RequestIDString, r.Header.Get("X-Request-ID")).
|
||||
Str("proto", r.Proto).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Logger().WithContext(r.Context())
|
||||
h.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
})
|
||||
r.Route("/wopi", func(r chi.Router) {
|
||||
|
||||
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusTeapot), stdhttp.StatusTeapot)
|
||||
})
|
||||
|
||||
r.Route("/files/{fileid}", func(r chi.Router) {
|
||||
|
||||
r.Use(
|
||||
func(h stdhttp.Handler) stdhttp.Handler {
|
||||
// authentication and wopi context
|
||||
return colabmiddleware.WopiContextAuthMiddleware(options.Config, options.Store, h)
|
||||
},
|
||||
colabmiddleware.CollaborationTracingMiddleware,
|
||||
)
|
||||
|
||||
// check whether we should check for proof keys
|
||||
if !options.Config.App.ProofKeys.Disable {
|
||||
r.Use(func(h stdhttp.Handler) stdhttp.Handler {
|
||||
return colabmiddleware.ProofKeysMiddleware(options.Config, h)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
adapter.CheckFileInfo(w, r)
|
||||
})
|
||||
|
||||
r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
action := r.Header.Get("X-WOPI-Override")
|
||||
switch action {
|
||||
|
||||
case "LOCK":
|
||||
// "UnlockAndRelock" operation goes through here
|
||||
adapter.Lock(w, r)
|
||||
case "GET_LOCK":
|
||||
adapter.GetLock(w, r)
|
||||
case "REFRESH_LOCK":
|
||||
adapter.RefreshLock(w, r)
|
||||
case "UNLOCK":
|
||||
adapter.UnLock(w, r)
|
||||
|
||||
case "PUT_USER_INFO":
|
||||
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putuserinfo
|
||||
stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented)
|
||||
case "PUT_RELATIVE":
|
||||
adapter.PutRelativeFile(w, r)
|
||||
case "RENAME_FILE":
|
||||
adapter.RenameFile(w, r)
|
||||
case "DELETE":
|
||||
adapter.DeleteFile(w, r)
|
||||
|
||||
default:
|
||||
stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusInternalServerError), stdhttp.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
r.Route("/contents", func(r chi.Router) {
|
||||
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
adapter.GetFile(w, r)
|
||||
})
|
||||
|
||||
r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
action := r.Header.Get("X-WOPI-Override")
|
||||
switch action {
|
||||
|
||||
case "PUT":
|
||||
adapter.PutFile(w, r)
|
||||
|
||||
default:
|
||||
stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusInternalServerError), stdhttp.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
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, options.Store, h)
|
||||
},
|
||||
colabmiddleware.CollaborationTracingMiddleware,
|
||||
)
|
||||
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
adapter.GetFile(w, r)
|
||||
})
|
||||
})
|
||||
|
||||
// Avatar proxy: serves user avatars authenticated by the WOPI token.
|
||||
// Collabora loads avatars via img.src (plain GET, no auth headers),
|
||||
// so the token must be in the URL query string.
|
||||
r.Route("/avatars/{userID}", func(r chi.Router) {
|
||||
r.Use(
|
||||
func(h stdhttp.Handler) stdhttp.Handler {
|
||||
return colabmiddleware.WopiContextAuthMiddleware(options.Config, options.Store, h)
|
||||
},
|
||||
colabmiddleware.CollaborationTracingMiddleware,
|
||||
)
|
||||
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
adapter.GetAvatar(w, r)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
AppURLs *helpers.AppURLs
|
||||
GatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]
|
||||
Store microstore.Store
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the Logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the Config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// AppURLs provides a function to set the AppURLs option.
|
||||
func AppURLs(val *helpers.AppURLs) Option {
|
||||
return func(o *Options) {
|
||||
o.AppURLs = val
|
||||
}
|
||||
}
|
||||
|
||||
// GatewaySelector provides a function to set the GatewaySelector option.
|
||||
func GatewaySelector(val pool.Selectable[gatewayv1beta1.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewaySelector = val
|
||||
}
|
||||
}
|
||||
|
||||
// Store proivdes a function to set the store
|
||||
func Store(val microstore.Store) Option {
|
||||
return func(o *Options) {
|
||||
o.Store = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
microstore "go-micro.dev/v4/store"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/middleware"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/wopisrc"
|
||||
)
|
||||
|
||||
// NewHandler creates a new grpc service implementing the OpenInApp interface
|
||||
func NewHandler(opts ...Option) (*Service, func(), error) {
|
||||
teardown := func() {
|
||||
/* this is required as a argument for the return value to satisfy the interface */
|
||||
/* in case you are wondering about the necessity of this comment, sonarcloud is asking for it */
|
||||
}
|
||||
options := newOptions(opts...)
|
||||
|
||||
gatewaySelector := options.GatewaySelector
|
||||
var err error
|
||||
if gatewaySelector == nil {
|
||||
gatewaySelector, err = pool.GatewaySelector(options.Config.CS3Api.Gateway.Name)
|
||||
if err != nil {
|
||||
return nil, teardown, err
|
||||
}
|
||||
}
|
||||
|
||||
if options.AppURLs == nil {
|
||||
return nil, teardown, errors.New("AppURLs option is required")
|
||||
}
|
||||
|
||||
return &Service{
|
||||
id: options.Config.GRPC.Namespace + "." + options.Config.Service.Name,
|
||||
appURLs: options.AppURLs,
|
||||
logger: options.Logger,
|
||||
config: options.Config,
|
||||
gatewaySelector: gatewaySelector,
|
||||
store: options.Store,
|
||||
}, teardown, nil
|
||||
}
|
||||
|
||||
// Service implements the OpenInApp interface
|
||||
type Service struct {
|
||||
id string
|
||||
appURLs *helpers.AppURLs
|
||||
logger log.Logger
|
||||
config *config.Config
|
||||
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]
|
||||
store microstore.Store
|
||||
}
|
||||
|
||||
// OpenInApp will implement the OpenInApp interface of the app provider
|
||||
func (s *Service) OpenInApp(
|
||||
ctx context.Context,
|
||||
req *appproviderv1beta1.OpenInAppRequest,
|
||||
) (*appproviderv1beta1.OpenInAppResponse, error) {
|
||||
|
||||
// get the current user
|
||||
var user *userv1beta1.User = nil
|
||||
meReq := &gatewayv1beta1.WhoAmIRequest{
|
||||
Token: req.GetAccessToken(),
|
||||
}
|
||||
gwc, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("OpenInApp: could not select a gateway client")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
meResp, err := gwc.WhoAmI(ctx, meReq)
|
||||
if err == nil {
|
||||
if meResp.GetStatus().GetCode() == rpcv1beta1.Code_CODE_OK {
|
||||
user = meResp.GetUser()
|
||||
}
|
||||
}
|
||||
|
||||
// required for the response, it will be used also for logs
|
||||
providerFileRef := providerv1beta1.Reference{
|
||||
ResourceId: req.GetResourceInfo().GetId(),
|
||||
Path: ".",
|
||||
}
|
||||
|
||||
logger := s.logger.With().
|
||||
Str("FileReference", providerFileRef.String()).
|
||||
Str("ViewMode", req.GetViewMode().String()).
|
||||
Str("Requester", user.GetId().String()).
|
||||
Logger()
|
||||
|
||||
// get the file extension to use the right wopi app url
|
||||
fileExt := path.Ext(req.GetResourceInfo().GetPath())
|
||||
|
||||
// get the appURL we need to use
|
||||
appURL := s.getAppUrl(fileExt, req.GetViewMode())
|
||||
if appURL == "" {
|
||||
logger.Error().Msg("OpenInApp: neither edit nor view app URL found")
|
||||
return nil, errors.New("neither edit nor view app URL found")
|
||||
}
|
||||
|
||||
// append the parameters we need
|
||||
appURL, err = s.addQueryToURL(appURL, req)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("OpenInApp: error parsing appUrl")
|
||||
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
|
||||
wopiContext := middleware.WopiContext{
|
||||
AccessToken: req.GetAccessToken(), // it will be encrypted
|
||||
ViewOnlyToken: utils.ReadPlainFromOpaque(req.GetOpaque(), "viewOnlyToken"),
|
||||
FileReference: &providerFileRef,
|
||||
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, s.store)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("OpenInApp: error generating the token")
|
||||
return &appproviderv1beta1.OpenInAppResponse{
|
||||
Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_INTERNAL},
|
||||
}, err
|
||||
}
|
||||
|
||||
logger.Debug().Msg("OpenInApp: success")
|
||||
|
||||
return &appproviderv1beta1.OpenInAppResponse{
|
||||
Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_OK},
|
||||
AppUrl: &appproviderv1beta1.OpenInAppURL{
|
||||
AppUrl: appURL,
|
||||
Method: "POST",
|
||||
FormParameters: map[string]string{
|
||||
// these parameters will be passed to the web server by the app provider application
|
||||
"access_token": accessToken,
|
||||
// milliseconds since Jan 1, 1970 UTC as required in https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#access_token_ttl
|
||||
//"access_token_ttl": strconv.FormatInt(claims.ExpiresAt.UnixMilli(), 10),
|
||||
"access_token_ttl": strconv.FormatInt(accessExpiration, 10),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getAppUrl will get the appURL that should be used based on the extension
|
||||
// and the provided view mode.
|
||||
// "view" urls will be chosen first, then if the view mode is "read/write",
|
||||
// "edit" urls will be prioritized. Note that "view" url might be returned for
|
||||
// "read/write" view mode if no "edit" url is found.
|
||||
func (s *Service) getAppUrl(fileExt string, viewMode appproviderv1beta1.ViewMode) string {
|
||||
// prioritize view action if possible
|
||||
appURL := s.appURLs.GetAppURLFor("view", fileExt)
|
||||
|
||||
if strings.ToLower(s.config.App.Product) == "collabora" {
|
||||
// collabora provides only one action per extension. usual options
|
||||
// are "view" (checked above), "edit" or "view_comment" (this last one
|
||||
// is exclusive of collabora)
|
||||
if appURL == "" {
|
||||
if editURL := s.appURLs.GetAppURLFor("edit", fileExt); editURL != "" {
|
||||
return editURL
|
||||
}
|
||||
if commentURL := s.appURLs.GetAppURLFor("view_comment", fileExt); commentURL != "" {
|
||||
return commentURL
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If not collabora, there might be an edit action for the extension.
|
||||
// If read/write mode has been requested, prioritize edit action.
|
||||
if viewMode == appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE {
|
||||
if editAppURL := s.appURLs.GetAppURLFor("edit", fileExt); editAppURL != "" {
|
||||
appURL = editAppURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return appURL
|
||||
}
|
||||
|
||||
// addQueryToURL will add specific query parameters to the baseURL. These
|
||||
// parameters are:
|
||||
// * "WOPISrc" pointing to the requested resource in the OpenInAppRequest
|
||||
// * "dchat" to disable the chat, based on configuration
|
||||
// * "lang" (WOPI app dependent) with the language in the request. "lang"
|
||||
// for collabora, "ui" for onlyoffice and "UI_LLCC" for the rest
|
||||
func (s *Service) addQueryToURL(baseURL string, req *appproviderv1beta1.OpenInAppRequest) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// build a urlsafe and stable file reference that can be used for proxy routing,
|
||||
// so that all sessions on one file end on the same office server
|
||||
fileRef := helpers.HashResourceId(req.GetResourceInfo().GetId())
|
||||
|
||||
wopiSrcURL, err := wopisrc.GenerateWopiSrc(fileRef, s.config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Add("WOPISrc", wopiSrcURL.String())
|
||||
|
||||
if s.config.Wopi.DisableChat {
|
||||
q.Add("dchat", "1")
|
||||
}
|
||||
|
||||
lang := utils.ReadPlainFromOpaque(req.GetOpaque(), "lang")
|
||||
|
||||
// @TODO: this is a temporary solution until we figure out how to send these from oc web
|
||||
switch lang {
|
||||
case "bg":
|
||||
lang = "bg-BG"
|
||||
case "cs":
|
||||
lang = "cs-CZ"
|
||||
case "de":
|
||||
lang = "de-DE"
|
||||
case "en":
|
||||
lang = "en-GB"
|
||||
case "es":
|
||||
lang = "es-ES"
|
||||
case "fr":
|
||||
lang = "fr-FR"
|
||||
case "gl":
|
||||
lang = "gl-ES"
|
||||
case "it":
|
||||
lang = "it-IT"
|
||||
case "nl":
|
||||
lang = "nl-NL"
|
||||
case "ko":
|
||||
lang = "ko-KR"
|
||||
case "sq":
|
||||
lang = "sq-AL"
|
||||
case "sv":
|
||||
lang = "sv-SE"
|
||||
case "tr":
|
||||
lang = "tr-TR"
|
||||
case "zh":
|
||||
lang = "zh-CN"
|
||||
}
|
||||
|
||||
if lang != "" {
|
||||
switch strings.ToLower(s.config.App.Product) {
|
||||
case "collabora":
|
||||
q.Add("lang", lang)
|
||||
case "onlyoffice":
|
||||
q.Add("ui", lang)
|
||||
default:
|
||||
q.Add("UI_LLCC", lang)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.ToLower(s.config.App.Product) == "collabora" {
|
||||
q.Add("closebutton", "false")
|
||||
}
|
||||
|
||||
qs := q.Encode()
|
||||
u.RawQuery = qs
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestService(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Service Suite")
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
|
||||
authpb "github.com/cs3org/go-cs3apis/cs3/auth/provider/v1beta1"
|
||||
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/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/golang-jwt/jwt/v5"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/collaboration/mocks"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/helpers"
|
||||
service "github.com/qsfera/server/services/collaboration/pkg/service/grpc/v0"
|
||||
)
|
||||
|
||||
// Based on https://github.com/cs3org/reva/blob/b99ad4865401144a981d4cfd1ae28b5a018ea51d/pkg/token/manager/jwt/jwt.go#L82
|
||||
func MintToken(u *userv1beta1.User, secret string, nowTime time.Time) string {
|
||||
scopes := make(map[string]*authpb.Scope)
|
||||
scopes["user"] = &authpb.Scope{
|
||||
Resource: &types.OpaqueEntry{
|
||||
Decoder: "json",
|
||||
Value: []byte("{\"Path\":\"/\"}"),
|
||||
},
|
||||
Role: authpb.Role_ROLE_OWNER,
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"exp": nowTime.Add(5 * time.Hour).Unix(),
|
||||
"iss": "myself",
|
||||
"aud": "reva",
|
||||
"iat": nowTime.Unix(),
|
||||
"user": u,
|
||||
"scope": scopes,
|
||||
}
|
||||
/*
|
||||
claims := claims{
|
||||
StandardClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: time.Now().Add(5 * time.Hour),
|
||||
Issuer: "myself",
|
||||
Audience: "reva",
|
||||
IssuedAt: time.Now(),
|
||||
},
|
||||
User: u,
|
||||
Scope: scopes,
|
||||
}
|
||||
*/
|
||||
|
||||
t := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), claims)
|
||||
|
||||
tkn, _ := t.SignedString([]byte(secret))
|
||||
|
||||
return tkn
|
||||
}
|
||||
|
||||
var _ = Describe("Discovery", func() {
|
||||
var (
|
||||
cfg *config.Config
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
srv *service.Service
|
||||
srvTear func()
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
cfg = &config.Config{}
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
|
||||
gatewaySelector := mocks.NewSelectable[gatewayv1beta1.GatewayAPIClient](GinkgoT())
|
||||
gatewaySelector.On("Next").Return(gatewayClient, nil)
|
||||
|
||||
appURLs := helpers.NewAppURLs()
|
||||
appURLs.Store(map[string]map[string]string{
|
||||
"view": {
|
||||
".pdf": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".djvu": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".docx": "https://cloud.qsfera.test/hosting/wopi/word/view",
|
||||
".xls": "https://cloud.qsfera.test/hosting/wopi/cell/view",
|
||||
".xlsb": "https://cloud.qsfera.test/hosting/wopi/cell/view",
|
||||
},
|
||||
"edit": {
|
||||
".docx": "https://cloud.qsfera.test/hosting/wopi/word/edit",
|
||||
".invalid": "://cloud.qsfera.test/hosting/wopi/cell/edit",
|
||||
},
|
||||
})
|
||||
|
||||
srv, srvTear, _ = service.NewHandler(
|
||||
service.Logger(log.NopLogger()),
|
||||
service.Config(cfg),
|
||||
service.AppURLs(appURLs),
|
||||
service.GatewaySelector(gatewaySelector),
|
||||
)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
srvTear()
|
||||
})
|
||||
|
||||
Describe("OpenInApp", func() {
|
||||
It("Invalid access token", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
cfg.Wopi.WopiSrc = "https://wopi.qsfera.test"
|
||||
|
||||
req := &appproviderv1beta1.OpenInAppRequest{
|
||||
ResourceInfo: &providerv1beta1.ResourceInfo{
|
||||
Id: &providerv1beta1.ResourceId{
|
||||
StorageId: "myStorage",
|
||||
OpaqueId: "storageOpaque001",
|
||||
SpaceId: "SpaceA",
|
||||
},
|
||||
Path: "/path/to/file",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
AccessToken: "goodAccessToken",
|
||||
}
|
||||
|
||||
gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "myIdp",
|
||||
OpaqueId: "opaque001",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "username",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
resp, err := srv.OpenInApp(ctx, req)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(resp).To(BeNil())
|
||||
})
|
||||
|
||||
DescribeTable(
|
||||
"Success",
|
||||
func(appName, lang string, disableChat bool, expectedAppUrl string) {
|
||||
ctx := context.Background()
|
||||
nowTime := time.Now()
|
||||
|
||||
cfg.Wopi.WopiSrc = "https://wopi.qsfera.test"
|
||||
cfg.Wopi.Secret = "my_supa_secret"
|
||||
cfg.Wopi.DisableChat = disableChat
|
||||
cfg.App.Name = appName
|
||||
cfg.App.Product = appName
|
||||
|
||||
myself := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "myIdp",
|
||||
OpaqueId: "opaque001",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "username",
|
||||
}
|
||||
|
||||
req := &appproviderv1beta1.OpenInAppRequest{
|
||||
ResourceInfo: &providerv1beta1.ResourceInfo{
|
||||
Id: &providerv1beta1.ResourceId{
|
||||
StorageId: "myStorage",
|
||||
OpaqueId: "storageOpaque001",
|
||||
SpaceId: "SpaceA",
|
||||
},
|
||||
Path: "/path/to/file.docx",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
AccessToken: MintToken(myself, cfg.Wopi.Secret, nowTime),
|
||||
}
|
||||
if lang != "" {
|
||||
req.Opaque = utils.AppendPlainToOpaque(req.Opaque, "lang", lang)
|
||||
}
|
||||
|
||||
gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: myself,
|
||||
}, nil)
|
||||
|
||||
resp, err := srv.OpenInApp(ctx, req)
|
||||
Expect(err).To(Succeed())
|
||||
Expect(resp.GetStatus().GetCode()).To(Equal(rpcv1beta1.Code_CODE_OK))
|
||||
Expect(resp.GetAppUrl().GetMethod()).To(Equal("POST"))
|
||||
Expect(resp.GetAppUrl().GetAppUrl()).To(Equal(expectedAppUrl))
|
||||
Expect(resp.GetAppUrl().GetFormParameters()["access_token_ttl"]).To(Equal(strconv.FormatInt(nowTime.Add(5*time.Hour).Unix()*1000, 10)))
|
||||
},
|
||||
Entry("Microsoft chat no lang", "Microsoft", "", false, "https://cloud.qsfera.test/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e"),
|
||||
Entry("Collabora chat no lang", "Collabora", "", false, "https://cloud.qsfera.test/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&closebutton=false"),
|
||||
Entry("OnlyOffice chat no lang", "OnlyOffice", "", false, "https://cloud.qsfera.test/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e"),
|
||||
Entry("Microsoft chat lang", "Microsoft", "de", false, "https://cloud.qsfera.test/hosting/wopi/word/edit?UI_LLCC=de-DE&WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e"),
|
||||
Entry("Collabora chat lang", "Collabora", "de", false, "https://cloud.qsfera.test/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&closebutton=false&lang=de-DE"),
|
||||
Entry("OnlyOffice chat lang", "OnlyOffice", "de", false, "https://cloud.qsfera.test/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&ui=de-DE"),
|
||||
Entry("Microsoft no chat no lang", "Microsoft", "", true, "https://cloud.qsfera.test/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1"),
|
||||
Entry("Collabora no chat no lang", "Collabora", "", true, "https://cloud.qsfera.test/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&closebutton=false&dchat=1"),
|
||||
Entry("OnlyOffice no chat no lang", "OnlyOffice", "", true, "https://cloud.qsfera.test/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1"),
|
||||
Entry("Microsoft no chat lang", "Microsoft", "de", true, "https://cloud.qsfera.test/hosting/wopi/word/edit?UI_LLCC=de-DE&WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1"),
|
||||
Entry("Collabora no chat lang", "Collabora", "de", true, "https://cloud.qsfera.test/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&closebutton=false&dchat=1&lang=de-DE"),
|
||||
Entry("OnlyOffice no chat lang", "OnlyOffice", "de", true, "https://cloud.qsfera.test/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1&ui=de-DE"),
|
||||
)
|
||||
It("Success with Wopi Proxy", func() {
|
||||
ctx := context.Background()
|
||||
nowTime := time.Now()
|
||||
|
||||
cfg.Wopi.WopiSrc = "https://wopi.qsfera.test"
|
||||
cfg.Wopi.Secret = "my_supa_secret"
|
||||
cfg.Wopi.ProxyURL = "https://office.proxy.qsfera.test"
|
||||
cfg.Wopi.ProxySecret = "your_supa_secret"
|
||||
cfg.App.Name = "Microsoft"
|
||||
|
||||
myself := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "myIdp",
|
||||
OpaqueId: "opaque001",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "username",
|
||||
}
|
||||
|
||||
req := &appproviderv1beta1.OpenInAppRequest{
|
||||
ResourceInfo: &providerv1beta1.ResourceInfo{
|
||||
Id: &providerv1beta1.ResourceId{
|
||||
StorageId: "myStorage",
|
||||
OpaqueId: "storageOpaque001",
|
||||
SpaceId: "SpaceA",
|
||||
},
|
||||
Path: "/path/to/file.docx",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
AccessToken: MintToken(myself, cfg.Wopi.Secret, nowTime),
|
||||
}
|
||||
req.Opaque = utils.AppendPlainToOpaque(req.Opaque, "lang", "en")
|
||||
|
||||
gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: myself,
|
||||
}, nil)
|
||||
|
||||
resp, err := srv.OpenInApp(ctx, req)
|
||||
Expect(err).To(Succeed())
|
||||
Expect(resp.GetStatus().GetCode()).To(Equal(rpcv1beta1.Code_CODE_OK))
|
||||
Expect(resp.GetAppUrl().GetMethod()).To(Equal("POST"))
|
||||
Expect(resp.GetAppUrl().GetAppUrl()).To(Equal("https://cloud.qsfera.test/hosting/wopi/word/edit?UI_LLCC=en-GB&WOPISrc=https%3A%2F%2Foffice.proxy.qsfera.test%2Fwopi%2Ffiles%2FeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1IjoiaHR0cHM6Ly93b3BpLm9wZW5jbG91ZC50ZXN0L3dvcGkvZmlsZXMvIiwiZiI6IjJmNmVjMTg2OTZkZDEwMDgxMDY3NDliZDk0MTA2ZTVjZmFkNWMwOWUxNWRlN2I3NzA4OGQwMzg0M2U3MWI0M2UifQ.j873xu7TkqtIokSIQXW5y7-BRRrHgIURqAx4WY_zxTA"))
|
||||
Expect(resp.GetAppUrl().GetFormParameters()["access_token_ttl"]).To(Equal(strconv.FormatInt(nowTime.Add(5*time.Hour).Unix()*1000, 10)))
|
||||
})
|
||||
It("Fail with invalid app url", func() {
|
||||
ctx := context.Background()
|
||||
nowTime := time.Now()
|
||||
|
||||
cfg.Wopi.WopiSrc = "htttps://wopi.qsfera.test"
|
||||
cfg.Wopi.Secret = "my_supa_secret"
|
||||
cfg.App.Name = "Microsoft"
|
||||
|
||||
myself := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "myIdp",
|
||||
OpaqueId: "opaque001",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "username",
|
||||
}
|
||||
|
||||
req := &appproviderv1beta1.OpenInAppRequest{
|
||||
ResourceInfo: &providerv1beta1.ResourceInfo{
|
||||
Id: &providerv1beta1.ResourceId{
|
||||
StorageId: "myStorage",
|
||||
OpaqueId: "storageOpaque001",
|
||||
SpaceId: "SpaceA",
|
||||
},
|
||||
Path: "/path/to/file.invalid",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
AccessToken: MintToken(myself, cfg.Wopi.Secret, nowTime),
|
||||
}
|
||||
req.Opaque = utils.AppendPlainToOpaque(req.Opaque, "lang", "en")
|
||||
|
||||
gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: myself,
|
||||
}, nil)
|
||||
|
||||
resp, err := srv.OpenInApp(ctx, req)
|
||||
Expect(err).To(Succeed())
|
||||
Expect(resp.GetStatus().GetCode()).To(Equal(rpcv1beta1.Code_CODE_INVALID_ARGUMENT))
|
||||
Expect(resp.GetStatus().GetMessage()).To(Equal("OpenInApp: error parsing appUrl"))
|
||||
})
|
||||
It("Fail with invalid template id", func() {
|
||||
ctx := context.Background()
|
||||
nowTime := time.Now()
|
||||
|
||||
cfg.Wopi.WopiSrc = "htttps://wopi.qsfera.test"
|
||||
cfg.Wopi.Secret = "my_supa_secret"
|
||||
cfg.App.Name = "Microsoft"
|
||||
|
||||
myself := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "myIdp",
|
||||
OpaqueId: "opaque001",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "username",
|
||||
}
|
||||
|
||||
req := &appproviderv1beta1.OpenInAppRequest{
|
||||
ResourceInfo: &providerv1beta1.ResourceInfo{
|
||||
Id: &providerv1beta1.ResourceId{
|
||||
StorageId: "myStorage",
|
||||
OpaqueId: "storageOpaque001",
|
||||
SpaceId: "SpaceA",
|
||||
},
|
||||
Path: "/path/to/file.docx",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
AccessToken: MintToken(myself, cfg.Wopi.Secret, nowTime),
|
||||
}
|
||||
req.Opaque = utils.AppendPlainToOpaque(req.Opaque, "lang", "en")
|
||||
req.Opaque = utils.AppendPlainToOpaque(req.Opaque, "template", "&file_id")
|
||||
|
||||
gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: myself,
|
||||
}, nil)
|
||||
|
||||
resp, err := srv.OpenInApp(ctx, req)
|
||||
Expect(err).To(Succeed())
|
||||
Expect(resp.GetStatus().GetCode()).To(Equal(rpcv1beta1.Code_CODE_INVALID_ARGUMENT))
|
||||
Expect(resp.GetStatus().GetMessage()).To(Equal("OpenInApp: error parsing templateID"))
|
||||
})
|
||||
It("Success with valid template id", func() {
|
||||
ctx := context.Background()
|
||||
nowTime := time.Now()
|
||||
|
||||
cfg.Wopi.WopiSrc = "htttps://wopi.qsfera.test"
|
||||
cfg.Wopi.Secret = "my_supa_secret"
|
||||
cfg.App.Name = "OnlyOffice"
|
||||
cfg.App.Product = "OnlyOffice"
|
||||
|
||||
myself := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "myIdp",
|
||||
OpaqueId: "opaque001",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "username",
|
||||
}
|
||||
|
||||
req := &appproviderv1beta1.OpenInAppRequest{
|
||||
ResourceInfo: &providerv1beta1.ResourceInfo{
|
||||
Id: &providerv1beta1.ResourceId{
|
||||
StorageId: "myStorage",
|
||||
OpaqueId: "storageOpaque001",
|
||||
SpaceId: "SpaceA",
|
||||
},
|
||||
Path: "/path/to/file.docx",
|
||||
},
|
||||
ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE,
|
||||
AccessToken: MintToken(myself, cfg.Wopi.Secret, nowTime),
|
||||
}
|
||||
req.Opaque = utils.AppendPlainToOpaque(req.Opaque, "lang", "en")
|
||||
req.Opaque = utils.AppendPlainToOpaque(req.Opaque, "template", "prodiderID$spaceID!opaqueID")
|
||||
|
||||
gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
User: myself,
|
||||
}, nil)
|
||||
|
||||
resp, err := srv.OpenInApp(ctx, req)
|
||||
Expect(err).To(Succeed())
|
||||
Expect(resp.GetStatus().GetCode()).To(Equal(rpcv1beta1.Code_CODE_OK))
|
||||
Expect(resp.GetAppUrl().GetMethod()).To(Equal("POST"))
|
||||
Expect(resp.GetAppUrl().GetAppUrl()).To(Equal("https://cloud.qsfera.test/hosting/wopi/word/edit?WOPISrc=htttps%3A%2F%2Fwopi.qsfera.test%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&ui=en-GB"))
|
||||
Expect(resp.GetAppUrl().GetFormParameters()["access_token_ttl"]).To(Equal(strconv.FormatInt(nowTime.Add(5*time.Hour).Unix()*1000, 10)))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
package wopisrc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
)
|
||||
|
||||
// GenerateWopiSrc generates a WOPI src URL for the given file reference.
|
||||
// If a proxy URL and proxy secret are configured, the URL will be generated
|
||||
// as a jwt token that is signed with the proxy secret and contains the file reference
|
||||
// and the WOPI src URL.
|
||||
// Example:
|
||||
// https://cloud.proxy.com/wopi/files/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1IjoiaHR0cHM6Ly9vY2lzLnRlYW0vd29waS9maWxlcy8iLCJmIjoiMTIzNDU2In0.6ol9PQXGKktKfAri8tsJ4X_a9rIeosJ7id6KTQW6Ui0
|
||||
//
|
||||
// If no proxy URL and proxy secret are configured, the URL will be generated
|
||||
// as a direct URL that contains the file reference.
|
||||
// Example:
|
||||
// https:/cloud.example.test/wopi/files/12312678470610632091729803710923
|
||||
func GenerateWopiSrc(fileRef string, cfg *config.Config) (*url.URL, error) {
|
||||
wopiSrcURL, err := url.Parse(cfg.Wopi.WopiSrc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wopiSrcURL.Host == "" {
|
||||
return nil, errors.New("invalid WopiSrc URL")
|
||||
}
|
||||
|
||||
if cfg.Wopi.ProxyURL != "" && cfg.Wopi.ProxySecret != "" {
|
||||
return generateProxySrc(fileRef, cfg.Wopi.ProxyURL, cfg.Wopi.ProxySecret, wopiSrcURL)
|
||||
}
|
||||
|
||||
return generateDirectSrc(fileRef, wopiSrcURL)
|
||||
}
|
||||
|
||||
func generateDirectSrc(fileRef string, wopiSrcURL *url.URL) (*url.URL, error) {
|
||||
wopiSrcURL.Path = path.Join("wopi", "files", fileRef)
|
||||
return wopiSrcURL, nil
|
||||
}
|
||||
|
||||
func generateProxySrc(fileRef string, proxyUrl string, proxySecret string, wopiSrcURL *url.URL) (*url.URL, error) {
|
||||
proxyURL, err := url.Parse(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proxyURL.Host == "" {
|
||||
return nil, errors.New("invalid proxy URL")
|
||||
}
|
||||
|
||||
wopiSrcURL.Path = path.Join("wopi", "files")
|
||||
|
||||
type tokenClaims struct {
|
||||
URL string `json:"u"`
|
||||
FileID string `json:"f"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, tokenClaims{
|
||||
FileID: fileRef,
|
||||
// the string value from the URL package always ends with a slash
|
||||
// the office365 proxy assumes that we have a trailing slash
|
||||
URL: wopiSrcURL.String() + "/",
|
||||
})
|
||||
tokenString, err := token.SignedString([]byte(proxySecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxyURL.Path = path.Join("wopi", "files", tokenString)
|
||||
return proxyURL, nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package wopisrc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestWopisrc(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Wopisrc Suite")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package wopisrc_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/config"
|
||||
"github.com/qsfera/server/services/collaboration/pkg/wopisrc"
|
||||
)
|
||||
|
||||
var _ = Describe("Wopisrc Test", func() {
|
||||
var (
|
||||
c *config.Config
|
||||
)
|
||||
|
||||
Context("GenerateWopiSrc", func() {
|
||||
BeforeEach(func() {
|
||||
c = &config.Config{
|
||||
Wopi: config.Wopi{
|
||||
WopiSrc: "https://cloud.example.test/wopi/files",
|
||||
ProxyURL: "https://cloud.proxy.com",
|
||||
ProxySecret: "secret",
|
||||
},
|
||||
}
|
||||
})
|
||||
When("WopiSrc URL is incorrect", func() {
|
||||
c = &config.Config{
|
||||
Wopi: config.Wopi{
|
||||
WopiSrc: "https:&//cloud.example.test/wopi/files",
|
||||
},
|
||||
}
|
||||
url, err := wopisrc.GenerateWopiSrc("123456", c)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(url).To(BeNil())
|
||||
})
|
||||
When("proxy URL is incorrect", func() {
|
||||
c = &config.Config{
|
||||
Wopi: config.Wopi{
|
||||
WopiSrc: "https://cloud.example.test/wopi/files",
|
||||
ProxyURL: "cloud",
|
||||
ProxySecret: "secret",
|
||||
},
|
||||
}
|
||||
url, err := wopisrc.GenerateWopiSrc("123456", c)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(url).To(BeNil())
|
||||
})
|
||||
When("proxy URL and proxy secret are configured", func() {
|
||||
It("should generate a WOPI src URL as a jwt token", func() {
|
||||
url, err := wopisrc.GenerateWopiSrc("123456", c)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(url.String()).To(Equal("https://cloud.proxy.com/wopi/files/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1IjoiaHR0cHM6Ly9jbG91ZC5leGFtcGxlLnRlc3Qvd29waS9maWxlcy8iLCJmIjoiMTIzNDU2In0.LzyGPanHKxjLlIPoyfGU4cAUxzy3FAmBqMIqLCSHclg"))
|
||||
})
|
||||
})
|
||||
When("proxy URL and proxy secret are not configured", func() {
|
||||
It("should generate a WOPI src URL as a direct URL", func() {
|
||||
c.Wopi.ProxyURL = ""
|
||||
c.Wopi.ProxySecret = ""
|
||||
url, err := wopisrc.GenerateWopiSrc("123456", c)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(url.String()).To(Equal("https://cloud.example.test/wopi/files/123456"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user