Files
tai/examples/webdav-server.py
T

1052 lines
38 KiB
Python
Executable File

#!/usr/bin/env python3
"""Complete WebDAV Class 1 server. Stdlib only, single file, Python 3.6+.
Run:
python3 webdav-server.py --root ./share --port 8080
python3 webdav-server.py --root ./share --port 8080 --user alice --password s3cret
Works with: cadaver, Nautilus/GNOME Files (dav://), rclone (webdav remote),
Windows Explorer (Map network drive), curl.
Spec (this file is the spec, DAV class 1):
Method Statusen Beschrijving
OPTIONS 200 DAV: 1, Allow-lijst, MS-Author-Via
GET 200/206/304/404 File download, Range, ETag/If-*
HEAD als GET zonder body Headers only
PUT 201/204/409/423 File aanmaken/overschrijven
DELETE 204/404/423 File/dir verwijderen (recursief)
MKCOL 201/405/409/415 Directory aanmaken
PROPFIND 207 Metadata als multistatus XML
PROPPATCH 207 Dead properties set/remove
COPY 201/204/412/409 File/dir kopieren (Depth, Overwrite)
MOVE 201/204/412/409 File/dir verplaatsen
LOCK 200/423 Exclusive write lock, opaquelocktoken
UNLOCK 204/400/409 Lock opheffen via Lock-Token
Caps (harde plafonds, zie DavConfig):
MAX_XML_BODY 1 MiB PROPFIND/PROPPATCH/LOCK request body
MAX_UPLOAD 200 MiB PUT body (instelbaar via --max-upload-mb)
MAX_PROPFIND 2000 max entries per PROPFIND (infinity afgekapt)
LOCK_TIMEOUT 1 uur default, max 1 dag via Timeout-header
Veiligheid: alle paden gejailed onder --root (symlink-escape geeft 403),
optionele Basic-auth met constant-time vergelijking.
"""
import argparse
import base64
import hashlib
import hmac
import json
import mimetypes
import os
import posixpath
import shutil
import socketserver
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import unquote, urlparse
from xml.etree import ElementTree as ET
DAV_NS = "DAV:"
ET.register_namespace("D", DAV_NS)
def _d(tag):
return "{%s}%s" % (DAV_NS, tag)
def _http_date(ts):
from email.utils import formatdate
return formatdate(ts, usegmt=True)
class DavConfig(object):
"""Alle knoppen van de server op een plek. Defaults zijn de spec."""
MAX_XML_BODY = 1024 * 1024
MAX_PROPFIND = 2000
LOCK_DEFAULT_TIMEOUT = 3600
LOCK_MAX_TIMEOUT = 86400
def __init__(self, root, user=None, password=None, max_upload_mb=200,
readonly=False):
self.root = os.path.realpath(os.path.abspath(root))
self.user = user
self.password = password
self.max_upload = max_upload_mb * 1024 * 1024
self.readonly = readonly
@property
def auth_enabled(self):
return bool(self.user) and self.password is not None
class DavStore(object):
"""Filesystem-laag. Enige plek die paden naar schijf vertaalt."""
def __init__(self, config):
self.config = config
self._lock = threading.RLock()
def resolve(self, uri_path):
"""Vertaal URI-pad naar schijfpad. None bij jail-escape."""
path = unquote(urlparse(uri_path).path)
path = posixpath.normpath(path)
if path.startswith("/"):
path = path[1:]
full = os.path.realpath(os.path.join(self.config.root, path))
root = self.config.root
if full != root and not full.startswith(root + os.sep):
return None
return full
def uri_of(self, full, is_dir):
rel = os.path.relpath(full, self.config.root)
if rel == ".":
return "/"
parts = rel.split(os.sep)
from urllib.parse import quote
uri = "/" + "/".join(quote(p, safe="") for p in parts)
if is_dir and not uri.endswith("/"):
uri += "/"
return uri
@staticmethod
def etag(full):
try:
st = os.stat(full)
except OSError:
return None
h = hashlib.sha1()
h.update(("%d:%d" % (int(st.st_mtime), st.st_size)).encode("ascii"))
return '"%s"' % h.hexdigest()[:16]
@staticmethod
def content_type(full):
guess = mimetypes.guess_type(full)[0]
return guess or "application/octet-stream"
class DavLocks(object):
"""In-memory exclusive write locks. Thread-safe."""
def __init__(self):
self._lock = threading.RLock()
self._by_token = {}
self._by_path = {}
def _prune(self):
now = time.time()
dead = [t for t, e in self._by_token.items() if e["expires"] < now]
for t in dead:
self._release_token(t)
def _release_token(self, token):
entry = self._by_token.pop(token, None)
if entry is not None:
paths = self._by_path.get(entry["path"], [])
if token in paths:
paths.remove(token)
def create(self, path, depth, owner, timeout):
with self._lock:
self._prune()
token = "opaquelocktoken:%s" % uuid.uuid4()
entry = {"token": token, "path": path, "depth": depth,
"owner": owner or "",
"expires": time.time() + timeout}
self._by_token[token] = entry
self._by_path.setdefault(path, []).append(token)
return entry
def release(self, token):
with self._lock:
if token not in self._by_token:
return False
self._release_token(token)
return True
def refresh(self, token, timeout):
with self._lock:
self._prune()
entry = self._by_token.get(token)
if entry is None:
return None
entry["expires"] = time.time() + timeout
return entry
def locked(self, path, token=None):
"""True als path onder een lock valt die NIET via token is vrijgegeven."""
with self._lock:
self._prune()
if token and token in self._by_token:
entry = self._by_token[token]
if self._covers(entry, path):
return False
for entry in self._by_token.values():
if entry["token"] == token:
continue
if self._covers(entry, path):
return True
return False
@staticmethod
def _covers(entry, path):
if path == entry["path"]:
return True
if entry["depth"] == "infinity" and path.startswith(entry["path"] + os.sep):
return True
return False
def for_path(self, path):
with self._lock:
self._prune()
return [e for e in self._by_token.values() if e["path"] == path]
class DavProps(object):
"""Dead properties. JSON-persistent onder root, thread-safe."""
FILENAME = ".davprops.json"
def __init__(self, config):
self._lock = threading.RLock()
self._file = os.path.join(config.root, self.FILENAME)
self._data = {}
self._load()
def _load(self):
try:
with open(self._file, "r", encoding="utf-8") as fh:
self._data = json.load(fh)
except (OSError, ValueError):
self._data = {}
def _save(self):
tmp = self._file + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self._data, fh)
os.replace(tmp, self._file)
except OSError:
try:
os.unlink(tmp)
except OSError:
pass
def get_all(self, uri):
with self._lock:
return dict(self._data.get(uri, {}))
def patch(self, uri, set_props, remove_props):
with self._lock:
cur = self._data.setdefault(uri, {})
for qname, value in set_props.items():
cur[qname] = value
for qname in remove_props:
cur.pop(qname, None)
if not cur and uri in self._data:
del self._data[uri]
self._save()
class DavXml(object):
"""Bouw- en parse-hulpjes voor PROPFIND/PROPPATCH/LOCK bodies."""
ALLPROPS = ("creationdate", "displayname", "getcontentlength",
"getcontenttype", "getlastmodified", "getetag",
"resourcetype", "supportedlock", "lockdiscovery")
@staticmethod
def parse_body(handler, limit):
try:
length = int(handler.headers.get("Content-Length", "0") or "0")
except ValueError:
return None, 400
if length < 0 or length > limit:
return None, 413
if length == 0:
return None, 0
try:
raw = handler.rfile.read(length)
except OSError:
return None, 400
try:
return ET.fromstring(raw), 0
except ET.ParseError:
return None, 400
@staticmethod
def propfind_mode(root):
if root is None:
return "allprop", []
if root.find(_d("allprop")) is not None:
return "allprop", []
if root.find(_d("propname")) is not None:
return "propname", []
prop = root.find(_d("prop"))
if prop is None:
return "allprop", []
return "named", list(prop)
@staticmethod
def lockinfo(root):
scope = "exclusive"
owner = ""
if root is not None:
if root.find(".//%s" % _d("shared")) is not None:
scope = "shared"
owner_el = root.find(".//%s" % _d("owner"))
if owner_el is not None:
owner = "".join(owner_el.itertext()).strip()[:500]
return scope, owner
@staticmethod
def proppatch(root):
to_set, to_remove = {}, []
if root is None:
return to_set, to_remove
for op in root:
kind = op.tag.replace("{%s}" % DAV_NS, "")
prop = op.find(_d("prop"))
if prop is None:
continue
for child in prop:
if kind == "set":
inner = b"".join(
ET.tostring(g) for g in child) if len(child) else b""
text = (child.text or "").strip()
if inner:
value = inner.decode("utf-8", "replace")[:4000]
else:
value = text[:4000]
to_set[child.tag] = value
elif kind == "remove":
to_remove.append(child.tag)
return to_set, to_remove
class DavHandler(BaseHTTPRequestHandler):
"""Een DavHandler-instantie per request. Deelt store/locks/props via server."""
server_version = "TaiWebDAV/1.0"
protocol_version = "HTTP/1.1"
@property
def dav(self):
return self.server.dav
def log_message(self, fmt, *args):
pass
# -- generieke hulpjes ------------------------------------------------
def _send(self, code, body=b"", ctype="text/plain; charset=utf-8",
extra=None):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("DAV", "1")
self.send_header("MS-Author-Via", "DAV")
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
if self.command != "HEAD" and body:
self.wfile.write(body)
def _error(self, code, message):
self._send(code, message + "\n")
def _check_auth(self):
cfg = self.dav["config"]
if not cfg.auth_enabled:
return True
auth = self.headers.get("Authorization", "")
expect = "Basic " + base64.b64encode(
("%s:%s" % (cfg.user, cfg.password)).encode("utf-8")).decode("ascii")
if hmac.compare_digest(auth, expect):
return True
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="webdav"')
self.send_header("Content-Length", "13")
self.send_header("DAV", "1")
self.end_headers()
self.wfile.write(b"unauthorized\n")
return False
def _if_token(self):
"""Haal lock-token uit If-header: (<opaquelocktoken:...>)."""
header = self.headers.get("If", "")
start = header.find("(<")
if start == -1:
start = header.find("<")
if start == -1:
return None
end = header.find(">", start)
else:
end = header.find(">)", start)
if end == -1:
return None
return header[start + 2 if header[start:start + 2] == "(<" else start + 1:end]
# -- OPTIONS / GET / HEAD ---------------------------------------------
def do_OPTIONS(self):
if not self._check_auth():
return
self._send(200, "", extra={
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, PROPFIND, "
"PROPPATCH, COPY, MOVE, LOCK, UNLOCK",
})
def _serve_file(self, full, uri):
store = self.dav["store"] # type: DavStore
try:
size = os.path.getsize(full)
mtime = os.path.getmtime(full)
except OSError:
self._error(404, "not found")
return
etag = store.etag(full)
if self.headers.get("If-None-Match") == etag:
self._send(304, "", extra={"ETag": etag})
return
ctype = store.content_type(full)
range_header = self.headers.get("Range", "")
start, end = 0, size - 1
partial = False
if range_header.startswith("bytes="):
try:
spec = range_header[len("bytes="):].strip()
if spec.startswith("-"):
suffix = int(spec[1:])
start = max(0, size - suffix)
else:
first, _, last = spec.partition("-")
start = int(first)
if last.strip():
end = min(int(last), size - 1)
if 0 <= start <= end < size:
partial = True
else:
self._send(416, "range not satisfiable", extra={
"Content-Range": "bytes */%d" % size})
return
except ValueError:
pass
length = end - start + 1
self.send_response(206 if partial else 200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(length))
self.send_header("ETag", etag)
self.send_header("Last-Modified", _http_date(mtime))
self.send_header("Accept-Ranges", "bytes")
self.send_header("DAV", "1")
if partial:
self.send_header("Content-Range",
"bytes %d-%d/%d" % (start, end, size))
self.end_headers()
if self.command == "HEAD":
return
try:
with open(full, "rb") as fh:
fh.seek(start)
remaining = length
while remaining > 0:
chunk = fh.read(min(65536, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
except OSError:
pass
def _serve_dir_listing(self, full, uri):
try:
names = sorted(os.listdir(full))
except OSError:
self._error(404, "not found")
return
rows = []
for name in names:
if name == DavProps.FILENAME:
continue
child = os.path.join(full, name)
is_dir = os.path.isdir(child)
href = self.dav["store"].uri_of(child, is_dir)
label = name + ("/" if is_dir else "")
try:
size = "" if is_dir else str(os.path.getsize(child))
except OSError:
size = "?"
rows.append('<tr><td><a href="%s">%s</a></td><td>%s</td></tr>'
% (href, label, size))
html = ("<!DOCTYPE html><html><head><meta charset=utf-8>"
"<meta name=viewport content='width=device-width,initial-scale=1'>"
"<title>Index of %s</title>"
"<style>body{font-family:sans-serif;max-width:40em;margin:2em auto;"
"padding:0 1em}table{width:100%%;border-collapse:collapse}"
"td{padding:.3em .5em;border-bottom:1px solid #ddd}</style>"
"</head><body><h1>Index of %s</h1>"
"<table>%s</table></body></html>") % (uri, uri, "".join(rows))
self._send(200, html, "text/html; charset=utf-8",
extra={"ETag": self.dav["store"].etag(full) or ""})
def do_GET(self):
if not self._check_auth():
return
store = self.dav["store"] # type: DavStore
full = store.resolve(self.path)
if full is None:
self._error(403, "outside root")
return
if os.path.isdir(full):
uri = store.uri_of(full, True)
if not self.path.endswith("/"):
self.send_response(301)
self.send_header("Location", uri)
self.send_header("Content-Length", "0")
self.send_header("DAV", "1")
self.end_headers()
return
self._serve_dir_listing(full, uri)
elif os.path.exists(full):
self._serve_file(full, store.uri_of(full, False))
else:
self._error(404, "not found")
def do_HEAD(self):
self.do_GET()
# -- PUT / DELETE / MKCOL ---------------------------------------------
def _mutate_guards(self, full, token=None):
cfg = self.dav["config"] # type: DavConfig
if cfg.readonly:
self._error(403, "read-only server")
return False
if self.dav["locks"].locked(full, token):
self._error(423, "locked")
return False
return True
def do_PUT(self):
if not self._check_auth():
return
store = self.dav["store"] # type: DavStore
cfg = self.dav["config"] # type: DavConfig
full = store.resolve(self.path)
if full is None:
self._error(403, "outside root")
return
if os.path.isdir(full):
self._error(405, "is a collection")
return
token = self._if_token()
if not self._mutate_guards(full, token):
return
parent = os.path.dirname(full)
if not os.path.isdir(parent):
self._error(409, "parent missing")
return
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
self._error(400, "bad Content-Length")
return
if length < 0 or length > cfg.max_upload:
self._error(413, "upload too large (max %d bytes)" % cfg.max_upload)
return
existed = os.path.exists(full)
tmp = full + ".davtmp-%d" % os.getpid()
try:
remaining = length
with open(tmp, "wb") as fh:
while remaining > 0:
chunk = self.rfile.read(min(65536, remaining))
if not chunk:
break
fh.write(chunk)
remaining -= len(chunk)
os.replace(tmp, full)
except OSError:
try:
os.unlink(tmp)
except OSError:
pass
self._error(507, "insufficient storage")
return
self._send(204 if existed else 201, "",
extra={"ETag": store.etag(full) or ""})
def do_DELETE(self):
if not self._check_auth():
return
store = self.dav["store"] # type: DavStore
full = store.resolve(self.path)
if full is None:
self._error(403, "outside root")
return
if full == self.dav["config"].root:
self._error(403, "refusing to delete root")
return
if not os.path.exists(full):
self._error(404, "not found")
return
if not self._mutate_guards(full, self._if_token()):
return
try:
if os.path.isdir(full) and not os.path.islink(full):
shutil.rmtree(full)
else:
os.unlink(full)
except OSError:
self._error(507, "cannot delete")
return
self._send(204, "")
def do_MKCOL(self):
if not self._check_auth():
return
store = self.dav["store"] # type: DavStore
full = store.resolve(self.path)
if full is None:
self._error(403, "outside root")
return
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
length = 0
if length:
self._error(415, "MKCOL takes no body")
return
if os.path.exists(full):
self._error(405, "already exists")
return
if not self._mutate_guards(full, self._if_token()):
return
parent = os.path.dirname(full)
if not os.path.isdir(parent):
self._error(409, "parent missing")
return
try:
os.mkdir(full)
except OSError:
self._error(507, "cannot create collection")
return
self._send(201, "")
# -- PROPFIND / PROPPATCH ---------------------------------------------
def _live_props(self, full, uri, is_dir):
store = self.dav["store"] # type: DavStore
try:
st = os.stat(full)
except OSError:
return {}
name = posixpath.basename(uri.rstrip("/")) or "/"
props = {
_d("creationdate"): time.strftime("%Y-%m-%dT%H:%M:%SZ",
time.gmtime(st.st_ctime)),
_d("displayname"): name,
_d("getlastmodified"): _http_date(st.st_mtime),
_d("getetag"): store.etag(full) or "",
_d("supportedlock"): "<D:lockentry><D:lockscope><D:exclusive/>"
"</D:lockscope><D:locktype><D:write/>"
"</D:locktype></D:lockentry>",
}
if is_dir:
rt = ET.Element(_d("resourcetype"))
ET.SubElement(rt, _d("collection"))
props[_d("resourcetype")] = rt
else:
props[_d("resourcetype")] = ET.Element(_d("resourcetype"))
props[_d("getcontentlength")] = str(st.st_size)
props[_d("getcontenttype")] = store.content_type(full)
locks = self.dav["locks"].for_path(full)
if locks:
discs = []
for entry in locks:
discs.append(
"<D:activelock><D:locktype><D:write/></D:locktype>"
"<D:lockscope><D:exclusive/></D:lockscope>"
"<D:depth>%s</D:depth>"
"<D:locktoken><D:href>%s</D:href></D:locktoken>"
"</D:activelock>" % (entry["depth"], entry["token"]))
props[_d("lockdiscovery")] = "".join(discs)
else:
props[_d("lockdiscovery")] = ET.Element(_d("lockdiscovery"))
return props
def _prop_response(self, full, uri, mode, wanted):
is_dir = os.path.isdir(full)
live = self._live_props(full, uri, is_dir)
dead = self.dav["props"].get_all(uri)
resp = ET.Element(_d("response"))
href = ET.SubElement(resp, _d("href"))
href.text = uri
if mode == "propname":
propstat = ET.SubElement(resp, _d("propstat"))
prop = ET.SubElement(propstat, _d("prop"))
for qname in list(live) + list(dead):
ET.SubElement(prop, qname)
status = ET.SubElement(propstat, _d("status"))
status.text = "HTTP/1.1 200 OK"
return resp
if mode == "allprop":
names = [q for q in DavXml.ALLPROPS]
wanted_tags = [_d(n) for n in names] + list(dead)
else:
wanted_tags = [child.tag for child in wanted]
found = ET.Element(_d("prop"))
missing = ET.Element(_d("prop"))
for qname in wanted_tags:
if qname in live:
el = ET.SubElement(found, qname)
value = live[qname]
if isinstance(value, ET.Element):
for child in list(value):
el.append(child)
elif value.startswith("<"):
try:
el.append(ET.fromstring(
"<x xmlns:D='DAV:'>%s</x>" % value))
except ET.ParseError:
el.text = value
else:
el.text = value
elif qname in dead:
el = ET.SubElement(found, qname)
el.text = dead[qname]
else:
ET.SubElement(missing, qname)
if len(found):
propstat = ET.SubElement(resp, _d("propstat"))
propstat.append(found)
status = ET.SubElement(propstat, _d("status"))
status.text = "HTTP/1.1 200 OK"
if len(missing):
propstat = ET.SubElement(resp, _d("propstat"))
propstat.append(missing)
status = ET.SubElement(propstat, _d("status"))
status.text = "HTTP/1.1 404 Not Found"
return resp
def do_PROPFIND(self):
if not self._check_auth():
return
store = self.dav["store"] # type: DavStore
full = store.resolve(self.path)
if full is None:
self._error(403, "outside root")
return
if not os.path.exists(full):
self._error(404, "not found")
return
root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY)
if err:
self._error(err, "bad xml body")
return
mode, wanted = DavXml.propfind_mode(root)
depth = self.headers.get("Depth", "infinity").strip().lower()
if depth not in ("0", "1", "infinity"):
self._error(400, "bad Depth (want 0, 1, infinity)")
return
targets = [(full, store.uri_of(full, os.path.isdir(full)))]
if depth in ("1", "infinity") and os.path.isdir(full):
try:
names = sorted(os.listdir(full))
except OSError:
self._error(404, "not found")
return
for name in names:
if name == DavProps.FILENAME:
continue
child = os.path.join(full, name)
targets.append(
(child, store.uri_of(child, os.path.isdir(child))))
if len(targets) >= DavConfig.MAX_PROPFIND:
break
if depth == "infinity":
extra = []
for base, _ in list(targets[1:]):
if not os.path.isdir(base):
continue
for dirpath, dirnames, filenames in os.walk(base):
for name in sorted(dirnames) + sorted(filenames):
if name == DavProps.FILENAME:
continue
child = os.path.join(dirpath, name)
extra.append(
(child, store.uri_of(
child, os.path.isdir(child))))
if len(targets) + len(extra) >= DavConfig.MAX_PROPFIND:
break
if len(targets) + len(extra) >= DavConfig.MAX_PROPFIND:
break
targets.extend(extra[:DavConfig.MAX_PROPFIND - len(targets)])
multi = ET.Element(_d("multistatus"))
for target_full, uri in targets:
multi.append(self._prop_response(target_full, uri, mode, wanted))
body = (b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(
multi, encoding="utf-8"))
self._send(207, body, "application/xml; charset=utf-8")
def do_PROPPATCH(self):
if not self._check_auth():
return
store = self.dav["store"] # type: DavStore
full = store.resolve(self.path)
if full is None:
self._error(403, "outside root")
return
if not os.path.exists(full):
self._error(404, "not found")
return
if not self._mutate_guards(full, self._if_token()):
return
root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY)
if err or root is None:
self._error(err or 400, "bad xml body")
return
to_set, to_remove = DavXml.proppatch(root)
uri = store.uri_of(full, os.path.isdir(full))
self.dav["props"].patch(uri, to_set, to_remove)
multi = ET.Element(_d("multistatus"))
resp = ET.SubElement(multi, _d("response"))
href = ET.SubElement(resp, _d("href"))
href.text = uri
propstat = ET.SubElement(resp, _d("propstat"))
prop = ET.SubElement(propstat, _d("prop"))
for qname in list(to_set) + to_remove:
ET.SubElement(prop, qname)
status = ET.SubElement(propstat, _d("status"))
status.text = "HTTP/1.1 200 OK"
body = (b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(
multi, encoding="utf-8"))
self._send(207, body, "application/xml; charset=utf-8")
# -- COPY / MOVE -------------------------------------------------------
def _destination(self):
dest = self.headers.get("Destination", "")
if not dest:
return None
parsed = urlparse(dest)
return self.dav["store"].resolve(parsed.path or "/")
def _copy_move(self, is_move):
if not self._check_auth():
return
store = self.dav["store"] # type: DavStore
src = store.resolve(self.path)
dst = self._destination()
if src is None or dst is None:
self._error(403, "outside root")
return
if not os.path.exists(src):
self._error(404, "source not found")
return
if src == dst or dst.startswith(src + os.sep):
self._error(502, "bad gateway: overlapping paths")
return
if not self._mutate_guards(src, self._if_token()):
return
overwrite = self.headers.get("Overwrite", "T").strip().upper() != "F"
existed = os.path.exists(dst)
if existed and not overwrite:
self._error(412, "destination exists")
return
parent = os.path.dirname(dst)
if not os.path.isdir(parent):
self._error(409, "destination parent missing")
return
try:
if existed:
if os.path.isdir(dst) and not os.path.islink(dst):
shutil.rmtree(dst)
else:
os.unlink(dst)
if os.path.isdir(src) and not os.path.islink(src):
if not is_move and self.headers.get(
"Depth", "infinity").strip() == "0":
os.mkdir(dst)
else:
shutil.copytree(src, dst, symlinks=True)
else:
shutil.copy2(src, dst)
if is_move:
if os.path.isdir(src) and not os.path.islink(src):
shutil.rmtree(src)
else:
os.unlink(src)
except OSError:
self._error(507, "insufficient storage")
return
self._send(204 if existed else 201, "")
def do_COPY(self):
self._copy_move(False)
def do_MOVE(self):
self._copy_move(True)
# -- LOCK / UNLOCK -----------------------------------------------------
@staticmethod
def _parse_timeout(header):
if not header:
return DavConfig.LOCK_DEFAULT_TIMEOUT
header = header.strip().lower()
if header.startswith("second-"):
try:
value = int(header[len("second-"):])
except ValueError:
return DavConfig.LOCK_DEFAULT_TIMEOUT
return min(max(1, value), DavConfig.LOCK_MAX_TIMEOUT)
return DavConfig.LOCK_DEFAULT_TIMEOUT
def do_LOCK(self):
if not self._check_auth():
return
store = self.dav["store"] # type: DavStore
full = store.resolve(self.path)
if full is None:
self._error(403, "outside root")
return
if self.dav["config"].readonly:
self._error(403, "read-only server")
return
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
length = 0
if length == 0:
token = self._if_token()
if not token:
self._error(400, "lock refresh needs If-header with token")
return
entry = self.dav["locks"].refresh(
token, self._parse_timeout(self.headers.get("Timeout")))
if entry is None:
self._error(409, "unknown lock token")
return
self._send_lock_response(entry)
return
if not os.path.exists(full):
parent = os.path.dirname(full)
if not os.path.isdir(parent):
self._error(409, "parent missing")
return
try:
open(full, "wb").close()
except OSError:
self._error(507, "insufficient storage")
return
if self.dav["locks"].locked(full):
self._error(423, "locked")
return
root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY)
if err:
self._error(err, "bad xml body")
return
scope, owner = DavXml.lockinfo(root)
if scope != "exclusive":
self._error(422, "only exclusive locks supported")
return
depth = self.headers.get("Depth", "infinity").strip().lower()
if depth not in ("0", "infinity"):
depth = "infinity"
entry = self.dav["locks"].create(
full, depth, owner,
self._parse_timeout(self.headers.get("Timeout")))
self._send_lock_response(entry)
def _send_lock_response(self, entry):
prop = ET.Element(_d("prop"))
disco = ET.SubElement(prop, _d("lockdiscovery"))
active = ET.SubElement(disco, _d("activelock"))
ET.SubElement(ET.SubElement(active, _d("locktype")), _d("write"))
ET.SubElement(ET.SubElement(active, _d("lockscope")), _d("exclusive"))
depth = ET.SubElement(active, _d("depth"))
depth.text = entry["depth"]
owner = ET.SubElement(active, _d("owner"))
owner.text = entry["owner"]
token = ET.SubElement(active, _d("locktoken"))
href = ET.SubElement(token, _d("href"))
href.text = entry["token"]
body = (b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(
prop, encoding="utf-8"))
self._send(200, body, "application/xml; charset=utf-8",
extra={"Lock-Token": "<%s>" % entry["token"]})
def do_UNLOCK(self):
if not self._check_auth():
return
header = self.headers.get("Lock-Token", "").strip()
if header.startswith("<") and header.endswith(">"):
header = header[1:-1]
if not header or not self.dav["locks"].release(header):
self._error(409, "unknown lock token")
return
self._send(204, "")
class DavServer(socketserver.ThreadingMixIn, HTTPServer):
"""Thread-per-request HTTP server (3.6-safe ThreadingHTTPServer)."""
daemon_threads = True
allow_reuse_address = True
def build_server(host, port, config):
server = DavServer((host, port), DavHandler)
server.dav = {"config": config,
"store": DavStore(config),
"locks": DavLocks(),
"props": DavProps(config)}
return server
def main(argv=None):
parser = argparse.ArgumentParser(
description="Complete WebDAV Class 1 server (stdlib only).")
parser.add_argument("--root", default="./share",
help="directory to share (created if missing)")
parser.add_argument("--host", default="127.0.0.1",
help="bind host (default 127.0.0.1)")
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--user", default=None, help="Basic-auth user")
parser.add_argument("--password", default=None, help="Basic-auth password")
parser.add_argument("--max-upload-mb", type=int, default=200)
parser.add_argument("--readonly", action="store_true",
help="refuse all mutations")
args = parser.parse_args(argv)
try:
os.makedirs(args.root, exist_ok=True)
except OSError as exc:
parser.error("cannot create --root: %s" % exc)
config = DavConfig(args.root, user=args.user, password=args.password,
max_upload_mb=args.max_upload_mb,
readonly=args.readonly)
server = build_server(args.host, args.port, config)
print("webdav: serving %s on http://%s:%d/ (DAV class 1)%s" % (
config.root, args.host, args.port,
" [auth: %s]" % args.user if args.user else ""))
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()