460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|