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 }