feat: Add new collaboration service (WOPI)

This commit is contained in:
Juan Pablo Villafáñez
2024-02-06 10:35:55 +01:00
parent f52f2d4a5c
commit 3e90402350
45 changed files with 2762 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ OCIS_MODULES = \
services/auth-machine \
services/auth-service \
services/clientlog \
services/collaboration \
services/eventhistory \
services/frontend \
services/gateway \

2
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/MicahParks/keyfunc v1.9.0
github.com/Nerzal/gocloak/v13 v13.9.0
github.com/bbalet/stopwords v1.0.0
github.com/beevik/etree v1.3.0
github.com/blevesearch/bleve/v2 v2.4.0
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/coreos/go-oidc/v3 v3.10.0
@@ -133,7 +134,6 @@ require (
github.com/armon/go-radix v1.0.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.45.1 // indirect
github.com/beevik/etree v1.3.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bitly/go-simplejson v0.5.0 // indirect
github.com/bits-and-blooms/bitset v1.2.1 // indirect

2
go.sum
View File

@@ -1031,6 +1031,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4=
github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo=
github.com/deepmap/oapi-codegen v1.3.11/go.mod h1:suMvK7+rKlx3+tpa8ByptmvoXbAV70wERKTOGH3hLp0=

View File

@@ -11,6 +11,7 @@ import (
authmachine "github.com/owncloud/ocis/v2/services/auth-machine/pkg/config"
authservice "github.com/owncloud/ocis/v2/services/auth-service/pkg/config"
clientlog "github.com/owncloud/ocis/v2/services/clientlog/pkg/config"
collaboration "github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
eventhistory "github.com/owncloud/ocis/v2/services/eventhistory/pkg/config"
frontend "github.com/owncloud/ocis/v2/services/frontend/pkg/config"
gateway "github.com/owncloud/ocis/v2/services/gateway/pkg/config"
@@ -88,6 +89,7 @@ type Config struct {
AuthMachine *authmachine.Config `yaml:"auth_machine"`
AuthService *authservice.Config `yaml:"auth_service"`
Clientlog *clientlog.Config `yaml:"clientlog"`
Collaboration *collaboration.Config `yaml:"collaboration"`
EventHistory *eventhistory.Config `yaml:"eventhistory"`
Frontend *frontend.Config `yaml:"frontend"`
Gateway *gateway.Config `yaml:"gateway"`

View File

@@ -10,6 +10,7 @@ import (
authmachine "github.com/owncloud/ocis/v2/services/auth-machine/pkg/config/defaults"
authservice "github.com/owncloud/ocis/v2/services/auth-service/pkg/config/defaults"
clientlog "github.com/owncloud/ocis/v2/services/clientlog/pkg/config/defaults"
collaboration "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/defaults"
eventhistory "github.com/owncloud/ocis/v2/services/eventhistory/pkg/config/defaults"
frontend "github.com/owncloud/ocis/v2/services/frontend/pkg/config/defaults"
gateway "github.com/owncloud/ocis/v2/services/gateway/pkg/config/defaults"
@@ -60,6 +61,7 @@ func DefaultConfig() *Config {
AuthMachine: authmachine.DefaultConfig(),
AuthService: authservice.DefaultConfig(),
Clientlog: clientlog.DefaultConfig(),
Collaboration: collaboration.DefaultConfig(),
EventHistory: eventhistory.DefaultConfig(),
Frontend: frontend.DefaultConfig(),
Gateway: gateway.DefaultConfig(),

View File

@@ -17,6 +17,7 @@ import (
authmachine "github.com/owncloud/ocis/v2/services/auth-machine/pkg/command"
authservice "github.com/owncloud/ocis/v2/services/auth-service/pkg/command"
clientlog "github.com/owncloud/ocis/v2/services/clientlog/pkg/command"
collaboration "github.com/owncloud/ocis/v2/services/collaboration/pkg/command"
eventhistory "github.com/owncloud/ocis/v2/services/eventhistory/pkg/command"
frontend "github.com/owncloud/ocis/v2/services/frontend/pkg/command"
gateway "github.com/owncloud/ocis/v2/services/gateway/pkg/command"
@@ -96,6 +97,11 @@ var svccmds = []register.Command{
cfg.Clientlog.Commons = cfg.Commons
})
},
func(cfg *config.Config) *cli.Command {
return ServiceCommand(cfg, cfg.Collaboration.Service.Name, collaboration.GetCommands(cfg.Collaboration), func(c *config.Config) {
cfg.Collaboration.Commons = cfg.Commons
})
},
func(cfg *config.Config) *cli.Command {
return ServiceCommand(cfg, cfg.EventHistory.Service.Name, eventhistory.GetCommands(cfg.EventHistory), func(c *config.Config) {
cfg.EventHistory.Commons = cfg.Commons

View File

@@ -0,0 +1,37 @@
SHELL := bash
NAME := collaboration
include ../../.make/recursion.mk
############ tooling ############
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
include ../../.bingo/Variables.mk
endif
############ go tooling ############
include ../../.make/go.mk
############ release ############
include ../../.make/release.mk
############ docs generate ############
include ../../.make/docs.mk
.PHONY: docs-generate
docs-generate: config-docs-generate
############ generate ############
include ../../.make/generate.mk
.PHONY: ci-go-generate
ci-go-generate: # CI runs ci-node-generate automatically before this target
.PHONY: ci-node-generate
ci-node-generate:
############ licenses ############
.PHONY: ci-node-check-licenses
ci-node-check-licenses:
.PHONY: ci-node-save-licenses
ci-node-save-licenses:

View File

@@ -0,0 +1,29 @@
package command
import (
"os"
"github.com/owncloud/ocis/v2/ocis-pkg/clihelper"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/urfave/cli/v2"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) cli.Commands {
return []*cli.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(&cli.App{
Name: "collaboration",
Usage: "Serve WOPI for oCIS",
Commands: GetCommands(cfg),
})
return app.Run(os.Args)
}

View File

@@ -0,0 +1,109 @@
package command
import (
"context"
"fmt"
"net"
"github.com/oklog/run"
"github.com/owncloud/ocis/v2/ocis-pkg/config/configlog"
"github.com/owncloud/ocis/v2/ocis-pkg/tracing"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config/parser"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/cs3wopiserver"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/logging"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/server/grpc"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/server/http"
"github.com/urfave/cli/v2"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "server",
Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
Category: "server",
Before: func(c *cli.Context) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
Action: func(c *cli.Context) error {
logger := logging.Configure(cfg.Service.Name, cfg.Log)
traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name)
if err != nil {
return err
}
gr := run.Group{}
ctx, cancel := func() (context.Context, context.CancelFunc) {
if cfg.Context == nil {
return context.WithCancel(context.Background())
}
return context.WithCancel(cfg.Context)
}()
defer cancel()
app, err := cs3wopiserver.Start(cfg) // grpc server needs decoupling
if err != nil {
return err
}
grpcServer, teardown, err := grpc.Server(
grpc.App(app),
grpc.Config(cfg),
grpc.Logger(logger),
)
defer teardown()
if err != nil {
logger.Info().
Err(err).
Str("transport", "grpc").
Msg("Failed to initialize server")
return err
}
gr.Add(func() error {
l, err := net.Listen("tcp", cfg.GRPC.Addr)
if err != nil {
return err
}
grpcServer.Serve(l)
return nil
},
func(_ error) {
logger.Error().
Err(err).
Str("server", "grpc").
Msg("shutting down server")
cancel()
})
/*
server, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(server.ListenAndServe, func(_ error) {
_ = server.Shutdown(ctx)
cancel()
})
*/
server, err := http.Server(
http.App(app),
http.Logger(logger),
http.Config(cfg),
http.Context(ctx),
http.TracerProvider(traceProvider),
)
gr.Add(server.Run, func(_ error) {
cancel()
})
return gr.Run()
},
}
}

View File

@@ -0,0 +1,9 @@
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"`
Description string `yaml:"description" env:"COLLABORATION_APP_DESCRIPTION" desc:"App description"`
Icon string `yaml:"icon" env:"COLLABORATION_APP_ICON" desc:"Icon for the app"`
LockName string `yaml:"lockname" env:"COLLABORATION_APP_LOCKNAME" desc:"Name for the app lock"`
}

View File

@@ -0,0 +1,47 @@
package config
import (
"context"
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
"github.com/owncloud/ocis/v2/ocis-pkg/tracing"
)
// 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"`
Secret string `yaml:"secret" env:"COLLABORATION_SECRET" desc:"Used as JWT token and to encrypt access token."`
GRPC GRPC `yaml:"grpc"`
HTTP HTTP `yaml:"http"`
WopiApp WopiApp `yaml:"wopiapp"`
CS3Api CS3Api `yaml:"cs3api"`
Tracing *Tracing `yaml:"tracing"`
Log *Log `yaml:"log"`
Debug Debug `yaml:"debug"`
Context context.Context `yaml:"-"`
}
// Tracing defines the available tracing configuration. Not used at the moment
type Tracing struct {
Enabled bool `yaml:"enabled" env:"OCIS_TRACING_ENABLED;COLLABORATION_TRACING_ENABLED" desc:"Activates tracing."`
Type string `yaml:"type" env:"OCIS_TRACING_TYPE;COLLABORATION_TRACING_TYPE" desc:"The type of tracing. Defaults to '', which is the same as 'jaeger'. Allowed tracing types are 'jaeger' and '' as of now."`
Endpoint string `yaml:"endpoint" env:"OCIS_TRACING_ENDPOINT;COLLABORATION_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent."`
Collector string `yaml:"collector" env:"OCIS_TRACING_COLLECTOR;COLLABORATION_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset."`
}
// Convert Tracing to the tracing package's Config struct.
func (t Tracing) Convert() tracing.Config {
return tracing.Config{
Enabled: t.Enabled,
Type: t.Type,
Endpoint: t.Endpoint,
Collector: t.Collector,
}
}

View File

@@ -0,0 +1,15 @@
package config
// WopiApp defines the available configuration in order to connect to a WOPI app.
type CS3Api struct {
Gateway Gateway `yaml:"gateway"`
DataGateway DataGateway `yaml:"datagateway"`
}
type Gateway struct {
Name string `yaml: "name" env:"COLLABORATION_CS3API_GATEWAY_NAME" desc:"service name of the CS3API gateway"`
}
type DataGateway struct {
Insecure bool `yaml:"insecure" env:"COLLABORATION_CS3API_DATAGATEWAY_INSECURE" desc:"connect to the CS3API data gateway insecurely"`
}

View File

@@ -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."`
Token string `yaml:"token" env:"COLLABORATION_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint."`
Pprof bool `yaml:"pprof" env:"COLLABORATION_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling."`
Zpages bool `yaml:"zpages" env:"COLLABORATION_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces."`
}

View File

@@ -0,0 +1,83 @@
package defaults
import (
"github.com/dchest/uniuri"
"github.com/owncloud/ocis/v2/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: "WOPI app",
Description: "Open office documents with a WOPI app",
Icon: "image-edit",
LockName: "com.github.owncloud.collaboration",
},
Secret: uniuri.NewLen(32),
GRPC: config.GRPC{
Addr: "127.0.0.1:56778",
Namespace: "com.owncloud.collaboration",
},
HTTP: config.HTTP{
Addr: "127.0.0.1:6789",
Namespace: "com.owncloud.collaboration",
//Scheme: "http",
},
WopiApp: config.WopiApp{
Addr: "https://127.0.0.1:8080",
Insecure: true, // TODO: this should have a secure default
},
CS3Api: config.CS3Api{
Gateway: config.Gateway{
Name: "com.owncloud.api.gateway",
},
DataGateway: config.DataGateway{
Insecure: true, // TODO: this should have a secure default
},
},
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
// provide with defaults for shared logging, since we need a valid destination address for "envdecode".
if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil {
cfg.Log = &config.Log{
Level: cfg.Commons.Log.Level,
Pretty: cfg.Commons.Log.Pretty,
Color: cfg.Commons.Log.Color,
File: cfg.Commons.Log.File,
}
} else if cfg.Log == nil {
cfg.Log = &config.Log{}
}
// provide with defaults for shared tracing, since we need a valid destination address for "envdecode".
if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil {
cfg.Tracing = &config.Tracing{
Enabled: cfg.Commons.Tracing.Enabled,
Type: cfg.Commons.Tracing.Type,
Endpoint: cfg.Commons.Tracing.Endpoint,
Collector: cfg.Commons.Tracing.Collector,
}
} else if cfg.Tracing == nil {
cfg.Tracing = &config.Tracing{}
}
}
// Sanitize sanitized the configuration
func Sanitize(cfg *config.Config) {
// sanitize config
}

View File

@@ -0,0 +1,7 @@
package config
// Service defines the available grpc configuration.
type GRPC struct {
Addr string `yaml:"addr" env:"COLLABORATION_GRPC_ADDR" desc:"The bind address of the GRPC service"`
Namespace string `yaml:"-"`
}

View File

@@ -0,0 +1,14 @@
package config
import (
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
)
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The address of the HTTP service."`
BindAddr string `yaml:"bindaddr" env:"COLLABORATION_HTTP_BINDADDR" desc:"The bind address of the HTTP service."`
Namespace string `yaml:"-"`
Scheme string `yaml:"scheme" env:"COLLABORATION_HTTP_SCHEME" desc:"Either http or https"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
}

View File

@@ -0,0 +1,9 @@
package config
// Log defines the available log configuration.
type Log struct {
Level string `yaml:"level" env:"OCIS_LOG_LEVEL;COLLABORATION_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'."`
Pretty bool `yaml:"pretty" env:"OCIS_LOG_PRETTY;COLLABORATION_LOG_PRETTY" desc:"Activates pretty log output."`
Color bool `yaml:"color" env:"OCIS_LOG_COLOR;COLLABORATION_LOG_COLOR" desc:"Activates colorized log output."`
File string `yaml:"file" env:"OCIS_LOG_FILE;COLLABORATION_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set."`
}

View File

@@ -0,0 +1,38 @@
package parser
import (
"errors"
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config/defaults"
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
_, err := ociscfg.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 {
return nil
}

View File

@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}

View File

@@ -0,0 +1,7 @@
package config
// WopiApp defines the available configuration in order to connect to a WOPI app.
type WopiApp struct {
Addr string `yaml:"addr" env:"COLLABORATION_WOPIAPP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080."`
Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Connect insecurely"`
}

