mirror of
https://github.com/transloadit/uppy.git
synced 2026-07-17 16:50:22 +00:00
@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:
parent
52cc4b3e5e
commit
6011ef1b9d
3 changed files with 230 additions and 17 deletions
11
.changeset/ocis-webdav-public-links.md
Normal file
11
.changeset/ocis-webdav-public-links.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
127
packages/@uppy/companion/test/webdav.test.ts
Normal file
127
packages/@uppy/companion/test/webdav.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue