diff --git a/src/app/webdav.ts b/src/app/webdav.ts index e2263a3..f4fc513 100644 --- a/src/app/webdav.ts +++ b/src/app/webdav.ts @@ -165,26 +165,72 @@ function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo { // ----- High-level operations ------------------------------------------- -/** List the contents of a collection. Returns a sorted array of entries. */ -export async function listDirectory(path: string): Promise { +/** + * List the contents of a collection. Returns a sorted array of entries. + * Pass `recursive: true` to request `Depth: infinity` from the server and + * return all descendants flattened — requires the server to support it + * (webdav3.py does after the depth-infinity fix). + */ +export async function listDirectory(path: string, recursive = false): Promise { const manager = getWebDAVClient(); const base = manager.client.baseUrl; - // The manager's `open` requires an absolute URL on the configured server. const collectionUrl = pathToUrl(normalizePath(path), base); - const listing = await manager.open(collectionUrl); const selfPath = normalizePath(path); + + // Parse the PROPFIND response directly rather than going through + // parsePropfindListing, which keys entries by display name and would + // silently drop any entries that share the same filename. Parsing + // the XML ourselves also avoids mutating the shared WebDAVManager + // navigation state (navToken, this.files). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const depth: any = recursive ? 'infinity' : 1; + const doc = await manager.client.propfind(collectionUrl, PROPFIND_LIST_BODY, depth); const entries: EntryInfo[] = []; - for (const e of Object.values(listing)) { - if (!e || !e.name) continue; - const info = toEntryInfo(e, base); - // Filter out the listing root itself (it appears as an entry whose - // path equals the requested collection) and any entries whose path - // doesn't sit directly under the requested collection. - if (info.path === selfPath) continue; - const parent = splitPath(info.path).parent; - if (parent !== selfPath) continue; - entries.push(info); + + for (const node of Array.from(doc.querySelectorAll('response'))) { + const hrefEl = node.querySelector('href'); + if (!hrefEl?.textContent) continue; + + // Find the first 200 propstat. + let propsNode: Element | null = null; + for (const propstat of Array.from(node.querySelectorAll('propstat'))) { + const status = propstat.querySelector('status'); + if (status && /200/.test(status.textContent ?? '')) { + propsNode = propstat; + break; + } + } + if (!propsNode) continue; + + const fullPath = pathFromUri(hrefEl.textContent, base); + + // Skip the collection itself. + if (fullPath === selfPath) continue; + + // Non-recursive: only include direct children. + if (!recursive && splitPath(fullPath).parent !== selfPath) continue; + + const isDir = !!node.querySelector('resourcetype collection'); + const displaynameEl = propsNode.querySelector('displayname'); + const lengthEl = propsNode.querySelector('getcontentlength'); + const mimeEl = propsNode.querySelector('getcontenttype'); + const modifiedEl = propsNode.querySelector('getlastmodified'); + + // Prefer server displayname unless it looks like a full path. + const nameFromPath = basename(fullPath) || fullPath; + const dn = displaynameEl?.textContent ?? ''; + const name = dn && !dn.includes('/') ? dn : nameFromPath; + + entries.push({ + name, + path: fullPath, + type: isDir ? 'folder' : 'file', + size: !isDir && lengthEl?.textContent ? parseInt(lengthEl.textContent, 10) : 0, + lastModified: modifiedEl?.textContent ? new Date(modifiedEl.textContent) : null, + contentType: !isDir && mimeEl?.textContent ? mimeEl.textContent : null, + }); } + // Folders first, then alpha (case-insensitive). entries.sort((a, b) => { if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; @@ -334,9 +380,11 @@ export async function getFileRange(path: string, start: number, end: number): Pr /** Convert a server-relative path to an absolute URL on the configured base. */ function pathToUrl(p: string, baseUrl: string): string { - // baseUrl may already end with `/`; we just want "". - const sep = baseUrl.endsWith('/') || p.startsWith('/') ? '' : '/'; - return baseUrl + sep + p; + // Encode each segment so spaces and special characters become valid URL + // components, while preserving the '/' separators. + const encoded = p.split('/').map(seg => seg ? encodeURIComponent(seg) : '').join('/'); + const sep = baseUrl.endsWith('/') || encoded.startsWith('/') ? '' : '/'; + return baseUrl + sep + encoded; } /** Convert a raw byte count to a short human string (e.g. "1.4 MB"). */ diff --git a/webdav3.py b/webdav3.py index 2e9e46c..86a708c 100644 --- a/webdav3.py +++ b/webdav3.py @@ -710,13 +710,25 @@ class DAVRequestHandler(BaseHTTPRequestHandler): if (wp in props) == False: w.write(' \n' % wp) else: - w.write(' %s\n' % (wp, str(props[wp]), wp)) + val = str(props[wp]) + if wp != 'resourcetype': + val = val.replace('&', '&').replace('<', '<').replace('>', '>') + w.write(' %s\n' % (wp, val, wp)) w.write('\nHTTP/1.1 200 OK\n\n\n') + def write_recursive(m): + write_props_member(w, m) + if m.type == Member.M_COLLECTION: + for child in m.getMembers(): + write_recursive(child) + write_props_member(w, elem) if depth == '1' and elem.type == Member.M_COLLECTION: for m in elem.getMembers(): - write_props_member(w,m) + write_props_member(w, m) + elif depth == 'infinity' and elem.type == Member.M_COLLECTION: + for m in elem.getMembers(): + write_recursive(m) w.write('') self.send_header('Content-Length', str(w.getSize())) self.end_headers() @@ -967,19 +979,23 @@ class DAVRequestHandler(BaseHTTPRequestHandler): class BufWriter: def __init__(self, w, debug=False): self.w = w - self.buf = '' + self.parts = [] + self._size = 0 self.debug = debug def write(self, s): - self.buf += s + b = s.encode('utf-8') + self.parts.append(b) + self._size += len(b) def flush(self): - if self.debug: print('<- XML:', self.buf) # sys.stderr.write(self.buf) - self.w.write(self.buf.encode('utf-8')) + data = b''.join(self.parts) + if self.debug: print('<- XML:', data.decode('utf-8', errors='replace')) + self.w.write(data) self.w.flush() def getSize(self): - return len(self.buf.encode('utf-8')) + return self._size class DAVServer(ThreadingMixIn, HTTPServer): def __init__(self, addr, handler, root, userpwd):