View File

@@ -0,0 +1,42 @@
package cs3wopiserver
import (
"context"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app"
)
func Start(cfg *config.Config) (*app.DemoApp, error) {
ctx := context.Background()
app, err := app.New(cfg)
if err != nil {
return nil, err
}
if err := app.RegisterOcisService(ctx); err != nil {
return nil, err
}
if err := app.WopiDiscovery(ctx); err != nil {
return nil, err
}
if err := app.GetCS3apiClient(); err != nil {
return nil, err
}
if err := app.RegisterDemoApp(ctx); err != nil {
return nil, err
}
// NOTE:
// GRPC and HTTP server are started using the standard
// `ocis collaboration server` command through the usual means
// TODO:
// "app" initialization needs to be moved
return app, nil
}

View File

@@ -0,0 +1,109 @@
package app
import (
"context"
"errors"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config/defaults"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/logging"
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/cs3org/reva/v2/pkg/mime"
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
"github.com/gofrs/uuid"
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
"google.golang.org/grpc"
)
type DemoApp struct {
gwc gatewayv1beta1.GatewayAPIClient
grpcServer *grpc.Server
AppURLs map[string]map[string]string
Config *config.Config
Logger log.Logger
}
func New(cfg *config.Config) (*DemoApp, error) {
app := &DemoApp{
Config: cfg,
}
err := envdecode.Decode(app)
if err != nil {
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return nil, err
}
}
app.Logger = logging.Configure("wopiserver", defaults.FullDefaultConfig().Log)
return app, nil
}
func (app *DemoApp) GetCS3apiClient() error {
// establish a connection to the cs3 api endpoint
// in this case a REVA gateway, started by oCIS
gwc, err := pool.GetGatewayServiceClient(app.Config.CS3Api.Gateway.Name)
if err != nil {
return err
}
app.gwc = gwc
return nil
}
func (app *DemoApp) RegisterOcisService(ctx context.Context) error {
svc := registry.BuildGRPCService(app.Config.Service.Name, uuid.Must(uuid.NewV4()).String(), app.Config.GRPC.Addr, "0.0.0")
return registry.RegisterService(ctx, svc, app.Logger)
}
func (app *DemoApp) RegisterDemoApp(ctx context.Context) error {
mimeTypesMap := make(map[string]bool)
for _, extensions := range app.AppURLs {
for ext := range extensions {
m := mime.Detect(false, ext)
mimeTypesMap[m] = true
}
}
mimeTypes := make([]string, 0, len(mimeTypesMap))
for m := range mimeTypesMap {
mimeTypes = append(mimeTypes, m)
}
// TODO: REVA has way to filter supported mimetypes (do we need to implement it here or is it in the registry?)
// 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: app.Config.App.Name,
Description: app.Config.App.Description,
Icon: app.Config.App.Icon,
Address: app.Config.Service.Name,
MimeTypes: mimeTypes,
},
}
resp, err := app.gwc.AddAppProvider(ctx, req)
if err != nil {
app.Logger.Error().Err(err).Msg("AddAppProvider failed")
return err
}
if resp.Status.Code != rpcv1beta1.Code_CODE_OK {
app.Logger.Error().Str("status_code", resp.Status.Code.String()).Msg("AddAppProvider failed")
return errors.New("status code != CODE_OK")
}
return nil
}

