Files
magpie/internal/davsync/dav.go
T

131 lines
3.6 KiB
Go

package davsync
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// folder and file are where the backup lives under the address given.
const (
folder = "magpie"
file = "magpie.magpie-backup"
)
// errChanged is a PUT the server refused because the file changed since it
// was read: another computer synced in between.
var errChanged = errors.New("the file on the server changed meanwhile")
type dav struct {
base *url.URL
user, pass string
client *http.Client
}
func newDAV(c Config) (*dav, error) {
u, err := url.Parse(strings.TrimSpace(c.URL))
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return nil, fmt.Errorf("%q is not a WebDAV address (https://…)", c.URL)
}
u.Path = strings.TrimRight(u.Path, "/")
return &dav{base: u, user: c.User, pass: c.Password, client: http.DefaultClient}, nil
}
func (d *dav) url(parts ...string) string {
u := *d.base
u.Path += "/" + strings.Join(parts, "/")
return u.String()
}
func (d *dav) do(ctx context.Context, method, u string, body []byte, h map[string]string) (*http.Response, error) {
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, r)
if err != nil {
return nil, err
}
if d.user != "" || d.pass != "" {
req.SetBasicAuth(d.user, d.pass)
}
for k, v := range h {
req.Header.Set(k, v)
}
res, err := d.client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
res.Body.Close()
return nil, fmt.Errorf("the WebDAV server refused the user name or password (HTTP %d)", res.StatusCode)
}
return res, nil
}
// get reads the backup; nil data and no error when there is none yet.
func (d *dav) get(ctx context.Context) (data []byte, etag string, err error) {
res, err := d.do(ctx, http.MethodGet, d.url(folder, file), nil, nil)
if err != nil {
return nil, "", err
}
defer res.Body.Close()
switch {
case res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone:
return nil, "", nil
case res.StatusCode != http.StatusOK:
return nil, "", fmt.Errorf("reading %s from the WebDAV server: HTTP %d", file, res.StatusCode)
}
data, err = io.ReadAll(io.LimitReader(res.Body, 64<<20))
if err != nil {
return nil, "", err
}
return data, res.Header.Get("ETag"), nil
}
// put writes the backup, only over the version read (etag) when there was
// one, making the folder if the server has none.
func (d *dav) put(ctx context.Context, data []byte, etag string) error {
h := map[string]string{"Content-Type": "application/octet-stream"}
if etag != "" {
h["If-Match"] = etag
}
for try := 0; ; try++ {
res, err := d.do(ctx, http.MethodPut, d.url(folder, file), data, h)
if err != nil {
return err
}
res.Body.Close()
switch {
case res.StatusCode >= 200 && res.StatusCode < 300:
return nil
case res.StatusCode == http.StatusPreconditionFailed:
return errChanged
case (res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusConflict) && try == 0:
if err := d.mkcol(ctx); err != nil {
return err
}
continue
}
return fmt.Errorf("writing %s to the WebDAV server: HTTP %d", file, res.StatusCode)
}
}
func (d *dav) mkcol(ctx context.Context) error {
res, err := d.do(ctx, "MKCOL", d.url(folder)+"/", nil, nil)
if err != nil {
return err
}
res.Body.Close()
// 405: it is there already
if res.StatusCode >= 300 && res.StatusCode != http.StatusMethodNotAllowed {
return fmt.Errorf("making the folder %s on the WebDAV server: HTTP %d", folder, res.StatusCode)
}
return nil
}