Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
SHELL := bash
NAME := webdav
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
include ../../.bingo/Variables.mk
endif
include ../../.make/default.mk
include ../../.make/go.mk
include ../../.make/release.mk
include ../../.make/docs.mk
+23
View File
@@ -0,0 +1,23 @@
# Webdav
The webdav service, like the [frontend](../frontend) service, provides a HTTP API following the webdav protocol. It receives HTTP calls from requestors like clients and issues gRPC calls to other services executing these requests. After the called service has finished the request, the webdav service will render their responses in `xml` and sends them back to the requestor.
## Endpoints Overview
Currently, the webdav service handles request for two functionalities, which are `Thumbnails` and `Search`.
### Thumbnails
The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints.
See the [thumbnail](https://github.com/qsfera/server/tree/main/services/thumbnails) service for more information about thumbnails.
### Search
The webdav service provides access to the search functionality. It offers multiple `REPORT` endpoints for getting search results.
See the [search](https://github.com/qsfera/server/tree/main/services/search) service for more details about search functionality.
## Scalability
The webdav service does not persist any data and does not cache any information. Therefore multiple instances of this service can be spawned in a bigger deployment like when using container orchestration with Kubernetes, without any extra configuration.
@@ -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, "/")
}
}
+20
View File
@@ -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
}
+170
View File
@@ -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
}
+48
View File
@@ -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"
)
+12
View File
@@ -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()
}
+128
View File
@@ -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))
}