View File

@@ -0,0 +1,8 @@
package app
import "github.com/golang-jwt/jwt/v4"
type Claims struct {
WopiContext WopiContext `json:"WopiContext"`
jwt.StandardClaims
}

View File

@@ -0,0 +1,74 @@
package app
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
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{}
}
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
}
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
}

View File

@@ -0,0 +1,141 @@
package app
type FileInfo struct {
// ------------
// Microsoft WOPI check file info specification:
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo
// ------------
// 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"`
// A string value uniquely identifying the user currently accessing the file.
UserID string `json:"UserId,omitempty"`
// The size of the file in bytes, expressed as a long, a 64-bit signed integer.
Size int64 `json:"Size,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"`
// A string that is the name of the user, suitable for displaying in UI.
UserFriendlyName string `json:"UserFriendlyName,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 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 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"`
// A Boolean value that indicates that, for this user, the file cannot be changed.
ReadOnly bool `json:"ReadOnly"`
// A Boolean value that indicates that if host is temporarily unable to process writes on a file
TemporarilyNotWritable bool `json:"TemporarilyNotWritable,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 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 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 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 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"`
// 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"`
// 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 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"`
// 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"`
// 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"`
// 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 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"`
// 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 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"`
// 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 DeleteFile operation.
SupportsDeleteFile bool `json:"SupportsDeleteFile"`
// A Boolean value that indicates that the host supports the PutUserInfo operation.
SupportsUserInfo bool `json:"SupportsUserInfo"`
// 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,omitempty"`
// ------------
// Collabora WOPI check file info specification:
// https://sdk.collaboraonline.com/docs/advanced_integration.html
// ------------
// 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"`
// 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"`
}

View File

@@ -0,0 +1,5 @@
package app
const (
HeaderWopiLock string = "X-WOPI-Lock"
)

View File

