diff --git a/src/main.ts b/src/main.ts index bf73d51..bced8c4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,9 @@ import { readFileSync } from "node:fs"; import { ConfigStore, paths } from "./config.ts"; import { runService, servicePid } from "./service.ts"; +import { discoverRoot, withScheme } from "./sync/discover.ts"; import { syncFolder } from "./sync/engine.ts"; +import { listCollections } from "./sync/remote.ts"; import { StateStore } from "./sync/state.ts"; import type { Action, FolderConfig } from "./sync/types.ts"; @@ -14,6 +16,9 @@ usage: davsync [folder...] sync [folder...] sync once (all enabled folders, or the ones named) and exit plan [folder...] show what a sync would do, without doing it status list folders and how their last sync went + discover [user] + find the WebDAV root behind a server's name, and say how + (the password is read from DAVSYNC_PASSWORD, or asked for) version print the version Folders are named by name or id. Configuration: ${paths.config} @@ -52,6 +57,8 @@ async function main(command: string, args: string[]): Promise { return syncOnce(args, command === "plan"); case "status": return status(); + case "discover": + return discover(args); case "version": case "--version": console.log(`davsync ${VERSION}`); @@ -116,6 +123,51 @@ async function status(): Promise { return 0; } +async function discover([typed, username = ""]: string[]): Promise { + if (!typed) throw new Error("usage: davsync discover [user]"); + const password = username ? (process.env.DAVSYNC_PASSWORD ?? (await askHidden(`Password for ${username}: `))) : ""; + try { + const found = await discoverRoot({ serverUrl: withScheme(typed), username, password }); + for (const step of found.steps) console.log(` ${step}`); + console.log(`WebDAV root: ${found.serverUrl}`); + const listing = await listCollections({ serverUrl: found.serverUrl, username, password }, ""); + console.log(`Folders in it: ${listing.folders.map((folder) => folder.label).join(", ") || "(none)"}`); + return 0; + } catch (error) { + console.error((error as Error).message); + return 1; + } +} + +/** Reads a line from the terminal without echoing it. */ +function askHidden(prompt: string): Promise { + return new Promise((resolve, reject) => { + if (!process.stdin.isTTY) return reject(new Error("no terminal to ask for the password on; set DAVSYNC_PASSWORD")); + process.stderr.write(prompt); + process.stdin.setRawMode(true); + process.stdin.resume(); + let entered = ""; + const onData = (chunk: Buffer) => { + for (const char of chunk.toString("utf8")) { + if (char === "\r" || char === "\n") { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdin.off("data", onData); + process.stderr.write("\n"); + return resolve(entered); + } + if (char === "\u0003") { + process.stdin.setRawMode(false); + return reject(new Error("cancelled")); + } + if (char === "\u007f") entered = entered.slice(0, -1); + else entered += char; + } + }; + process.stdin.on("data", onData); + }); +} + function describe(action: Action): string { switch (action.type) { case "move": diff --git a/src/sync/discover.ts b/src/sync/discover.ts index 3392479..d02dc2b 100644 --- a/src/sync/discover.ts +++ b/src/sync/discover.ts @@ -1,5 +1,4 @@ import { DavClient, DavHttpError, type FetchLike } from "@ordner.fyi/dav-client"; -import type { QName } from "@ordner.fyi/dav-core"; import { DAV } from "@ordner.fyi/dav-core/wire"; import { checkServerUrl } from "../config.ts"; import { explain, type ServerOptions } from "./remote.ts"; @@ -11,28 +10,47 @@ export interface Discovered { steps: string[]; } -/** Only what tells a collection apart: asking for more (current-user-principal) makes some servers refuse anonymous probes. */ -const PROBE_PROPS: QName[] = [{ ns: DAV, name: "resourcetype" }]; +/** + * Where common servers keep WebDAV, tried when nothing better is known. The + * origin's own root comes after all of them: some servers answer it with an + * empty collection, which would read as a root with nothing in it. + */ +const KNOWN_PATHS = [ + "/remote.php/dav/", // Nextcloud, ownCloud + "/servlet/webdav.infostore/", // Open-Xchange (mailbox.org) + "/remote.php/webdav/", // Nextcloud's and ownCloud's older address: the user's files directly + "/seafdav/", // Seafile + "/dav/", + "/webdav/", +]; + +const CURRENT_USER_PRINCIPAL = `{${DAV}}current-user-principal`; + +/** Refused logins after which discovery stops trying further addresses. */ +const MAX_REFUSALS = 2; /** How many redirects `.well-known/webdav` may chain before we stop following. */ const MAX_HOPS = 5; /** - * Finds the WebDAV root behind what a person typed: `cloud.example.com`, the - * provider's documented address, or a full URL. + * Finds the WebDAV root behind what a person typed: `dav.mailbox.org`, the + * provider's documented address, or a full URL. Tried in order, the first + * WebDAV collection wins: * - * 1. A path that was typed is tried first. It is a deliberate answer, and wins - * whenever it is a WebDAV collection. - * 2. Otherwise `/.well-known/webdav` (what Nextcloud and ownCloud answer, by - * analogy with RFC 6764's caldav and carddav), asked without credentials, - * its redirects read rather than followed, so we learn where it points. - * 3. Otherwise the origin's root. + * 1. a path that was typed: a deliberate answer; + * 2. where `/.well-known/webdav` points (asked without credentials first, and + * with them if the server will not say anything before a login; redirects + * are read, not followed, so we learn where they point); + * 3. where common servers keep WebDAV (KNOWN_PATHS); + * 4. the origin's root. * - * Whatever answers PROPFIND as a collection is the DAV root, at the URL it - * actually answered at (after any redirects). Below it, the user's own files - * home (`files//`, the Nextcloud/ownCloud layout) is used when the server - * has one, so browsing starts among the user's files rather than among - * principals, calendars and address books. + * The root is taken from where PROPFIND answered after redirects. Below it, the + * user's own files home is used when there is one (`files//`, as + * Nextcloud and ownCloud lay it out; the id comes from current-user-principal, + * since it is not always what was typed to log in). + * + * Credentials only go over https (plain http only to this machine), and never + * follow a redirect to another origin. */ export async function discoverRoot(server: ServerOptions): Promise { const typed = withScheme(server.serverUrl.trim()); @@ -42,42 +60,57 @@ export async function discoverRoot(server: ServerOptions): Promise { const candidates: string[] = []; if (base.pathname !== "/") candidates.push(withSlash(base.href)); - const known = await wellKnown(base, fetcher, steps); + const known = await wellKnown(base, server, fetcher, steps); if (known) candidates.push(known); + for (const path of KNOWN_PATHS) candidates.push(new URL(path, base.origin).href); candidates.push(`${base.origin}/`); let root: string | undefined; + let principal: string | undefined; let lastError: unknown; - let refused = false; + let refused = 0; for (const candidate of [...new Set(candidates)]) { + // With credentials, a server authenticates before it looks at the path, so + // refusals mean a wrong password; trying on would only add failed logins, + // which providers count towards locking the account. + if (server.username && refused >= MAX_REFUSALS) break; + const url = secure(candidate, base); + if (!url) { + steps.push(`${candidate}: not tried, it is plain http`); + continue; + } try { - root = await probe({ ...server, serverUrl: candidate }, fetcher); - steps.push(`${candidate}: a WebDAV collection${root !== candidate ? `, at ${root}` : ""}`); + ({ root, principal } = await probe({ ...server, serverUrl: url }, fetcher)); + steps.push(`${url}: a WebDAV collection${root !== url ? `, at ${root}` : ""}`); break; } catch (error) { // A 401 is not proof of a wrong password: servers answer it at addresses // that are not WebDAV at all. It is the answer only if nothing else is. - if (error instanceof DavHttpError && error.status === 401) refused = true; - steps.push(`${candidate}: ${explain(error, candidate, "").message}`); + const login = error instanceof DavHttpError && error.status === 401; + if (login) refused++; + steps.push(`${url}: ${login && !server.username ? "asks for a login" : explain(error, url, "").message}`); lastError = error; } } - if (!root && refused) { - throw new Error(server.username ? "The server did not accept this user name and password." : "The server asks for a user name and password."); - } if (!root) { + const tried = `\n\nTried:\n${steps.map((step) => `• ${step}`).join("\n")}`; + if (refused) { + throw new Error((server.username ? "The server did not accept this user name and password." : "The server asks for a user name and password.") + tried); + } const reason = lastError === undefined ? "" : ` ${explain(lastError, base.href, "").message}`; - throw new Error(`No WebDAV server found at ${base.host}.${reason}`); + throw new Error(`No WebDAV server found at ${base.host}.${reason}${tried}`); } - if (server.username) { - const home = new URL(`files/${encodeURIComponent(server.username)}/`, root).href; + // The user's own files, where the server keeps them below the root. + const ids = [principalId(principal), server.username].filter((id): id is string => Boolean(id)); + for (const id of [...new Set(ids)]) { + const home = new URL(`files/${encodeURIComponent(id)}/`, root).href; try { - const answered = await probe({ ...server, serverUrl: home }, fetcher); + const found = await probe({ ...server, serverUrl: home }, fetcher); steps.push(`${home}: the user's files`); - return { serverUrl: answered, steps }; + return { serverUrl: found.root, steps }; } catch { - steps.push(`no files/${server.username}/ below it`); + steps.push(`no files/${id}/ below it`); } } return { serverUrl: root, steps }; @@ -85,9 +118,9 @@ export async function discoverRoot(server: ServerOptions): Promise { /** * PROPFIND (Depth: 0) on a URL; resolves with the URL that answered, after any - * redirects, when it is a collection. Throws otherwise. + * redirects, when it is a collection, and the current-user-principal it reports. */ -async function probe(server: ServerOptions, fetcher: FetchLike): Promise { +async function probe(server: ServerOptions, fetcher: FetchLike): Promise<{ root: string; principal: string | undefined }> { let answeredAt = server.serverUrl; const client = new DavClient({ baseUrl: server.serverUrl, @@ -100,25 +133,39 @@ async function probe(server: ServerOptions, fetcher: FetchLike): Promise userAgent: "davsync", timeoutMs: 20_000, }); - const [self] = await client.propfind("", { depth: "0", props: PROBE_PROPS }); + const [self] = await client.propfind("", { depth: "0", props: [{ ns: DAV, name: "resourcetype" }] }); if (!self?.resource?.collection) throw new Error("not a WebDAV collection"); - // Wherever redirects led, a collection's own address ends in "/". const url = new URL(answeredAt); - // Credentials are never sent over plain http to anywhere but this machine. checkServerUrl(url.href); - return withSlash(`${url.origin}${url.pathname}`); + // Asked on its own: some servers refuse a whole PROPFIND over this one property. + let principal: string | undefined; + if (server.username) { + const answer = await client.propfind("", { depth: "0", props: [{ ns: DAV, name: "current-user-principal" }] }).catch(() => []); + const value = answer[0]?.resource?.properties?.get(CURRENT_USER_PRINCIPAL); + principal = value?.kind === "href" ? value.hrefs[0] : undefined; + } + // Wherever redirects led, a collection's own address ends in "/". + return { root: withSlash(`${url.origin}${url.pathname}`), principal }; } -/** Where `/.well-known/webdav` points, if anywhere. Asked without credentials. */ -async function wellKnown(base: URL, fetcher: FetchLike, steps: string[]): Promise { +/** + * Where `/.well-known/webdav` points, if anywhere: a redirect's target, or the + * address itself when it answers as WebDAV. Asked without credentials; if the + * server wants a login before it says anything (Open-Xchange does), asked again + * with them, on the typed origin only. + */ +async function wellKnown(base: URL, server: ServerOptions, fetcher: FetchLike, steps: string[]): Promise { let url = new URL("/.well-known/webdav", base.origin).href; - for (let hop = 0; hop < MAX_HOPS; hop++) { + let authorized = false; + for (let hop = 0; hop <= MAX_HOPS; ) { let response: Response; try { + const headers: Record = { depth: "0", "content-type": 'application/xml; charset="utf-8"' }; + if (authorized) headers.authorization = `Basic ${Buffer.from(`${server.username}:${server.password}`, "utf8").toString("base64")}`; response = await fetcher(url, { method: "PROPFIND", redirect: "manual", - headers: { depth: "0", "content-type": 'application/xml; charset="utf-8"' }, + headers, body: '', signal: AbortSignal.timeout(20_000), }); @@ -127,23 +174,60 @@ async function wellKnown(base: URL, fetcher: FetchLike, steps: string[]): Promis return undefined; } await response.body?.cancel().catch(() => {}); - // 207, or 401 asking for credentials: the well-known address is itself the root. - if (response.status === 207 || response.status === 401) { - steps.push(`/.well-known/webdav answered ${response.status}`); + if (response.status === 207) { + steps.push(`/.well-known/webdav answered as WebDAV`); return withSlash(url); } + if (response.status === 401 && !authorized && server.username && new URL(url).origin === base.origin && secure(url, base)) { + authorized = true; + continue; + } const location = response.headers.get("location"); if (response.status < 300 || response.status >= 400 || !location) { - steps.push(`/.well-known/webdav: HTTP ${response.status}`); + steps.push(`/.well-known/webdav: HTTP ${response.status}${authorized ? " (logged in)" : ""}`); return undefined; } const next = new URL(location, url).href; steps.push(`/.well-known/webdav → ${next}`); + // Credentials do not follow a redirect to another origin. + if (new URL(next).origin !== new URL(url).origin) authorized = false; url = next; + hop++; } return withSlash(url); } +/** + * The URL to send credentials to, or null if it must not get them: plain http + * is only for this machine. A server that redirects its own https address to + * http (a common proxy misconfiguration) is asked over https instead. + */ +function secure(candidate: string, typed: URL): string | null { + const url = new URL(candidate); + if (url.protocol === "http:" && typed.protocol === "https:" && url.hostname === typed.hostname) { + url.protocol = "https:"; + url.port = typed.port; + } + try { + checkServerUrl(url.href); + return url.href; + } catch { + return null; + } +} + +/** The user id at the end of a principal URL like /remote.php/dav/principals/users//. */ +function principalId(principal: string | undefined): string | undefined { + if (!principal) return undefined; + const match = /\/principals\/(?:users\/)?([^/]+)\/?$/.exec(principal); + if (!match) return undefined; + try { + return decodeURIComponent(match[1]!); + } catch { + return match[1]; + } +} + /** A bare host gets https:// (http:// for this machine, which may not speak TLS). */ export function withScheme(typed: string): string { if (/^[a-z][a-z0-9+.-]*:\/\//i.test(typed)) return typed; diff --git a/test/discover.test.ts b/test/discover.test.ts index dbc130a..a0bffd3 100644 --- a/test/discover.test.ts +++ b/test/discover.test.ts @@ -76,9 +76,8 @@ test("a wrong password is said so, not searched around", async () => { await assert.rejects(discoverRoot({ serverUrl: cloud, username: "alice", password: "wrong" }), /did not accept this user name and password/); }); -test("without .well-known, a bare host finds nothing, and a typed path is used as given", async () => { - // dav-core answers 401 at its unserved root, as many servers do. - await assert.rejects(discoverRoot({ serverUrl: bare, username: "", password: "" }), /asks for a user name and password|No WebDAV server found/); +test("without .well-known, a bare host is found where servers usually keep WebDAV, and a typed path is used as given", async () => { + assert.equal((await discoverRoot({ serverUrl: bare, username: "", password: "" })).serverUrl, `http://${bare}/dav/`); const found = await discoverRoot({ serverUrl: `${bare}/dav/shared`, username: "", password: "" }); assert.equal(found.serverUrl, `http://${bare}/dav/shared/`); }); @@ -94,3 +93,94 @@ test("a bare host means https, except on this machine", () => { assert.equal(withScheme("[::1]:8080/dav"), "http://[::1]:8080/dav"); assert.equal(withScheme("https://x.example/"), "https://x.example/"); }); + +// ---- Servers as they are out there ----------------------------------------------- + +/** Wraps dav-core in a front that answers some paths itself and requires a login for the rest. */ +async function server(options: { + hrefs: string[]; + principal?: string; + front: (url: string, authorized: boolean) => { status: number; location?: string } | undefined; + login: string; +}): Promise<{ host: string; requests: string[]; close: () => void }> { + const requests: string[] = []; + const dav = nodeListener( + createDavEngine({ + provider: memoryProvider(options.hrefs.map(collection)), + ...(options.principal ? { principal: () => options.principal } : {}), + }), + ); + const expected = `Basic ${Buffer.from(options.login).toString("base64")}`; + const http = createServer((req, res) => { + const authorized = req.headers.authorization === expected; + requests.push(`${req.method} ${req.url}${req.headers.authorization ? (authorized ? " (logged in)" : " (wrong login)") : ""}`); + const answer = options.front(req.url ?? "/", authorized); + if (answer) return void res.writeHead(answer.status, answer.location ? { location: answer.location } : {}).end(); + if (!authorized) return void res.writeHead(401, { "www-authenticate": 'Basic realm="dav"' }).end(); + dav(req, res); + }); + const host = await listen(http); + return { + host, + requests, + close: () => { + http.close(); + http.closeAllConnections(); + }, + }; +} + +test("Open-Xchange (mailbox.org): a login before anything, no .well-known, files under /servlet/webdav.infostore/", async () => { + const ox = await server({ + // An empty collection at the root, which must not be taken for the files. + hrefs: ["/", "/servlet/webdav.infostore/", "/servlet/webdav.infostore/Userstore/", "/servlet/webdav.infostore/Public files/"], + login: "me@mailbox.org:secret", + front: (url, authorized) => (!authorized ? { status: 401 } : url === "/.well-known/webdav" ? { status: 404 } : undefined), + }); + try { + const found = await discoverRoot({ serverUrl: ox.host, username: "me@mailbox.org", password: "secret" }); + assert.equal(found.serverUrl, `http://${ox.host}/servlet/webdav.infostore/`); + } finally { + ox.close(); + } +}); + +test("Nextcloud behind nginx, logged in with an email address: found at /remote.php/dav/, files under the user id", async () => { + const cloud = await server({ + hrefs: ["/remote.php/dav/", "/remote.php/dav/files/", "/remote.php/dav/files/alice/", "/remote.php/dav/principals/"], + principal: "/remote.php/dav/principals/users/alice/", + login: "alice@example.com:secret", + front: (url) => + url === "/.well-known/webdav" + ? { status: 301, location: "/index.php/.well-known/webdav" } + : url.startsWith("/index.php") + ? { status: 404 } + : url === "/" + ? { status: 302, location: "/login" } + : url === "/login" + ? { status: 200 } + : undefined, + }); + try { + const found = await discoverRoot({ serverUrl: cloud.host, username: "alice@example.com", password: "secret" }); + assert.equal(found.serverUrl, `http://${cloud.host}/remote.php/dav/files/alice/`); + } finally { + cloud.close(); + } +}); + +test("a wrong password is tried at no more than two addresses", async () => { + const ox = await server({ + hrefs: ["/servlet/webdav.infostore/"], + login: "me@mailbox.org:secret", + front: (_url, authorized) => (!authorized ? { status: 401 } : undefined), + }); + try { + await assert.rejects(discoverRoot({ serverUrl: ox.host, username: "me@mailbox.org", password: "typo" }), /did not accept/); + const wrongLogins = ox.requests.filter((request) => request.endsWith("(wrong login)")); + // .well-known once with the login, then two addresses. + assert.ok(wrongLogins.length <= 3, ox.requests.join("\n")); + } finally { + ox.close(); + } +}); diff --git a/web/styles.css b/web/styles.css index fb07798..e97d141 100644 --- a/web/styles.css +++ b/web/styles.css @@ -510,6 +510,7 @@ table.grid { .status { margin: 0; min-height: 1.4em; + white-space: pre-line; } .status.ok {