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

Draft: Webdav provider poc #4621

Closed
wants to merge 55 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
df98cca
remove useless line
mifi Aug 9, 2023
9ccf2ae
fix broken cookie removal logic
mifi Aug 9, 2023
04128f9
fix mime type of thumbnails
mifi Aug 9, 2023
9ceac7c
simplify/speedup token generation
mifi Aug 9, 2023
600b2d0
use instanceof instead of prop check
mifi Aug 9, 2023
da67ac9
Implement alternative provider auth
mifi Aug 9, 2023
7d59489
refactor
mifi Aug 13, 2023
ab06da6
use respondWithError
mifi Aug 13, 2023
16d4e3f
fix prepareStream
mifi Aug 13, 2023
2092387
don't throw when missing i18n key
mifi Aug 13, 2023
f575dbc
fix bugged try/catch
mifi Aug 13, 2023
754e2e0
allow aborting login too
mifi Aug 13, 2023
6441123
add json http error support
mifi Aug 13, 2023
f503d1f
don't tightly couple auth form with html form
mifi Aug 13, 2023
f0ba00f
implement webdav poc
mifi Aug 13, 2023
272e3d1
fix i18n
mifi Aug 14, 2023
f2e5aa4
make contentType parameterized
mifi Aug 14, 2023
8016a5d
merge main
mifi Sep 6, 2023
9f160f4
Merge branch 'main' into provider-user-sessions
mifi Sep 6, 2023
a28fcec
allow sending certain errors to the user
mifi Sep 6, 2023
3166db9
don't have default content-type
mifi Sep 6, 2023
5c20186
make a loginSimpleAuth api too
mifi Sep 7, 2023
b10edf9
Merge branch 'provider-user-sessions' into webdav-provider-poc
mifi Sep 7, 2023
367a0de
implement `size` for webdav
mifi Sep 7, 2023
9600104
use new loginSimpleAuth method
mifi Sep 7, 2023
55a9351
todo-fixme
mifi Sep 7, 2023
13642fb
hopefully fix url replacer
mifi Sep 7, 2023
59262c1
upgrade webdav
mifi Sep 7, 2023
b33f5b1
Merge branch 'main' into provider-user-sessions
mifi Oct 2, 2023
47c4025
Merge branch 'provider-user-sessions' into webdav-provider-poc
mifi Oct 2, 2023
4be2b6f
make removeAuthToken protected
mifi Oct 2, 2023
ff13823
support both nextcloud and normal webdavs
mifi Oct 2, 2023
63d0b0c
trim inpiut
mifi Oct 2, 2023
7549da4
make removeAuthToken protected
mifi Oct 2, 2023
a795d05
Merge branch 'provider-user-sessions' into webdav-provider-poc
mifi Oct 2, 2023
0542371
rename webdav to webdavOauth, pass providerOptions
arturi Oct 19, 2023
1302574
Fix incorrect options nesting when creating the webdav client
arturi Oct 20, 2023
8794f63
Merge branch 'main' into provider-user-sessions
mifi Nov 27, 2023
42f74b2
fix lint
mifi Nov 27, 2023
7a84c8d
run yarn format
mifi Nov 27, 2023
38eee72
Apply suggestions from code review
mifi Nov 29, 2023
70a7a48
fix broken merge conflict
mifi Nov 29, 2023
0d81a9b
improve inheritance
mifi Dec 1, 2023
96eb565
fix bug
mifi Dec 1, 2023
67d8595
fix bug with dynamic grant config
mifi Dec 2, 2023
5025a73
use duck typing for error checks
mifi Dec 5, 2023
761dcdc
Apply suggestions from code review
mifi Dec 5, 2023
95c8e38
Merge branch 'main' into provider-user-sessions
mifi Dec 5, 2023
64ce471
fix broken lint fix script
mifi Dec 5, 2023
654da1a
fix broken merge code
mifi Dec 5, 2023
a69a1fe
try to fix flakey tets
mifi Dec 5, 2023
6d766cb
fix lint
mifi Dec 5, 2023
2af7b24
Merge branch 'main' into provider-user-sessions
mifi Dec 7, 2023
c6cafdc
Merge branch 'provider-user-sessions' into webdav-provider-poc
mifi Dec 7, 2023
828c0ae
Merge branch 'main' into webdav-provider-poc
mifi Dec 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ module.exports = {
'packages/@uppy/utils/src/**/*.js',
'packages/@uppy/vue/src/**/*.js',
'packages/@uppy/webcam/src/**/*.js',
'packages/@uppy/webdav/src/**/*.js',
'packages/@uppy/xhr-upload/src/**/*.js',
'packages/@uppy/zoom/src/**/*.js',
],
Expand Down
2 changes: 2 additions & 0 deletions packages/@uppy/companion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"express-prom-bundle": "6.5.0",
"express-request-id": "1.4.1",
"express-session": "1.17.3",
"fast-xml-parser": "^4.2.7",
"form-data": "^3.0.0",
"got": "11",
"grant": "5.4.21",
Expand All @@ -67,6 +68,7 @@
"serialize-javascript": "^6.0.0",
"tus-js-client": "^3.0.0",
"validator": "^13.0.0",
"webdav": "^5.3.0",
"ws": "8.8.1"
},
"devDependencies": {
Expand Down
11 changes: 11 additions & 0 deletions packages/@uppy/companion/src/config/grant.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,16 @@ module.exports = () => {
access_url: 'https://zoom.us/oauth/token',
callback: '/zoom/callback',
},
webdav: {
transport: 'session',
// use 'subdomain' for full domain (hostname) similar to mastodon:
// https://github.com/simov/grant/blob/6e0692dfdd83edbc4ee82629ba0fe8f986d5879d/config/oauth.json#L631
dynamic: ['subdomain'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should really set this as default/encourage it, it might be possible to craft a malicious redirect url with that and use it for phishing... haven't come up with anything clever for that so far (one of the reasons we did not enable this in production so far)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good point, but I wonder why grant does it for Mastodon if it's possible to abuse it for malicious purposes... Do you have any example of how one could abuse it?

authorize_url: 'https://[subdomain]/apps/oauth2/authorize',
access_url: 'https://[subdomain]/apps/oauth2/api/v1/token',
scope: ['profile'],
oauth: 2,
callback: '/webdavOauth/callback',
},
}
}
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 })
}

