Initial QSfera import
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config/parser"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Health is the entrypoint for the health command.
|
||||
func Health(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "check health status",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnError(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
|
||||
resp, err := http.Get(
|
||||
fmt.Sprintf(
|
||||
"http://%s/healthz",
|
||||
cfg.Debug.Addr,
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to request health check")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Fatal().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health seems to be in bad state")
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health got a good state")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/clihelper"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GetCommands provides all commands for this service
|
||||
func GetCommands(cfg *config.Config) []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
// start this service
|
||||
Server(cfg),
|
||||
|
||||
// interaction with this service
|
||||
|
||||
// infos about this service
|
||||
Health(cfg),
|
||||
Version(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point for the qsfera-webdav command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cobra.Command{
|
||||
Use: "webdav",
|
||||
Short: "Serve WebDAV API for КуСфера",
|
||||
})
|
||||
app.AddCommand(GetCommands(cfg)...)
|
||||
app.SetArgs(os.Args[1:])
|
||||
|
||||
return app.ExecuteContext(cfg.Context)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/signal"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/runner"
|
||||
ogrpc "github.com/qsfera/server/pkg/service/grpc"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config/parser"
|
||||
"github.com/qsfera/server/services/webdav/pkg/metrics"
|
||||
"github.com/qsfera/server/services/webdav/pkg/server/debug"
|
||||
"github.com/qsfera/server/services/webdav/pkg/server/http"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Server is the entrypoint for the server command.
|
||||
func Server(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "server",
|
||||
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnFatal(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.GrpcClient, err = ogrpc.NewClient(
|
||||
append(ogrpc.GetClientOptions(cfg.GRPCClientTLS),
|
||||
ogrpc.WithTraceProvider(traceProvider),
|
||||
)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if cfg.Context == nil {
|
||||
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
|
||||
defer cancel()
|
||||
}
|
||||
ctx := cfg.Context
|
||||
|
||||
metrics := metrics.New()
|
||||
metrics.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
gr := runner.NewGroup()
|
||||
{
|
||||
server, err := http.Server(
|
||||
http.Logger(logger),
|
||||
http.Context(ctx),
|
||||
http.Config(cfg),
|
||||
http.Metrics(metrics),
|
||||
http.TraceProvider(traceProvider),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Info().
|
||||
Err(err).
|
||||
Str("server", "http").
|
||||
Msg("Failed to initialize server")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
|
||||
}
|
||||
|
||||
{
|
||||
debugServer, 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(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
|
||||
}
|
||||
|
||||
grResults := gr.Run(ctx)
|
||||
|
||||
// return the first non-nil error found in the results
|
||||
for _, grResult := range grResults {
|
||||
if grResult.RunnerError != nil {
|
||||
return grResult.RunnerError
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version prints the service versions of all running instances.
|
||||
func Version(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "print the version of this binary and the running service instances",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Version: " + version.GetString())
|
||||
fmt.Printf("Compiled: %s\n", version.Compiled())
|
||||
fmt.Println("")
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No running " + cfg.Service.Name + " service found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
|
||||
table.Header([]string{"Version", "Address", "Id"})
|
||||
for _, s := range services {
|
||||
for _, n := range s.Nodes {
|
||||
table.Append([]string{s.Version, n.Address, n.Id})
|
||||
}
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"go-micro.dev/v4/client"
|
||||
)
|
||||
|
||||
// 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:"-"`
|
||||
|
||||
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;WEBDAV_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
|
||||
Debug Debug `yaml:"debug"`
|
||||
|
||||
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
|
||||
GrpcClient client.Client `yaml:"-"`
|
||||
|
||||
HTTP HTTP `yaml:"http"`
|
||||
|
||||
DisablePreviews bool `yaml:"disablePreviews" env:"OC_DISABLE_PREVIEWS;WEBDAV_DISABLE_PREVIEWS" desc:"Set this option to 'true' to disable rendering of thumbnails triggered via webdav access. Note that when disabled, all access to preview related webdav paths will return a 404." introductionVersion:"1.0.0"`
|
||||
QsferaPublicURL string `yaml:"qsfera_public_url" env:"OC_URL;OC_PUBLIC_URL" desc:"URL, where КуСфера is reachable for users." introductionVersion:"1.0.0"`
|
||||
WebdavNamespace string `yaml:"webdav_namespace" env:"WEBDAV_WEBDAV_NAMESPACE" desc:"CS3 path layout to use when forwarding /webdav requests" introductionVersion:"1.0.0"`
|
||||
RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"1.0.0"`
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration.
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"WEBDAV_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
|
||||
Token string `yaml:"token" env:"WEBDAV_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
|
||||
Pprof bool `yaml:"pprof" env:"WEBDAV_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
|
||||
Zpages bool `yaml:"zpages" env:"WEBDAV_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/pkg/structs"
|
||||
"github.com/qsfera/server/services/webdav/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{
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9119",
|
||||
Token: "",
|
||||
Pprof: false,
|
||||
Zpages: false,
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "127.0.0.1:9115",
|
||||
Root: "/",
|
||||
Namespace: "qsfera.web",
|
||||
CORS: config.CORS{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Cache-Control"},
|
||||
AllowCredentials: true,
|
||||
},
|
||||
},
|
||||
Service: config.Service{
|
||||
Name: "webdav",
|
||||
},
|
||||
QsferaPublicURL: "https://localhost:9200",
|
||||
WebdavNamespace: "/users/{{.Id.OpaqueId}}",
|
||||
RevaGateway: shared.DefaultRevaConfig().Address,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDefaults adds default values to the configuration if they are not set yet
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "error"
|
||||
}
|
||||
|
||||
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
|
||||
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
|
||||
}
|
||||
|
||||
if cfg.Commons != nil {
|
||||
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize sanitized the configuration
|
||||
func Sanitize(cfg *config.Config) {
|
||||
// sanitize config
|
||||
if cfg.HTTP.Root != "/" {
|
||||
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
import "github.com/qsfera/server/pkg/shared"
|
||||
|
||||
// CORS defines the available cors configuration.
|
||||
type CORS struct {
|
||||
AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;WEBDAV_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;WEBDAV_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;WEBDAV_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
AllowCredentials bool `yaml:"allow_credentials" env:"OC_CORS_ALLOW_CREDENTIALS;WEBDAV_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Addr string `yaml:"addr" env:"WEBDAV_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
|
||||
Namespace string `yaml:"-"`
|
||||
Root string `yaml:"root" env:"WEBDAV_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
|
||||
CORS CORS `yaml:"cors"`
|
||||
TLS shared.HTTPServiceTLS `yaml:"tls"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
occfg "github.com/qsfera/server/pkg/config"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config/defaults"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/envdecode"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
func ParseConfig(cfg *config.Config) error {
|
||||
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.EnsureDefaults(cfg)
|
||||
|
||||
// load all env variables relevant to the config in the current context.
|
||||
if err := envdecode.Decode(cfg); err != nil {
|
||||
// no environment variable set for this config is an expected "error"
|
||||
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
}
|
||||
|
||||
func Validate(cfg *config.Config) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package constants
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
ContextKeyID contextKey = iota
|
||||
ContextKeyPath
|
||||
|
||||
// RFC1123 time that mimics oc10. time.RFC1123 would end in "UTC", see https://github.com/golang/go/issues/13781
|
||||
// Format copied from internal package https://github.com/cs3org/reva/blob/edge/internal/http/services/owncloud/ocdav/net/net.go.
|
||||
// It's needed to match the times shown in PROPFIND and REPORT requests.
|
||||
RFC1123 = "Mon, 02 Jan 2006 15:04:05 GMT"
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
package requests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/qsfera/server/services/webdav/pkg/constants"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultWidth defines the default width of a thumbnail
|
||||
DefaultWidth = 32
|
||||
// DefaultHeight defines the default height of a thumbnail
|
||||
DefaultHeight = 32
|
||||
)
|
||||
|
||||
// ThumbnailRequest combines all parameters provided when requesting a thumbnail
|
||||
type ThumbnailRequest struct {
|
||||
// The file path of the source file
|
||||
Filepath string
|
||||
// The file name of the source file including the extension
|
||||
Filename string
|
||||
// The file extension
|
||||
Extension string
|
||||
// The requested width of the thumbnail
|
||||
Width int32
|
||||
// The requested height of the thumbnail
|
||||
Height int32
|
||||
// In case of a public share the public link token.
|
||||
PublicLinkToken string
|
||||
// Indicates which image processor to use
|
||||
Processor string
|
||||
// The Identifier from the requested URL
|
||||
Identifier string
|
||||
}
|
||||
|
||||
func addMissingStorageID(id string) string {
|
||||
rid := &providerv1beta1.ResourceId{}
|
||||
rid.StorageId, rid.SpaceId, rid.OpaqueId, _ = storagespace.SplitID(id)
|
||||
if rid.StorageId == "" && rid.SpaceId == utils.ShareStorageSpaceID {
|
||||
rid.StorageId = utils.ShareStorageProviderID
|
||||
}
|
||||
return storagespace.FormatResourceID(rid)
|
||||
}
|
||||
|
||||
// ParseThumbnailRequest extracts all required parameters from a http request.
|
||||
func ParseThumbnailRequest(r *http.Request) (*ThumbnailRequest, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
fp := ctx.Value(constants.ContextKeyPath).(string)
|
||||
|
||||
id := ""
|
||||
v := ctx.Value(constants.ContextKeyID)
|
||||
if v != nil {
|
||||
id = addMissingStorageID(v.(string))
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
width, height, err := parseDimensions(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ThumbnailRequest{
|
||||
Filepath: fp,
|
||||
Filename: filepath.Base(fp),
|
||||
Extension: filepath.Ext(fp),
|
||||
Width: int32(width),
|
||||
Height: int32(height),
|
||||
Processor: q.Get("processor"),
|
||||
PublicLinkToken: chi.URLParam(r, "token"),
|
||||
Identifier: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseDimensions(q url.Values) (int64, int64, error) {
|
||||
width, err := parseDimension(q.Get("x"), "width", DefaultWidth)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
height, err := parseDimension(q.Get("y"), "height", DefaultHeight)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return width, height, nil
|
||||
}
|
||||
|
||||
func parseDimension(d, name string, defaultValue int64) (int64, error) {
|
||||
if d == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
result, err := strconv.ParseInt(d, 10, 32)
|
||||
if err != nil || result < 1 {
|
||||
// The error message doesn't fit but for OC10 API compatibility reasons we have to set this.
|
||||
return 0, fmt.Errorf("Cannot set %s of 0 or smaller!", name)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var sabreException = map[int]string{
|
||||
|
||||
// the commented states have no corresponding exception in sabre/dav,
|
||||
// see https://github.com/sabre-io/dav/tree/master/lib/DAV/Exception
|
||||
|
||||
// http.StatusMultipleChoices: "Multiple Choices",
|
||||
// http.StatusMovedPermanently: "Moved Permanently",
|
||||
// http.StatusFound: "Found",
|
||||
// http.StatusSeeOther: "See Other",
|
||||
// http.StatusNotModified: "Not Modified",
|
||||
// http.StatusUseProxy: "Use Proxy",
|
||||
// http.StatusTemporaryRedirect: "Temporary Redirect",
|
||||
// http.StatusPermanentRedirect: "Permanent Redirect",
|
||||
|
||||
http.StatusBadRequest: "Sabre\\DAV\\Exception\\BadRequest",
|
||||
http.StatusUnauthorized: "Sabre\\DAV\\Exception\\NotAuthenticated",
|
||||
http.StatusPaymentRequired: "Sabre\\DAV\\Exception\\PaymentRequired",
|
||||
http.StatusForbidden: "Sabre\\DAV\\Exception\\Forbidden", // InvalidResourceType, InvalidSyncToken, TooManyMatches
|
||||
http.StatusNotFound: "Sabre\\DAV\\Exception\\NotFound",
|
||||
http.StatusMethodNotAllowed: "Sabre\\DAV\\Exception\\MethodNotAllowed",
|
||||
// http.StatusNotAcceptable: "Not Acceptable",
|
||||
// http.StatusProxyAuthRequired: "Proxy Authentication Required",
|
||||
// http.StatusRequestTimeout: "Request Timeout",
|
||||
http.StatusConflict: "Sabre\\DAV\\Exception\\Conflict", // LockTokenMatchesRequestUri
|
||||
// http.StatusGone: "Gone",
|
||||
http.StatusLengthRequired: "Sabre\\DAV\\Exception\\LengthRequired",
|
||||
http.StatusPreconditionFailed: "Sabre\\DAV\\Exception\\PreconditionFailed",
|
||||
// http.StatusRequestEntityTooLarge: "Request Entity Too Large",
|
||||
// http.StatusRequestURITooLong: "Request URI Too Long",
|
||||
http.StatusUnsupportedMediaType: "Sabre\\DAV\\Exception\\UnsupportedMediaType", // ReportNotSupported
|
||||
http.StatusRequestedRangeNotSatisfiable: "Sabre\\DAV\\Exception\\RequestedRangeNotSatisfiable",
|
||||
// http.StatusExpectationFailed: "Expectation Failed",
|
||||
// http.StatusTeapot: "I'm a teapot",
|
||||
// http.StatusMisdirectedRequest: "Misdirected Request",
|
||||
// http.StatusUnprocessableEntity: "Unprocessable Entity",
|
||||
http.StatusLocked: "Sabre\\DAV\\Exception\\Locked", // ConflictingLock
|
||||
// http.StatusFailedDependency: "Failed Dependency",
|
||||
// http.StatusTooEarly: "Too Early",
|
||||
// http.StatusUpgradeRequired: "Upgrade Required",
|
||||
// http.StatusPreconditionRequired: "Precondition Required",
|
||||
// http.StatusTooManyRequests: "Too Many Requests",
|
||||
// http.StatusRequestHeaderFieldsTooLarge: "Request Header Fields Too Large",
|
||||
// http.StatusUnavailableForLegalReasons: "Unavailable For Legal Reasons",
|
||||
|
||||
// http.StatusInternalServerError: "Internal Server Error",
|
||||
http.StatusNotImplemented: "Sabre\\DAV\\Exception\\NotImplemented",
|
||||
// http.StatusBadGateway: "Bad Gateway",
|
||||
http.StatusServiceUnavailable: "Sabre\\DAV\\Exception\\ServiceUnavailable",
|
||||
// http.StatusGatewayTimeout: "Gateway Timeout",
|
||||
// http.StatusHTTPVersionNotSupported: "HTTP Version Not Supported",
|
||||
// http.StatusVariantAlsoNegotiates: "Variant Also Negotiates",
|
||||
http.StatusInsufficientStorage: "Sabre\\DAV\\Exception\\InsufficientStorage",
|
||||
// http.StatusLoopDetected: "Loop Detected",
|
||||
// http.StatusNotExtended: "Not Extended",
|
||||
// http.StatusNetworkAuthenticationRequired: "Network Authentication Required",
|
||||
}
|
||||
|
||||
// SabreException returns a sabre exception text for the HTTP status code. It returns the empty
|
||||
// string if the code is unknown.
|
||||
func SabreException(code int) string {
|
||||
return sabreException[code]
|
||||
}
|
||||
|
||||
// Exception represents a ocdav exception
|
||||
type Exception struct {
|
||||
Code int
|
||||
Message string
|
||||
Header string
|
||||
}
|
||||
|
||||
// Marshal just calls the xml marshaller for a given exception.
|
||||
func Marshal(code int, message string, header string) ([]byte, error) {
|
||||
xmlstring, err := xml.Marshal(&ErrorXML{
|
||||
Xmlnsd: "DAV",
|
||||
Xmlnss: "http://sabredav.org/ns",
|
||||
Exception: sabreException[code],
|
||||
Message: message,
|
||||
Header: header,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(xml.Header)
|
||||
buf.Write(xmlstring)
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
// ErrorXML holds the xml representation of an error
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_error
|
||||
type ErrorXML struct {
|
||||
XMLName xml.Name `xml:"d:error"`
|
||||
Xmlnsd string `xml:"xmlns:d,attr"`
|
||||
Xmlnss string `xml:"xmlns:s,attr"`
|
||||
Exception string `xml:"s:exception"`
|
||||
Message string `xml:"s:message"`
|
||||
InnerXML []byte `xml:",innerxml"`
|
||||
// Header is used to indicate the conflicting request header
|
||||
Header string `xml:"s:header,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidDepth is an invalid depth header error
|
||||
ErrInvalidDepth = errors.New("webdav: invalid depth")
|
||||
// ErrInvalidPropfind is an invalid propfind error
|
||||
ErrInvalidPropfind = errors.New("webdav: invalid propfind")
|
||||
// ErrInvalidProppatch is an invalid proppatch error
|
||||
ErrInvalidProppatch = errors.New("webdav: invalid proppatch")
|
||||
// ErrInvalidLockInfo is an invalid lock error
|
||||
ErrInvalidLockInfo = errors.New("webdav: invalid lock info")
|
||||
// ErrUnsupportedLockInfo is an unsupported lock error
|
||||
ErrUnsupportedLockInfo = errors.New("webdav: unsupported lock info")
|
||||
// ErrInvalidTimeout is an invalid timeout error
|
||||
ErrInvalidTimeout = errors.New("webdav: invalid timeout")
|
||||
// ErrInvalidIfHeader is an invalid if header error
|
||||
ErrInvalidIfHeader = errors.New("webdav: invalid If header")
|
||||
// ErrUnsupportedMethod is an unsupported method error
|
||||
ErrUnsupportedMethod = errors.New("webdav: unsupported method")
|
||||
// ErrInvalidLockToken is an invalid lock token error
|
||||
ErrInvalidLockToken = errors.New("webdav: invalid lock token")
|
||||
// ErrConfirmationFailed is returned by a LockSystem's Confirm method.
|
||||
ErrConfirmationFailed = errors.New("webdav: confirmation failed")
|
||||
// ErrForbidden is returned by a LockSystem's Unlock method.
|
||||
ErrForbidden = errors.New("webdav: forbidden")
|
||||
// ErrLocked is returned by a LockSystem's Create, Refresh and Unlock methods.
|
||||
ErrLocked = errors.New("webdav: locked")
|
||||
// ErrNoSuchLock is returned by a LockSystem's Refresh and Unlock methods.
|
||||
ErrNoSuchLock = errors.New("webdav: no such lock")
|
||||
// ErrNotImplemented is returned when hitting not implemented code paths
|
||||
ErrNotImplemented = errors.New("webdav: not implemented")
|
||||
)
|
||||
|
||||
// HandleErrorStatus checks the status code, logs a Debug or Error level message
|
||||
// and writes an appropriate http status
|
||||
func HandleErrorStatus(log *zerolog.Logger, w http.ResponseWriter, s *rpc.Status) {
|
||||
hsc := status.HTTPStatusFromCode(s.Code)
|
||||
if hsc == http.StatusInternalServerError {
|
||||
log.Error().Interface("status", s).Int("code", hsc).Msg(http.StatusText(hsc))
|
||||
} else {
|
||||
log.Debug().Interface("status", s).Int("code", hsc).Msg(http.StatusText(hsc))
|
||||
}
|
||||
w.WriteHeader(hsc)
|
||||
}
|
||||
|
||||
// HandleWebdavError checks the status code, logs an error and creates a webdav response body
|
||||
// if needed
|
||||
func HandleWebdavError(log *zerolog.Logger, w http.ResponseWriter, b []byte, err error) {
|
||||
if err != nil {
|
||||
log.Error().Msgf("error marshaling xml response: %s", b)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("error writing response")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
var (
|
||||
// Namespace defines the namespace for the defines metrics.
|
||||
Namespace = "qsfera"
|
||||
|
||||
// Subsystem defines the subsystem for the defines metrics.
|
||||
Subsystem = "webdav"
|
||||
)
|
||||
|
||||
// Metrics defines the available metrics of this service.
|
||||
type Metrics struct {
|
||||
// Counter *prometheus.CounterVec
|
||||
BuildInfo *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New() *Metrics {
|
||||
m := &Metrics{
|
||||
// Counter: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
// Namespace: Namespace,
|
||||
// Subsystem: Subsystem,
|
||||
// Name: "greet_total",
|
||||
// Help: "How many greeting requests processed",
|
||||
// }, []string{}),
|
||||
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "build_info",
|
||||
Help: "Build Information",
|
||||
}, []string{"version"}),
|
||||
}
|
||||
|
||||
// prometheus.Register(
|
||||
// m.Counter,
|
||||
// )
|
||||
|
||||
_ = prometheus.Register(
|
||||
m.BuildInfo,
|
||||
)
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package net
|
||||
|
||||
// Common HTTP headers.
|
||||
const (
|
||||
HeaderAcceptRanges = "Accept-Ranges"
|
||||
HeaderAccessControlAllowHeaders = "Access-Control-Allow-Headers"
|
||||
HeaderAccessControlExposeHeaders = "Access-Control-Expose-Headers"
|
||||
HeaderContentDisposistion = "Content-Disposition"
|
||||
HeaderContentLength = "Content-Length"
|
||||
HeaderContentRange = "Content-Range"
|
||||
HeaderContentType = "Content-Type"
|
||||
HeaderETag = "ETag"
|
||||
HeaderLastModified = "Last-Modified"
|
||||
HeaderLocation = "Location"
|
||||
HeaderRange = "Range"
|
||||
HeaderIfMatch = "If-Match"
|
||||
)
|
||||
|
||||
// webdav headers
|
||||
const (
|
||||
HeaderDav = "DAV" // https://datatracker.ietf.org/doc/html/rfc4918#section-10.1
|
||||
HeaderDepth = "Depth" // https://datatracker.ietf.org/doc/html/rfc4918#section-10.2
|
||||
HeaderDestination = "Destination" // https://datatracker.ietf.org/doc/html/rfc4918#section-10.3
|
||||
HeaderIf = "If" // https://datatracker.ietf.org/doc/html/rfc4918#section-10.4
|
||||
HeaderLockToken = "Lock-Token" // https://datatracker.ietf.org/doc/html/rfc4918#section-10.5
|
||||
HeaderOverwrite = "Overwrite" // https://datatracker.ietf.org/doc/html/rfc4918#section-10.6
|
||||
HeaderTimeout = "Timeout" // https://datatracker.ietf.org/doc/html/rfc4918#section-10.7
|
||||
)
|
||||
|
||||
// Non standard HTTP headers.
|
||||
const (
|
||||
HeaderOCFileID = "OC-FileId"
|
||||
HeaderOCETag = "OC-ETag"
|
||||
HeaderOCChecksum = "OC-Checksum"
|
||||
HeaderOCPermissions = "OC-Perm"
|
||||
HeaderTusResumable = "Tus-Resumable"
|
||||
HeaderTusVersion = "Tus-Version"
|
||||
HeaderTusExtension = "Tus-Extension"
|
||||
HeaderTusChecksumAlgorithm = "Tus-Checksum-Algorithm"
|
||||
HeaderTusUploadExpires = "Upload-Expires"
|
||||
HeaderUploadChecksum = "Upload-Checksum"
|
||||
HeaderUploadLength = "Upload-Length"
|
||||
HeaderUploadMetadata = "Upload-Metadata"
|
||||
HeaderUploadOffset = "Upload-Offset"
|
||||
HeaderOCMtime = "X-OC-Mtime"
|
||||
HeaderExpectedEntityLength = "X-Expected-Entity-Length"
|
||||
HeaderLitmus = "X-Litmus"
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package net
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// EncodePath encodes the path of a url.
|
||||
//
|
||||
// slashes (/) are treated as path-separators.
|
||||
func EncodePath(path string) string {
|
||||
return (&url.URL{Path: path}).EscapedPath()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package prop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
)
|
||||
|
||||
// PropertyXML represents a single DAV resource property as defined in RFC 4918.
|
||||
// http://www.webdav.org/specs/rfc4918.html#data.model.for.resource.properties
|
||||
type PropertyXML struct {
|
||||
// XMLName is the fully qualified name that identifies this property.
|
||||
XMLName xml.Name
|
||||
|
||||
// Lang is an optional xml:lang attribute.
|
||||
Lang string `xml:"xml:lang,attr,omitempty"`
|
||||
|
||||
// InnerXML contains the XML representation of the property value.
|
||||
// See http://www.webdav.org/specs/rfc4918.html#property_values
|
||||
//
|
||||
// Property values of complex type or mixed-content must have fully
|
||||
// expanded XML namespaces or be self-contained with according
|
||||
// XML namespace declarations. They must not rely on any XML
|
||||
// namespace declarations within the scope of the XML document,
|
||||
// even including the DAV: namespace.
|
||||
InnerXML []byte `xml:",innerxml"`
|
||||
}
|
||||
|
||||
func xmlEscaped(val string) []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
xml.Escape(buf, []byte(val))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// EscapedNS returns a new PropertyXML instance while xml-escaping the value
|
||||
func EscapedNS(namespace string, local string, val string) PropertyXML {
|
||||
return PropertyXML{
|
||||
XMLName: xml.Name{Space: namespace, Local: local},
|
||||
Lang: "",
|
||||
InnerXML: xmlEscaped(val),
|
||||
}
|
||||
}
|
||||
|
||||
// Escaped returns a new PropertyXML instance while xml-escaping the value
|
||||
// TODO properly use the space
|
||||
func Escaped(key, val string) PropertyXML {
|
||||
return PropertyXML{
|
||||
XMLName: xml.Name{Space: "", Local: key},
|
||||
Lang: "",
|
||||
InnerXML: xmlEscaped(val),
|
||||
}
|
||||
}
|
||||
|
||||
// NotFound returns a new PropertyXML instance with an empty value
|
||||
func NotFound(key string) PropertyXML {
|
||||
return PropertyXML{
|
||||
XMLName: xml.Name{Space: "", Local: key},
|
||||
Lang: "",
|
||||
}
|
||||
}
|
||||
|
||||
// NotFoundNS returns a new PropertyXML instance with the given namespace and an empty value
|
||||
func NotFoundNS(namespace, key string) PropertyXML {
|
||||
return PropertyXML{
|
||||
XMLName: xml.Name{Space: namespace, Local: key},
|
||||
Lang: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Raw returns a new PropertyXML instance for the given key/value pair
|
||||
// TODO properly use the space
|
||||
func Raw(key, val string) PropertyXML {
|
||||
return PropertyXML{
|
||||
XMLName: xml.Name{Space: "", Local: key},
|
||||
Lang: "",
|
||||
InnerXML: []byte(val),
|
||||
}
|
||||
}
|
||||
|
||||
// Next returns the next token, if any, in the XML stream of d.
|
||||
// RFC 4918 requires to ignore comments, processing instructions
|
||||
// and directives.
|
||||
// http://www.webdav.org/specs/rfc4918.html#property_values
|
||||
// http://www.webdav.org/specs/rfc4918.html#xml-extensibility
|
||||
func Next(d *xml.Decoder) (xml.Token, error) {
|
||||
for {
|
||||
t, err := d.Token()
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
switch t.(type) {
|
||||
case xml.Comment, xml.Directive, xml.ProcInst:
|
||||
continue
|
||||
default:
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveLock holds active lock xml data
|
||||
//
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_activelock
|
||||
//
|
||||
// <!ELEMENT activelock (lockscope, locktype, depth, owner?, timeout?,
|
||||
//
|
||||
// locktoken?, lockroot)>
|
||||
type ActiveLock struct {
|
||||
XMLName xml.Name `xml:"activelock"`
|
||||
Exclusive *struct{} `xml:"lockscope>exclusive,omitempty"`
|
||||
Shared *struct{} `xml:"lockscope>shared,omitempty"`
|
||||
Write *struct{} `xml:"locktype>write,omitempty"`
|
||||
Depth string `xml:"depth"`
|
||||
Owner Owner `xml:"owner,omitempty"`
|
||||
Timeout string `xml:"timeout,omitempty"`
|
||||
Locktoken string `xml:"locktoken>href"`
|
||||
Lockroot string `xml:"lockroot>href,omitempty"`
|
||||
}
|
||||
|
||||
// Owner captures the inner UML of a lock owner element http://www.webdav.org/specs/rfc4918.html#ELEMENT_owner
|
||||
type Owner struct {
|
||||
InnerXML string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
// Escape repaces ", &, ', < and > with their xml representation
|
||||
func Escape(s string) string {
|
||||
b := bytes.NewBuffer(nil)
|
||||
_ = xml.EscapeText(b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package propfind
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/services/webdav/pkg/errors"
|
||||
"github.com/qsfera/server/services/webdav/pkg/prop"
|
||||
)
|
||||
|
||||
type countingReader struct {
|
||||
n int
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Props represents properties related to a resource
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_prop (for propfind)
|
||||
type Props []xml.Name
|
||||
|
||||
// XML holds the xml representation of a propfind
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propfind
|
||||
type XML struct {
|
||||
XMLName xml.Name `xml:"DAV: propfind"`
|
||||
Allprop *struct{} `xml:"DAV: allprop"`
|
||||
Propname *struct{} `xml:"DAV: propname"`
|
||||
Prop Props `xml:"DAV: prop"`
|
||||
Include Props `xml:"DAV: include"`
|
||||
}
|
||||
|
||||
// PropstatXML holds the xml representation of a propfind response
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propstat
|
||||
type PropstatXML struct {
|
||||
// Prop requires DAV: to be the default namespace in the enclosing
|
||||
// XML. This is due to the standard encoding/xml package currently
|
||||
// not honoring namespace declarations inside a xmltag with a
|
||||
// parent element for anonymous slice elements.
|
||||
// Use of multistatusWriter takes care of this.
|
||||
Prop []prop.PropertyXML `xml:"d:prop>_ignored_"`
|
||||
Status string `xml:"d:status"`
|
||||
Error *errors.ErrorXML `xml:"d:error"`
|
||||
ResponseDescription string `xml:"d:responsedescription,omitempty"`
|
||||
}
|
||||
|
||||
// ResponseXML holds the xml representation of a propfind response
|
||||
type ResponseXML struct {
|
||||
XMLName xml.Name `xml:"d:response"`
|
||||
Href string `xml:"d:href"`
|
||||
Propstat []PropstatXML `xml:"d:propstat"`
|
||||
Status string `xml:"d:status,omitempty"`
|
||||
Error *errors.ErrorXML `xml:"d:error"`
|
||||
ResponseDescription string `xml:"d:responsedescription,omitempty"`
|
||||
}
|
||||
|
||||
// MultiStatusResponseXML holds the xml representation of a multistatus propfind response
|
||||
type MultiStatusResponseXML struct {
|
||||
XMLName xml.Name `xml:"d:multistatus"`
|
||||
XmlnsS string `xml:"xmlns:s,attr,omitempty"`
|
||||
XmlnsD string `xml:"xmlns:d,attr,omitempty"`
|
||||
XmlnsOC string `xml:"xmlns:oc,attr,omitempty"`
|
||||
|
||||
Responses []*ResponseXML `xml:"d:response"`
|
||||
}
|
||||
|
||||
// ResponseUnmarshalXML is a workaround for https://github.com/golang/go/issues/13400
|
||||
type ResponseUnmarshalXML struct {
|
||||
XMLName xml.Name `xml:"response"`
|
||||
Href string `xml:"href"`
|
||||
Propstat []PropstatUnmarshalXML `xml:"propstat"`
|
||||
Status string `xml:"status,omitempty"`
|
||||
Error *errors.ErrorXML `xml:"d:error"`
|
||||
ResponseDescription string `xml:"responsedescription,omitempty"`
|
||||
}
|
||||
|
||||
// MultiStatusResponseUnmarshalXML is a workaround for https://github.com/golang/go/issues/13400
|
||||
type MultiStatusResponseUnmarshalXML struct {
|
||||
XMLName xml.Name `xml:"multistatus"`
|
||||
XmlnsS string `xml:"xmlns:s,attr,omitempty"`
|
||||
XmlnsD string `xml:"xmlns:d,attr,omitempty"`
|
||||
XmlnsOC string `xml:"xmlns:oc,attr,omitempty"`
|
||||
|
||||
Responses []*ResponseUnmarshalXML `xml:"response"`
|
||||
}
|
||||
|
||||
// PropstatUnmarshalXML is a workaround for https://github.com/golang/go/issues/13400
|
||||
type PropstatUnmarshalXML struct {
|
||||
// Prop requires DAV: to be the default namespace in the enclosing
|
||||
// XML. This is due to the standard encoding/xml package currently
|
||||
// not honoring namespace declarations inside a xmltag with a
|
||||
// parent element for anonymous slice elements.
|
||||
// Use of multistatusWriter takes care of this.
|
||||
Prop []*prop.PropertyXML `xml:"prop"`
|
||||
Status string `xml:"status"`
|
||||
Error *errors.ErrorXML `xml:"d:error"`
|
||||
ResponseDescription string `xml:"responsedescription,omitempty"`
|
||||
}
|
||||
|
||||
// NewMultiStatusResponseXML returns a preconfigured instance of MultiStatusResponseXML
|
||||
func NewMultiStatusResponseXML() *MultiStatusResponseXML {
|
||||
return &MultiStatusResponseXML{
|
||||
XmlnsD: "DAV:",
|
||||
XmlnsS: "http://sabredav.org/ns",
|
||||
XmlnsOC: "http://owncloud.org/ns",
|
||||
}
|
||||
}
|
||||
|
||||
// ReadPropfind extracts and parses the propfind XML information from a Reader
|
||||
// from https://github.com/golang/net/blob/e514e69ffb8bc3c76a71ae40de0118d794855992/webdav/xml.go#L178-L205
|
||||
func ReadPropfind(r io.Reader) (pf XML, status int, err error) {
|
||||
c := countingReader{r: r}
|
||||
if err = xml.NewDecoder(&c).Decode(&pf); err != nil {
|
||||
if err == io.EOF {
|
||||
if c.n == 0 {
|
||||
// An empty body means to propfind allprop.
|
||||
// http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND
|
||||
return XML{Allprop: new(struct{})}, 0, nil
|
||||
}
|
||||
err = errors.ErrInvalidPropfind
|
||||
}
|
||||
return XML{}, http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
if pf.Allprop == nil && pf.Include != nil {
|
||||
return XML{}, http.StatusBadRequest, errors.ErrInvalidPropfind
|
||||
}
|
||||
if pf.Allprop != nil && (pf.Prop != nil || pf.Propname != nil) {
|
||||
return XML{}, http.StatusBadRequest, errors.ErrInvalidPropfind
|
||||
}
|
||||
if pf.Prop != nil && pf.Propname != nil {
|
||||
return XML{}, http.StatusBadRequest, errors.ErrInvalidPropfind
|
||||
}
|
||||
if pf.Propname == nil && pf.Allprop == nil && pf.Prop == nil {
|
||||
// jfd: I think <d:prop></d:prop> is perfectly valid ... treat it as allprop
|
||||
return XML{Allprop: new(struct{})}, 0, nil
|
||||
}
|
||||
return pf, 0, nil
|
||||
}
|
||||
|
||||
// UnmarshalXML appends the property names enclosed within start to pn.
|
||||
//
|
||||
// It returns an error if start does not contain any properties or if
|
||||
// properties contain values. Character data between properties is ignored.
|
||||
func (pn *Props) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
for {
|
||||
t, err := prop.Next(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch e := t.(type) {
|
||||
case xml.EndElement:
|
||||
// jfd: I think <d:prop></d:prop> is perfectly valid ... treat it as allprop
|
||||
/*
|
||||
if len(*pn) == 0 {
|
||||
return fmt.Errorf("%s must not be empty", start.Name.Local)
|
||||
}
|
||||
*/
|
||||
return nil
|
||||
case xml.StartElement:
|
||||
t, err = prop.Next(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := t.(xml.EndElement); !ok {
|
||||
return fmt.Errorf("unexpected token %T", t)
|
||||
}
|
||||
*pn = append(*pn, e.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/checks"
|
||||
"github.com/qsfera/server/pkg/handlers"
|
||||
"github.com/qsfera/server/pkg/service/debug"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
checkHandler := handlers.NewCheckHandler(
|
||||
handlers.NewCheckHandlerConfiguration().
|
||||
WithLogger(options.Logger).
|
||||
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr)),
|
||||
)
|
||||
|
||||
return debug.NewService(
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Health(checkHandler),
|
||||
debug.Ready(checkHandler),
|
||||
debug.CorsAllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
|
||||
debug.CorsAllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
|
||||
debug.CorsAllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
|
||||
debug.CorsAllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
"github.com/qsfera/server/services/webdav/pkg/metrics"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"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 {
|
||||
Namespace string
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Metrics *metrics.Metrics
|
||||
Flags []pflag.Flag
|
||||
TraceProvider trace.TracerProvider
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
|
||||
// Flags provides a function to set the flags option.
|
||||
func Flags(flags ...pflag.Flag) Option {
|
||||
return func(o *Options) {
|
||||
o.Flags = append(o.Flags, flags...)
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace provides a function to set the namespace option.
|
||||
func Namespace(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.Namespace = val
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to set the TraceProvider option.
|
||||
func TraceProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/qsfera/server/pkg/cors"
|
||||
"github.com/qsfera/server/pkg/middleware"
|
||||
"github.com/qsfera/server/pkg/service/http"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
svc "github.com/qsfera/server/services/webdav/pkg/service/v0"
|
||||
"go-micro.dev/v4"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (http.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
service, err := http.NewService(
|
||||
http.TLSConfig(options.Config.HTTP.TLS),
|
||||
http.Logger(options.Logger),
|
||||
http.Namespace(options.Config.HTTP.Namespace),
|
||||
http.Name(options.Config.Service.Name),
|
||||
http.Version(version.GetString()),
|
||||
http.Address(options.Config.HTTP.Addr),
|
||||
http.Context(options.Context),
|
||||
http.Flags(options.Flags...),
|
||||
http.TraceProvider(options.TraceProvider),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
handle, err := svc.NewService(
|
||||
svc.Logger(options.Logger),
|
||||
svc.Config(options.Config),
|
||||
svc.Middleware(
|
||||
chimiddleware.RealIP,
|
||||
chimiddleware.RequestID,
|
||||
middleware.NoCache,
|
||||
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.Version(
|
||||
options.Config.Service.Name,
|
||||
version.GetString(),
|
||||
),
|
||||
middleware.Logger(
|
||||
options.Logger,
|
||||
),
|
||||
),
|
||||
svc.TraceProvider(options.TraceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
{
|
||||
handle = svc.NewInstrument(handle, options.Metrics)
|
||||
handle = svc.NewLogging(handle, options.Logger)
|
||||
}
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/services/webdav/pkg/metrics"
|
||||
)
|
||||
|
||||
// NewInstrument returns a service that instruments metrics.
|
||||
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
|
||||
return instrument{
|
||||
next: next,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
type instrument struct {
|
||||
next Service
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Thumbnail implements the Service interface.
|
||||
func (i instrument) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.Thumbnail(w, r)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
)
|
||||
|
||||
// NewLogging returns a service that logs messages.
|
||||
func NewLogging(next Service, logger log.Logger) Service {
|
||||
return logging{
|
||||
next: next,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type logging struct {
|
||||
next Service
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Dummy implements the Service interface.
|
||||
func (l logging) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.Thumbnail(w, r)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
"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 {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
TraceProvider trace.TracerProvider
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware provides a function to set the middleware option.
|
||||
func Middleware(val ...func(http.Handler) http.Handler) Option {
|
||||
return func(o *Options) {
|
||||
o.Middleware = val
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to set the traceProvider option.
|
||||
func TraceProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
"go-micro.dev/v4/metadata"
|
||||
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/tags"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail"
|
||||
"github.com/qsfera/server/services/webdav/pkg/constants"
|
||||
"github.com/qsfera/server/services/webdav/pkg/net"
|
||||
"github.com/qsfera/server/services/webdav/pkg/prop"
|
||||
"github.com/qsfera/server/services/webdav/pkg/propfind"
|
||||
)
|
||||
|
||||
const (
|
||||
elementNameSearchFiles = "search-files"
|
||||
// TODO elementNameFilterFiles = "filter-files"
|
||||
)
|
||||
|
||||
// Search is the endpoint for retrieving search results for REPORT requests
|
||||
func (g Webdav) Search(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.log.SubloggerWithRequestID(r.Context())
|
||||
t := r.Header.Get(revactx.TokenHeader)
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get reva gatewayClient")
|
||||
renderError(w, r, errInternalError("could not get reva gatewayClient"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := whoami(gatewayClient, r.Context(), t)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user")
|
||||
renderError(w, r, errInternalError("could not get user"))
|
||||
return
|
||||
}
|
||||
|
||||
rep, err := readReport(r.Body)
|
||||
if err != nil {
|
||||
renderError(w, r, errBadRequest(err.Error()))
|
||||
logger.Debug().Err(err).Msg("error reading report")
|
||||
return
|
||||
}
|
||||
|
||||
if rep.SearchFiles == nil {
|
||||
renderError(w, r, errBadRequest("missing search-files tag"))
|
||||
logger.Debug().Err(err).Msg("error reading report")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := revactx.ContextSetToken(r.Context(), t)
|
||||
ctx = metadata.Set(ctx, revactx.TokenHeader, t)
|
||||
|
||||
req := &searchsvc.SearchRequest{
|
||||
Query: rep.SearchFiles.Search.Pattern,
|
||||
PageSize: int32(rep.SearchFiles.Search.Limit),
|
||||
}
|
||||
|
||||
// Limit search to the according space when searching /dav/spaces/
|
||||
if strings.HasPrefix(r.URL.Path, "/dav/spaces") {
|
||||
space := strings.TrimPrefix(r.URL.Path, "/dav/spaces/")
|
||||
rid, err := storagespace.ParseID(space)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("error parsing the space id for filtering")
|
||||
} else {
|
||||
req.Ref = &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: rid.StorageId,
|
||||
SpaceId: rid.SpaceId,
|
||||
OpaqueId: rid.SpaceId,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rsp, err := g.searchClient.Search(ctx, req)
|
||||
if err != nil {
|
||||
e := merrors.Parse(err.Error())
|
||||
switch e.Code {
|
||||
case http.StatusBadRequest:
|
||||
renderError(w, r, errBadRequest(e.Detail))
|
||||
default:
|
||||
renderError(w, r, errInternalError(err.Error()))
|
||||
}
|
||||
logger.Error().Err(err).Msg("could not get search results")
|
||||
return
|
||||
}
|
||||
g.sendSearchResponse(rsp, w, r, user)
|
||||
}
|
||||
|
||||
func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request, user *userv1beta1.User) {
|
||||
logger := g.log.SubloggerWithRequestID(r.Context())
|
||||
responsesXML, err := multistatusResponse(r.Context(), g.config.QsferaPublicURL, rsp.Matches, user)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("error formatting propfind")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set(net.HeaderDav, "1, 3, extended-mkcol")
|
||||
w.Header().Set(net.HeaderContentType, "application/xml; charset=utf-8")
|
||||
if len(rsp.Matches) > 0 {
|
||||
w.Header().Set(net.HeaderContentRange, fmt.Sprintf("rows 0-%d/%d", len(rsp.Matches)-1, rsp.TotalMatches))
|
||||
}
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
if _, err := w.Write(responsesXML); err != nil {
|
||||
logger.Err(err).Msg("error writing response")
|
||||
}
|
||||
}
|
||||
|
||||
// multistatusResponse converts a list of matches into a multistatus response string
|
||||
func multistatusResponse(ctx context.Context, publicURL string, matches []*searchmsg.Match, user *userv1beta1.User) ([]byte, error) {
|
||||
responses := make([]*propfind.ResponseXML, 0, len(matches))
|
||||
for i := range matches {
|
||||
res, err := matchToPropResponse(ctx, publicURL, matches[i], user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responses = append(responses, res)
|
||||
}
|
||||
|
||||
msr := propfind.NewMultiStatusResponseXML()
|
||||
msr.Responses = responses
|
||||
msg, err := xml.Marshal(msr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func matchToPropResponse(ctx context.Context, publicURL string, match *searchmsg.Match, user *userv1beta1.User) (*propfind.ResponseXML, error) {
|
||||
// unfortunately, search uses own versions of ResourceId and Ref. So we need to assert them here
|
||||
var (
|
||||
ref string
|
||||
err error
|
||||
)
|
||||
|
||||
// to copy PROPFIND behaviour, we need to deliver different ids
|
||||
// for shares it needs to be sharestorageproviderid!shareid
|
||||
// for other spaces it needs to be storageproviderid$spaceid
|
||||
switch match.Entity.Ref.ResourceId.StorageId {
|
||||
default:
|
||||
ref, err = storagespace.FormatReference(&provider.Reference{
|
||||
ResourceId: &provider.ResourceId{
|
||||
StorageId: match.Entity.Ref.ResourceId.StorageId,
|
||||
SpaceId: match.Entity.Ref.ResourceId.SpaceId,
|
||||
},
|
||||
Path: match.Entity.Ref.Path,
|
||||
})
|
||||
case utils.ShareStorageProviderID:
|
||||
ref, err = storagespace.FormatReference(&provider.Reference{
|
||||
ResourceId: &provider.ResourceId{
|
||||
//StorageId: match.Entity.Ref.ResourceId.StorageId,
|
||||
SpaceId: match.Entity.Ref.ResourceId.SpaceId,
|
||||
OpaqueId: match.Entity.Ref.ResourceId.OpaqueId,
|
||||
},
|
||||
Path: match.Entity.Ref.Path,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := propfind.ResponseXML{
|
||||
Href: net.EncodePath(path.Join("/remote.php/dav/spaces/", ref)),
|
||||
Propstat: []propfind.PropstatXML{},
|
||||
}
|
||||
|
||||
propstatOK := propfind.PropstatXML{
|
||||
Status: "HTTP/1.1 200 OK",
|
||||
Prop: []prop.PropertyXML{},
|
||||
}
|
||||
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:fileid", storagespace.FormatResourceID(&provider.ResourceId{
|
||||
StorageId: match.Entity.Id.StorageId,
|
||||
SpaceId: match.Entity.Id.SpaceId,
|
||||
OpaqueId: match.Entity.Id.OpaqueId,
|
||||
})))
|
||||
if match.Entity.ParentId != nil {
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:file-parent", storagespace.FormatResourceID(&provider.ResourceId{
|
||||
StorageId: match.Entity.ParentId.StorageId,
|
||||
SpaceId: match.Entity.ParentId.SpaceId,
|
||||
OpaqueId: match.Entity.ParentId.OpaqueId,
|
||||
})))
|
||||
}
|
||||
if match.Entity.Ref.ResourceId.StorageId == utils.ShareStorageProviderID {
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:shareid", match.Entity.Ref.ResourceId.OpaqueId))
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:shareroot", match.Entity.ShareRootName))
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:remote-item-id", storagespace.FormatResourceID(&provider.ResourceId{
|
||||
StorageId: match.Entity.GetRemoteItemId().GetStorageId(),
|
||||
SpaceId: match.Entity.GetRemoteItemId().GetSpaceId(),
|
||||
OpaqueId: match.Entity.GetRemoteItemId().GetOpaqueId(),
|
||||
})))
|
||||
}
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:name", match.Entity.Name))
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getlastmodified", match.Entity.LastModifiedTime.AsTime().Format(constants.RFC1123)))
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:permissions", match.Entity.Permissions))
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:highlights", match.Entity.Highlights))
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType))
|
||||
_, isSupportedMimeType := thumbnail.SupportedMimeTypes[match.Entity.MimeType]
|
||||
if isSupportedMimeType {
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1"))
|
||||
} else {
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "0"))
|
||||
}
|
||||
|
||||
t := tags.New(match.Entity.Tags...)
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:tags", t.AsList()))
|
||||
|
||||
// those seem empty - bug?
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getetag", match.Entity.Etag))
|
||||
|
||||
size := strconv.FormatUint(match.Entity.Size, 10)
|
||||
if match.Entity.Type == uint64(provider.ResourceType_RESOURCE_TYPE_CONTAINER) {
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Raw("d:resourcetype", "<d:collection/>"))
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:size", size))
|
||||
} else {
|
||||
propstatOK.Prop = append(propstatOK.Prop,
|
||||
prop.Escaped("d:resourcetype", ""),
|
||||
prop.Escaped("d:getcontentlength", size),
|
||||
)
|
||||
}
|
||||
score := strconv.FormatFloat(float64(match.Score), 'f', -1, 64)
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:score", score))
|
||||
|
||||
if privateURL, err := url.Parse(publicURL); err == nil && match.Entity.Id != nil {
|
||||
privateURL.Path = path.Join(privateURL.Path, "f", storagespace.FormatResourceID(&provider.ResourceId{
|
||||
StorageId: match.Entity.Id.StorageId,
|
||||
SpaceId: match.Entity.Id.SpaceId,
|
||||
OpaqueId: match.Entity.Id.OpaqueId,
|
||||
}))
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:privatelink", privateURL.String()))
|
||||
}
|
||||
|
||||
// enrich results with favorite flag for the user performing the search
|
||||
if slices.Contains(match.Entity.Favorites, user.Id.OpaqueId) {
|
||||
propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:favorite", "1"))
|
||||
}
|
||||
|
||||
if len(propstatOK.Prop) > 0 {
|
||||
response.Propstat = append(response.Propstat, propstatOK)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func hasPreview(md *provider.ResourceInfo, appendToOK func(p ...prop.PropertyXML)) {
|
||||
_, match := thumbnail.SupportedMimeTypes[md.MimeType]
|
||||
if match {
|
||||
appendToOK(prop.Escaped("oc:has-preview", "1"))
|
||||
} else {
|
||||
appendToOK(prop.Escaped("oc:has-preview", "0"))
|
||||
}
|
||||
}
|
||||
|
||||
type report struct {
|
||||
SearchFiles *reportSearchFiles
|
||||
// FilterFiles TODO add this for tag based search
|
||||
FilterFiles *reportFilterFiles `xml:"filter-files"`
|
||||
}
|
||||
type reportSearchFiles struct {
|
||||
XMLName xml.Name `xml:"search-files"`
|
||||
Lang string `xml:"xml:lang,attr,omitempty"`
|
||||
Prop Props `xml:"DAV: prop"`
|
||||
Search reportSearchFilesSearch `xml:"search"`
|
||||
}
|
||||
type reportSearchFilesSearch struct {
|
||||
Pattern string `xml:"pattern"`
|
||||
Limit int `xml:"limit"`
|
||||
Offset int `xml:"offset"`
|
||||
}
|
||||
|
||||
type reportFilterFiles struct {
|
||||
XMLName xml.Name `xml:"filter-files"`
|
||||
Lang string `xml:"xml:lang,attr,omitempty"`
|
||||
Prop Props `xml:"DAV: prop"`
|
||||
Rules reportFilterFilesRules `xml:"filter-rules"`
|
||||
}
|
||||
|
||||
type reportFilterFilesRules struct {
|
||||
Favorite bool `xml:"favorite"`
|
||||
SystemTag int `xml:"systemtag"`
|
||||
}
|
||||
|
||||
// Props represents properties related to a resource
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_prop (for propfind)
|
||||
type Props []xml.Name
|
||||
|
||||
// XML holds the xml representation of a propfind
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propfind
|
||||
type XML struct {
|
||||
XMLName xml.Name `xml:"DAV: propfind"`
|
||||
Allprop *struct{} `xml:"DAV: allprop"`
|
||||
Propname *struct{} `xml:"DAV: propname"`
|
||||
Prop Props `xml:"DAV: prop"`
|
||||
Include Props `xml:"DAV: include"`
|
||||
}
|
||||
|
||||
func readReport(r io.Reader) (rep *report, err error) {
|
||||
decoder := xml.NewDecoder(r)
|
||||
rep = &report{}
|
||||
for {
|
||||
t, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
// io.EOF is a successful end
|
||||
return rep, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if v, ok := t.(xml.StartElement); ok {
|
||||
if v.Name.Local == elementNameSearchFiles {
|
||||
var repSF reportSearchFiles
|
||||
err = decoder.DecodeElement(&repSF, &v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep.SearchFiles = &repSF
|
||||
/*
|
||||
} else if v.Name.Local == elementNameFilterFiles {
|
||||
var repFF reportFilterFiles
|
||||
err = decoder.DecodeElement(&repFF, &v)
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, err
|
||||
}
|
||||
rep.FilterFiles = &repFF
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
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"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/templates"
|
||||
"github.com/riandyrn/otelchi"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
grpcmetadata "google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
thumbnailsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/thumbnails/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
thumbnailssvc "github.com/qsfera/server/protogen/gen/qsfera/services/thumbnails/v0"
|
||||
"github.com/qsfera/server/services/webdav/pkg/config"
|
||||
"github.com/qsfera/server/services/webdav/pkg/constants"
|
||||
"github.com/qsfera/server/services/webdav/pkg/dav/requests"
|
||||
)
|
||||
|
||||
var (
|
||||
codesEnum = map[int]string{
|
||||
http.StatusBadRequest: "Sabre\\DAV\\Exception\\BadRequest",
|
||||
http.StatusUnauthorized: "Sabre\\DAV\\Exception\\NotAuthenticated",
|
||||
http.StatusNotFound: "Sabre\\DAV\\Exception\\NotFound",
|
||||
http.StatusMethodNotAllowed: "Sabre\\DAV\\Exception\\MethodNotAllowed",
|
||||
}
|
||||
)
|
||||
|
||||
// register the REPORT method at init so it cannot race with concurrent route setup in other services.
|
||||
func init() {
|
||||
chi.RegisterMethod("REPORT")
|
||||
}
|
||||
|
||||
// Service defines the extension handlers.
|
||||
type Service interface {
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
Thumbnail(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// NewService returns a service implementation for Service.
|
||||
func NewService(opts ...Option) (Service, error) {
|
||||
options := newOptions(opts...)
|
||||
conf := options.Config
|
||||
|
||||
m := chi.NewMux()
|
||||
m.Use(
|
||||
otelchi.Middleware(
|
||||
conf.Service.Name,
|
||||
otelchi.WithChiRoutes(m),
|
||||
otelchi.WithTracerProvider(options.TraceProvider),
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
),
|
||||
)
|
||||
|
||||
tm, err := pool.StringToTLSMode(conf.GRPCClientTLS.Mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gatewaySelector, err := pool.GatewaySelector(conf.RevaGateway,
|
||||
pool.WithTLSCACert(conf.GRPCClientTLS.CACert),
|
||||
pool.WithTLSMode(tm),
|
||||
pool.WithRegistry(registry.GetRegistry()),
|
||||
pool.WithTracerProvider(options.TraceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svc := Webdav{
|
||||
config: conf,
|
||||
log: options.Logger,
|
||||
mux: m,
|
||||
searchClient: searchsvc.NewSearchProviderService("qsfera.api.search", conf.GrpcClient),
|
||||
thumbnailsClient: thumbnailssvc.NewThumbnailService("qsfera.api.thumbnails", conf.GrpcClient),
|
||||
gatewaySelector: gatewaySelector,
|
||||
}
|
||||
|
||||
if svc.config.DisablePreviews {
|
||||
svc.thumbnailsClient = nil
|
||||
}
|
||||
|
||||
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
|
||||
if !svc.config.DisablePreviews {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(svc.DavUserContext())
|
||||
|
||||
r.Get("/remote.php/dav/spaces/{id}", svc.SpacesThumbnail)
|
||||
r.Get("/remote.php/dav/spaces/{id}/*", svc.SpacesThumbnail)
|
||||
r.Get("/dav/spaces/{id}", svc.SpacesThumbnail)
|
||||
r.Get("/dav/spaces/{id}/*", svc.SpacesThumbnail)
|
||||
r.MethodFunc("REPORT", "/remote.php/dav/spaces*", svc.Search)
|
||||
r.MethodFunc("REPORT", "/dav/spaces*", svc.Search)
|
||||
|
||||
r.Get("/remote.php/dav/files/{id}", svc.Thumbnail)
|
||||
r.Get("/remote.php/dav/files/{id}/*", svc.Thumbnail)
|
||||
r.Get("/dav/files/{id}", svc.Thumbnail)
|
||||
r.Get("/dav/files/{id}/*", svc.Thumbnail)
|
||||
|
||||
r.MethodFunc("REPORT", "/remote.php/dav/files*", svc.Search)
|
||||
r.MethodFunc("REPORT", "/dav/files*", svc.Search)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(svc.DavPublicContext())
|
||||
|
||||
r.Head("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnailHead)
|
||||
r.Head("/dav/public-files/{token}/*", svc.PublicThumbnailHead)
|
||||
|
||||
r.Get("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnail)
|
||||
r.Get("/dav/public-files/{token}/*", svc.PublicThumbnail)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(svc.WebDAVContext())
|
||||
r.Get("/remote.php/webdav/*", svc.Thumbnail)
|
||||
r.Get("/webdav/*", svc.Thumbnail)
|
||||
|
||||
r.MethodFunc("REPORT", "/remote.php/webdav*", svc.Search)
|
||||
r.MethodFunc("REPORT", "/webdav*", svc.Search)
|
||||
})
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
_ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
|
||||
return nil
|
||||
})
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// Webdav implements the business logic for Service.
|
||||
type Webdav struct {
|
||||
config *config.Config
|
||||
log log.Logger
|
||||
mux *chi.Mux
|
||||
searchClient searchsvc.SearchProviderService
|
||||
thumbnailsClient thumbnailssvc.ThumbnailService
|
||||
gatewaySelector pool.Selectable[gatewayv1beta1.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (g Webdav) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (g Webdav) DavUserContext() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
filePath := r.URL.Path
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
id, err := url.QueryUnescape(id)
|
||||
if err == nil && id != "" {
|
||||
ctx = context.WithValue(ctx, constants.ContextKeyID, id)
|
||||
}
|
||||
|
||||
if id != "" {
|
||||
filePath = strings.TrimPrefix(filePath, path.Join("/remote.php/dav/spaces", id))
|
||||
filePath = strings.TrimPrefix(filePath, path.Join("/dav/spaces", id))
|
||||
|
||||
filePath = strings.TrimPrefix(filePath, path.Join("/remote.php/dav/files", id))
|
||||
filePath = strings.TrimPrefix(filePath, path.Join("/dav/files", id))
|
||||
filePath = strings.TrimPrefix(filePath, "/")
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, constants.ContextKeyPath, filePath)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (g Webdav) DavPublicContext() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
filePath := r.URL.Path
|
||||
|
||||
if token := chi.URLParam(r, "token"); token != "" {
|
||||
filePath = strings.TrimPrefix(filePath, path.Join("/remote.php/dav/public-files", token)+"/")
|
||||
filePath = strings.TrimPrefix(filePath, path.Join("/dav/public-files", token)+"/")
|
||||
}
|
||||
ctx = context.WithValue(ctx, constants.ContextKeyPath, filePath)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
func (g Webdav) WebDAVContext() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
filePath := r.URL.Path
|
||||
filePath = strings.TrimPrefix(filePath, "/remote.php")
|
||||
filePath = strings.TrimPrefix(filePath, "/webdav/")
|
||||
|
||||
ctx := context.WithValue(r.Context(), constants.ContextKeyPath, filePath)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SpacesThumbnail is the endpoint for retrieving thumbnails inside of spaces.
|
||||
func (g Webdav) SpacesThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.log.SubloggerWithRequestID(r.Context())
|
||||
tr, err := requests.ParseThumbnailRequest(r)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not create Request")
|
||||
renderError(w, r, errBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
t := r.Header.Get(revactx.TokenHeader)
|
||||
|
||||
fullPath := filepath.Join(tr.Identifier, tr.Filepath)
|
||||
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
|
||||
Filepath: strings.TrimLeft(tr.Filepath, "/"),
|
||||
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
|
||||
Width: tr.Width,
|
||||
Height: tr.Height,
|
||||
Processor: tr.Processor,
|
||||
Source: &thumbnailssvc.GetThumbnailRequest_Cs3Source{
|
||||
Cs3Source: &thumbnailsmsg.CS3Source{
|
||||
Path: fullPath,
|
||||
Authorization: t,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
e := merrors.Parse(err.Error())
|
||||
switch e.Code {
|
||||
case http.StatusNotFound:
|
||||
// StatusNotFound is expected for unsupported files
|
||||
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
|
||||
return
|
||||
case http.StatusTooEarly:
|
||||
// StatusTooEarly if file is processing
|
||||
renderError(w, r, errTooEarly(e.Detail))
|
||||
return
|
||||
case http.StatusTooManyRequests:
|
||||
addRetryAfterHeader(w)
|
||||
renderError(w, r, errTooManyRequests(e.Detail))
|
||||
case http.StatusBadRequest:
|
||||
renderError(w, r, errBadRequest(e.Detail))
|
||||
case http.StatusForbidden:
|
||||
renderError(w, r, errPermissionDenied(e.Detail))
|
||||
default:
|
||||
renderError(w, r, errInternalError(err.Error()))
|
||||
}
|
||||
logger.Debug().Err(err).Msg("could not get thumbnail")
|
||||
return
|
||||
}
|
||||
|
||||
g.sendThumbnailResponse(rsp, w, r)
|
||||
}
|
||||
|
||||
func whoami(gatewayClient gatewayv1beta1.GatewayAPIClient, ctx context.Context, token string) (*userv1beta1.User, error) {
|
||||
// look up user from token via WhoAmI
|
||||
userRes, err := gatewayClient.WhoAmI(ctx, &gatewayv1beta1.WhoAmIRequest{
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userRes.Status.Code != rpcv1beta1.Code_CODE_OK {
|
||||
return nil, fmt.Errorf("could not get user: %s", userRes.GetStatus().GetMessage())
|
||||
}
|
||||
return userRes.GetUser(), nil
|
||||
}
|
||||
|
||||
// Thumbnail implements the Service interface.
|
||||
func (g Webdav) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.log.SubloggerWithRequestID(r.Context())
|
||||
tr, err := requests.ParseThumbnailRequest(r)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not create Request")
|
||||
renderError(w, r, errBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
t := r.Header.Get(revactx.TokenHeader)
|
||||
|
||||
gatewayClient, err := g.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get reva gatewayClient")
|
||||
renderError(w, r, errInternalError("could not get reva gatewayClient"))
|
||||
return
|
||||
}
|
||||
|
||||
var user *userv1beta1.User
|
||||
if tr.Identifier == "" {
|
||||
user, err = whoami(gatewayClient, r.Context(), t)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user")
|
||||
renderError(w, r, errInternalError("could not get user"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// look up user from URL via GetUserByClaim
|
||||
ctx := grpcmetadata.AppendToOutgoingContext(r.Context(), revactx.TokenHeader, t)
|
||||
userRes, err := gatewayClient.GetUserByClaim(ctx, &userv1beta1.GetUserByClaimRequest{
|
||||
Claim: "username",
|
||||
Value: tr.Identifier,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get user: transport error")
|
||||
renderError(w, r, errInternalError("could not get user"))
|
||||
return
|
||||
}
|
||||
if userRes.Status.Code != rpcv1beta1.Code_CODE_OK {
|
||||
logger.Debug().Str("grpcmessage", userRes.GetStatus().GetMessage()).Msg("could not get user")
|
||||
renderError(w, r, errInternalError("could not get user"))
|
||||
return
|
||||
}
|
||||
user = userRes.GetUser()
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(templates.WithUser(user, g.config.WebdavNamespace), tr.Filepath)
|
||||
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
|
||||
Filepath: strings.TrimLeft(tr.Filepath, "/"),
|
||||
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
|
||||
Width: tr.Width,
|
||||
Height: tr.Height,
|
||||
Processor: tr.Processor,
|
||||
Source: &thumbnailssvc.GetThumbnailRequest_Cs3Source{
|
||||
Cs3Source: &thumbnailsmsg.CS3Source{
|
||||
Path: fullPath,
|
||||
Authorization: t,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
e := merrors.Parse(err.Error())
|
||||
switch e.Code {
|
||||
case http.StatusNotFound:
|
||||
// StatusNotFound is expected for unsupported files
|
||||
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
|
||||
return
|
||||
case http.StatusTooEarly:
|
||||
// StatusTooEarly if file is processing
|
||||
renderError(w, r, errTooEarly(e.Detail))
|
||||
return
|
||||
case http.StatusTooManyRequests:
|
||||
addRetryAfterHeader(w)
|
||||
renderError(w, r, errTooManyRequests(e.Detail))
|
||||
case http.StatusBadRequest:
|
||||
renderError(w, r, errBadRequest(e.Detail))
|
||||
case http.StatusForbidden:
|
||||
renderError(w, r, errPermissionDenied(e.Detail))
|
||||
default:
|
||||
renderError(w, r, errInternalError(err.Error()))
|
||||
}
|
||||
g.log.Error().Err(err).Msg("could not get thumbnail")
|
||||
return
|
||||
}
|
||||
|
||||
g.sendThumbnailResponse(rsp, w, r)
|
||||
}
|
||||
|
||||
func (g Webdav) PublicThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.log.SubloggerWithRequestID(r.Context())
|
||||
tr, err := requests.ParseThumbnailRequest(r)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not create Request")
|
||||
renderError(w, r, errBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
rsp, err := g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
|
||||
Filepath: strings.TrimLeft(tr.Filepath, "/"),
|
||||
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
|
||||
Width: tr.Width,
|
||||
Height: tr.Height,
|
||||
Processor: tr.Processor,
|
||||
Source: &thumbnailssvc.GetThumbnailRequest_WebdavSource{
|
||||
WebdavSource: &thumbnailsmsg.WebdavSource{
|
||||
Url: g.config.QsferaPublicURL + r.URL.RequestURI(),
|
||||
IsPublicLink: true,
|
||||
PublicLinkToken: tr.PublicLinkToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
e := merrors.Parse(err.Error())
|
||||
switch e.Code {
|
||||
case http.StatusNotFound:
|
||||
// StatusNotFound is expected for unsupported files
|
||||
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
|
||||
return
|
||||
case http.StatusBadRequest:
|
||||
renderError(w, r, errBadRequest(e.Detail))
|
||||
case http.StatusTooManyRequests:
|
||||
addRetryAfterHeader(w)
|
||||
renderError(w, r, errTooManyRequests(e.Detail))
|
||||
default:
|
||||
renderError(w, r, errInternalError(err.Error()))
|
||||
}
|
||||
g.log.Error().Err(err).Msg("could not get thumbnail")
|
||||
return
|
||||
}
|
||||
|
||||
g.sendThumbnailResponse(rsp, w, r)
|
||||
}
|
||||
|
||||
func (g Webdav) PublicThumbnailHead(w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.log.SubloggerWithRequestID(r.Context())
|
||||
tr, err := requests.ParseThumbnailRequest(r)
|
||||
if err != nil {
|
||||
logger.Debug().Err(err).Msg("could not create Request")
|
||||
renderError(w, r, errBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = g.thumbnailsClient.GetThumbnail(r.Context(), &thumbnailssvc.GetThumbnailRequest{
|
||||
Filepath: strings.TrimLeft(tr.Filepath, "/"),
|
||||
ThumbnailType: extensionToThumbnailType(strings.TrimLeft(tr.Extension, ".")),
|
||||
Width: tr.Width,
|
||||
Height: tr.Height,
|
||||
Processor: tr.Processor,
|
||||
Source: &thumbnailssvc.GetThumbnailRequest_WebdavSource{
|
||||
WebdavSource: &thumbnailsmsg.WebdavSource{
|
||||
Url: g.config.QsferaPublicURL + r.URL.RequestURI(),
|
||||
IsPublicLink: true,
|
||||
PublicLinkToken: tr.PublicLinkToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
e := merrors.Parse(err.Error())
|
||||
switch e.Code {
|
||||
case http.StatusNotFound:
|
||||
// StatusNotFound is expected for unsupported files
|
||||
renderError(w, r, errNotFound(notFoundMsg(tr.Filename)))
|
||||
return
|
||||
case http.StatusBadRequest:
|
||||
renderError(w, r, errBadRequest(e.Detail))
|
||||
case http.StatusTooManyRequests:
|
||||
addRetryAfterHeader(w)
|
||||
renderError(w, r, errTooManyRequests(e.Detail))
|
||||
default:
|
||||
renderError(w, r, errInternalError(err.Error()))
|
||||
}
|
||||
logger.Debug().Err(err).Msg("could not get thumbnail")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (g Webdav) sendThumbnailResponse(rsp *thumbnailssvc.GetThumbnailResponse, w http.ResponseWriter, r *http.Request) {
|
||||
logger := g.log.SubloggerWithRequestID(r.Context())
|
||||
client := &http.Client{
|
||||
// Timeout: time.Second * 5,
|
||||
}
|
||||
|
||||
dlReq, err := http.NewRequest(http.MethodGet, rsp.DataEndpoint, http.NoBody)
|
||||
if err != nil {
|
||||
renderError(w, r, errInternalError(err.Error()))
|
||||
logger.Error().Err(err).Msg("could not create download thumbnail request")
|
||||
return
|
||||
}
|
||||
dlReq.Header.Set("Transfer-Token", rsp.TransferToken)
|
||||
|
||||
dlRsp, err := client.Do(dlReq)
|
||||
if err != nil {
|
||||
renderError(w, r, errInternalError(err.Error()))
|
||||
logger.Error().Err(err).Msg("could not download thumbnail: transport error")
|
||||
return
|
||||
}
|
||||
defer dlRsp.Body.Close()
|
||||
|
||||
if dlRsp.StatusCode != http.StatusOK {
|
||||
logger.Debug().
|
||||
Str("transfer_token", rsp.GetTransferToken()).
|
||||
Str("data_endpoint", rsp.GetDataEndpoint()).
|
||||
Str("response_status", dlRsp.Status).
|
||||
Msg("could not download thumbnail")
|
||||
renderError(w, r, newErrResponse(dlRsp.StatusCode, "could not download thumbnail"))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", dlRsp.Header.Get("Content-Type"))
|
||||
_, err = io.Copy(w, dlRsp.Body)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to write thumbnail to response writer")
|
||||
}
|
||||
}
|
||||
|
||||
func extensionToThumbnailType(ext string) thumbnailsmsg.ThumbnailType {
|
||||
switch strings.ToUpper(ext) {
|
||||
case "GIF":
|
||||
return thumbnailsmsg.ThumbnailType_GIF
|
||||
case "PNG":
|
||||
return thumbnailsmsg.ThumbnailType_PNG
|
||||
default:
|
||||
return thumbnailsmsg.ThumbnailType_JPG
|
||||
}
|
||||
}
|
||||
|
||||
// http://www.webdav.org/specs/rfc4918.html#ELEMENT_error
|
||||
type errResponse struct {
|
||||
HTTPStatusCode int `json:"-" xml:"-"`
|
||||
XMLName xml.Name `xml:"d:error"`
|
||||
Xmlnsd string `xml:"xmlns:d,attr"`
|
||||
Xmlnss string `xml:"xmlns:s,attr"`
|
||||
Exception string `xml:"s:exception"`
|
||||
Message string `xml:"s:message"`
|
||||
InnerXML []byte `xml:",innerxml"`
|
||||
}
|
||||
|
||||
func newErrResponse(statusCode int, msg string) *errResponse {
|
||||
rsp := &errResponse{
|
||||
HTTPStatusCode: statusCode,
|
||||
Xmlnsd: "DAV",
|
||||
Xmlnss: "http://sabredav.org/ns",
|
||||
Exception: codesEnum[statusCode],
|
||||
}
|
||||
if msg != "" {
|
||||
rsp.Message = msg
|
||||
}
|
||||
return rsp
|
||||
}
|
||||
|
||||
func errInternalError(msg string) *errResponse {
|
||||
return newErrResponse(http.StatusInternalServerError, msg)
|
||||
}
|
||||
|
||||
func errBadRequest(msg string) *errResponse {
|
||||
return newErrResponse(http.StatusBadRequest, msg)
|
||||
}
|
||||
|
||||
func errPermissionDenied(msg string) *errResponse {
|
||||
return newErrResponse(http.StatusForbidden, msg)
|
||||
}
|
||||
|
||||
func errNotFound(msg string) *errResponse {
|
||||
return newErrResponse(http.StatusNotFound, msg)
|
||||
}
|
||||
|
||||
func errTooEarly(msg string) *errResponse {
|
||||
return newErrResponse(http.StatusTooEarly, msg)
|
||||
}
|
||||
|
||||
func errTooManyRequests(msg string) *errResponse {
|
||||
return newErrResponse(http.StatusTooManyRequests, msg)
|
||||
}
|
||||
|
||||
func renderError(w http.ResponseWriter, r *http.Request, err *errResponse) {
|
||||
render.Status(r, err.HTTPStatusCode)
|
||||
render.XML(w, r, err)
|
||||
}
|
||||
|
||||
func notFoundMsg(name string) string {
|
||||
return "File with name " + name + " could not be located"
|
||||
}
|
||||
|
||||
func addRetryAfterHeader(w http.ResponseWriter) {
|
||||
after := rand.IntN(14) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(after))
|
||||
}
|
||||
Reference in New Issue
Block a user