Tofu/app/services/webdav/createWebdavClient.js

93 lines
3.5 KiB
JavaScript

// The WebDAV vocabulary of the app, expressed over webdavRequest.
//
// Callers speak in paths relative to the user root ('Tofu/plannings/2026-W38.json')
// and get result objects back — never an exception, never a Response. The URL
// shape, the HTTP verbs and the Basic header stay here.
import { webdavRequest } from '#tofu/services/webdav/webdavRequest.js'
import { buildBasicAuthHeader } from '#tofu/tools/webdav/buildBasicAuthHeader.js'
import { buildDavUrl } from '#tofu/tools/webdav/buildDavUrl.js'
import { describeWebdavFailure } from '#tofu/tools/webdav/describeWebdavFailure.js'
import { parsePropfindXml } from '#tofu/tools/webdav/parsePropfindXml.js'
import { PROPFIND_BODY } from '#tofu/const/PROPFIND_BODY.js'
// MKCOL on a collection that already exists answers 405. For ensureFolder that
// is the goal reached, not a failure.
const FOLDER_ALREADY_EXISTS = 405
const XML_CONTENT_TYPE = 'application/xml; charset=utf-8'
const JSON_CONTENT_TYPE = 'application/json'
export function createWebdavClient({ serverUrl, username, password, folder }) {
// Built once: the password is read here and never leaves this closure.
const authorization = buildBasicAuthHeader(username, password)
function urlFor(path) {
return buildDavUrl({ serverUrl, username, path })
}
// Everything the UI may display about the target — deliberately without the
// password.
function describeTarget() {
return { serverUrl, username, folder, baseUrl: urlFor('') }
}
async function listFolder(path) {
const result = await webdavRequest({
url: urlFor(path),
method: 'PROPFIND',
headers: { Depth: '1', 'Content-Type': XML_CONTENT_TYPE },
body: PROPFIND_BODY,
authorization
})
if (!result.ok) return result
return { ok: true, entries: parsePropfindXml(result.text, path) }
}
async function readJson(path) {
const result = await webdavRequest({
url: urlFor(path),
method: 'GET',
headers: { Accept: JSON_CONTENT_TYPE },
authorization
})
// A 404 already arrives as failure.kind === 'notFound': a file that was
// never written is a normal case, the caller decides what it means.
if (!result.ok) return result
try {
return { ok: true, data: JSON.parse(result.text) }
} catch (error) {
// The transport worked, the content did not. JSON.parse throws a
// SyntaxError, which describeWebdavFailure reports as kind 'parse'.
const failure = describeWebdavFailure({ error, response: null, method: 'GET' })
return { ok: false, failure: { ...failure, status: result.status } }
}
}
async function writeJson(path, value) {
const result = await webdavRequest({
url: urlFor(path),
method: 'PUT',
headers: { 'Content-Type': JSON_CONTENT_TYPE },
body: JSON.stringify(value, null, 2),
authorization
})
if (!result.ok) return result
return { ok: true }
}
// MKCOL creates one level at a time: the caller creates parents first.
async function ensureFolder(path) {
const result = await webdavRequest({ url: urlFor(path), method: 'MKCOL', authorization })
if (result.ok || result.failure.status === FOLDER_ALREADY_EXISTS) return { ok: true }
return result
}
async function remove(path) {
const result = await webdavRequest({ url: urlFor(path), method: 'DELETE', authorization })
if (!result.ok) return result
return { ok: true }
}
return { describeTarget, listFolder, readJson, writeJson, ensureFolder, remove }
}