async function download () {
Expand Down
3 changes: 2 additions & 1 deletion packages/@uppy/companion/src/server/provider/Provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ class Provider {
*
* @param {{providerName: string, allowLocalUrls: boolean, providerGrantConfig?: object}} options
*/
constructor ({ allowLocalUrls, providerGrantConfig }) {
constructor ({ allowLocalUrls, providerGrantConfig, providerOptions }) {
// Some providers might need cookie auth for the thumbnails fetched via companion
this.needsCookieAuth = false
this.allowLocalUrls = allowLocalUrls
this.providerGrantConfig = providerGrantConfig
this.providerOptions = providerOptions
return this
}

Expand Down
10 changes: 8 additions & 2 deletions packages/@uppy/companion/src/server/provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const facebook = require('./facebook')
const onedrive = require('./onedrive')
const unsplash = require('./unsplash')
const zoom = require('./zoom')
const webdavSimpleAuth = require('./webdav/WebdavSimpleAuthProvider')
const webdavOauth = require('./webdav/WebdavOauthProvider')
const { getURLBuilder } = require('../helpers/utils')
const logger = require('../logger')
const { getCredentialsResolver } = require('./credentials')
Expand Down Expand Up @@ -72,7 +74,9 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => {
req.companion.providerGrantConfig = providerGrantConfig
}

req.companion.provider = new ProviderClass({ providerName, providerGrantConfig, allowLocalUrls })
const providerOptions = req.companion.options.providerOptions[providerName] || {}

req.companion.provider = new ProviderClass({ providerName, providerGrantConfig, providerOptions, allowLocalUrls })
req.companion.providerClass = ProviderClass
} else {
logger.warn('invalid provider options detected. Provider will not be loaded', 'provider.middleware.invalid', req.id)
Expand All @@ -87,7 +91,9 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => {
* @returns {Record<string, typeof Provider>}
*/
module.exports.getDefaultProviders = () => {
const providers = { dropbox, box, drive, facebook, onedrive, zoom, instagram, unsplash }
const providers = {
dropbox, box, drive, facebook, onedrive, zoom, instagram, unsplash, webdavOauth, webdavSimpleAuth,
}

return providers
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// eslint-disable-next-line import/no-extraneous-dependencies
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. I think something wrong with eslint or its import resolver (eslint gives an error if removed)

const { XMLParser } = require('fast-xml-parser')

const WebdavProvider = require('./common')
const { getProtectedGot, validateURL } = require('../../helpers/request')

const cloudTypePathMappings = {
nextcloud: {
manual_revoke_url: '/settings/user/security',
},
owncloud: {
manual_revoke_url: '/settings/personal?sectionid=security',
},
}

class WebdavOauth extends WebdavProvider {
constructor (options) {
super(options)
this.authProvider = WebdavOauth.authProvider
}

// for "grant"
static getExtraConfig () {
return {}
}

// eslint-disable-next-line class-methods-use-this
static grantDynamicToUserSession ({ grantDynamic }) {
return {
subdomain: grantDynamic.subdomain,
}
}

static get authProvider () {
return 'webdav'
}

#getBaseUrl ({ providerUserSession: { subdomain } }) {
const { protocol } = this.providerOptions

return `${protocol}://${subdomain}`
}

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

async getUsername ({ token, providerUserSession }) {
const { allowLocalUrls } = this

const url = `${this.#getBaseUrl({ providerUserSession })}/ocs/v1.php/cloud/user`
if (!validateURL(url, allowLocalUrls)) {
throw new Error('invalid user url')
}

const response = await getProtectedGot({ url, blockLocalIPs: !allowLocalUrls }).get(url, {
headers: {
Authorization: `Bearer ${token}`,
},
}).text()

const parser = new XMLParser()
const data = parser.parse(response)
return data?.ocs?.data?.id
}

async getClient ({ username, token, providerUserSession }) {
const url = `${this.#getBaseUrl({ providerUserSession })}/remote.php/dav/files/${username}`

const { AuthType } = await import('webdav') // eslint-disable-line import/no-unresolved
return this.getClientHelper({
url,
authType: AuthType.Token,
token: {
access_token: token,
token_type: 'Bearer',
},
})
}

async logout ({ providerUserSession }) {
const { cloudType } = providerUserSession
const manual_revoke_url = cloudTypePathMappings[cloudType]?.manual_revoke_url
return {
revoked: false,
...(manual_revoke_url && { manual_revoke_url: `${this.#getBaseUrl({ providerUserSession })}${manual_revoke_url}` }),
}
}
}

module.exports = WebdavOauth
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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).
*/
mifi marked this conversation as resolved.
Show resolved Hide resolved
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 a nextcloud 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.simpleAuth.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
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 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
Loading
Loading