@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/<token> public share links. That works for Nextcloud and
ownCloud 10, but oCIS (ownCloud Infinite Scale) serves public shares at
/dav/public-files/<token>, 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) <noreply@anthropic.com>
Signed-off-by: David Walter <david.walter@kiteworks.com>
This commit is contained in:
David Walter 2026-06-15 15:55:32 +02:00
parent 52cc4b3e5e
commit 6011ef1b9d
No known key found for this signature in database
GPG key ID: 90CCF130D75586F0
3 changed files with 230 additions and 17 deletions

View file

@ -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/<token>` public share links. That works for Nextcloud and ownCloud 10, but oCIS serves public shares at `/dav/public-files/<token>`, 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.

View file

@ -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/<token>
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<typeof createClient>
type WebdavListItem = {
@ -59,21 +67,11 @@ export default class WebdavProvider extends Provider<WebdavUserSession> {
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<WebdavUserSession> {
})
}
/**
* Build a WebDAV client for a public share link (e.g. https://host/s/<token>).
*
* Servers expose public shares at different, non-discoverable WebDAV paths:
* - Nextcloud / ownCloud 10: <base>/public.php/webdav/
* - oCIS (ownCloud Infinite Scale): <base>/dav/public-files/<token>
*
* 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<WebdavUserSession> {
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)

View file

@ -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/<token> 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')
})
})