Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -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 browsers 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 servers 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 hosts 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 users 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?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="pdf" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="view" ext="djvu" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="djvu" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="view" ext="docx" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="docx" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="editnew" ext="docx" requires="locks,update" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/edit?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="edit" ext="docx" default="true" requires="locks,update" urlsrc="https://cloud.qsfera.test/hosting/wopi/word/edit?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
</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?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="xls" urlsrc="https://cloud.qsfera.test/hosting/wopi/cell/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="convert" ext="xls" targetext="xlsx" requires="update" urlsrc="https://cloud.qsfera.test/hosting/wopi/convert-and-edit/xls/xlsx?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="view" ext="xlsb" urlsrc="https://cloud.qsfera.test/hosting/wopi/cell/view?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="xlsb" urlsrc="https://cloud.qsfera.test/hosting/wopi/cell/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="convert" ext="xlsb" targetext="xlsx" requires="update" urlsrc="https://cloud.qsfera.test/hosting/wopi/convert-and-edit/xlsb/xlsx?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
</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?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
</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 := &registryv1beta1.AddAppProviderRequest{
Provider: &registryv1beta1.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(&microstore.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"))
})
})
})
})