Files
CloudFlare-ImgBed/functions/utils/storage/webdavAPI.js
T

241 lines
7.2 KiB
JavaScript

/**
* WebDAV API helper
*
* Uses only Fetch/Web APIs so it works in Cloudflare Pages Functions,
* Cloudflare Workers, and the local Node-based test/runtime paths.
*/
export class WebDAVAPI {
constructor(config = {}) {
const baseUrl = config.baseUrl;
if (!baseUrl) {
throw new Error('WebDAV baseUrl is required');
}
this.baseUrl = normalizeBaseUrl(baseUrl);
this.username = config.username || '';
this.password = config.password || '';
this.headers = normalizeHeaders(config.headers || {});
this.createDirectory = config.createDirectory !== false;
}
buildObjectUrl(path) {
return buildWebDAVUrl(this.baseUrl, path);
}
buildPublicUrl(path, publicUrl = '') {
if (!publicUrl) return '';
return buildWebDAVUrl(normalizeBaseUrl(publicUrl), path);
}
getRequestHeaders(extraHeaders = {}) {
const headers = new Headers(this.headers);
if ((this.username || this.password) && !headers.has('Authorization')) {
headers.set('Authorization', `Basic ${base64EncodeUtf8(`${this.username}:${this.password}`)}`);
}
for (const [key, value] of Object.entries(extraHeaders || {})) {
if (value !== undefined && value !== null && value !== '') {
headers.set(key, value);
}
}
return headers;
}
async ensureDirectory(path) {
if (!this.createDirectory) return;
const dirParts = getDirectoryParts(path);
if (dirParts.length === 0) return;
let currentPath = '';
for (const part of dirParts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const response = await fetch(this.buildObjectUrl(currentPath), {
method: 'MKCOL',
headers: this.getRequestHeaders(),
redirect: 'manual',
});
// 405 commonly means the collection already exists. Some servers return 200/204.
if (![200, 201, 204, 405].includes(response.status)) {
throw new Error(`WebDAV MKCOL failed for ${currentPath}: ${response.status} ${response.statusText}`);
}
}
}
async putFile(path, body, contentType = '') {
await this.ensureDirectory(path);
const headers = this.getRequestHeaders(contentType ? { 'Content-Type': contentType } : {});
const response = await fetch(this.buildObjectUrl(path), {
method: 'PUT',
headers,
body,
redirect: 'manual',
});
if (!isSuccessStatus(response.status)) {
const detail = await safeReadResponseText(response);
throw new Error(`WebDAV PUT failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`);
}
return response;
}
async getFile(path, options = {}) {
const response = await fetch(this.buildObjectUrl(path), {
method: options.method || 'GET',
headers: this.getRequestHeaders(options.headers || {}),
redirect: 'manual',
});
if (!isSuccessStatus(response.status) && response.status !== 304) {
const detail = await safeReadResponseText(response);
throw new Error(`WebDAV ${options.method || 'GET'} failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`);
}
return response;
}
async moveFile(oldPath, newPath, overwrite = true) {
await this.ensureDirectory(newPath);
const response = await fetch(this.buildObjectUrl(oldPath), {
method: 'MOVE',
headers: this.getRequestHeaders({
Destination: this.buildObjectUrl(newPath),
Overwrite: overwrite ? 'T' : 'F',
}),
redirect: 'manual',
});
if (!isSuccessStatus(response.status)) {
const detail = await safeReadResponseText(response);
throw new Error(`WebDAV MOVE failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`);
}
return true;
}
async deleteFile(path) {
const response = await fetch(this.buildObjectUrl(path), {
method: 'DELETE',
headers: this.getRequestHeaders(),
redirect: 'manual',
});
// DELETE is idempotent for app semantics; a missing remote object should not block DB cleanup.
if (response.status === 404) return true;
if (!isSuccessStatus(response.status)) {
const detail = await safeReadResponseText(response);
throw new Error(`WebDAV DELETE failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`);
}
return true;
}
}
export function normalizeBaseUrl(baseUrl) {
const normalized = String(baseUrl || '').trim();
if (!normalized) {
throw new Error('WebDAV baseUrl is required');
}
const url = new URL(normalized);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('WebDAV baseUrl must use http or https');
}
if (!url.pathname.endsWith('/')) {
url.pathname = `${url.pathname}/`;
}
return url.toString();
}
export function buildWebDAVUrl(baseUrl, path) {
const cleanPath = String(path || '')
.replace(/^\/+/, '')
.split('/')
.filter(Boolean)
.map(encodeURIComponent)
.join('/');
return new URL(cleanPath, normalizeBaseUrl(baseUrl)).toString();
}
export function normalizeWebDAVHeaders(headers) {
return normalizeHeaders(headers);
}
function getDirectoryParts(path) {
const parts = String(path || '').replace(/^\/+/, '').split('/').filter(Boolean);
parts.pop();
return parts;
}
function normalizeHeaders(headers) {
if (!headers) return {};
if (typeof headers === 'string') {
try {
const parsed = JSON.parse(headers);
return normalizeHeaders(parsed);
} catch {
return {};
}
}
if (headers instanceof Headers) {
const result = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
if (typeof headers === 'object' && !Array.isArray(headers)) {
const result = {};
for (const [key, value] of Object.entries(headers)) {
if (value !== undefined && value !== null && value !== '') {
result[key] = String(value);
}
}
return result;
}
return {};
}
function isSuccessStatus(status) {
return status >= 200 && status < 300;
}
async function safeReadResponseText(response) {
try {
const text = await response.text();
return text.slice(0, 500);
} catch {
return '';
}
}
function base64EncodeUtf8(value) {
const bytes = new TextEncoder().encode(value);
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
if (typeof btoa === 'function') {
return btoa(binary);
}
// Node-based local tests/runtimes.
return Buffer.from(value, 'utf8').toString('base64');
}