@@ -0,0 +1,86 @@
package app
import (
"context"
"errors"
"fmt"
"net/http"
appproviderv1beta1 "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"
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/golang-jwt/jwt/v4"
"google.golang.org/grpc/metadata"
)
type key int
const (
wopiContextKey key = iota
)
type WopiContext struct {
AccessToken string
FileReference providerv1beta1.Reference
User *userv1beta1.User
ViewMode appproviderv1beta1.ViewMode
EditAppUrl string
ViewAppUrl string
}
func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessToken := r.URL.Query().Get("access_token")
if accessToken == "" {
fmt.Println("wopicontext", "accesstoken empty")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
claims := &Claims{}
_, err := jwt.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(app.Config.Secret), nil
})
if err != nil {
fmt.Println("wopicontext", err)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
if err := claims.Valid(); err != nil {
fmt.Println("wopicontext", err)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
ctx := r.Context()
wopiContextAccessToken, err := DecryptAES([]byte(app.Config.Secret), claims.WopiContext.AccessToken)
if err != nil {
fmt.Println("wopicontext", err)
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)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
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")
}

View File

@@ -0,0 +1,123 @@
package app
import (
"context"
"crypto/tls"
"io"
"net/http"
"net/url"
"strings"
"github.com/beevik/etree"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/pkg/errors"
)
func (app *DemoApp) WopiDiscovery(ctx context.Context) error {
res, err := getAppURLs(app.Config.WopiApp.Addr, app.Config.WopiApp.Insecure, app.Logger)
if err != nil {
// logging is already covered inside the `getAppURLs` function
return err
}
app.AppURLs = res
return nil
}
func getAppURLs(wopiAppUrl string, insecure bool, logger log.Logger) (map[string]map[string]string, error) {
wopiAppUrl = wopiAppUrl + "/hosting/discovery"
httpClient := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecure,
},
},
}
httpResp, err := httpClient.Get(wopiAppUrl)
if err != nil {
logger.Error().
Err(err).
Str("WopiAppUrl", wopiAppUrl).
Msg("WopiDiscovery: failed to access wopi app url")
return nil, err
}
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")
}
defer httpResp.Body.Close()
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")
}
// TODO: Log appUrls? not easy with the format
// It's also a one-time call during service setup, so it's pointless
// to use an "all-is-good" debug log
return appURLs, nil
}
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" {
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
}

View File

