Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @uppy/webdav #5551

Merged
merged 14 commits into from
Dec 17, 2024
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
8 changes: 8 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 @@ -106,6 +107,7 @@ en_US.strings = {
inferiorSize: 'This file is smaller than the allowed size of %{size}',
loadedXFiles: 'Loaded %{numFiles} files',
loading: 'Loading...',
logIn: 'Log in',
logOut: 'Log out',
micDisabled: 'Microphone access denied by user',
missingRequiredMetaField: 'Missing required meta fields',
Expand All @@ -131,6 +133,9 @@ en_US.strings = {
pause: 'Pause',
paused: 'Paused',
pauseUpload: 'Pause upload',
pickFiles: 'Pick files',
pickPhotos: 'Pick photos',
pleaseWait: 'Please wait',
pluginNameAudio: 'Audio',
pluginNameBox: 'Box',
pluginNameCamera: 'Camera',
Expand All @@ -143,7 +148,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 @@ -202,7 +202,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
Loading