1052 lines
38 KiB
Python
Executable File
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()
|
|
|