@@ -0,0 +1,97 @@
package app
import (
"io"
"net/http"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/helpers"
)
// GetFile downloads the file from the storage
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile
func GetFile(app *DemoApp, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
wopiContext, _ := WopiContextFromCtx(ctx)
// download the file
resp, err := helpers.DownloadFile(
ctx,
&wopiContext.FileReference,
app.gwc,
wopiContext.AccessToken,
app.Config.CS3Api.DataGateway.Insecure,
app.Logger,
)
if err != nil || resp.StatusCode != http.StatusOK {
app.Logger.Error().
Err(err).
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Int("HttpCode", resp.StatusCode).
Msg("GetFile: downloading the file failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// read the file from the body
defer resp.Body.Close()
_, err = io.Copy(w, resp.Body)
if err != nil {
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("GetFile: copying the file content to the response body failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Logger.Debug().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("GetFile: success")
http.Error(w, "", http.StatusOK)
}
// PutFile uploads the file to the storage
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile
func PutFile(app *DemoApp, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
wopiContext, _ := WopiContextFromCtx(ctx)
// read the file from the body
defer r.Body.Close()
// upload the file
err := helpers.UploadFile(
ctx,
r.Body,
&wopiContext.FileReference,
app.gwc,
wopiContext.AccessToken,
r.Header.Get(HeaderWopiLock),
app.Config.CS3Api.DataGateway.Insecure,
app.Logger,
)
if err != nil {
app.Logger.Error().
Err(err).
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("PutFile: uploading the file failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Logger.Debug().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("PutFile: success")
http.Error(w, "", http.StatusOK)
}

View File

@@ -0,0 +1,136 @@
package app
import (
"encoding/json"
"net/http"
"path"
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/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/google/uuid"
)
func WopiInfoHandler(app *DemoApp, w http.ResponseWriter, r *http.Request) {
// Logs for this endpoint will be covered by the access log. We can't extract
// more info
http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
}
// CheckFileInfo returns information about the requested file and capabilities of the wopi server
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo
func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
wopiContext, _ := WopiContextFromCtx(ctx)
statRes, err := app.gwc.Stat(ctx, &providerv1beta1.StatRequest{
Ref: &wopiContext.FileReference,
})
if err != nil {
app.Logger.Error().
Err(err).
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("CheckFileInfo: stat failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if statRes.Status.Code != rpcv1beta1.Code_CODE_OK {
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("StatusCode", statRes.Status.Code.String()).
Str("StatusMsg", statRes.Status.Message).
Msg("CheckFileInfo: stat failed with unexpected status")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fileInfo := FileInfo{
OwnerID: statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp,
Size: int64(statRes.Info.Size),
Version: statRes.Info.Mtime.String(),
BaseFileName: path.Base(statRes.Info.Path),
BreadcrumbDocName: path.Base(statRes.Info.Path),
// to get the folder we actually need to do a GetPath() request
//BreadcrumbFolderName: path.Dir(statRes.Info.Path),
UserCanNotWriteRelative: true,
HostViewUrl: wopiContext.ViewAppUrl,
HostEditUrl: wopiContext.EditAppUrl,
EnableOwnerTermination: true,
SupportsExtendedLockLength: true,
SupportsGetLock: true,
SupportsLocks: true,
}
switch wopiContext.ViewMode {
case appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE:
fileInfo.SupportsUpdate = true
fileInfo.UserCanWrite = true
case appproviderv1beta1.ViewMode_VIEW_MODE_READ_ONLY:
// nothing special to do here for now
case appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY:
fileInfo.DisableExport = true
fileInfo.DisableCopy = true
fileInfo.DisablePrint = true
}
// user logic from reva wopi driver #TODO: refactor
var isPublicShare bool = false
if wopiContext.User != nil {
if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT {
fileInfo.UserID = statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp
} else {
fileInfo.UserID = wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp
}
if wopiContext.User.Opaque != nil {
if _, ok := wopiContext.User.Opaque.Map["public-share-role"]; ok {
isPublicShare = true
}
}
if !isPublicShare {
fileInfo.UserFriendlyName = wopiContext.User.Username
fileInfo.UserID = wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp
}
}
if wopiContext.User == nil || isPublicShare {
randomID, _ := uuid.NewUUID()
fileInfo.UserID = "guest-" + randomID.String()
fileInfo.UserFriendlyName = "Guest " + randomID.String()
fileInfo.IsAnonymousUser = true
}
jsonFileInfo, err := json.Marshal(fileInfo)
if err != nil {
app.Logger.Error().
Err(err).
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("CheckFileInfo: failed to marshal fileinfo")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Logger.Debug().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("CheckFileInfo: success")
w.Header().Set("Content-Type", "application/json")
w.Write(jsonFileInfo)
w.WriteHeader(http.StatusOK)
}

View File

@@ -0,0 +1,280 @@
package app
import (
"net/http"
"time"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
)
const (
// WOPI Locks generally have a lock duration of 30 minutes and will be refreshed before expiration if needed
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#lock
lockDuration time.Duration = 30 * time.Minute
)
// GetLock returns a lock or an empty string if no lock exists
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock
func GetLock(app *DemoApp, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
wopiContext, _ := WopiContextFromCtx(ctx)
req := &providerv1beta1.GetLockRequest{
Ref: &wopiContext.FileReference,
}
resp, err := app.gwc.GetLock(ctx, req)
if err != nil {
app.Logger.Error().
Err(err).
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("GetLock failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if resp.Status.Code != rpcv1beta1.Code_CODE_OK {
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("StatusCode", resp.Status.Code.String()).
Str("StatusMsg", resp.Status.Message).
Msg("GetLock failed with unexpected status")
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
lockID := ""
if resp.Lock != nil {
lockID = resp.Lock.LockId
}
// log the success at debug level
app.Logger.Debug().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("StatusCode", resp.Status.Code.String()).
Str("LockID", lockID).
Msg("GetLock success")
w.Header().Set(HeaderWopiLock, lockID)
http.Error(w, http.StatusText(http.StatusOK), http.StatusOK)
}
// Lock returns a WOPI lock or performs an unlock and relock
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock
func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
wopiContext, _ := WopiContextFromCtx(ctx)
// TODO: handle un- and relock
lockID := r.Header.Get(HeaderWopiLock)
if lockID == "" {
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("Lock failed due to empty lockID")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
req := &providerv1beta1.SetLockRequest{
Ref: &wopiContext.FileReference,
Lock: &providerv1beta1.Lock{
LockId: lockID,
AppName: app.Config.App.LockName,
Type: providerv1beta1.LockType_LOCK_TYPE_WRITE,
Expiration: &typesv1beta1.Timestamp{
Seconds: uint64(time.Now().Add(lockDuration).Unix()),
},
},
}
app.Logger.Debug().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Msg("Performing SetLock")
resp, err := app.gwc.SetLock(ctx, req)
if err != nil {
app.Logger.Error().
Err(err).
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Msg("SetLock failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
switch resp.Status.Code {
case rpcv1beta1.Code_CODE_OK:
app.Logger.Debug().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Msg("SetLock successful")
http.Error(w, http.StatusText(http.StatusOK), http.StatusOK)
return
case rpcv1beta1.Code_CODE_FAILED_PRECONDITION:
// already locked
req := &providerv1beta1.GetLockRequest{
Ref: &wopiContext.FileReference,
}
resp, err := app.gwc.GetLock(ctx, req)
if err != nil {
app.Logger.Error().
Err(err).
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Msg("SetLock failed, fallback to GetLock failed too")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if resp.Status.Code != rpcv1beta1.Code_CODE_OK {
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Str("StatusCode", resp.Status.Code.String()).
Str("StatusMsg", resp.Status.Message).
Msg("SetLock failed, fallback to GetLock failed with unexpected status")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
if resp.Lock != nil {
if resp.Lock.LockId != lockID {
app.Logger.Warn().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Str("LockID", resp.Lock.LockId).
Msg("SetLock conflict")
w.Header().Set(HeaderWopiLock, resp.Lock.LockId)
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
return
}
// TODO: according to the spec we need to treat this as a RefreshLock
app.Logger.Warn().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Str("LockID", resp.Lock.LockId).
Msg("SetLock lock refreshed instead")
http.Error(w, http.StatusText(http.StatusOK), http.StatusOK)
return
}
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Msg("SetLock failed and could not refresh")
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
default:
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Str("StatusCode", resp.Status.Code.String()).
Str("StatusMsg", resp.Status.Message).
Msg("SetLock failed with unexpected status")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
// RefreshLock refreshes a provided lock for 30 minutes
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock
func RefreshLock(app *DemoApp, w http.ResponseWriter, r *http.Request) {
// TODO: implement
http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
}
// UnLock removes a given lock from a file
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock
func UnLock(app *DemoApp, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
wopiContext, _ := WopiContextFromCtx(ctx)
lockID := r.Header.Get(HeaderWopiLock)
if lockID == "" {
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Msg("Unlock failed due to empty lockID")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
req := &providerv1beta1.UnlockRequest{
Ref: &wopiContext.FileReference,
Lock: &providerv1beta1.Lock{
LockId: lockID,
AppName: app.Config.App.LockName,
},
}
resp, err := app.gwc.Unlock(ctx, req)
if err != nil {
app.Logger.Error().
Err(err).
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Msg("Unlock failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if resp.Status.Code != rpcv1beta1.Code_CODE_OK {
app.Logger.Error().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Str("StatusCode", resp.Status.Code.String()).
Str("StatusMsg", resp.Status.Message).
Msg("Unlock failed with unexpected status")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
app.Logger.Debug().
Str("FileReference", wopiContext.FileReference.String()).
Str("ViewMode", wopiContext.ViewMode.String()).
Str("Requester", wopiContext.User.GetId().String()).
Str("RequestedLockID", lockID).
Msg("Unlock successful")
http.Error(w, http.StatusText(http.StatusOK), http.StatusOK)
}

View File

@@ -0,0 +1,106 @@
package helpers
import (
"bytes"
"context"
"crypto/tls"
"errors"
"net/http"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
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"
)
func DownloadFile(
ctx context.Context,
ref *providerv1beta1.Reference,
gwc gatewayv1beta1.GatewayAPIClient,
token string,
insecure bool,
logger log.Logger,
) (http.Response, error) {
req := &providerv1beta1.InitiateFileDownloadRequest{
Ref: ref,
}
resp, err := gwc.InitiateFileDownload(ctx, req)
if err != nil {
logger.Error().
Err(err).
Str("FileReference", ref.String()).
Msg("DownloadHelper: InitiateFileDownload failed")
return http.Response{}, err
}
if resp.Status.Code != rpcv1beta1.Code_CODE_OK {
logger.Error().
Str("FileReference", ref.String()).
Str("StatusCode", resp.Status.Code.String()).
Str("StatusMsg", resp.Status.Message).
Msg("DownloadHelper: InitiateFileDownload failed with wrong status")
return http.Response{}, errors.New("InitiateFileDownload failed with status " + resp.Status.Code.String())
}
downloadEndpoint := ""
downloadToken := ""
hasDownloadToken := false
for _, proto := range resp.Protocols {
if proto.Protocol == "simple" || proto.Protocol == "spaces" {
downloadEndpoint = proto.DownloadEndpoint
downloadToken = proto.Token
hasDownloadToken = proto.Token != ""
}
}
if downloadEndpoint == "" {
logger.Error().
Str("FileReference", ref.String()).
Str("Endpoint", downloadEndpoint).
Bool("HasDownloadToken", hasDownloadToken).
Msg("DownloadHelper: Download endpoint or token is missing")
return http.Response{}, errors.New("download endpoint is missing")
}
httpClient := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecure,
},
},
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadEndpoint, bytes.NewReader([]byte("")))
if err != nil {
logger.Error().
Err(err).
Str("FileReference", ref.String()).
Str("Endpoint", downloadEndpoint).
Bool("HasDownloadToken", hasDownloadToken).
Msg("DownloadHelper: Could not create the request to the endpoint")
return http.Response{}, err
}
if downloadToken != "" {
// public link downloads have the token in the download endpoint
httpReq.Header.Add("X-Reva-Transfer", downloadToken)
}
// TODO: the access token shouldn't be needed
httpReq.Header.Add("x-access-token", token)
httpResp, err := httpClient.Do(httpReq)
if err != nil {
logger.Error().
Err(err).
Str("FileReference", ref.String()).
Str("Endpoint", downloadEndpoint).
Bool("HasDownloadToken", hasDownloadToken).
Msg("DownloadHelper: Get request to the download endpoint failed")
return http.Response{}, err
}
return *httpResp, nil
}

View File

@@ -0,0 +1,127 @@
package helpers
import (
"context"
"crypto/tls"
"errors"
"io"
"net/http"
"strconv"
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"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
)
func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1.Reference, gwc gatewayv1beta1.GatewayAPIClient, token string, lockID string, insecure bool, logger log.Logger) error {
req := &providerv1beta1.InitiateFileUploadRequest{
Ref: ref,
LockId: lockID,
// TODO: if-match
//Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{
// IfMatch: "",
//},
}
resp, err := gwc.InitiateFileUpload(ctx, req)
if err != nil {
logger.Error().
Err(err).
Str("FileReference", ref.String()).
Str("RequestedLockID", lockID).
Msg("UploadHelper: InitiateFileUpload failed")
return err
}
if resp.Status.Code != rpcv1beta1.Code_CODE_OK {
logger.Error().
Str("FileReference", ref.String()).
Str("RequestedLockID", lockID).
Str("StatusCode", resp.Status.Code.String()).
Str("StatusMsg", resp.Status.Message).
Msg("UploadHelper: InitiateFileUpload failed with wrong status")
return errors.New("InitiateFileUpload failed with status " + resp.Status.Code.String())
}
uploadEndpoint := ""
uploadToken := ""
hasUploadToken := false
for _, proto := range resp.Protocols {
if proto.Protocol == "simple" || proto.Protocol == "spaces" {
uploadEndpoint = proto.UploadEndpoint
uploadToken = proto.Token
hasUploadToken = proto.Token != ""
break
}
}
if uploadEndpoint == "" {
logger.Error().
Str("FileReference", ref.String()).
Str("RequestedLockID", lockID).
Str("Endpoint", uploadEndpoint).
Bool("HasUploadToken", hasUploadToken).
Msg("UploadHelper: Upload endpoint or token is missing")
return errors.New("upload endpoint or token is missing")
}
httpClient := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecure,
},
},
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadEndpoint, content)
if err != nil {
logger.Error().
Err(err).
Str("FileReference", ref.String()).
Str("RequestedLockID", lockID).
Str("Endpoint", uploadEndpoint).
Bool("HasUploadToken", hasUploadToken).
Msg("UploadHelper: Could not create the request to the endpoint")
return err
}
if uploadToken != "" {
// public link uploads have the token in the upload endpoint
httpReq.Header.Add("X-Reva-Transfer", uploadToken)
}
// TODO: the access token shouldn't be needed
httpReq.Header.Add("x-access-token", token)
// 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).
Str("FileReference", ref.String()).
Str("RequestedLockID", lockID).
Str("Endpoint", uploadEndpoint).
Bool("HasUploadToken", hasUploadToken).
Msg("UploadHelper: Put request to the upload endpoint failed")
return err
}
if httpResp.StatusCode != http.StatusOK {
logger.Error().
Str("FileReference", ref.String()).
Str("RequestedLockID", lockID).
Str("Endpoint", uploadEndpoint).
Bool("HasUploadToken", hasUploadToken).
Int("HttpCode", httpResp.StatusCode).
Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status")
return errors.New("Put request failed with status " + strconv.Itoa(httpResp.StatusCode))
}
return nil
}

View File

@@ -0,0 +1,17 @@
package logging
import (
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
)
// LoggerFromConfig initializes a service-specific logger instance.
func Configure(name string, cfg *config.Log) log.Logger {
return log.NewLogger(
log.Name(name),
log.Level(cfg.Level),
log.Pretty(cfg.Pretty),
log.Color(cfg.Color),
log.File(cfg.File),
)
}

View File

@@ -0,0 +1,32 @@
package middleware
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
)
// 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()
wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(wrap, r)
logger.Info().
Str("proto", r.Proto).
Str("request", chimiddleware.GetReqID(r.Context())).
Str("remote-addr", r.RemoteAddr).
Str("method", r.Method).
Int("status", wrap.Status()).
Str("path", r.URL.Path).
Dur("duration", time.Since(start)).
Int("bytes", wrap.BytesWritten()).
Msg("access-log")
})
}
}

View File

@@ -0,0 +1,76 @@
package grpc
import (
"context"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app"
"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 {
App *app.DemoApp
Name string
Logger log.Logger
Context context.Context
Config *config.Config
TraceProvider trace.TracerProvider
}
// 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 App(val *app.DemoApp) Option {
return func(o *Options) {
o.App = 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
}
}

View File

@@ -0,0 +1,32 @@
package grpc
import (
appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
svc "github.com/owncloud/ocis/v2/services/collaboration/pkg/service/grpc/v0"
"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) {
grpcOpts := []grpc.ServerOption{}
options := newOptions(opts...)
grpcServer := grpc.NewServer(grpcOpts...)
handle, teardown, err := svc.NewHandler(
svc.Config(options.Config),
svc.Logger(options.Logger),
svc.AppURLs(options.App.AppURLs),
)
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
}

View File

@@ -0,0 +1,68 @@
package http
import (
"context"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app"
"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 {
App *app.DemoApp
Logger log.Logger
Context context.Context
Config *config.Config
TracerProvider trace.TracerProvider
}
// 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 App(val *app.DemoApp) Option {
return func(o *Options) {
o.App = 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
}
}

View File

@@ -0,0 +1,157 @@
package http
import (
"fmt"
stdhttp "net/http"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/owncloud/ocis/v2/ocis-pkg/account"
"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
"github.com/owncloud/ocis/v2/ocis-pkg/service/http"
"github.com/owncloud/ocis/v2/ocis-pkg/tracing"
"github.com/owncloud/ocis/v2/ocis-pkg/version"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app"
"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.BindAddr),
http.Context(options.Context),
)
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(
"userlog",
version.GetString(),
),
middleware.Logger(
options.Logger,
),
middleware.ExtractAccountUUID(
account.Logger(options.Logger),
account.JWTSecret(options.Config.Secret), // previously, secret came from 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),
),
*/
middleware.Secure,
}
mux := chi.NewMux()
mux.Use(middlewares...)
mux.Use(
otelchi.Middleware(
"collaboration",
otelchi.WithChiRoutes(mux),
otelchi.WithTracerProvider(options.TracerProvider),
otelchi.WithPropagators(tracing.GetPropagator()),
),
)
prepareRoutes(mux, options.App)
if err := micro.RegisterHandler(service.Server(), mux); err != nil {
return http.Service{}, err
}
return service, nil
}
func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) {
r.Route("/wopi", func(r chi.Router) {
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
app.WopiInfoHandler(demoapp, w, r)
})
r.Route("/files/{fileid}", func(r chi.Router) {
r.Use(func(h stdhttp.Handler) stdhttp.Handler {
// authentication and wopi context
return app.WopiContextAuthMiddleware(demoapp, h)
},
)
r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
app.CheckFileInfo(demoapp, w, r)
})
r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
action := r.Header.Get("X-WOPI-Override")
switch action {
case "LOCK":
app.Lock(demoapp, w, r)
case "GET_LOCK":
app.GetLock(demoapp, w, r)
case "REFRESH_LOCK":
app.RefreshLock(demoapp, w, r)
case "UNLOCK":
app.UnLock(demoapp, 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":
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putrelativefile
stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented)
case "RENAME_FILE":
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/renamefile
stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented)
case "DELETE":
// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/deletefile
stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented)
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) {
app.GetFile(demoapp, w, r)
})
r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
action := r.Header.Get("X-WOPI-Override")
switch action {
case "PUT":
app.PutFile(demoapp, w, r)
default:
stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusInternalServerError), stdhttp.StatusInternalServerError)
}
})
})
})
})
}

