meatloaf-config/src/app/webdav.ts

460 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* WebDAV integration for the Meatloaf Manipulator UI.
*
* Protocol layer is provided by the vendored `webdav-component`
* (see src/app/vendor/webdav-component). We deliberately do NOT use
* its `WebDAVUI` — the React components in this app render their own
* file browser, search, and directory listings.
*
* Public surface (kept stable for our own React components):
* getWebDAVClient() lazy WebDAVManager (high-level state + actions)
* getWebDAVBaseUrl() the server base URL we're talking to
* listDirectory(p) returns EntryInfo[] for a collection
* fileExists(p) boolean
* createFolder(p) create a new (possibly nested) collection
* deletePath(p) delete a file or (empty) collection
* movePath(from, to) MOVE
* putFileContents(p, data) upload
* getFileContents(p) download as Blob
* humanFileSize(n) "1.4 MB" formatter
* normalizePath / splitPath / joinPath / basename path helpers
*/
import {
WebDAVManager,
PROPFIND_LIST_BODY,
type WebDAVEntry,
} from './vendor/webdav-component/esm/index.js';
export type { WebDAVEntry };
// ----- Connection settings ----------------------------------------------
const DEFAULT_BASE_URL =
(typeof import.meta !== 'undefined' &&
(import.meta as any).env?.VITE_WEBDAV_URL) ||
(typeof window !== 'undefined'
? `${window.location.protocol}//${window.location.hostname}`
: 'http://localhost');
let _manager: WebDAVManager | null = null;
export function getWebDAVBaseUrl(): string {
return DEFAULT_BASE_URL;
}
export function getWebDAVClient(): WebDAVManager {
if (!_manager) {
_manager = new WebDAVManager({
url: DEFAULT_BASE_URL,
});
}
return _manager;
}
// ----- Path helpers (kept stable for our React UI) --------------------
/** Normalize a path so it always starts with `/` and has no trailing `/`. */
export function normalizePath(p: string): string {
if (!p) return '/';
let s = p.trim();
if (!s.startsWith('/')) s = '/' + s;
s = s.replace(/\/+/g, '/');
if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
return s || '/';
}
/** Split a path into parent + name. */
export function splitPath(p: string): { parent: string; name: string } {
const n = normalizePath(p);
if (n === '/') return { parent: '/', name: '' };
const idx = n.lastIndexOf('/');
if (idx === 0) return { parent: '/', name: n.slice(1) };
return { parent: n.slice(0, idx), name: n.slice(idx + 1) };
}
/** Join a parent path and a child name. */
export function joinPath(parent: string, name: string): string {
if (parent === '/' || parent === '') return '/' + name;
return (parent.endsWith('/') ? parent : parent + '/') + name;
}
/** Last segment of a path. */
export function basename(p: string): string {
return splitPath(p).name;
}
// ----- Mapping webdav-component entries to our UI's EntryInfo -----------
export type EntryType = 'folder' | 'file';
export interface EntryInfo {
name: string;
/** Absolute path on the WebDAV server (always begins with `/`). */
path: string;
type: EntryType;
/** Bytes, or 0 for collections. */
size: number;
lastModified: Date | null;
contentType: string | null;
}
/**
* Build an absolute WebDAV path (with leading `/`) for a relative name
* under a given collection URL. The vendored `WebDAVManager` operates on
* full URLs, so we keep URL-relative work in `manager.open()` and
* path-relative work everywhere else.
*/
function pathFromUri(uri: string, baseUrl: string): string {
let p: string;
try {
// Absolute URL → take just the pathname.
if (/^https?:\/\//i.test(uri)) {
const u = new URL(uri);
p = u.pathname;
} else {
p = uri;
}
} catch {
p = uri;
}
// Strip a host prefix if the server added one (e.g. lighttpd).
try {
const host = new URL(baseUrl).hostname;
if (p.startsWith('/' + host + '/')) p = p.slice(host.length + 1);
} catch {
/* ignore */
}
// Always URL-decode so the rest of the app sees literal characters
// (e.g. spaces) instead of %20 in names and paths.
try {
p = decodeURIComponent(p);
} catch {
/* leave as-is on malformed encoding */
}
return normalizePath(p);
}
function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo {
// The vendored `webdav-component` computes `entry.path` by stripping
// `baseUrl.length` from the href — that only works when the server
// sends absolute hrefs (e.g. `http://localhost/sd/foo`). When the
// server sends relative hrefs (`/sd/foo`), the strip chops off the
// start of the path. Use `entry.uri` directly instead, which the
// parser sets verbatim from the href and is always reliable.
const rawPath = (e.uri && e.uri.length > 0 ? e.uri : e.path) || '';
const fullPath = pathFromUri(rawPath, baseUrl);
// Prefer the server-provided displayname when it looks like a leaf
// (no slashes). Some servers (notably older webdav3.py) used to
// return the full path as the displayname; in that case fall back to
// the leaf of the resolved path.
const looksLikePath = (s: string) => s.includes('/');
const name =
(e.name && !looksLikePath(e.name) ? e.name : '') ||
basename(fullPath) ||
e.name;
return {
name,
path: fullPath,
type: e.isDir ? 'folder' : 'file',
size: typeof e.size === 'number' ? e.size : 0,
lastModified: e.modified ?? null,
contentType: e.mime ?? null,
};
}
// ----- High-level operations -------------------------------------------
/**
* 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,
onProgress?: (bytes: number) => void,
): Promise<EntryInfo[]> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
const collectionUrl = pathToUrl(normalizePath(path), base);
const selfPath = normalizePath(path);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const depth: any = recursive ? 'infinity' : 1;
let doc: Document;
if (onProgress) {
const response = await fetch(collectionUrl, {
method: 'PROPFIND',
headers: { 'Depth': String(depth), 'Content-Type': 'text/xml; charset=utf-8' },
body: PROPFIND_LIST_BODY,
});
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.byteLength;
onProgress(received);
}
const combined = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
doc = new DOMParser().parseFromString(new TextDecoder().decode(combined), 'text/xml');
} else {
doc = await manager.client.propfind(collectionUrl, PROPFIND_LIST_BODY, depth);
}
const entries: EntryInfo[] = [];
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;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
return entries;
}
export async function fileExists(path: string): Promise<boolean> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
return manager.client.exists(pathToUrl(normalizePath(path), base));
}
/**
* Probe a path via a depth-0 PROPFIND to determine whether it is a file
* or a folder, and return its parsed `WebDAVEntry`. Returns `null` if the
* path does not exist (or the server returns 404).
*/
export async function stat(path: string): Promise<EntryInfo | null> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
const url = pathToUrl(normalizePath(path), base);
try {
const doc = await manager.client.propfind(url, PROPFIND_LIST_BODY, 0);
const resp = doc.querySelector('response');
if (!resp) return null;
const isDir = !!doc.querySelector('resourcetype collection');
// We re-use the same path-resolution logic as `toEntryInfo` to keep
// naming and path handling consistent.
const href = doc.querySelector('href')?.textContent ?? url;
const fakeEntry = {
uri: href,
url: href,
path: href,
name: doc.querySelector('displayname')?.textContent ?? '',
isDir,
size: null,
mime: null,
modified: null,
permissions: null,
} as unknown as WebDAVEntry;
return toEntryInfo(fakeEntry, base);
} catch {
return null;
}
}
export async function createFolder(path: string, _recursive = true): Promise<void> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
// The vendored client only does flat MKCOL; emulate "recursive" by
// walking up and creating each missing parent first.
const target = normalizePath(path);
const parts = target.split('/').filter(Boolean);
let built = '';
for (const part of parts) {
built += '/' + part;
if (await fileExists(built)) continue;
const r = await manager.client.mkcol(pathToUrl(built, base));
if (!r.ok && r.status !== 405 /* already exists */) {
throw new Error(`MKCOL ${built} failed: ${r.status} ${r.statusText}`);
}
}
}
export async function deletePath(path: string): Promise<void> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
const r = await manager.client.delete(pathToUrl(normalizePath(path), base));
if (!r.ok && r.status !== 204 && r.status !== 200) {
throw new Error(`DELETE failed: ${r.status} ${r.statusText}`);
}
}
export async function movePath(from: string, to: string): Promise<void> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
await manager.client.copymove(
'MOVE',
pathToUrl(normalizePath(from), base),
pathToUrl(normalizePath(to), base),
true,
);
}
export async function copyPath(from: string, to: string): Promise<void> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
await manager.client.copymove(
'COPY',
pathToUrl(normalizePath(from), base),
pathToUrl(normalizePath(to), base),
true,
);
}
export async function putFileContents(
path: string,
data: string | ArrayBuffer | Uint8Array | Blob,
): Promise<void> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
const url = pathToUrl(normalizePath(path), base);
// The vendored client expects a `BodyInit`. Convert our variants to one.
let body: BodyInit;
if (typeof data === 'string') body = data;
else if (data instanceof Blob) body = data;
else if (data instanceof Uint8Array) {
body = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
} else {
body = data;
}
const r = await manager.client.put(url, body);
if (!r.ok) {
throw new Error(`PUT failed: ${r.status} ${r.statusText}`);
}
}
export async function getFileContents(path: string): Promise<Blob> {
const manager = getWebDAVClient();
const base = manager.client.baseUrl;
const r = await manager.client.get(pathToUrl(normalizePath(path), base));
if (!r.ok) {
throw new Error(`GET failed: ${r.status} ${r.statusText}`);
}
return r.blob();
}
/**
* Fetch a byte range of a file using HTTP Range requests.
* Returns a Blob containing the requested bytes. Falls back to a full
* fetch if the server responds 200 (no Range support).
*/
export async function getFileRange(path: string, start: number, end: number): Promise<Blob> {
const base = getWebDAVClient().client.baseUrl;
const url = pathToUrl(normalizePath(path), base);
const r = await fetch(url, { headers: { Range: `bytes=${start}-${end}` } });
if (r.status === 206 || r.status === 200) return r.blob();
throw new Error(`GET range failed: ${r.status} ${r.statusText}`);
}
// ----- Helpers --------------------------------------------------------
/** Convert a server-relative path to an absolute URL on the configured base. */
function pathToUrl(p: string, baseUrl: string): string {
// 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"). */
export function humanFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—';
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
}
export type FetchProgress = { received: number; total: number };
export async function clearDirectory(path: string): Promise<void> {
try {
const entries = await listDirectory(path);
await Promise.all(entries.filter(e => e.type === 'file').map(e => deletePath(e.path)));
} catch {
// Directory may not exist yet — ignore
}
}
export async function streamFetch(
res: Response,
onProgress: (p: FetchProgress) => void,
): Promise<ArrayBuffer> {
if (!res.body) return res.arrayBuffer();
const total = +(res.headers.get('content-length') ?? 0);
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.byteLength;
onProgress({ received, total });
}
const out = new Uint8Array(received);
let off = 0;
for (const c of chunks) { out.set(c, off); off += c.byteLength; }
return out.buffer;
}