github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/pkg/webdav/webdav.go (about)

     1  // Package webdav is a webdav client library.
     2  package webdav
     3  
     4  import (
     5  	"encoding/xml"
     6  	"io"
     7  	"net/http"
     8  	"net/url"
     9  	"runtime"
    10  	"strconv"
    11  	"strings"
    12  	"time"
    13  
    14  	build "github.com/cozy/cozy-stack/pkg/config"
    15  	"github.com/cozy/cozy-stack/pkg/logger"
    16  	"github.com/cozy/cozy-stack/pkg/safehttp"
    17  	"github.com/labstack/echo/v4"
    18  )
    19  
    20  type Client struct {
    21  	Scheme   string
    22  	Host     string
    23  	Username string
    24  	Password string
    25  	BasePath string
    26  	Logger   *logger.Entry
    27  }
    28  
    29  func (c *Client) Mkcol(path string) error {
    30  	res, err := c.req("MKCOL", path, nil, nil)
    31  	if err != nil {
    32  		return err
    33  	}
    34  	defer res.Body.Close()
    35  	switch res.StatusCode {
    36  	case 201:
    37  		return nil
    38  	case 401, 403:
    39  		return ErrInvalidAuth
    40  	case 405:
    41  		return ErrAlreadyExist
    42  	case 409:
    43  		return ErrParentNotFound
    44  	default:
    45  		return ErrInternalServerError
    46  	}
    47  }
    48  
    49  func (c *Client) Delete(path string) error {
    50  	res, err := c.req("DELETE", path, nil, nil)
    51  	if err != nil {
    52  		return err
    53  	}
    54  	defer res.Body.Close()
    55  	switch res.StatusCode {
    56  	case 204:
    57  		return nil
    58  	case 401, 403:
    59  		return ErrInvalidAuth
    60  	case 404:
    61  		return ErrNotFound
    62  	default:
    63  		return ErrInternalServerError
    64  	}
    65  }
    66  
    67  func (c *Client) Move(oldPath, newPath string) error {
    68  	u := url.URL{
    69  		Scheme: c.Scheme,
    70  		Host:   c.Host,
    71  		User:   url.UserPassword(c.Username, c.Password),
    72  		Path:   c.BasePath + fixSlashes(newPath),
    73  	}
    74  	headers := map[string]string{
    75  		"Destination": u.String(),
    76  		"Overwrite":   "F",
    77  	}
    78  	res, err := c.req("MOVE", oldPath, headers, nil)
    79  	if err != nil {
    80  		return err
    81  	}
    82  	defer res.Body.Close()
    83  	switch res.StatusCode {
    84  	case 201, 204:
    85  		return nil
    86  	case 401, 403:
    87  		return ErrInvalidAuth
    88  	case 404, 409:
    89  		return ErrNotFound
    90  	case 412:
    91  		return ErrAlreadyExist
    92  	default:
    93  		return ErrInternalServerError
    94  	}
    95  }
    96  
    97  func (c *Client) Copy(oldPath, newPath string) error {
    98  	u := url.URL{
    99  		Scheme: c.Scheme,
   100  		Host:   c.Host,
   101  		User:   url.UserPassword(c.Username, c.Password),
   102  		Path:   c.BasePath + fixSlashes(newPath),
   103  	}
   104  	headers := map[string]string{
   105  		"Destination": u.String(),
   106  		"Overwrite":   "F",
   107  	}
   108  	res, err := c.req("COPY", oldPath, headers, nil)
   109  	if err != nil {
   110  		return err
   111  	}
   112  	defer res.Body.Close()
   113  	switch res.StatusCode {
   114  	case 201, 204:
   115  		return nil
   116  	case 401, 403:
   117  		return ErrInvalidAuth
   118  	case 404, 409:
   119  		return ErrNotFound
   120  	case 412:
   121  		return ErrAlreadyExist
   122  	default:
   123  		return ErrInternalServerError
   124  	}
   125  }
   126  
   127  func (c *Client) Put(path string, headers map[string]string, body io.Reader) error {
   128  	res, err := c.req("PUT", path, headers, body)
   129  	if err != nil {
   130  		return err
   131  	}
   132  	defer res.Body.Close()
   133  	switch res.StatusCode {
   134  	case 201:
   135  		return nil
   136  	case 401, 403:
   137  		return ErrInvalidAuth
   138  	case 405:
   139  		return ErrAlreadyExist
   140  	case 404, 409:
   141  		return ErrParentNotFound
   142  	default:
   143  		return ErrInternalServerError
   144  	}
   145  }
   146  
   147  func (c *Client) Get(path string) (*Download, error) {
   148  	res, err := c.req("GET", path, nil, nil)
   149  	if err != nil {
   150  		return nil, err
   151  	}
   152  
   153  	if res.StatusCode == 200 {
   154  		return &Download{
   155  			Content:      res.Body,
   156  			ETag:         res.Header.Get("Etag"),
   157  			Length:       res.Header.Get(echo.HeaderContentLength),
   158  			Mime:         res.Header.Get(echo.HeaderContentType),
   159  			LastModified: res.Header.Get(echo.HeaderLastModified),
   160  		}, nil
   161  	}
   162  
   163  	defer res.Body.Close()
   164  	switch res.StatusCode {
   165  	case 401, 403:
   166  		return nil, ErrInvalidAuth
   167  	case 404:
   168  		return nil, ErrNotFound
   169  	default:
   170  		return nil, ErrInternalServerError
   171  	}
   172  }
   173  
   174  type Download struct {
   175  	Content      io.ReadCloser
   176  	ETag         string
   177  	Length       string
   178  	Mime         string
   179  	LastModified string
   180  }
   181  
   182  func (c *Client) List(path string) ([]Item, error) {
   183  	path = fixSlashes(path)
   184  	headers := map[string]string{
   185  		"Content-Type": "application/xml;charset=UTF-8",
   186  		"Accept":       "application/xml",
   187  		"Depth":        "1",
   188  	}
   189  	payload := strings.NewReader(ListFilesPayload)
   190  	res, err := c.req("PROPFIND", path, headers, payload)
   191  	if err != nil {
   192  		return nil, err
   193  	}
   194  	defer func() {
   195  		// Flush the body, so that the connection can be reused by keep-alive
   196  		_, _ = io.Copy(io.Discard, res.Body)
   197  		_ = res.Body.Close()
   198  	}()
   199  
   200  	switch res.StatusCode {
   201  	case 200, 207:
   202  		// OK continue the work
   203  	case 401, 403:
   204  		return nil, ErrInvalidAuth
   205  	case 404:
   206  		return nil, ErrNotFound
   207  	default:
   208  		return nil, ErrInternalServerError
   209  	}
   210  
   211  	// https://docs.nextcloud.com/server/20/developer_manual/client_apis/WebDAV/basic.html#requesting-properties
   212  	var multistatus multistatus
   213  	if err := xml.NewDecoder(res.Body).Decode(&multistatus); err != nil {
   214  		return nil, err
   215  	}
   216  
   217  	var items []Item
   218  	for _, response := range multistatus.Responses {
   219  		// We want only the children, not the directory itself
   220  		parts := strings.Split(strings.TrimPrefix(response.Href, c.BasePath), "/")
   221  		for i, part := range parts {
   222  			if p, err := url.PathUnescape(part); err == nil {
   223  				parts[i] = p
   224  			}
   225  		}
   226  		href := strings.Join(parts, "/")
   227  		if href == path {
   228  			continue
   229  		}
   230  
   231  		for _, props := range response.Props {
   232  			// Only looks for the HTTP/1.1 200 OK status
   233  			parts := strings.Split(props.Status, " ")
   234  			if len(parts) < 2 || parts[1] != "200" {
   235  				continue
   236  			}
   237  			item := Item{
   238  				ID:           props.FileID,
   239  				Type:         "directory",
   240  				Name:         props.Name,
   241  				LastModified: props.LastModified,
   242  				ETag:         props.ETag,
   243  			}
   244  			if props.Type.Local == "" {
   245  				item.Type = "file"
   246  				if props.Size != "" {
   247  					if size, err := strconv.ParseUint(props.Size, 10, 64); err == nil {
   248  						item.Size = size
   249  					}
   250  				}
   251  			}
   252  			items = append(items, item)
   253  		}
   254  	}
   255  	return items, nil
   256  }
   257  
   258  type Item struct {
   259  	ID           string
   260  	Type         string
   261  	Name         string
   262  	Size         uint64
   263  	ContentType  string
   264  	LastModified string
   265  	ETag         string
   266  }
   267  
   268  type multistatus struct {
   269  	XMLName   xml.Name   `xml:"multistatus"`
   270  	Responses []response `xml:"response"`
   271  }
   272  
   273  type response struct {
   274  	Href  string  `xml:"DAV: href"`
   275  	Props []props `xml:"DAV: propstat"`
   276  }
   277  
   278  type props struct {
   279  	Status       string   `xml:"status"`
   280  	Type         xml.Name `xml:"prop>resourcetype>collection"`
   281  	Name         string   `xml:"prop>displayname"`
   282  	Size         string   `xml:"prop>getcontentlength"`
   283  	ContentType  string   `xml:"prop>getcontenttype"`
   284  	LastModified string   `xml:"prop>getlastmodified"`
   285  	ETag         string   `xml:"prop>getetag"`
   286  	FileID       string   `xml:"prop>fileid"`
   287  }
   288  
   289  const ListFilesPayload = `<?xml version="1.0"?>
   290  <d:propfind  xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
   291    <d:prop>
   292          <d:resourcetype />
   293          <d:displayname />
   294          <d:getlastmodified />
   295          <d:getetag />
   296          <d:getcontentlength />
   297          <d:getcontenttype />
   298          <oc:fileid />
   299    </d:prop>
   300  </d:propfind>
   301  `
   302  
   303  func (c *Client) req(method, path string, headers map[string]string, body io.Reader) (*http.Response, error) {
   304  	path = c.BasePath + fixSlashes(path)
   305  	u := url.URL{
   306  		Scheme: c.Scheme,
   307  		Host:   c.Host,
   308  		User:   url.UserPassword(c.Username, c.Password),
   309  		Path:   path,
   310  	}
   311  	req, err := http.NewRequest(method, u.String(), body)
   312  	if err != nil {
   313  		return nil, err
   314  	}
   315  	req.Header.Set("User-Agent", "cozy-stack "+build.Version+" ("+runtime.Version()+")")
   316  	for k, v := range headers {
   317  		req.Header.Set(k, v)
   318  	}
   319  	start := time.Now()
   320  	res, err := safehttp.ClientWithKeepAlive.Do(req)
   321  	elapsed := time.Since(start)
   322  	if err != nil {
   323  		c.Logger.Warnf("%s %s %s: %s (%s)", method, c.Host, path, err, elapsed)
   324  		return nil, err
   325  	}
   326  	c.Logger.Infof("%s %s %s: %d (%s)", method, c.Host, path, res.StatusCode, elapsed)
   327  	return res, nil
   328  }
   329  
   330  func fixSlashes(s string) string {
   331  	if !strings.HasPrefix(s, "/") {
   332  		s = "/" + s
   333  	}
   334  	if !strings.HasSuffix(s, "/") {
   335  		s += "/"
   336  	}
   337  	return s
   338  }