From 6011ef1b9d9d0ef26c8a6ffbfefdf62d47063af6 Mon Sep 17 00:00:00 2001 From: David Walter Date: Mon, 15 Jun 2026 15:55:32 +0200 Subject: [PATCH] @uppy/companion: support oCIS public share links in the WebDAV provider Companion's WebDAV provider hard-coded the /public.php/webdav/ endpoint when resolving /s/ public share links. That works for Nextcloud and ownCloud 10, but oCIS (ownCloud Infinite Scale) serves public shares at /dav/public-files/, so importing an oCIS public link failed with "Invalid response: No root multistatus found". Probe the known public-share WebDAV endpoints and use the first that answers as WebDAV, caching the resolved endpoint on the session so later list/download requests don't re-probe. This fixes oCIS public links without changing behavior for Nextcloud / ownCloud 10. Fixes #6007. Addresses #6117. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: David Walter --- .changeset/ocis-webdav-public-links.md | 11 ++ .../src/server/provider/webdav/index.ts | 109 ++++++++++++--- packages/@uppy/companion/test/webdav.test.ts | 127 ++++++++++++++++++ 3 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 .changeset/ocis-webdav-public-links.md create mode 100644 packages/@uppy/companion/test/webdav.test.ts diff --git a/.changeset/ocis-webdav-public-links.md b/.changeset/ocis-webdav-public-links.md new file mode 100644 index 000000000..523beccaf --- /dev/null +++ b/.changeset/ocis-webdav-public-links.md @@ -0,0 +1,11 @@ +--- +"@uppy/companion": patch +--- + +@uppy/companion: support oCIS (ownCloud Infinite Scale) public share links in the WebDAV provider + +The WebDAV provider hard-coded the `/public.php/webdav/` endpoint when resolving `/s/` public share links. That works for Nextcloud and ownCloud 10, but oCIS serves public shares at `/dav/public-files/`, so importing an oCIS public link failed with `Invalid response: No root multistatus found`. + +Companion now probes the known public-share WebDAV endpoints and uses the first that responds as WebDAV, caching the resolved endpoint on the session. This fixes oCIS public links without affecting Nextcloud / ownCloud 10. + +Fixes #6007. diff --git a/packages/@uppy/companion/src/server/provider/webdav/index.ts b/packages/@uppy/companion/src/server/provider/webdav/index.ts index c932b9691..87ab0c11e 100644 --- a/packages/@uppy/companion/src/server/provider/webdav/index.ts +++ b/packages/@uppy/companion/src/server/provider/webdav/index.ts @@ -13,7 +13,15 @@ import Provider, { type Query } from '../Provider.js' const defaultDirectory = '/' -type WebdavUserSession = { webdavUrl?: string } +// Nextcloud/ownCloud/oCIS public share links look like https://host/s/ +const isPublicLinkUrl = (url: string): boolean => /\/s\/([^/]+)/.test(url) + +type WebdavUserSession = { + webdavUrl?: string + // For public share links, the actual WebDAV endpoint resolved from the share + // URL (see getPublicLinkClient). Cached so we only probe for it once. + publicLinkUrl?: string +} type WebdavClient = ReturnType type WebdavListItem = { @@ -59,21 +67,11 @@ export default class WebdavProvider extends Provider { throw new Error('invalid public link url') } - // Is this an ownCloud or Nextcloud public link URL? e.g. https://example.com/s/kFy9Lek5sm928xP - // they have specific urls that we can identify - // todo not sure if this is the right way to support nextcloud and other webdavs - if (/\/s\/([^/]+)/.test(webdavUrl)) { - const [baseURL, publicLinkToken] = webdavUrl.split('/s/') - if (!baseURL) { - throw new Error('invalid public link url') - } - - return this.getClientHelper({ - url: `${baseURL.replace('/index.php', '')}/public.php/webdav/`, - authType: AuthType.Password, - username: publicLinkToken!, - password: 'null', - }) + // Is this an ownCloud, Nextcloud or oCIS public link URL? + // e.g. https://example.com/s/kFy9Lek5sm928xP + if (isPublicLinkUrl(webdavUrl)) { + const { client } = await this.getPublicLinkClient({ providerUserSession }) + return client } // normal public WebDAV urls @@ -83,6 +81,74 @@ export default class WebdavProvider extends Provider { }) } + /** + * Build a WebDAV client for a public share link (e.g. https://host/s/). + * + * Servers expose public shares at different, non-discoverable WebDAV paths: + * - Nextcloud / ownCloud 10: /public.php/webdav/ + * - oCIS (ownCloud Infinite Scale): /dav/public-files/ + * + * There is no standard WebDAV (or reliably-shaped capabilities) way to discover + * which one a given server uses, so we probe the known candidates with a cheap + * PROPFIND and use the first that answers as WebDAV. Hitting the wrong endpoint + * returns an HTML page, which the webdav client rejects with + * "Invalid response: No root multistatus found", so we just move on to the next + * candidate. The resolved endpoint is cached on the session (`publicLinkUrl`) so + * we only probe once per share. + */ + async getPublicLinkClient({ + providerUserSession, + }: { + providerUserSession: WebdavUserSession + }): Promise<{ client: WebdavClient; url: string }> { + const { webdavUrl, publicLinkUrl } = providerUserSession + const [baseURL, publicLinkToken] = (webdavUrl ?? '').split('/s/') + if (!baseURL || !publicLinkToken) { + throw new Error('invalid public link url') + } + + const makeClient = (url: string) => + this.getClientHelper({ + url, + authType: AuthType.Password, + username: publicLinkToken, + password: 'null', + }) + + // Already resolved for this session: reuse it without probing again. + if (publicLinkUrl != null && publicLinkUrl.length > 0) { + return { client: await makeClient(publicLinkUrl), url: publicLinkUrl } + } + + const base = baseURL.replace('/index.php', '') + const candidateUrls = [ + `${base}/public.php/webdav/`, // Nextcloud, ownCloud 10 + `${base}/dav/public-files/${publicLinkToken}`, // oCIS (ownCloud Infinite Scale) + ] + + let lastErr: unknown + for (const url of candidateUrls) { + const client = await makeClient(url) + try { + // cheap depth-0 PROPFIND to verify this endpoint actually speaks WebDAV + await client.stat(defaultDirectory) + return { client, url } + } catch (err) { + lastErr = err + } + } + + // None answered as WebDAV. Fall back to the default endpoint so the caller + // surfaces a meaningful error instead of a probe artifact. + const errForLog = + lastErr instanceof Error ? lastErr : new Error(String(lastErr)) + logger.error(errForLog, 'provider.webdav.publicLink.unresolved') + return { + client: await makeClient(candidateUrls[0]!), + url: candidateUrls[0]!, + } + } + override async logout(): Promise<{ revoked: true }> { return { revoked: true } } @@ -103,7 +169,16 @@ export default class WebdavProvider extends Provider { const providerUserSession: WebdavUserSession = { webdavUrl } - const client = await this.getClient({ providerUserSession }) + let client: WebdavClient + if (isPublicLinkUrl(webdavUrl)) { + // Resolve the actual WebDAV endpoint now and cache it on the session so + // subsequent list/download requests don't have to probe again. + const resolved = await this.getPublicLinkClient({ providerUserSession }) + providerUserSession.publicLinkUrl = resolved.url + client = resolved.client + } else { + client = await this.getClient({ providerUserSession }) + } // call the list operation as a way to validate the url await client.getDirectoryContents(defaultDirectory) diff --git a/packages/@uppy/companion/test/webdav.test.ts b/packages/@uppy/companion/test/webdav.test.ts new file mode 100644 index 000000000..6a7d9112d --- /dev/null +++ b/packages/@uppy/companion/test/webdav.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import WebdavProvider from '../src/server/provider/webdav/index.js' + +// Build a WebdavProvider whose getClientHelper is stubbed so we never touch the +// network or the real `webdav` client. Each stubbed client's `stat()` succeeds +// only for URLs that `respondsAsWebdav` accepts; for any other URL it throws the +// same way the real client does when it hits a non-WebDAV (HTML) endpoint. +function setup(respondsAsWebdav: (url: string) => boolean) { + const provider = new WebdavProvider({ allowLocalUrls: true }) + const createdUrls: string[] = [] + + vi.spyOn(provider, 'getClientHelper').mockImplementation( + // @ts-expect-error returning a partial client is enough for these tests + async ({ url }: { url: string }) => { + createdUrls.push(url) + return { + stat: async () => { + if (respondsAsWebdav(url)) return { type: 'directory', size: 0 } + throw new Error('Invalid response: No root multistatus found') + }, + getDirectoryContents: async () => [], + createReadStream: () => undefined, + } + }, + ) + + return { provider, createdUrls } +} + +describe('WebdavProvider public share link resolution', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + test('uses /public.php/webdav/ for Nextcloud / ownCloud public links', async () => { + const { provider, createdUrls } = setup((url) => + url.includes('/public.php/webdav/'), + ) + + const { url } = await provider.getPublicLinkClient({ + providerUserSession: { + webdavUrl: 'https://nextcloud.example.com/s/TOKEN123', + }, + }) + + expect(url).toBe('https://nextcloud.example.com/public.php/webdav/') + // the first candidate works, so the oCIS endpoint is never probed + expect(createdUrls).not.toContain( + 'https://nextcloud.example.com/dav/public-files/TOKEN123', + ) + }) + + test('falls back to /dav/public-files/ for oCIS public links', async () => { + const { provider, createdUrls } = setup((url) => + url.includes('/dav/public-files/'), + ) + + const { url } = await provider.getPublicLinkClient({ + providerUserSession: { + webdavUrl: 'https://ocis.example.com/s/TOKEN123', + }, + }) + + expect(url).toBe('https://ocis.example.com/dav/public-files/TOKEN123') + // the ownCloud/Nextcloud path is probed first, then oCIS + expect(createdUrls).toEqual([ + 'https://ocis.example.com/public.php/webdav/', + 'https://ocis.example.com/dav/public-files/TOKEN123', + ]) + }) + + test('reuses the cached publicLinkUrl without probing again', async () => { + const { provider, createdUrls } = setup(() => true) + + const { url } = await provider.getPublicLinkClient({ + providerUserSession: { + webdavUrl: 'https://ocis.example.com/s/TOKEN123', + publicLinkUrl: 'https://ocis.example.com/dav/public-files/TOKEN123', + }, + }) + + expect(url).toBe('https://ocis.example.com/dav/public-files/TOKEN123') + expect(createdUrls).toEqual([ + 'https://ocis.example.com/dav/public-files/TOKEN123', + ]) + }) + + test('falls back to the default endpoint when no candidate responds as WebDAV', async () => { + const { provider, createdUrls } = setup(() => false) + + const { url } = await provider.getPublicLinkClient({ + providerUserSession: { + webdavUrl: 'https://unknown.example.com/s/TOKEN123', + }, + }) + + expect(url).toBe('https://unknown.example.com/public.php/webdav/') + expect(createdUrls).toEqual([ + 'https://unknown.example.com/public.php/webdav/', + 'https://unknown.example.com/dav/public-files/TOKEN123', + // final fall-back client for the default endpoint + 'https://unknown.example.com/public.php/webdav/', + ]) + }) + + test('strips a trailing /index.php from the base url', async () => { + const { provider } = setup((url) => url.includes('/public.php/webdav/')) + + const { url } = await provider.getPublicLinkClient({ + providerUserSession: { + webdavUrl: 'https://oc10.example.com/index.php/s/TOKEN123', + }, + }) + + expect(url).toBe('https://oc10.example.com/public.php/webdav/') + }) + + test('throws on a public link url without a token', async () => { + const { provider } = setup(() => true) + + await expect( + provider.getPublicLinkClient({ + providerUserSession: { webdavUrl: 'https://example.com/s/' }, + }), + ).rejects.toThrow('invalid public link url') + }) +})