View File

@@ -0,0 +1,47 @@
package service
import (
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/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
Config *config.Config
AppURLs map[string]map[string]string
}
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
}
}
// ViewUrl provides a function to set the ViewUrl option.
func AppURLs(val map[string]map[string]string) Option {
return func(o *Options) {
o.AppURLs = val
}
}

View File

@@ -0,0 +1,227 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/url"
"path"
"strconv"
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/cs3org/reva/v2/pkg/rgrpc/todo/pool"
"github.com/golang-jwt/jwt/v4"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app"
)
func NewHandler(opts ...Option) (*Service, func(), error) {
teardown := func() {}
options := newOptions(opts...)
gwc, err := pool.GetGatewayServiceClient(options.Config.CS3Api.Gateway.Name)
if err != nil {
return nil, teardown, err
}
return &Service{
id: options.Config.GRPC.Namespace + "." + options.Config.Service.Name,
appURLs: options.AppURLs,
logger: options.Logger,
config: options.Config,
gwc: gwc,
}, teardown, nil
}
// Service implements the searchServiceHandler interface
type Service struct {
id string
appURLs map[string]map[string]string
logger log.Logger
config *config.Config
gwc gatewayv1beta1.GatewayAPIClient
}
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.AccessToken,
}
meResp, err := s.gwc.WhoAmI(ctx, meReq)
if err == nil {
if meResp.Status.Code == rpcv1beta1.Code_CODE_OK {
user = meResp.User
}
}
// required for the response, it will be used also for logs
providerFileRef := providerv1beta1.Reference{
ResourceId: req.GetResourceInfo().GetId(),
Path: ".",
}
// 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
c := sha256.New()
c.Write([]byte(req.ResourceInfo.Id.StorageId + "$" + req.ResourceInfo.Id.SpaceId + "!" + req.ResourceInfo.Id.OpaqueId))
fileRef := hex.EncodeToString(c.Sum(nil))
// get the file extension to use the right wopi app url
fileExt := path.Ext(req.GetResourceInfo().Path)
var viewAppURL string
var editAppURL string
if viewAppURLs, ok := s.appURLs["view"]; ok {
if url := viewAppURLs[fileExt]; ok {
viewAppURL = url
}
}
if editAppURLs, ok := s.appURLs["edit"]; ok {
if url, ok := editAppURLs[fileExt]; ok {
editAppURL = url
}
}
if editAppURL == "" {
// assuming that an view action is always available in the /hosting/discovery manifest
// eg. Collabora does support viewing jpgs but no editing
// eg. OnlyOffice does support viewing pdfs but no editing
// there is no known case of supporting edit only without view
editAppURL = viewAppURL
}
wopiSrcURL := url.URL{
Scheme: s.config.HTTP.Scheme,
Host: s.config.HTTP.Addr,
Path: path.Join("wopi", "files", fileRef),
}
addWopiSrcQueryParam := func(baseURL string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", err
}
q := u.Query()
q.Add("WOPISrc", wopiSrcURL.String())
qs := q.Encode()
u.RawQuery = qs
return u.String(), nil
}
viewAppURL, err = addWopiSrcQueryParam(viewAppURL)
if err != nil {
s.logger.Error().
Err(err).
Str("FileReference", providerFileRef.String()).
Str("ViewMode", req.ViewMode.String()).
Str("Requester", user.GetId().String()).
Msg("OpenInApp: error parsing viewAppUrl")
return nil, err
}
editAppURL, err = addWopiSrcQueryParam(editAppURL)
if err != nil {
s.logger.Error().
Err(err).
Str("FileReference", providerFileRef.String()).
Str("ViewMode", req.ViewMode.String()).
Str("Requester", user.GetId().String()).
Msg("OpenInApp: error parsing editAppUrl")
return nil, err
}
appURL := viewAppURL
if req.ViewMode == appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE {
appURL = editAppURL
}
cryptedReqAccessToken, err := app.EncryptAES([]byte(s.config.Secret), req.AccessToken)
if err != nil {
s.logger.Error().
Err(err).
Str("FileReference", providerFileRef.String()).
Str("ViewMode", req.ViewMode.String()).
Str("Requester", user.GetId().String()).
Msg("OpenInApp: error encrypting access token")
return &appproviderv1beta1.OpenInAppResponse{
Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_INTERNAL},
}, err
}
wopiContext := app.WopiContext{
AccessToken: cryptedReqAccessToken,
FileReference: providerFileRef,
User: user,
ViewMode: req.ViewMode,
EditAppUrl: editAppURL,
ViewAppUrl: viewAppURL,
}
cs3Claims := &jwt.StandardClaims{}
cs3JWTparser := jwt.Parser{}
_, _, err = cs3JWTparser.ParseUnverified(req.AccessToken, cs3Claims)
if err != nil {
s.logger.Error().
Err(err).
Str("FileReference", providerFileRef.String()).
Str("ViewMode", req.ViewMode.String()).
Str("Requester", user.GetId().String()).
Msg("OpenInApp: error parsing JWT token")
return nil, err
}
claims := &app.Claims{
WopiContext: wopiContext,
StandardClaims: jwt.StandardClaims{
ExpiresAt: cs3Claims.ExpiresAt,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
accessToken, err := token.SignedString([]byte(s.config.Secret))
if err != nil {
s.logger.Error().
Err(err).
Str("FileReference", providerFileRef.String()).
Str("ViewMode", req.ViewMode.String()).
Str("Requester", user.GetId().String()).
Msg("OpenInApp: error signing access token")
return &appproviderv1beta1.OpenInAppResponse{
Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_INTERNAL},
}, err
}
s.logger.Debug().
Str("FileReference", providerFileRef.String()).
Str("ViewMode", req.ViewMode.String()).
Str("Requester", user.GetId().String()).
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*1000, 10),
},
},
}, nil
}

