Tofu/app/services/webdav/webdavRequest.js

68 lines
2.9 KiB
JavaScript

// The single network boundary of the whole project.
//
// NOTHING else in Tofu calls fetch: the WebDAV client, the diagnostics and the
// planning store all go through this function. One file to read to know how the
// app talks to the outside, how it authenticates, and what it does when that
// fails.
import { describeWebdavFailure } from '#tofu/tools/webdav/describeWebdavFailure.js'
// fetch was rejected before any response existed: there is no HTTP status at
// all, and 0 is the usual way to say so.
const NO_STATUS = 0
// Sends one WebDAV request. Never throws.
// -> { ok: true, status, text, responseHeaders }
// -> { ok: false, failure: { kind, message, hint, status } }
export async function webdavRequest({ url, method, headers = {}, body, authorization }) {
const requestHeaders = { ...headers }
if (authorization) requestHeaders.Authorization = authorization
try {
// `credentials: 'omit'` is a deliberate choice, and it is the reason a page
// served from another origin can reach a Nextcloud at all.
//
// We build the `Authorization: Basic …` header ourselves, so the request
// stays "non credentialed" in the CORS sense. The browser then accepts
// `Access-Control-Allow-Origin: *` from the server. With
// `credentials: 'include'` the very same request would become credentialed:
// the wildcard would be rejected, and the server would have to echo our
// exact origin AND send `Access-Control-Allow-Credentials: true` — which a
// shared Nextcloud almost never does.
//
// The trade-off is explicit: the password stays in memory and is sent by
// us on every request, instead of being held by the browser in a session
// cookie. Nothing is persisted anywhere.
const response = await fetch(url, {
method,
mode: 'cors',
credentials: 'omit',
headers: requestHeaders,
body
})
// Read the body before branching: a 4xx from Nextcloud carries an XML
// explanation that is worth surfacing, and a consumed body cannot be read
// twice.
const text = await response.text()
if (!response.ok) {
const failure = describeWebdavFailure({ error: null, response, method })
return { ok: false, failure: { ...failure, status: response.status } }
}
return {
ok: true,
status: response.status,
text,
responseHeaders: Object.fromEntries(response.headers)
}
} catch (error) {
// A rejected fetch towards a third-party origin is almost always CORS. The
// browser hides which one it was on purpose: a blocked request and an
// unreachable server produce the exact same TypeError, with no detail. So
// describeWebdavFailure says both, honestly, instead of asserting one.
const failure = describeWebdavFailure({ error, response: null, method })
return { ok: false, failure: { ...failure, status: NO_STATUS } }
}
}