// Copyright (c) Acrosync LLC. All rights reserved. // Free for personal use and commercial trial // Commercial use requires per-user licenses available from https://duplicacy.com // // // This storage backend is based on the work by Yuri Karamani from https://github.com/karamani/webdavclnt, // released under the MIT license. // package duplicacy import ( "bytes" "encoding/xml" "errors" "fmt" "io" "math/rand" "net/http" //"net/http/httputil" "strconv" "strings" "sync" "time" "io/ioutil" ) type WebDAVStorage struct { StorageBase host string port int username string password string storageDir string useHTTP bool client *http.Client threads int directoryCache map[string]int // stores directories known to exist by this backend directoryCacheLock sync.Mutex // lock for accessing directoryCache } var ( errWebDAVAuthorizationFailure = errors.New("Authentication failed") errWebDAVMovedPermanently = errors.New("Moved permanently") errWebDAVNotExist = errors.New("Path does not exist") errWebDAVMaximumBackoff = errors.New("Maximum backoff reached") errWebDAVMethodNotAllowed = errors.New("Method not allowed") ) func CreateWebDAVStorage(host string, port int, username string, password string, storageDir string, useHTTP bool, threads int) (storage *WebDAVStorage, err error) { if len(storageDir) > 0 && storageDir[len(storageDir)-1] != '/' { storageDir += "/" } storage = &WebDAVStorage{ host: host, port: port, username: username, password: password, storageDir: "", useHTTP: useHTTP, client: http.DefaultClient, threads: threads, directoryCache: make(map[string]int), } // Make sure it doesn't follow redirect storage.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } exist, isDir, _, err := storage.GetFileInfo(0, storageDir) if err != nil { return nil, err } if !exist { return nil, fmt.Errorf("Storage path %s does not exist", storageDir) } if !isDir { return nil, fmt.Errorf("Storage path %s is not a directory", storageDir) } storage.storageDir = storageDir for _, dir := range []string{"snapshots", "chunks"} { storage.CreateDirectory(0, dir) } storage.DerivedStorage = storage storage.SetDefaultNestingLevels([]int{0}, 0) return storage, nil } func (storage *WebDAVStorage) createConnectionString(uri string) string { url := storage.host if storage.useHTTP { url = "http://" + url } else { url = "https://" + url } if storage.port > 0 { url += fmt.Sprintf(":%d", storage.port) } return url + "/" + storage.storageDir + uri } func (storage *WebDAVStorage) retry(backoff int) int { delay := rand.Intn(backoff*500) + backoff*500 time.Sleep(time.Duration(delay) * time.Millisecond) backoff *= 2 return backoff } func (storage *WebDAVStorage) sendRequest(method string, uri string, depth int, data []byte) (io.ReadCloser, http.Header, error) { backoff := 1 for i := 0; i < 8; i++ { var dataReader io.Reader headers := make(map[string]string) if method == "PROPFIND" { headers["Content-Type"] = "application/xml" headers["Depth"] = fmt.Sprintf("%d", depth) dataReader = bytes.NewReader(data) } else if method == "PUT" { headers["Content-Type"] = "application/octet-stream" headers["Content-Length"] = fmt.Sprintf("%d", len(data)) if storage.UploadRateLimit <= 0 { dataReader = bytes.NewReader(data) } else { dataReader = CreateRateLimitedReader(data, storage.UploadRateLimit/storage.threads) } } else if method == "MOVE" { headers["Destination"] = storage.createConnectionString(string(data)) headers["Content-Type"] = "application/octet-stream" dataReader = bytes.NewReader([]byte("")) } else { headers["Content-Type"] = "application/octet-stream" dataReader = bytes.NewReader(data) } request, err := http.NewRequest(method, storage.createConnectionString(uri), dataReader) if err != nil { return nil, nil, err } if len(storage.username) > 0 { request.SetBasicAuth(storage.username, storage.password) } for key, value := range headers { request.Header.Set(key, value) } if method == "PUT" { request.ContentLength = int64(len(data)) } //requestDump, err := httputil.DumpRequest(request, true) //LOG_INFO("debug", "Request: %s", requestDump) response, err := storage.client.Do(request) if err != nil { LOG_TRACE("WEBDAV_ERROR", "URL request '%s %s' returned an error (%v)", method, uri, err) backoff = storage.retry(backoff) continue } if response.StatusCode < 300 { return response.Body, response.Header, nil } io.Copy(ioutil.Discard, response.Body) response.Body.Close() if response.StatusCode == 301 { return nil, nil, errWebDAVMovedPermanently } if response.StatusCode == 404 { // Retry if it is UPLOAD, otherwise return immediately if method != "PUT" { return nil, nil, errWebDAVNotExist } } else if response.StatusCode == 405 { return nil, nil, errWebDAVMethodNotAllowed } LOG_INFO("WEBDAV_RETRY", "URL request '%s %s' returned status code %d", method, uri, response.StatusCode) backoff = storage.retry(backoff) } return nil, nil, errWebDAVMaximumBackoff } type WebDAVProperties map[string]string type WebDAVPropValue struct { XMLName xml.Name `xml:""` Value string `xml:",innerxml"` } type WebDAVProp struct { PropList []WebDAVPropValue `xml:",any"` } type WebDAVPropStat struct { Prop *WebDAVProp `xml:"prop"` } type WebDAVResponse struct { Href string `xml:"href"` PropStat *WebDAVPropStat `xml:"propstat"` } type WebDAVMultiStatus struct { Responses []WebDAVResponse `xml:"response"` } func (storage *WebDAVStorage) getProperties(uri string, depth int, properties ...string) (map[string]WebDAVProperties, error) { maxTries := 3 for tries := 0; ; tries++ { propfind := "" for _, p := range properties { propfind += fmt.Sprintf("<%s/>", p) } propfind += "" body := fmt.Sprintf(`%s`, propfind) readCloser, _, err := storage.sendRequest("PROPFIND", uri, depth, []byte(body)) if err != nil { return nil, err } defer readCloser.Close() defer io.Copy(ioutil.Discard, readCloser) object := WebDAVMultiStatus{} err = xml.NewDecoder(readCloser).Decode(&object) if err != nil { if strings.Contains(err.Error(), "unexpected EOF") && tries < maxTries { LOG_WARN("WEBDAV_RETRY", "Retrying on %v", err) continue } return nil, err } if object.Responses == nil || len(object.Responses) == 0 { return nil, errors.New("no WebDAV responses") } responses := make(map[string]WebDAVProperties) for _, responseTag := range object.Responses { if responseTag.PropStat == nil || responseTag.PropStat.Prop == nil || responseTag.PropStat.Prop.PropList == nil { return nil, errors.New("no WebDAV properties") } properties := make(WebDAVProperties) for _, prop := range responseTag.PropStat.Prop.PropList { properties[prop.XMLName.Local] = prop.Value } responseKey := responseTag.Href responses[responseKey] = properties } return responses, nil } } // ListFiles return the list of files and subdirectories under 'dir'. A subdirectories returned must have a trailing '/', with // a size of 0. If 'dir' is 'snapshots', only subdirectories will be returned. If 'dir' is 'snapshots/repository_id', then only // files will be returned. If 'dir' is 'chunks', the implementation can return the list either recusively or non-recusively. func (storage *WebDAVStorage) ListFiles(threadIndex int, dir string) (files []string, sizes []int64, err error) { if dir[len(dir)-1] != '/' { dir += "/" } properties, err := storage.getProperties(dir, 1, "getcontentlength", "resourcetype") if err != nil { return nil, nil, err } prefixLength := len(storage.storageDir) + len(dir) + 1 for file, m := range properties { if len(file) <= prefixLength { continue } isDir := false size := 0 if resourceType, exist := m["resourcetype"]; exist && strings.Contains(resourceType, "collection") { isDir = true } else if length, exist := m["getcontentlength"]; exist { if length == "" { isDir = true } else { size, _ = strconv.Atoi(length) } } else { continue } if !isDir { if dir != "snapshots/" { files = append(files, file[prefixLength:]) sizes = append(sizes, int64(size)) } } else { // This is a dir file := file[prefixLength:] if file[len(file)-1] != '/' { file += "/" } files = append(files, file) sizes = append(sizes, int64(0)) // Add the directory to the directory cache storage.directoryCacheLock.Lock() storage.directoryCache[dir + file] = 1 storage.directoryCacheLock.Unlock() } } return files, sizes, nil } // GetFileInfo returns the information about the file or directory at 'filePath'. func (storage *WebDAVStorage) GetFileInfo(threadIndex int, filePath string) (exist bool, isDir bool, size int64, err error) { properties, err := storage.getProperties(filePath, 0, "getcontentlength", "resourcetype") if err != nil { if err == errWebDAVNotExist { return false, false, 0, nil } if err == errWebDAVMovedPermanently { // This must be a directory return true, true, 0, nil } return false, false, 0, err } m, exist := properties["/"+storage.storageDir+filePath] // If no properties exist for the given filePath, remove the trailing / from filePath and search again if !exist && filePath != "" && filePath[len(filePath) - 1] == '/' { m, exist = properties["/"+storage.storageDir+filePath[:len(filePath) - 1]] } if !exist { return false, false, 0, nil } else if resourceType, exist := m["resourcetype"]; exist && strings.Contains(resourceType, "collection") { return true, true, 0, nil } else if length, exist := m["getcontentlength"]; exist && length != "" { value, _ := strconv.Atoi(length) return true, false, int64(value), nil } else { return true, true, 0, nil } } // DeleteFile deletes the file or directory at 'filePath'. func (storage *WebDAVStorage) DeleteFile(threadIndex int, filePath string) (err error) { readCloser, _, err := storage.sendRequest("DELETE", filePath, 0, []byte("")) if err != nil { return err } io.Copy(ioutil.Discard, readCloser) readCloser.Close() return nil } // MoveFile renames the file. func (storage *WebDAVStorage) MoveFile(threadIndex int, from string, to string) (err error) { readCloser, _, err := storage.sendRequest("MOVE", from, 0, []byte(to)) if err != nil { return err } io.Copy(ioutil.Discard, readCloser) readCloser.Close() return nil } // createParentDirectory creates the parent directory if it doesn't exist in the cache func (storage *WebDAVStorage) createParentDirectory(threadIndex int, dir string) (err error) { found := strings.LastIndex(dir, "/") if found == -1 { return nil } parent := dir[:found] return storage.CreateDirectory(threadIndex, parent) } // CreateDirectory creates a new directory. func (storage *WebDAVStorage) CreateDirectory(threadIndex int, dir string) (err error) { for dir != "" && dir[len(dir)-1] == '/' { dir = dir[:len(dir)-1] } if dir == "" { return nil } storage.directoryCacheLock.Lock() _, exist := storage.directoryCache[dir] storage.directoryCacheLock.Unlock() if exist { return nil } // If there is an error in creating the parent directory, proceed anyway storage.createParentDirectory(threadIndex, dir) readCloser, _, err := storage.sendRequest("MKCOL", dir, 0, []byte("")) if err != nil { if err == errWebDAVMethodNotAllowed || err == errWebDAVMovedPermanently || err == io.EOF { // We simply ignore these errors and assume that the directory already exists LOG_TRACE("WEBDAV_MKDIR", "Can't create directory %s: %v; error ignored", dir, err) storage.directoryCacheLock.Lock() storage.directoryCache[dir] = 1 storage.directoryCacheLock.Unlock() return nil } return err } io.Copy(ioutil.Discard, readCloser) readCloser.Close() storage.directoryCacheLock.Lock() storage.directoryCache[dir] = 1 storage.directoryCacheLock.Unlock() return nil } // DownloadFile reads the file at 'filePath' into the chunk. func (storage *WebDAVStorage) DownloadFile(threadIndex int, filePath string, chunk *Chunk) (err error) { readCloser, _, err := storage.sendRequest("GET", filePath, 0, nil) if err != nil { return err } _, err = RateLimitedCopy(chunk, readCloser, storage.DownloadRateLimit/storage.threads) return err } // UploadFile writes 'content' to the file at 'filePath'. func (storage *WebDAVStorage) UploadFile(threadIndex int, filePath string, content []byte) (err error) { // If there is an error in creating the parent directory, proceed anyway storage.createParentDirectory(threadIndex, filePath) readCloser, _, err := storage.sendRequest("PUT", filePath, 0, content) if err != nil { return err } io.Copy(ioutil.Discard, readCloser) readCloser.Close() return nil } // If a local snapshot cache is needed for the storage to avoid downloading/uploading chunks too often when // managing snapshots. func (storage *WebDAVStorage) IsCacheNeeded() bool { return true } // If the 'MoveFile' method is implemented. func (storage *WebDAVStorage) IsMoveFileImplemented() bool { return true } // If the storage can guarantee strong consistency. func (storage *WebDAVStorage) IsStrongConsistent() bool { return false } // If the storage supports fast listing of files names. func (storage *WebDAVStorage) IsFastListing() bool { return false } // Enable the test mode. func (storage *WebDAVStorage) EnableTestMode() {}