121
vendor/github.com/dchest/uniuri/COPYING generated vendored Normal file
View File

@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

95
vendor/github.com/dchest/uniuri/README.md generated vendored Normal file
View File

@@ -0,0 +1,95 @@
Package uniuri
=====================
```go
import "github.com/dchest/uniuri"
```
Package uniuri generates random strings good for use in URIs to identify
unique objects.
Example usage:
```go
s := uniuri.New() // s is now "apHCJBl7L1OmC57n"
```
A standard string created by New() is 16 bytes in length and consists of
Latin upper and lowercase letters, and numbers (from the set of 62 allowed
characters), which means that it has ~95 bits of entropy. To get more
entropy, you can use NewLen(UUIDLen), which returns 20-byte string, giving
~119 bits of entropy, or any other desired length.
Functions read from crypto/rand random source, and panic if they fail to
read from it.
Constants
---------
```go
const (
// StdLen is a standard length of uniuri string to achive ~95 bits of entropy.
StdLen = 16
// UUIDLen is a length of uniuri string to achive ~119 bits of entropy, closest
// to what can be losslessly converted to UUIDv4 (122 bits).
UUIDLen = 20
)
```
Variables
---------
```go
var StdChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
```
StdChars is a set of standard characters allowed in uniuri string.
Functions
---------
### func New
```go
func New() string
```
New returns a new random string of the standard length, consisting of
standard characters.
### func NewLen
```go
func NewLen(length int) string
```
NewLen returns a new random string of the provided length, consisting of
standard characters.
### func NewLenChars
```go
func NewLenChars(length int, chars []byte) string
```
NewLenChars returns a new random string of the provided length, consisting
of the provided byte slice of allowed characters (maximum 256).
Public domain dedication
------------------------
Written in 2011-2014 by Dmitry Chestnykh
The author(s) have dedicated all copyright and related and
neighboring rights to this software to the public domain
worldwide. Distributed without any warranty.
http://creativecommons.org/publicdomain/zero/1.0/

