#!/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: ().""" 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('%s%s' % (href, label, size)) html = ("" "" "Index of %s" "" "

Index of %s

" "%s
") % (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"): "" "" "", } 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( "" "" "%s" "%s" "" % (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( "%s" % 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'\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'\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'\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()