Skip to content

Commit

Permalink
feat(webdav): initial implementation for a WebDAV simple auth provider
Browse files Browse the repository at this point in the history
  • Loading branch information
dschmidt committed Nov 28, 2024
1 parent 44a378a commit 52e2882
Show file tree
Hide file tree
Showing 12 changed files with 579 additions and 4 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, googlephotos, facebook, onedrive, zoom, instagram, unsplash }
const providers = { dropbox, box, drive, googlephotos, facebook, onedrive, zoom, instagram, unsplash, webdav }

return providers
}
Expand Down
123 changes: 123 additions & 0 deletions packages/@uppy/companion/src/server/provider/webdav/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const Provider = require('../Provider')
const logger = require('../../logger')
const { getProtectedHttpAgent, validateURL } = require('../../helpers/request')
const { ProviderApiError, ProviderAuthError } = require('../error')

/**
* WebdavProvider base class provides implementations that could be shared by simple and oauth providers
*/
class WebdavProvider extends Provider {
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, blockLocalIPs: !allowLocalUrls })

const { createClient } = await import('webdav') // eslint-disable-line import/no-unresolved
return createClient(url, {
...options,
[`${protocol}Agent`] : new HttpAgentClass(),
})
}

async getClient ({ username, token, providerUserSession }) { // eslint-disable-line no-unused-vars,class-methods-use-this
logger.error('call to getUsername is not implemented', 'provider.webdav.getUsername.error')
throw new Error('call to getUsername is not implemented')
// todo: use @returns to specify the return type
return this.getClientHelper() // eslint-disable-line
}

async getUsername ({ token, providerUserSession }) { // eslint-disable-line no-unused-vars,class-methods-use-this
logger.error('call to getUsername is not implemented', 'provider.webdav.getUsername.error')
throw new Error('call to getUsername is not implemented')
}

/** @protected */
// eslint-disable-next-line class-methods-use-this
isAuthenticated () {
throw new Error('Not implemented')
}

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

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

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

dir.forEach(item => {
const isFolder = item.type === 'directory'
const requestPath = encodeURIComponent(`${directory || ''}/${item.basename}`)
data.items.push({
isFolder,
id: requestPath,
name: item.basename,
requestPath, // TODO FIXME
modifiedDate: item.lastmod, // TODO FIXME: convert 'Tue, 04 Jul 2023 13:09:47 GMT' to ISO 8601
...(!isFolder && {
mimeType: item.mime,
size: item.size,
thumbnail: null,

}),
})
})

return data
})
}

async download ({ id, token, providerUserSession }) {
return this.withErrorHandling('provider.webdav.download.error', async () => {
// maybe we can avoid this by putting the username in front of the request path/id
const username = await this.getUsername({ token, providerUserSession })
const client = await this.getClient({ username, token, 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')
}

// todo fixme implement
// eslint-disable-next-line
async size ({ id, token, providerUserSession }) {
return this.withErrorHandling('provider.webdav.size.error', async () => {
const username = await this.getUsername({ token, providerUserSession })
const client = await this.getClient({ username, token, providerUserSession })
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
80 changes: 80 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,80 @@

const { validateURL } = require('../../helpers/request')
const WebdavProvider = require('./common')
const { ProviderUserError } = require('../error')
const logger = require('../../logger')

const defaultDirectory = '/'

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

async getUsername () { // eslint-disable-line class-methods-use-this
return null
}

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

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
}
}
}

module.exports = WebdavSimpleAuthProvider
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": "3.1.1",
"license": "MIT",
"main": "lib/index.js",
"types": "types/index.d.ts",
"type": "module",
"keywords": [
"file uploader",
"uppy",
"uppy-plugin",
"instagram",
"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:^"
}
}
117 changes: 117 additions & 0 deletions packages/@uppy/webdav/src/Webdav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { h } from 'preact'
import { useCallback, useState } from 'preact/hooks'

import { UIPlugin } from '@uppy/core'
import { Provider, tokenStorage } from '@uppy/companion-client'
import { ProviderViews } from '@uppy/provider-views'

import packageJson from '../package.json'
import locale from './locale.ts'

class WebdavSimpleAuthProvider extends Provider {
async login({ authFormData, uppyVersions, signal }) {
return this.loginSimpleAuth({ uppyVersions, authFormData, signal })
}

async logout() {
this.removeAuthToken()
return { ok: true, revoked: true }
}
}

const AuthForm = ({ loading, i18n, onAuth }) => {
const [webdavUrl, setWebdavUrl] = useState('')

const onSubmit = useCallback(
(e) => {
e.preventDefault()
onAuth({ webdavUrl: webdavUrl.trim() })
},
[onAuth, webdavUrl],
)

return (
<form onSubmit={onSubmit}>
<label htmlFor="uppy-Provider-publicLinkURL">
<span style={{ display: 'block' }}>{i18n('publicLinkURLLabel')}</span>
<input
id="uppy-Provider-publicLinkURL"
name="webdavUrl"
type="text"
value={webdavUrl}
onChange={(e) => setWebdavUrl(e.target.value)}
disabled={loading}
/>
</label>
<span style={{ display: 'block' }}>
{i18n('publicLinkURLDescription')}
</span>

<button style={{ display: 'block' }} disabled={loading} type="submit">
Submit
</button>
</form>
)
}

export default class Webdav extends UIPlugin {
static VERSION = packageJson.version

constructor(uppy, opts) {
super(uppy, opts)
this.id = this.opts.id || 'webdav'
this.type = 'acquirer'
this.storage = this.opts.storage || tokenStorage

this.defaultLocale = locale

console.log(locale)

Check warning on line 68 in packages/@uppy/webdav/src/Webdav.tsx

View workflow job for this annotation

GitHub Actions / Lint JavaScript/TypeScript

Unexpected console statement
this.i18nInit()

this.title = this.i18n('pluginNameWebdav')

this.provider = new WebdavSimpleAuthProvider(uppy, {
companionUrl: this.opts.companionUrl,
companionHeaders: this.opts.companionHeaders,
companionKeysParams: this.opts.companionKeysParams,
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'webdav',
pluginId: this.id,
supportsRefreshToken: false,
})

this.onFirstRender = this.onFirstRender.bind(this)
this.render = this.render.bind(this)
}

install() {
this.view = new ProviderViews(this, {
provider: this.provider,
viewType: 'list',
showTitles: true,
showFilter: true,
showBreadcrumbs: true,
renderAuthForm: ({ i18n, loading, onAuth }) => (
<AuthForm loading={loading} onAuth={onAuth} i18n={i18n} />
),
})

const { target } = this.opts
if (target) {
this.mount(target, this)
}
}

uninstall() {
this.view.tearDown()
this.unmount()
}

onFirstRender() {
return this.view.getFolder()
}

render(state) {
return this.view.render(state)
}
}
2 changes: 2 additions & 0 deletions packages/@uppy/webdav/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './Webdav.tsx'
export type { WebcamOptions } from './Webcam.tsx'
Loading

0 comments on commit 52e2882

Please sign in to comment.