Skip to content

Commit

Permalink
Add @uppy/webdav (#5551)
Browse files Browse the repository at this point in the history
Co-authored-by: Dominik Schmidt <[email protected]>
  • Loading branch information
Murderlon and dschmidt authored Dec 17, 2024
1 parent 060734b commit 9164ad5
Show file tree
Hide file tree
Showing 19 changed files with 681 additions and 15 deletions.
1 change: 1 addition & 0 deletions packages/@uppy/companion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"supports-color": "8.x",
"tus-js-client": "^4.1.0",
"validator": "^13.0.0",
"webdav": "5.7.1",
"ws": "8.17.1"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/@uppy/companion/src/server/controllers/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ async function get (req, res) {
const { provider } = req.companion

async function getSize () {
return provider.size({ id, token: accessToken, query: req.query })
return provider.size({ id, token: accessToken, providerUserSession, query: req.query })
}

const download = () => provider.download({ id, token: accessToken, providerUserSession, query: req.query })
Expand Down
3 changes: 2 additions & 1 deletion packages/@uppy/companion/src/server/provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const instagram = require('./instagram/graph')
const facebook = require('./facebook')
const onedrive = require('./onedrive')
const unsplash = require('./unsplash')
const webdav = require('./webdav')
const zoom = require('./zoom')
const { getURLBuilder } = require('../helpers/utils')
const logger = require('../logger')
Expand Down Expand Up @@ -68,7 +69,7 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => {
* @returns {Record<string, typeof Provider>}
*/
module.exports.getDefaultProviders = () => {
const providers = { dropbox, box, drive: Drive, googlephotos, facebook, onedrive, zoom, instagram, unsplash }
const providers = { dropbox, box, drive: Drive, googlephotos, facebook, onedrive, zoom, instagram, unsplash, webdav }

return providers
}
Expand Down
182 changes: 182 additions & 0 deletions packages/@uppy/companion/src/server/provider/webdav/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@

const Provider = require('../Provider')
const { getProtectedHttpAgent, validateURL } = require('../../helpers/request')
const { ProviderApiError, ProviderAuthError } = require('../error')
const { ProviderUserError } = require('../error')
const logger = require('../../logger')

const defaultDirectory = '/'

/**
* Adapter for WebDAV servers that support simple auth (non-OAuth).
*/
class WebdavProvider extends Provider {
static get hasSimpleAuth () {
return true
}

// eslint-disable-next-line class-methods-use-this
isAuthenticated ({ providerUserSession }) {
return providerUserSession.webdavUrl != null
}

async getClient ({ providerUserSession }) {
const webdavUrl = providerUserSession?.webdavUrl
const { allowLocalUrls } = this
if (!validateURL(webdavUrl, allowLocalUrls)) {
throw new Error('invalid public link url')
}

// dynamic import because Companion currently uses CommonJS and webdav is shipped as ESM
// todo implement as regular require as soon as Node 20.17 or 22 is required
// or as regular import when Companion is ported to ESM
const { AuthType } = await import('webdav') // eslint-disable-line import/no-unresolved

// 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/')

return this.getClientHelper({
url: `${baseURL.replace('/index.php', '')}/public.php/webdav/`,
authType: AuthType.Password,
username: publicLinkToken,
password: 'null',
})
}

// normal public WebDAV urls
return this.getClientHelper({
url: webdavUrl,
authType: AuthType.None,
})
}

async logout () { // eslint-disable-line class-methods-use-this
return { revoked: true }
}

async simpleAuth ({ requestBody }) {
try {
const providerUserSession = { webdavUrl: requestBody.form.webdavUrl }

const client = await this.getClient({ providerUserSession })
// call the list operation as a way to validate the url
await client.getDirectoryContents(defaultDirectory)

return providerUserSession
} catch (err) {
logger.error(err, 'provider.webdav.error')
if (['ECONNREFUSED', 'ENOTFOUND'].includes(err.code)) {
throw new ProviderUserError({ message: 'Cannot connect to server' })
}
// todo report back to the user what actually went wrong
throw err
}
}

async getClientHelper ({ url, ...options }) {
const { allowLocalUrls } = this
if (!validateURL(url, allowLocalUrls)) {
throw new Error('invalid webdav url')
}
const { protocol } = new URL(url)
const HttpAgentClass = getProtectedHttpAgent({ protocol, allowLocalIPs: !allowLocalUrls })

// dynamic import because Companion currently uses CommonJS and webdav is shipped as ESM
// todo implement as regular require as soon as Node 20.17 or 22 is required
// or as regular import when Companion is ported to ESM
const { createClient } = await import('webdav')
return createClient(url, {
...options,
[`${protocol}Agent`] : new HttpAgentClass(),
})
}

async list ({ directory, providerUserSession }) {
return this.withErrorHandling('provider.webdav.list.error', async () => {
// @ts-ignore
if (!this.isAuthenticated({ providerUserSession })) {
throw new ProviderAuthError()
}

const data = { items: [] }
const client = await this.getClient({ providerUserSession })

/** @type {any} */
const dir = await client.getDirectoryContents(directory || '/')

dir.forEach(item => {
const isFolder = item.type === 'directory'
const requestPath = encodeURIComponent(`${directory || ''}/${item.basename}`)

let modifiedDate
try {
modifiedDate = new Date(item.lastmod).toISOString()
} catch (e) {
// ignore invalid date from server
}

data.items.push({
isFolder,
id: requestPath,
name: item.basename,
modifiedDate,
requestPath,
...(!isFolder && {
mimeType: item.mime,
size: item.size,
thumbnail: null,

}),
})
})

return data
})
}

async download ({ id, providerUserSession }) {
return this.withErrorHandling('provider.webdav.download.error', async () => {
const client = await this.getClient({ providerUserSession })
const stream = client.createReadStream(`/${id}`)
return { stream }
})
}

// eslint-disable-next-line
async thumbnail ({ id, providerUserSession }) {
// not implementing this because a public thumbnail from webdav will be used instead
logger.error('call to thumbnail is not implemented', 'provider.webdav.thumbnail.error')
throw new Error('call to thumbnail is not implemented')
}

// eslint-disable-next-line
async size ({ id, token, providerUserSession }) {
return this.withErrorHandling('provider.webdav.size.error', async () => {
const client = await this.getClient({ providerUserSession })

/** @type {any} */
const stat = await client.stat(id)
return stat.size
})
}

// eslint-disable-next-line class-methods-use-this
async withErrorHandling (tag, fn) {
try {
return await fn()
} catch (err) {
let err2 = err
if (err.status === 401) err2 = new ProviderAuthError()
if (err.response) {
err2 = new ProviderApiError('WebDAV API error', err.status) // todo improve (read err?.response?.body readable stream and parse response)
}
logger.error(err2, tag)
throw err2
}
}
}

module.exports = WebdavProvider
4 changes: 4 additions & 0 deletions packages/@uppy/locales/src/en_US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ en_US.strings = {
aspectRatioPortrait: 'Crop portrait (9:16)',
aspectRatioSquare: 'Crop square',
authAborted: 'Authentication aborted',
authenticate: 'Connect',
authenticateWith: 'Connect to %{pluginName}',
authenticateWithTitle:
'Please authenticate with %{pluginName} to select files',
Expand Down Expand Up @@ -148,7 +149,10 @@ en_US.strings = {
pluginNameScreenCapture: 'Screencast',
pluginNameUnsplash: 'Unsplash',
pluginNameUrl: 'Link',
pluginNameWebdav: 'WebDAV',
pluginNameZoom: 'Zoom',
pluginWebdavInputLabel:
'WebDAV URL for a file (e.g. from ownCloud or Nextcloud)',
poweredBy: 'Powered by %{uppy}',
processingXFiles: {
'0': 'Processing %{smart_count} file',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export default function GooglePickerView({
}
pluginIcon={pickerType === 'drive' ? GoogleDriveIcon : GooglePhotosIcon}
handleAuth={showPicker}
i18n={uppy.i18nArray}
i18n={uppy.i18n}
loading={loading}
/>
)
Expand Down
12 changes: 5 additions & 7 deletions packages/@uppy/provider-views/src/ProviderView/AuthView.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { h } from 'preact'
import { useCallback } from 'preact/hooks'
import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
import type Translator from '@uppy/utils/lib/Translator'
import type { I18n } from '@uppy/utils/lib/Translator'
import type { Opts } from './ProviderView.ts'
import type ProviderViews from './ProviderView.ts'

type AuthViewProps<M extends Meta, B extends Body> = {
loading: boolean | string
pluginName: string
pluginIcon: () => h.JSX.Element
i18n: Translator['translateArray']
i18n: I18n
handleAuth: ProviderViews<M, B>['handleAuth']
renderForm?: Opts<M, B>['renderAuthForm']
}
Expand Down Expand Up @@ -56,7 +56,7 @@ function DefaultForm<M extends Meta, B extends Body>({
onAuth,
}: {
pluginName: string
i18n: Translator['translateArray']
i18n: I18n
onAuth: AuthViewProps<M, B>['handleAuth']
}) {
// In order to comply with Google's brand we need to create a different button
Expand Down Expand Up @@ -100,7 +100,7 @@ const defaultRenderForm = ({
onAuth,
}: {
pluginName: string
i18n: Translator['translateArray']
i18n: I18n
onAuth: AuthViewProps<Meta, Body>['handleAuth']
}) => <DefaultForm pluginName={pluginName} i18n={i18n} onAuth={onAuth} />

Expand All @@ -121,9 +121,7 @@ export default function AuthView<M extends Meta, B extends Body>({
})}
</div>

<div className="uppy-Provider-authForm">
{renderForm({ pluginName, i18n, loading, onAuth: handleAuth })}
</div>
{renderForm({ pluginName, i18n, loading, onAuth: handleAuth })}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import type {
} from '@uppy/core/lib/Uppy.js'
import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
import type Translator from '@uppy/utils/lib/Translator'
import classNames from 'classnames'
import type { ValidateableFile } from '@uppy/core/lib/Restricter.js'
import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
import type { I18n } from '@uppy/utils/lib/Translator'
import AuthView from './AuthView.tsx'
import Header from './Header.tsx'
import Browser from '../Browser.tsx'
Expand Down Expand Up @@ -75,7 +75,7 @@ export interface Opts<M extends Meta, B extends Body> {
loadAllFiles: boolean
renderAuthForm?: (args: {
pluginName: string
i18n: Translator['translateArray']
i18n: I18n
loading: boolean | string
onAuth: (authFormData: unknown) => Promise<void>
}) => h.JSX.Element
Expand Down Expand Up @@ -434,7 +434,7 @@ export default class ProviderView<M extends Meta, B extends Body> {
pluginName={this.plugin.title}
pluginIcon={pluginIcon}
handleAuth={this.handleAuth}
i18n={this.plugin.uppy.i18nArray}
i18n={this.plugin.uppy.i18n}
renderForm={opts.renderAuthForm}
loading={loading}
/>
Expand Down
2 changes: 2 additions & 0 deletions packages/@uppy/provider-views/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export {

export { default as SearchProviderViews } from './SearchProviderView/index.ts'

export { default as SearchInput } from './SearchInput.tsx'

export { default as GooglePickerView } from './GooglePicker/GooglePickerView.tsx'
35 changes: 35 additions & 0 deletions packages/@uppy/webdav/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@uppy/webdav",
"description": "Import files from WebDAV into Uppy.",
"version": "0.1.0",
"license": "MIT",
"main": "lib/index.js",
"types": "types/index.d.ts",
"type": "module",
"keywords": [
"file uploader",
"uppy",
"uppy-plugin",
"webdav",
"provider",
"photos",
"videos"
],
"homepage": "https://uppy.io",
"bugs": {
"url": "https://github.com/transloadit/uppy/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/transloadit/uppy.git"
},
"dependencies": {
"@uppy/companion-client": "workspace:^",
"@uppy/provider-views": "workspace:^",
"@uppy/utils": "workspace:^",
"preact": "^10.5.13"
},
"peerDependencies": {
"@uppy/core": "workspace:^"
}
}
Loading

0 comments on commit 9164ad5

Please sign in to comment.