120
vendor/github.com/dchest/uniuri/uniuri.go generated vendored Normal file
View File

@@ -0,0 +1,120 @@
// Written in 2011-2014 by Dmitry Chestnykh
//
// The author(s) have dedicated all copyright and related and
// neighboring rights to this software to the public domain
// worldwide. Distributed without any warranty.
// http://creativecommons.org/publicdomain/zero/1.0/
// Package uniuri generates random strings good for use in URIs to identify
// unique objects.
//
// Example usage:
//
// s := uniuri.New() // s is now "apHCJBl7L1OmC57n"
//
// A standard string created by New() is 16 bytes in length and consists of
// Latin upper and lowercase letters, and numbers (from the set of 62 allowed
// characters), which means that it has ~95 bits of entropy. To get more
// entropy, you can use NewLen(UUIDLen), which returns 20-byte string, giving
// ~119 bits of entropy, or any other desired length.
//
// Functions read from crypto/rand random source, and panic if they fail to
// read from it.
package uniuri
import (
"crypto/rand"
"math"
)
const (
// StdLen is a standard length of uniuri string to achive ~95 bits of entropy.
StdLen = 16
// UUIDLen is a length of uniuri string to achive ~119 bits of entropy, closest
// to what can be losslessly converted to UUIDv4 (122 bits).
UUIDLen = 20
)
// StdChars is a set of standard characters allowed in uniuri string.
var StdChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
// New returns a new random string of the standard length, consisting of
// standard characters.
func New() string {
return NewLenChars(StdLen, StdChars)
}
// NewLen returns a new random string of the provided length, consisting of
// standard characters.
func NewLen(length int) string {
return NewLenChars(length, StdChars)
}
// maxBufLen is the maximum length of a temporary buffer for random bytes.
const maxBufLen = 2048
// minRegenBufLen is the minimum length of temporary buffer for random bytes
// to fill after the first rand.Read request didn't produce the full result.
// If the initial buffer is smaller, this value is ignored.
// Rationale: for performance, assume it's pointless to request fewer bytes from rand.Read.
const minRegenBufLen = 16
// estimatedBufLen returns the estimated number of random bytes to request
// given that byte values greater than maxByte will be rejected.
func estimatedBufLen(need, maxByte int) int {
return int(math.Ceil(float64(need) * (255 / float64(maxByte))))
}
// NewLenCharsBytes returns a new random byte slice of the provided length, consisting
// of the provided byte slice of allowed characters (maximum 256).
func NewLenCharsBytes(length int, chars []byte) []byte {
if length == 0 {
return nil
}
clen := len(chars)
if clen < 2 || clen > 256 {
panic("uniuri: wrong charset length for NewLenChars")
}
maxrb := 255 - (256 % clen)
buflen := estimatedBufLen(length, maxrb)
if buflen < length {
buflen = length
}
if buflen > maxBufLen {
buflen = maxBufLen
}
buf := make([]byte, buflen) // storage for random bytes
out := make([]byte, length) // storage for result
i := 0
for {
if _, err := rand.Read(buf[:buflen]); err != nil {
panic("uniuri: error reading random bytes: " + err.Error())
}
for _, rb := range buf[:buflen] {
c := int(rb)
if c > maxrb {
// Skip this number to avoid modulo bias.
continue
}
out[i] = chars[c%clen]
i++
if i == length {
return out
}
}
// Adjust new requested length, but no smaller than minRegenBufLen.
buflen = estimatedBufLen(length-i, maxrb)
if buflen < minRegenBufLen && minRegenBufLen < cap(buf) {
buflen = minRegenBufLen
}
if buflen > maxBufLen {
buflen = maxBufLen
}
}
}
// NewLenChars returns a new random string of the provided length, consisting
// of the provided byte slice of allowed characters (maximum 256).
func NewLenChars(length int, chars []byte) string {
return string(NewLenCharsBytes(length, chars))
}

3
vendor/modules.txt vendored
View File

@@ -737,6 +737,9 @@ github.com/cyphar/filepath-securejoin
# github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
## explicit
github.com/davecgh/go-spew/spew
# github.com/dchest/uniuri v1.2.0
## explicit; go 1.19
github.com/dchest/uniuri
# github.com/deckarep/golang-set v1.8.0
## explicit; go 1.17
github.com/deckarep/golang-set