Provider user sessions (#4619)

New concept "simple auth" - authentication that happens immediately (in one http request) without redirecting to any third party.

uppyAuthToken initially used to simply contain an encrypted & json encoded OAuth2 access_token for a specific provider. Then we added refresh tokens as well inside uppyAuthToken #4448. Now we also allow storing other state or parameters needed for that specific provider, like username, password, host name, webdav URL etc... This is needed for providers like webdav, ftp etc, where the user needs to give some more input data while authenticating

Companion:
- `providerTokens` has been renamed to `providerUserSession` because it now includes not only tokens, but a user's session with a provider.

Companion `Provider` class:
- New `hasSimpleAuth` static boolean property - whether this provider uses simple auth
- uppyAuthToken expiry default 24hr again for providers that don't support refresh tokens
- make uppyAuthToken expiry configurable per provider - new `authStateExpiry` static property (defaults to 24hr)
- new static property `grantDynamicToUserSession`, allows providers to specify which state from Grant `dynamic` to include into the provider's `providerUserSession`.
This commit is contained in:
Mikael Finstad 2023-12-06 05:55:41 +08:00 committed by GitHub
parent 0bea173c2a
commit ec4bc58508
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 622 additions and 359 deletions

View file

@ -1,6 +1,4 @@
import {
interceptCompanionUrlRequest,
interceptCompanionUnsplashRequest,
runRemoteUrlImageUploadTest,
runRemoteUnsplashUploadTest,
} from './reusable-tests'
@ -15,8 +13,6 @@ describe('Dashboard with Tus', () => {
cy.intercept('/files/*').as('tus')
cy.intercept({ method: 'POST', pathname: '/files' }).as('post')
cy.intercept({ method: 'PATCH', pathname: '/files/*' }).as('patch')
interceptCompanionUrlRequest()
interceptCompanionUnsplashRequest()
})
it('should upload cat image successfully', () => {

View file

@ -1,6 +1,5 @@
import {
interceptCompanionUrlRequest,
interceptCompanionUnsplashRequest,
interceptCompanionUrlMetaRequest,
runRemoteUrlImageUploadTest,
runRemoteUnsplashUploadTest,
} from './reusable-tests'
@ -8,8 +7,6 @@ import {
describe('Dashboard with XHR', () => {
beforeEach(() => {
cy.visit('/dashboard-xhr')
interceptCompanionUrlRequest()
interceptCompanionUnsplashRequest()
})
it('should upload remote image with URL plugin', () => {
@ -22,8 +19,9 @@ describe('Dashboard with XHR', () => {
cy.get('.uppy-Url-input').type(
'http://localhost:4678/file-with-content-disposition',
)
interceptCompanionUrlMetaRequest()
cy.get('.uppy-Url-importButton').click()
cy.wait('@url').then(() => {
cy.wait('@url-meta').then(() => {
cy.get('.uppy-Dashboard-Item-name').should('contain', fileName)
cy.get('.uppy-Dashboard-Item-status').should('contain', '84 KB')
})
@ -32,8 +30,9 @@ describe('Dashboard with XHR', () => {
it('should return correct file name with URL plugin from remote image without Content-Disposition', () => {
cy.get('[data-cy="Url"]').click()
cy.get('.uppy-Url-input').type('http://localhost:4678/file-no-headers')
interceptCompanionUrlMetaRequest()
cy.get('.uppy-Url-importButton').click()
cy.wait('@url').then(() => {
cy.wait('@url-meta').then(() => {
cy.get('.uppy-Dashboard-Item-name').should('contain', 'file-no')
cy.get('.uppy-Dashboard-Item-status').should('contain', '0')
})
@ -50,8 +49,9 @@ describe('Dashboard with XHR', () => {
cy.get('.uppy-Url-input').type(
'http://localhost:4678/file-with-content-disposition',
)
interceptCompanionUrlMetaRequest()
cy.get('.uppy-Url-importButton').click()
cy.wait('@url').then(() => {
cy.wait('@url-meta').then(() => {
cy.get('.uppy-Dashboard-Item-name').should('contain', 'file-with')
cy.get('.uppy-Dashboard-Item-status').should('contain', '123 B')
})

View file

@ -1,9 +1,13 @@
/* global cy */
export const interceptCompanionUrlRequest = () =>
cy.intercept('http://localhost:3020/url/*').as('url')
export const interceptCompanionUnsplashRequest = () =>
cy.intercept('http://localhost:3020/search/unsplash/*').as('unsplash')
const interceptCompanionUrlRequest = () =>
cy
.intercept({ method: 'POST', url: 'http://localhost:3020/url/get' })
.as('url')
export const interceptCompanionUrlMetaRequest = () =>
cy
.intercept({ method: 'POST', url: 'http://localhost:3020/url/meta' })
.as('url-meta')
export function runRemoteUrlImageUploadTest() {
cy.get('[data-cy="Url"]').click()
@ -11,6 +15,7 @@ export function runRemoteUrlImageUploadTest() {
'https://raw.githubusercontent.com/transloadit/uppy/main/e2e/cypress/fixtures/images/cat.jpg',
)
cy.get('.uppy-Url-importButton').click()
interceptCompanionUrlRequest()
cy.get('.uppy-StatusBar-actionBtn--upload').click()
cy.wait('@url').then(() => {
cy.get('.uppy-StatusBar-statusPrimary').should('contain', 'Complete')
@ -20,8 +25,12 @@ export function runRemoteUrlImageUploadTest() {
export function runRemoteUnsplashUploadTest() {
cy.get('[data-cy="Unsplash"]').click()
cy.get('.uppy-SearchProvider-input').type('book')
cy.intercept({
method: 'GET',
url: 'http://localhost:3020/search/unsplash/list?q=book',
}).as('unsplash-list')
cy.get('.uppy-SearchProvider-searchButton').click()
cy.wait('@unsplash')
cy.wait('@unsplash-list')
// Test that the author link is visible
cy.get('.uppy-ProviderBrowserItem')
.first()
@ -34,8 +43,12 @@ export function runRemoteUnsplashUploadTest() {
cy.get('a').should('have.css', 'display', 'block')
})
cy.get('.uppy-c-btn-primary').click()
cy.intercept({
method: 'POST',
url: 'http://localhost:3020/search/unsplash/get/*',
}).as('unsplash-get')
cy.get('.uppy-StatusBar-actionBtn--upload').click()
cy.wait('@unsplash').then(() => {
cy.wait('@unsplash-get').then(() => {
cy.get('.uppy-StatusBar-statusPrimary').should('contain', 'Complete')
})
}

View file

@ -129,7 +129,7 @@
"contributors:save": "yarn node ./bin/update-contributors.mjs",
"dev:with-companion": "npm-run-all --parallel start:companion dev",
"dev": "yarn workspace @uppy-dev/dev dev",
"lint:fix": "yarn run lint -- --fix",
"lint:fix": "yarn lint --fix",
"lint:markdown": "remark -f -q -i .remarkignore . .github/CONTRIBUTING.md",
"lint:staged": "lint-staged",
"lint:css": "stylelint ./packages/**/*.scss",

View file

@ -30,6 +30,7 @@ export default class Box extends UIPlugin {
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'box',
pluginId: this.id,
supportsRefreshToken: false,
})
this.defaultLocale = locale

View file

@ -1,9 +1,12 @@
'use strict'
class AuthError extends Error {
constructor () {
constructor() {
super('Authorization required')
this.name = 'AuthError'
// we use a property because of instanceof is unsafe:
// https://github.com/transloadit/uppy/pull/4619#discussion_r1406225982
this.isAuthError = true
}
}

View file

@ -1,18 +1,19 @@
'use strict'
import RequestClient from './RequestClient.js'
import RequestClient, { authErrorStatusCode } from './RequestClient.js'
import * as tokenStorage from './tokenStorage.js'
const getName = (id) => {
return id.split('-').map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(' ')
}
function getOrigin () {
function getOrigin() {
// eslint-disable-next-line no-restricted-globals
return location.origin
}
function getRegex (value) {
function getRegex(value) {
if (typeof value === 'string') {
return new RegExp(`^${value}$`)
} if (value instanceof RegExp) {
@ -21,7 +22,7 @@ function getRegex (value) {
return undefined
}
function isOriginAllowed (origin, allowedOrigin) {
function isOriginAllowed(origin, allowedOrigin) {
const patterns = Array.isArray(allowedOrigin) ? allowedOrigin.map(getRegex) : [getRegex(allowedOrigin)]
return patterns
.some((pattern) => pattern?.test(origin) || pattern?.test(`${origin}/`)) // allowing for trailing '/'
@ -30,7 +31,7 @@ function isOriginAllowed (origin, allowedOrigin) {
export default class Provider extends RequestClient {
#refreshingTokenPromise
constructor (uppy, opts) {
constructor(uppy, opts) {
super(uppy, opts)
this.provider = opts.provider
this.id = this.provider
@ -39,9 +40,10 @@ export default class Provider extends RequestClient {
this.tokenKey = `companion-${this.pluginId}-auth-token`
this.companionKeysParams = this.opts.companionKeysParams
this.preAuthToken = null
this.supportsRefreshToken = opts.supportsRefreshToken ?? true // todo false in next major
}
async headers () {
async headers() {
const [headers, token] = await Promise.all([super.headers(), this.#getAuthToken()])
const authHeaders = {}
if (token) {
@ -56,24 +58,25 @@ export default class Provider extends RequestClient {
return { ...headers, ...authHeaders }
}
onReceiveResponse (response) {
onReceiveResponse(response) {
super.onReceiveResponse(response)
const plugin = this.uppy.getPlugin(this.pluginId)
const oldAuthenticated = plugin.getPluginState().authenticated
const authenticated = oldAuthenticated ? response.status !== 401 : response.status < 400
const authenticated = oldAuthenticated ? response.status !== authErrorStatusCode : response.status < 400
plugin.setPluginState({ authenticated })
return response
}
async setAuthToken (token) {
async setAuthToken(token) {
return this.uppy.getPlugin(this.pluginId).storage.setItem(this.tokenKey, token)
}
async #getAuthToken () {
async #getAuthToken() {
return this.uppy.getPlugin(this.pluginId).storage.getItem(this.tokenKey)
}
async #removeAuthToken () {
/** @protected */
async removeAuthToken() {
return this.uppy.getPlugin(this.pluginId).storage.removeItem(this.tokenKey)
}
@ -81,7 +84,7 @@ export default class Provider extends RequestClient {
* Ensure we have a preauth token if necessary. Attempts to fetch one if we don't,
* or rejects if loading one fails.
*/
async ensurePreAuth () {
async ensurePreAuth() {
if (this.companionKeysParams && !this.preAuthToken) {
await this.fetchPreAuthToken()
@ -91,11 +94,18 @@ export default class Provider extends RequestClient {
}
}
authUrl (queries = {}) {
// eslint-disable-next-line class-methods-use-this
authQuery() {
return {}
}
authUrl({ authFormData, query } = {}) {
const params = new URLSearchParams({
...query,
state: btoa(JSON.stringify({ origin: getOrigin() })),
...queries,
...this.authQuery({ authFormData }),
})
if (this.preAuthToken) {
params.set('uppyPreAuthToken', this.preAuthToken)
}
@ -103,12 +113,24 @@ export default class Provider extends RequestClient {
return `${this.hostname}/${this.id}/connect?${params}`
}
async login (queries) {
/** @protected */
async loginSimpleAuth({ uppyVersions, authFormData, signal }) {
const response = await this.post(`${this.id}/simple-auth`, { form: authFormData }, { qs: { uppyVersions }, signal })
this.setAuthToken(response.uppyAuthToken)
}
/** @protected */
async loginOAuth({ uppyVersions, authFormData, signal }) {
await this.ensurePreAuth()
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const link = this.authUrl(queries)
const link = this.authUrl({ query: { uppyVersions }, authFormData })
const authWindow = window.open(link, '_blank')
let cleanup
const handleToken = (e) => {
if (e.source !== authWindow) {
let jsonData = ''
@ -148,24 +170,35 @@ export default class Provider extends RequestClient {
return
}
cleanup()
resolve(this.setAuthToken(data.token))
}
cleanup = () => {
authWindow.close()
window.removeEventListener('message', handleToken)
this.setAuthToken(data.token).then(() => resolve()).catch(reject)
signal.removeEventListener('abort', cleanup)
}
signal.addEventListener('abort', cleanup)
window.addEventListener('message', handleToken)
})
}
refreshTokenUrl () {
async login({ uppyVersions, authFormData, signal }) {
return this.loginOAuth({ uppyVersions, authFormData, signal })
}
refreshTokenUrl() {
return `${this.hostname}/${this.id}/refresh-token`
}
fileUrl (id) {
fileUrl(id) {
return `${this.hostname}/${this.id}/get/${id}`
}
/** @protected */
async request (...args) {
async request(...args) {
await this.#refreshingTokenPromise
try {
@ -177,6 +210,7 @@ export default class Provider extends RequestClient {
return await super.request(...args)
} catch (err) {
if (!this.supportsRefreshToken) throw err
// only handle auth errors (401 from provider), and only handle them if we have a (refresh) token
const authTokenAfter = await this.#getAuthToken()
if (!err.isAuthError || !authTokenAfter) throw err
@ -192,7 +226,7 @@ export default class Provider extends RequestClient {
} catch (refreshTokenErr) {
if (refreshTokenErr.isAuthError) {
// if refresh-token has failed with auth error, delete token, so we don't keep trying to refresh in future
await this.#removeAuthToken()
await this.removeAuthToken()
}
throw err
} finally {
@ -208,7 +242,7 @@ export default class Provider extends RequestClient {
}
}
async fetchPreAuthToken () {
async fetchPreAuthToken() {
if (!this.companionKeysParams) {
return
}
@ -221,17 +255,17 @@ export default class Provider extends RequestClient {
}
}
list (directory, options) {
list(directory, options) {
return this.get(`${this.id}/list/${directory || ''}`, options)
}
async logout (options) {
async logout(options) {
const response = await this.get(`${this.id}/logout`, options)
await this.#removeAuthToken()
await this.removeAuthToken()
return response
}
static initPlugin (plugin, opts, defaultOpts) {
static initPlugin(plugin, opts, defaultOpts) {
/* eslint-disable no-param-reassign */
plugin.type = 'acquirer'
plugin.files = []

View file

@ -1,5 +1,6 @@
'use strict'
import UserFacingApiError from '@uppy/utils/lib/UserFacingApiError'
// eslint-disable-next-line import/no-extraneous-dependencies
import pRetry, { AbortError } from 'p-retry'
@ -13,25 +14,26 @@ import AuthError from './AuthError.js'
import packageJson from '../package.json'
// Remove the trailing slash so we can always safely append /xyz.
function stripSlash (url) {
function stripSlash(url) {
return url.replace(/\/$/, '')
}
const retryCount = 10 // set to a low number, like 2 to test manual user retries
const socketActivityTimeoutMs = 5 * 60 * 1000 // set to a low number like 10000 to test this
const authErrorStatusCode = 401
export const authErrorStatusCode = 401
class HttpError extends Error {
statusCode
constructor({ statusCode, message }) {
super(message)
this.name = 'HttpError'
this.statusCode = statusCode
}
}
async function handleJSONResponse (res) {
async function handleJSONResponse(res) {
if (res.status === authErrorStatusCode) {
throw new AuthError()
}
@ -41,15 +43,19 @@ async function handleJSONResponse (res) {
}
let errMsg = `Failed request with status: ${res.status}. ${res.statusText}`
let errData
try {
const errData = await res.json()
errData = await res.json()
errMsg = errData.message ? `${errMsg} message: ${errData.message}` : errMsg
errMsg = errData.requestId
? `${errMsg} request-Id: ${errData.requestId}`
: errMsg
} catch {
/* if the response contains invalid JSON, let's ignore the error */
if (errData.message) errMsg = `${errMsg} message: ${errData.message}`
if (errData.requestId) errMsg = `${errMsg} request-Id: ${errData.requestId}`
} catch (cause) {
// if the response contains invalid JSON, let's ignore the error data
throw new Error(errMsg, { cause })
}
if (res.status >= 400 && res.status <= 499 && errData.message) {
throw new UserFacingApiError(errData.message)
}
throw new HttpError({ statusCode: res.status, message: errMsg })
@ -60,22 +66,22 @@ export default class RequestClient {
#companionHeaders
constructor (uppy, opts) {
constructor(uppy, opts) {
this.uppy = uppy
this.opts = opts
this.onReceiveResponse = this.onReceiveResponse.bind(this)
this.#companionHeaders = opts?.companionHeaders
}
setCompanionHeaders (headers) {
setCompanionHeaders(headers) {
this.#companionHeaders = headers
}
[Symbol.for('uppy test: getCompanionHeaders')] () {
[Symbol.for('uppy test: getCompanionHeaders')]() {
return this.#companionHeaders
}
get hostname () {
get hostname() {
const { companion } = this.uppy.getState()
const host = this.opts.companionUrl
return stripSlash(companion && companion[host] ? companion[host] : host)
@ -96,7 +102,7 @@ export default class RequestClient {
}
}
onReceiveResponse ({ headers }) {
onReceiveResponse({ headers }) {
const state = this.uppy.getState()
const companion = state.companion || {}
const host = this.opts.companionUrl
@ -109,7 +115,7 @@ export default class RequestClient {
}
}
#getUrl (url) {
#getUrl(url) {
if (/^(https?:|)\/\//.test(url)) {
return url
}
@ -117,7 +123,7 @@ export default class RequestClient {
}
/** @protected */
async request ({ path, method = 'GET', data, skipPostResponse, signal }) {
async request({ path, method = 'GET', data, skipPostResponse, signal }) {
try {
const headers = await this.headers(!data)
const response = await fetchWithNetworkError(this.#getUrl(path), {
@ -132,7 +138,7 @@ export default class RequestClient {
return await handleJSONResponse(response)
} catch (err) {
// pass these through
if (err instanceof AuthError || err.name === 'AbortError') throw err
if (err.isAuthError || err.name === 'UserFacingApiError' || err.name === 'AbortError') throw err
throw new ErrorWithCause(`Could not ${method} ${this.#getUrl(path)}`, {
cause: err,
@ -140,21 +146,21 @@ export default class RequestClient {
}
}
async get (path, options = undefined) {
async get(path, options = undefined) {
// TODO: remove boolean support for options that was added for backward compatibility.
// eslint-disable-next-line no-param-reassign
if (typeof options === 'boolean') options = { skipPostResponse: options }
return this.request({ ...options, path })
}
async post (path, data, options = undefined) {
async post(path, data, options = undefined) {
// TODO: remove boolean support for options that was added for backward compatibility.
// eslint-disable-next-line no-param-reassign
if (typeof options === 'boolean') options = { skipPostResponse: options }
return this.request({ ...options, path, method: 'POST', data })
}
async delete (path, data = undefined, options) {
async delete(path, data = undefined, options) {
// TODO: remove boolean support for options that was added for backward compatibility.
// eslint-disable-next-line no-param-reassign
if (typeof options === 'boolean') options = { skipPostResponse: options }
@ -174,7 +180,7 @@ export default class RequestClient {
* @param {*} options
* @returns
*/
async uploadRemoteFile (file, reqBody, options = {}) {
async uploadRemoteFile(file, reqBody, options = {}) {
try {
const { signal, getQueue } = options
@ -191,7 +197,7 @@ export default class RequestClient {
return await this.#requestSocketToken(...args)
} catch (outerErr) {
// throwing AbortError will cause p-retry to stop retrying
if (outerErr instanceof AuthError) throw new AbortError(outerErr)
if (outerErr.isAuthError) throw new AbortError(outerErr)
if (outerErr.cause == null) throw outerErr
const err = outerErr.cause
@ -200,7 +206,7 @@ export default class RequestClient {
[408, 409, 429, 418, 423].includes(err.statusCode)
|| (err.statusCode >= 500 && err.statusCode <= 599 && ![501, 505].includes(err.statusCode))
)
if (err instanceof HttpError && !isRetryableHttpError()) throw new AbortError(err);
if (err.name === 'HttpError' && !isRetryableHttpError()) throw new AbortError(err);
// p-retry will retry most other errors,
// but it will not retry TypeError (except network error TypeErrors)
@ -253,7 +259,7 @@ export default class RequestClient {
*
* @param {{ file: UppyFile, queue: RateLimitedQueue, signal: AbortSignal }} file
*/
async #awaitRemoteFileUpload ({ file, queue, signal }) {
async #awaitRemoteFileUpload({ file, queue, signal }) {
let removeEventHandlers
const { capabilities } = this.uppy.getState()

View file

@ -16,7 +16,7 @@ const jobs = require('./server/jobs')
const logger = require('./server/logger')
const middlewares = require('./server/middlewares')
const { getMaskableSecrets, defaultOptions, validateConfig } = require('./config/companion')
const { ProviderApiError, ProviderAuthError } = require('./server/provider/error')
const { ProviderApiError, ProviderUserError, ProviderAuthError } = require('./server/provider/error')
const { getCredentialsOverrideMiddleware } = require('./server/provider/credentials')
// @ts-ignore
const { version } = require('../package.json')
@ -52,7 +52,7 @@ const interceptGrantErrorResponse = interceptor((req, res) => {
})
// make the errors available publicly for custom providers
module.exports.errors = { ProviderApiError, ProviderAuthError }
module.exports.errors = { ProviderApiError, ProviderUserError, ProviderAuthError }
module.exports.socket = require('./server/socket')
module.exports.setLoggerProcessName = setLoggerProcessName
@ -126,6 +126,8 @@ module.exports.app = (optionsArg = {}) => {
app.get('/:providerName/logout', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.gentleVerifyToken, controllers.logout)
app.get('/:providerName/send-token', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.verifyToken, controllers.sendToken)
app.post('/:providerName/simple-auth', express.json(), middlewares.hasSessionAndProvider, middlewares.hasBody, middlewares.hasSimpleAuthProvider, controllers.simpleAuth)
app.get('/:providerName/list/:id?', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)
// backwards compat:
app.get('/search/:providerName/list', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)
@ -140,8 +142,8 @@ module.exports.app = (optionsArg = {}) => {
if (options.testDynamicOauthCredentials) {
app.post('/:providerName/test-dynamic-oauth-credentials', (req, res) => {
if (req.query.secret !== options.testDynamicOauthCredentialsSecret) throw new Error('Invalid secret')
logger.info('Returning dynamic OAuth2 credentials')
const { providerName } = req.params
logger.info(`Returning dynamic OAuth2 credentials for ${providerName}`)
// for simplicity, we just return the normal credentials for the provider, but in a real-world scenario,
// we would query based on parameters
const { key, secret } = options.providerOptions[providerName]

View file

@ -41,12 +41,12 @@ const PROTOCOLS = Object.freeze({
tus: 'tus',
})
function exceedsMaxFileSize (maxFileSize, size) {
function exceedsMaxFileSize(maxFileSize, size) {
return maxFileSize && size && size > maxFileSize
}
// TODO remove once we migrate away from form-data
function sanitizeMetadata (inputMetadata) {
function sanitizeMetadata(inputMetadata) {
if (inputMetadata == null) return {}
const outputMetadata = {}
@ -56,16 +56,24 @@ function sanitizeMetadata (inputMetadata) {
return outputMetadata
}
class AbortError extends Error {}
class AbortError extends Error {
isAbortError = true
}
class ValidationError extends Error {}
class ValidationError extends Error {
constructor(message) {
super(message)
this.name = 'ValidationError'
}
}
/**
* Validate the options passed down to the uplaoder
*
* @param {UploaderOptions} options
*/
function validateOptions (options) {
function validateOptions(options) {
// validate HTTP Method
if (options.httpMethod) {
if (typeof options.httpMethod !== 'string') {
@ -155,7 +163,7 @@ class Uploader {
*
* @param {UploaderOptions} options
*/
constructor (options) {
constructor(options) {
validateOptions(options)
this.options = options
@ -200,18 +208,18 @@ class Uploader {
this._paused = true
if (this.tus) {
const shouldTerminate = !!this.tus.url
this.tus.abort(shouldTerminate).catch(() => {})
this.tus.abort(shouldTerminate).catch(() => { })
}
this.abortReadStream(new AbortError())
})
}
abortReadStream (err) {
abortReadStream(err) {
this.uploadStopped = true
if (this.readStream) this.readStream.destroy(err)
}
async _uploadByProtocol () {
async _uploadByProtocol() {
// todo a default protocol should not be set. We should ensure that the user specifies their protocol.
// after we drop old versions of uppy client we can remove this
const protocol = this.options.protocol || PROTOCOLS.multipart
@ -228,7 +236,7 @@ class Uploader {
}
}
async _downloadStreamAsFile (stream) {
async _downloadStreamAsFile(stream) {
this.tmpPath = join(this.options.pathPrefix, this.fileName)
logger.debug('fully downloading file', 'uploader.download', this.shortToken)
@ -253,7 +261,7 @@ class Uploader {
this.readStream = fileStream
}
_needDownloadFirst () {
_needDownloadFirst() {
return !this.options.size || !this.options.companionOptions.streamingUpload
}
@ -261,7 +269,7 @@ class Uploader {
*
* @param {import('stream').Readable} stream
*/
async uploadStream (stream) {
async uploadStream(stream) {
try {
if (this.uploadStopped) throw new Error('Cannot upload stream after upload stopped')
if (this.readStream) throw new Error('Already uploading')
@ -289,15 +297,15 @@ class Uploader {
}
}
tryDeleteTmpPath () {
if (this.tmpPath) unlink(this.tmpPath).catch(() => {})
tryDeleteTmpPath() {
if (this.tmpPath) unlink(this.tmpPath).catch(() => { })
}
/**
*
* @param {import('stream').Readable} stream
*/
async tryUploadStream (stream) {
async tryUploadStream(stream) {
try {
emitter().emit('upload-start', { token: this.token })
@ -306,7 +314,7 @@ class Uploader {
const { url, extraData } = ret
this.#emitSuccess(url, extraData)
} catch (err) {
if (err instanceof AbortError) {
if (err?.isAbortError) {
logger.error('Aborted upload', 'uploader.aborted', this.shortToken)
return
}
@ -328,11 +336,11 @@ class Uploader {
* @param {string} token the token to Shorten
* @returns {string}
*/
static shortenToken (token) {
static shortenToken(token) {
return token.substring(0, 8)
}
static reqToOptions (req, size) {
static reqToOptions(req, size) {
const useFormDataIsSet = Object.prototype.hasOwnProperty.call(req.body, 'useFormData')
const useFormData = useFormDataIsSet ? req.body.useFormData : true
@ -365,11 +373,11 @@ class Uploader {
* we avoid using the entire token because this is meant to be a short term
* access token between uppy client and companion websocket
*/
get shortToken () {
get shortToken() {
return Uploader.shortenToken(this.token)
}
async awaitReady (timeout) {
async awaitReady(timeout) {
logger.debug('waiting for socket connection', 'uploader.socket.wait', this.shortToken)
// TODO: replace the Promise constructor call when dropping support for Node.js <16 with
@ -379,7 +387,7 @@ class Uploader {
let timer
let onEvent
function cleanup () {
function cleanup() {
emitter().removeListener(eventName, onEvent)
clearTimeout(timer)
}
@ -407,7 +415,7 @@ class Uploader {
* @typedef {{action: string, payload: object}} State
* @param {State} state
*/
saveState (state) {
saveState(state) {
if (!this.storage) return
// make sure the keys get cleaned up.
// https://github.com/transloadit/uppy/issues/3748
@ -434,7 +442,7 @@ class Uploader {
* @param {number} [bytesUploaded]
* @param {number | null} [bytesTotalIn]
*/
onProgress (bytesUploaded = 0, bytesTotalIn = 0) {
onProgress(bytesUploaded = 0, bytesTotalIn = 0) {
const bytesTotal = bytesTotalIn || this.size || 0
// If fully downloading before uploading, combine downloaded and uploaded bytes
@ -470,7 +478,7 @@ class Uploader {
* @param {string} url
* @param {object} extraData
*/
#emitSuccess (url, extraData) {
#emitSuccess(url, extraData) {
const emitData = {
action: 'success',
payload: { ...extraData, complete: true, url },
@ -483,7 +491,7 @@ class Uploader {
*
* @param {Error} err
*/
#emitError (err) {
#emitError(err) {
// delete stack to avoid sending server info to client
// todo remove also extraData from serializedErr in next major,
// see PR discussion https://github.com/transloadit/uppy/pull/3832
@ -502,7 +510,7 @@ class Uploader {
*
* @param {any} stream
*/
async #uploadTus (stream) {
async #uploadTus(stream) {
const uploader = this
const isFileStream = stream instanceof ReadStream
@ -531,7 +539,7 @@ class Uploader {
*
* @param {Error} error
*/
onError (error) {
onError(error) {
logger.error(error, 'uploader.tus.error')
// deleting tus originalRequest field because it uses the same http-agent
// as companion, and this agent may contain sensitive request details (e.g headers)
@ -550,10 +558,10 @@ class Uploader {
* @param {number} [bytesUploaded]
* @param {number} [bytesTotal]
*/
onProgress (bytesUploaded, bytesTotal) {
onProgress(bytesUploaded, bytesTotal) {
uploader.onProgress(bytesUploaded, bytesTotal)
},
onSuccess () {
onSuccess() {
resolve({ url: uploader.tus.url })
},
})
@ -564,12 +572,12 @@ class Uploader {
})
}
async #uploadMultipart (stream) {
async #uploadMultipart(stream) {
if (!this.options.endpoint) {
throw new Error('No multipart endpoint set')
}
function getRespObj (response) {
function getRespObj(response) {
// remove browser forbidden headers
const { 'set-cookie': deleted, 'set-cookie2': deleted2, ...responseHeaders } = response.headers
@ -642,7 +650,7 @@ class Uploader {
/**
* Upload the file to S3 using a Multipart upload.
*/
async #uploadS3Multipart (stream) {
async #uploadS3Multipart(stream) {
if (!this.options.s3) {
throw new Error('The S3 client is not configured on this companion instance.')
}

View file

@ -33,27 +33,30 @@ module.exports = function callback (req, res, next) { // eslint-disable-line no-
const grant = req.session.grant || {}
const grantDynamic = oAuthState.getGrantDynamicFromRequest(req)
const origin = grantDynamic.state && oAuthState.getFromState(grantDynamic.state, 'origin', req.companion.options.secret)
if (!grant.response?.access_token) {
logger.debug(`Did not receive access token for provider ${providerName}`, null, req.id)
logger.debug(grant.response, 'callback.oauth.resp', req.id)
const state = oAuthState.getDynamicStateFromRequest(req)
const origin = state && oAuthState.getFromState(state, 'origin', req.companion.options.secret)
return res.status(400).send(closePageHtml(origin))
}
const { access_token: accessToken, refresh_token: refreshToken } = grant.response
if (!req.companion.allProvidersTokens) req.companion.allProvidersTokens = {}
req.companion.allProvidersTokens[providerName] = {
req.companion.providerUserSession = {
accessToken,
refreshToken, // might be undefined for some providers
...req.companion.providerClass.grantDynamicToUserSession({ grantDynamic }),
}
logger.debug(`Generating auth token for provider ${providerName}. refreshToken: ${refreshToken ? 'yes' : 'no'}`, null, req.id)
const uppyAuthToken = tokenService.generateEncryptedAuthToken(
req.companion.allProvidersTokens, req.companion.options.secret,
{ [providerName]: req.companion.providerUserSession },
req.companion.options.secret, req.companion.providerClass.authStateExpiry,
)
tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken)
tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken, req.companion.providerClass.authStateExpiry)
return res.redirect(req.companion.buildURL(`/${providerName}/send-token?uppyAuthToken=${uppyAuthToken}`, true))
}

View file

@ -1,27 +1,56 @@
const atob = require('atob')
const oAuthState = require('../helpers/oauth-state')
const queryString = (params, prefix = '?') => {
const str = new URLSearchParams(params).toString()
return str ? `${prefix}${str}` : ''
}
/**
* initializes the oAuth flow for a provider.
*
* @param {object} req
* @param {object} res
*/
module.exports = function connect (req, res) {
module.exports = function connect(req, res) {
const { secret } = req.companion.options
let state = oAuthState.generateState(secret)
const stateObj = oAuthState.generateState()
if (req.query.state) {
const origin = JSON.parse(atob(req.query.state))
state = oAuthState.addToState(state, origin, secret)
const { origin } = JSON.parse(atob(req.query.state))
stateObj.origin = origin
}
if (req.companion.options.server.oauthDomain) {
state = oAuthState.addToState(state, { companionInstance: req.companion.buildURL('', true) }, secret)
stateObj.companionInstance = req.companion.buildURL('', true)
}
if (req.query.uppyPreAuthToken) {
state = oAuthState.addToState(state, { preAuthToken: req.query.uppyPreAuthToken }, secret)
stateObj.preAuthToken = req.query.uppyPreAuthToken
}
res.redirect(req.companion.buildURL(`/connect/${req.companion.provider.authProvider}?state=${state}`, true))
const state = oAuthState.encodeState(stateObj, secret)
const { provider, providerGrantConfig } = req.companion
// pass along grant's dynamic config (if specified for the provider in its grant config `dynamic` section)
// this is needed for things like custom oauth domain (e.g. webdav)
const grantDynamicConfig = Object.fromEntries(providerGrantConfig.dynamic?.flatMap((dynamicKey) => {
const queryValue = req.query[dynamicKey];
// note: when using credentialsURL (dynamic oauth credentials), dynamic has ['key', 'secret', 'redirect_uri']
// but in that case, query string is empty, so we need to only fetch these parameters from QS if they exist.
if (!queryValue) return []
return [[
dynamicKey, queryValue
]]
}) || [])
const providerName = provider.authProvider
const qs = queryString({
...grantDynamicConfig,
state,
})
// Now we redirect to grant's /connect endpoint, see `app.use(Grant(grantConfig))`
res.redirect(req.companion.buildURL(`/connect/${providerName}${qs}`, true))
}

View file

@ -3,7 +3,8 @@ const { startDownUpload } = require('../helpers/upload')
async function get (req, res) {
const { id } = req.params
const { accessToken } = req.companion.providerTokens
const { providerUserSession } = req.companion
const { accessToken } = providerUserSession
const { provider } = req.companion
async function getSize () {
@ -11,7 +12,7 @@ async function get (req, res) {
}
async function download () {
const { stream } = await provider.download({ id, token: accessToken, query: req.query })
const { stream } = await provider.download({ id, token: accessToken, providerUserSession, query: req.query })
return stream
}

View file

@ -6,6 +6,7 @@ module.exports = {
get: require('./get'),
thumbnail: require('./thumbnail'),
list: require('./list'),
simpleAuth: require('./simple-auth'),
logout: require('./logout'),
connect: require('./connect'),
preauth: require('./preauth'),

View file

@ -1,10 +1,14 @@
const { respondWithError } = require('../provider/error')
async function list ({ query, params, companion }, res, next) {
const { accessToken } = companion.providerTokens
const { providerUserSession } = companion
const { accessToken } = providerUserSession
try {
const data = await companion.provider.list({ companion, token: accessToken, directory: params.id, query })
// todo remove backward compat `token` param from all provider methods (because it can be found in providerUserSession)
const data = await companion.provider.list({
companion, token: accessToken, providerUserSession, directory: params.id, query,
})
res.json(data)
} catch (err) {
if (respondWithError(err, res)) return

View file

@ -13,20 +13,19 @@ async function logout (req, res, next) {
req.session.grant.dynamic = null
}
}
const { providerName } = req.params
const { companion } = req
const tokens = companion.allProvidersTokens ? companion.allProvidersTokens[providerName] : null
const { providerUserSession } = companion
if (!tokens) {
if (!providerUserSession) {
cleanSession()
res.json({ ok: true, revoked: false })
return
}
try {
const { accessToken } = tokens
const data = await companion.provider.logout({ token: accessToken, companion })
delete companion.allProvidersTokens[providerName]
const { accessToken } = providerUserSession
const data = await companion.provider.logout({ token: accessToken, providerUserSession, companion })
delete companion.providerUserSession
tokenService.removeFromCookies(res, companion.options, companion.provider.authProvider)
cleanSession()
res.json({ ok: true, ...data })

View file

@ -16,7 +16,7 @@ module.exports = function oauthRedirect (req, res) {
return
}
const state = oAuthState.getDynamicStateFromRequest(req)
const { state } = oAuthState.getGrantDynamicFromRequest(req)
if (!state) {
res.status(400).send('Cannot find state in session')
return

View file

@ -11,10 +11,10 @@ async function refreshToken (req, res, next) {
const { key: clientId, secret: clientSecret } = req.companion.options.providerOptions[providerName]
const { redirect_uri: redirectUri } = req.companion.providerGrantConfig
const providerTokens = req.companion.allProvidersTokens[providerName]
const { providerUserSession } = req.companion
// not all providers have refresh tokens
if (providerTokens.refreshToken == null || providerTokens.refreshToken === '') {
if (providerUserSession.refreshToken == null || providerUserSession.refreshToken === '') {
logger.warn('Tried to refresh token without having a token')
res.sendStatus(401)
return
@ -22,26 +22,21 @@ async function refreshToken (req, res, next) {
try {
const data = await req.companion.provider.refreshToken({
redirectUri, clientId, clientSecret, refreshToken: providerTokens.refreshToken,
redirectUri, clientId, clientSecret, refreshToken: providerUserSession.refreshToken,
})
const newAllProvidersTokens = {
...req.companion.allProvidersTokens,
[providerName]: {
...providerTokens,
accessToken: data.accessToken,
},
req.companion.providerUserSession = {
...providerUserSession,
accessToken: data.accessToken,
}
req.companion.allProvidersTokens = newAllProvidersTokens
req.companion.providerTokens = newAllProvidersTokens[providerName]
logger.debug(`Generating refreshed auth token for provider ${providerName}`, null, req.id)
const uppyAuthToken = tokenService.generateEncryptedAuthToken(
req.companion.allProvidersTokens, req.companion.options.secret,
{ [providerName]: req.companion.providerUserSession },
req.companion.options.secret, req.companion.providerClass.authStateExpiry,
)
tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken)
tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken, req.companion.providerClass.authStateExpiry)
res.send({ uppyAuthToken })
} catch (err) {

View file

@ -33,7 +33,7 @@ const htmlContent = (token, origin) => {
module.exports = function sendToken (req, res, next) {
const uppyAuthToken = req.companion.authToken
const state = oAuthState.getDynamicStateFromRequest(req)
const { state } = oAuthState.getGrantDynamicFromRequest(req)
if (state) {
const origin = oAuthState.getFromState(state, 'origin', req.companion.options.secret)
const allowedClients = req.companion.options.clients

View file

@ -0,0 +1,31 @@
const tokenService = require('../helpers/jwt')
const { respondWithError } = require('../provider/error')
const logger = require('../logger')
async function simpleAuth (req, res, next) {
const { providerName } = req.params
try {
const simpleAuthResponse = await req.companion.provider.simpleAuth({ requestBody: req.body })
req.companion.providerUserSession = {
...req.companion.providerUserSession,
...simpleAuthResponse,
}
logger.debug(`Generating simple auth token for provider ${providerName}`, null, req.id)
const uppyAuthToken = tokenService.generateEncryptedAuthToken(
{ [providerName]: req.companion.providerUserSession },
req.companion.options.secret, req.companion.providerClass.authStateExpiry,
)
tokenService.addToCookiesIfNeeded(req, res, uppyAuthToken, req.companion.providerClass.authStateExpiry)
res.send({ uppyAuthToken })
} catch (err) {
if (respondWithError(err, res)) return
next(err)
}
}
module.exports = simpleAuth

View file

@ -1,19 +1,22 @@
const { respondWithError } = require('../provider/error')
/**
*
* @param {object} req
* @param {object} res
*/
async function thumbnail (req, res, next) {
const { providerName, id } = req.params
const { accessToken } = req.companion.allProvidersTokens[providerName]
const { provider } = req.companion
const { id } = req.params
const { provider, providerUserSession } = req.companion
const { accessToken } = providerUserSession
try {
const { stream } = await provider.thumbnail({ id, token: accessToken })
const { stream, contentType } = await provider.thumbnail({ id, token: accessToken, providerUserSession })
if (contentType != null) res.set('Content-Type', contentType)
stream.pipe(res)
} catch (err) {
if (err.isAuthError) res.sendStatus(401)
else next(err)
if (respondWithError(err, res)) return
next(err)
}
}

View file

@ -1,10 +1,13 @@
const jwt = require('jsonwebtoken')
const { encrypt, decrypt } = require('./utils')
// The Uppy auth token is a (JWT) container around provider OAuth access & refresh tokens.
// Providers themselves will verify these inner tokens.
// The Uppy auth token is an encrypted JWT & JSON encoded container.
// It used to simply contain an OAuth access_token and refresh_token for a specific provider.
// However now we allow more data to be stored in it. This allows for storing other state or parameters needed for that
// specific provider, like username, password, host names etc.
// The different providers APIs themselves will verify these inner tokens through Provider classes.
// The expiry of the Uppy auth token should be higher than the expiry of the refresh token.
// Because some refresh tokens never expire, we set the Uppy auth token expiry very high.
// Because some refresh tokens normally never expire, we set the Uppy auth token expiry very high.
// Chrome has a maximum cookie expiry of 400 days, so we'll use that (we also store the auth token in a cookie)
//
// If the Uppy auth token expiry were set too low (e.g. 24hr), we could risk this situation:
@ -14,16 +17,21 @@ const { encrypt, decrypt } = require('./utils')
// even though the provider refresh token would still have been accepted and
// there's no way for them to retry their failed files.
// With 400 days, there's still a theoretical possibility but very low.
const EXPIRY = 60 * 60 * 24 * 400
const EXPIRY_MS = EXPIRY * 1000
const MAX_AGE_REFRESH_TOKEN = 60 * 60 * 24 * 400
const MAX_AGE_24H = 60 * 60 * 24
module.exports.MAX_AGE_24H = MAX_AGE_24H
module.exports.MAX_AGE_REFRESH_TOKEN = MAX_AGE_REFRESH_TOKEN
/**
*
* @param {*} data
* @param {string} secret
* @param {number} maxAge
*/
const generateToken = (data, secret) => {
return jwt.sign({ data }, secret, { expiresIn: EXPIRY })
const generateToken = (data, secret, maxAge) => {
return jwt.sign({ data }, secret, { expiresIn: maxAge })
}
/**
@ -41,18 +49,17 @@ const verifyToken = (token, secret) => {
* @param {*} payload
* @param {string} secret
*/
module.exports.generateEncryptedToken = (payload, secret) => {
module.exports.generateEncryptedToken = (payload, secret, maxAge = MAX_AGE_24H) => {
// return payload // for easier debugging
return encrypt(generateToken(payload, secret), secret)
return encrypt(generateToken(payload, secret, maxAge), secret)
}
/**
*
* @param {*} payload
* @param {string} secret
*/
module.exports.generateEncryptedAuthToken = (payload, secret) => {
return module.exports.generateEncryptedToken(JSON.stringify(payload), secret)
module.exports.generateEncryptedAuthToken = (payload, secret, maxAge) => {
return module.exports.generateEncryptedToken(JSON.stringify(payload), secret, maxAge)
}
/**
@ -78,14 +85,15 @@ module.exports.verifyEncryptedAuthToken = (token, secret, providerName) => {
return tokens
}
const addToCookies = (res, token, companionOptions, authProvider, prefix) => {
function getCommonCookieOptions ({ companionOptions }) {
const cookieOptions = {
maxAge: EXPIRY_MS,
httpOnly: true,
}
// Fix to show thumbnails on Chrome
// https://community.transloadit.com/t/dropbox-and-box-thumbnails-returning-401-unauthorized/15781/2
// Note that sameSite cookies also require secure (which needs https), so thumbnails don't work from localhost
// to test locally, you can manually find the URL of the image and open it in a separate browser tab
if (companionOptions.server && companionOptions.server.protocol === 'https') {
cookieOptions.sameSite = 'none'
cookieOptions.secure = true
@ -94,14 +102,32 @@ const addToCookies = (res, token, companionOptions, authProvider, prefix) => {
if (companionOptions.cookieDomain) {
cookieOptions.domain = companionOptions.cookieDomain
}
// send signed token to client.
res.cookie(`${prefix}--${authProvider}`, token, cookieOptions)
return cookieOptions
}
module.exports.addToCookiesIfNeeded = (req, res, uppyAuthToken) => {
const getCookieName = (authProvider) => `uppyAuthToken--${authProvider}`
const addToCookies = ({ res, token, companionOptions, authProvider, maxAge = MAX_AGE_24H * 1000 }) => {
const cookieOptions = {
...getCommonCookieOptions({ companionOptions }),
maxAge,
}
// send signed token to client.
res.cookie(getCookieName(authProvider), token, cookieOptions)
}
module.exports.addToCookiesIfNeeded = (req, res, uppyAuthToken, maxAge) => {
// some providers need the token in cookies for thumbnail/image requests
if (req.companion.provider.needsCookieAuth) {
addToCookies(res, uppyAuthToken, req.companion.options, req.companion.provider.authProvider, 'uppyAuthToken')
addToCookies({
res,
token: uppyAuthToken,
companionOptions: req.companion.options,
authProvider: req.companion.provider.authProvider,
maxAge,
})
}
}
@ -112,14 +138,9 @@ module.exports.addToCookiesIfNeeded = (req, res, uppyAuthToken) => {
* @param {string} authProvider
*/
module.exports.removeFromCookies = (res, companionOptions, authProvider) => {
const cookieOptions = {
maxAge: EXPIRY_MS,
httpOnly: true,
}
// options must be identical to those given to res.cookie(), excluding expires and maxAge.
// https://expressjs.com/en/api.html#res.clearCookie
const cookieOptions = getCommonCookieOptions({ companionOptions })
if (companionOptions.cookieDomain) {
cookieOptions.domain = companionOptions.cookieDomain
}
res.clearCookie(`uppyAuthToken--${authProvider}`, cookieOptions)
res.clearCookie(getCookieName(authProvider), cookieOptions)
}

View file

@ -2,33 +2,26 @@ const crypto = require('node:crypto')
const atob = require('atob')
const { encrypt, decrypt } = require('./utils')
const setState = (state, secret) => {
module.exports.encodeState = (state, secret) => {
const encodedState = Buffer.from(JSON.stringify(state)).toString('base64')
return encrypt(encodedState, secret)
}
const getState = (state, secret) => {
const decodeState = (state, secret) => {
const encodedState = decrypt(state, secret)
return JSON.parse(atob(encodedState))
}
module.exports.generateState = (secret) => {
const state = {}
state.id = crypto.randomBytes(10).toString('hex')
return setState(state, secret)
}
module.exports.addToState = (state, data, secret) => {
const stateObj = getState(state, secret)
return setState(Object.assign(stateObj, data), secret)
module.exports.generateState = () => {
return {
id: crypto.randomBytes(10).toString('hex'),
}
}
module.exports.getFromState = (state, name, secret) => {
return getState(state, secret)[name]
return decodeState(state, secret)[name]
}
module.exports.getDynamicStateFromRequest = (req) => {
const dynamic = (req.session.grant || {}).dynamic || {}
const { state } = dynamic
return state
module.exports.getGrantDynamicFromRequest = (req) => {
return req.session.grant?.dynamic ?? {}
}

View file

@ -2,9 +2,7 @@ const Uploader = require('../Uploader')
const logger = require('../logger')
const { respondWithError } = require('../provider/error')
const { ValidationError } = Uploader
async function startDownUpload ({ req, res, getSize, download }) {
async function startDownUpload({ req, res, getSize, download }) {
try {
const size = await getSize()
const { clientSocketConnectTimeout } = req.companion.options
@ -15,22 +13,22 @@ async function startDownUpload ({ req, res, getSize, download }) {
logger.debug('Starting download stream.', null, req.id)
const stream = await download()
// "Forking" off the upload operation to background, so we can return the http request:
;(async () => {
// wait till the client has connected to the socket, before starting
// the download, so that the client can receive all download/upload progress.
logger.debug('Waiting for socket connection before beginning remote download/upload.', null, req.id)
await uploader.awaitReady(clientSocketConnectTimeout)
logger.debug('Socket connection received. Starting remote download/upload.', null, req.id)
// "Forking" off the upload operation to background, so we can return the http request:
; (async () => {
// wait till the client has connected to the socket, before starting
// the download, so that the client can receive all download/upload progress.
logger.debug('Waiting for socket connection before beginning remote download/upload.', null, req.id)
await uploader.awaitReady(clientSocketConnectTimeout)
logger.debug('Socket connection received. Starting remote download/upload.', null, req.id)
await uploader.tryUploadStream(stream)
})().catch((err) => logger.error(err))
await uploader.tryUploadStream(stream)
})().catch((err) => logger.error(err))
// Respond the request
// NOTE: the Uploader will continue running after the http request is responded
res.status(200).json({ token: uploader.token })
} catch (err) {
if (err instanceof ValidationError) {
if (err.name === 'ValidationError') {
logger.debug(err.message, 'uploader.validator.fail')
res.status(400).json({ message: err.message })
return

View file

@ -74,7 +74,7 @@ module.exports.getURLBuilder = (options) => {
*
* @param {string|Buffer} secret
*/
function createSecret (secret) {
function createSecret(secret) {
const hash = crypto.createHash('sha256')
hash.update(secret)
return hash.digest()
@ -85,15 +85,15 @@ function createSecret (secret) {
*
* @returns {Buffer}
*/
function createIv () {
function createIv() {
return crypto.randomBytes(16)
}
function urlEncode (unencoded) {
function urlEncode(unencoded) {
return unencoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '~')
}
function urlDecode (encoded) {
function urlDecode(encoded) {
return encoded.replace(/-/g, '+').replace(/_/g, '/').replace(/~/g, '=')
}
@ -157,6 +157,7 @@ class StreamHttpJsonError extends Error {
super(`Request failed with status ${statusCode}`)
this.statusCode = statusCode
this.responseJson = responseJson
this.name = 'StreamHttpJsonError'
}
}
@ -188,7 +189,7 @@ module.exports.prepareStream = async (stream) => new Promise((resolve, reject) =
reject(err)
})
})
})
module.exports.getBasicAuthHeader = (key, secret) => {
const base64 = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')

View file

@ -1,7 +1,6 @@
const chalk = require('chalk')
const escapeStringRegexp = require('escape-string-regexp')
const util = require('node:util')
const { ProviderApiError, ProviderAuthError } = require('./provider/error')
const valuesToMask = []
/**
@ -24,7 +23,7 @@ exports.setMaskables = (maskables) => {
* @param {string} msg the message whose content should be masked
* @returns {string}
*/
function maskMessage (msg) {
function maskMessage(msg) {
let out = msg
for (const toBeMasked of valuesToMask) {
const toBeReplaced = new RegExp(toBeMasked, 'gi')
@ -53,10 +52,11 @@ const log = ({ arg, tag = '', level, traceId = '', color = (message) => message
const time = new Date().toISOString()
const whitespace = tag && traceId ? ' ' : ''
function msgToString () {
function msgToString() {
// We don't need to log stack trace on special errors that we ourselves have produced
// (to reduce log noise)
if ((arg instanceof ProviderApiError || arg instanceof ProviderAuthError) && typeof arg.message === 'string') {
// @ts-ignore
if ((arg instanceof Error && arg.name === 'ProviderApiError') && typeof arg.message === 'string') {
return arg.message
}
if (typeof arg === 'string') return arg

View file

@ -24,6 +24,7 @@ exports.hasSessionAndProvider = (req, res, next) => {
}
const isOAuthProviderReq = (req) => isOAuthProvider(req.companion.providerClass.authProvider)
const isSimpleAuthProviderReq = (req) => !!req.companion.providerClass.hasSimpleAuth
/**
* Middleware can be used to verify that the current request is to an OAuth provider
@ -38,6 +39,15 @@ exports.hasOAuthProvider = (req, res, next) => {
return next()
}
exports.hasSimpleAuthProvider = (req, res, next) => {
if (!isSimpleAuthProviderReq(req)) {
logger.debug('Provider does not support simple auth.', null, req.id)
return res.sendStatus(400)
}
return next()
}
exports.hasBody = (req, res, next) => {
if (!req.body) {
logger.debug('No body attached to req object. Exiting dispatcher.', null, req.id)
@ -57,7 +67,28 @@ exports.hasSearchQuery = (req, res, next) => {
}
exports.verifyToken = (req, res, next) => {
// for non oauth providers, we just load the static key from options
if (isOAuthProviderReq(req) || isSimpleAuthProviderReq(req)) {
// For OAuth / simple auth provider, we find the encrypted auth token from the header:
const token = req.companion.authToken
if (token == null) {
logger.info('cannot auth token', 'token.verify.unset', req.id)
res.sendStatus(401)
return
}
const { providerName } = req.params
try {
const payload = tokenService.verifyEncryptedAuthToken(token, req.companion.options.secret, providerName)
req.companion.providerUserSession = payload[providerName]
} catch (err) {
logger.error(err.message, 'token.verify.error', req.id)
res.sendStatus(401)
return
}
next()
return
}
// for non auth providers, we just load the static key from options
if (!isOAuthProviderReq(req)) {
const { providerOptions } = req.companion.options
const { providerName } = req.params
@ -67,31 +98,11 @@ exports.verifyToken = (req, res, next) => {
return
}
req.companion.providerTokens = {
req.companion.providerUserSession = {
accessToken: providerOptions[providerName].key,
}
next()
return
}
// Ok, OAuth provider, we fetch the token:
const token = req.companion.authToken
if (token == null) {
logger.info('cannot auth token', 'token.verify.unset', req.id)
res.sendStatus(401)
return
}
const { providerName } = req.params
try {
const payload = tokenService.verifyEncryptedAuthToken(token, req.companion.options.secret, providerName)
req.companion.allProvidersTokens = payload
req.companion.providerTokens = payload[providerName]
} catch (err) {
logger.error(err.message, 'token.verify.error', req.id)
res.sendStatus(401)
return
}
next()
}
// does not fail if token is invalid
@ -102,7 +113,7 @@ exports.gentleVerifyToken = (req, res, next) => {
const payload = tokenService.verifyEncryptedAuthToken(
req.companion.authToken, req.companion.options.secret, providerName,
)
req.companion.allProvidersTokens = payload
req.companion.providerUserSession = payload[providerName]
} catch (err) {
logger.error(err.message, 'token.gentle.verify.error', req.id)
}

View file

@ -1,20 +1,24 @@
const { MAX_AGE_24H } = require('../helpers/jwt')
/**
* Provider interface defines the specifications of any provider implementation
*/
class Provider {
/**
*
* @param {{providerName: string, allowLocalUrls: boolean}} options
* @param {{providerName: string, allowLocalUrls: boolean, providerGrantConfig?: object}} options
*/
constructor ({ allowLocalUrls }) {
constructor ({ allowLocalUrls, providerGrantConfig }) {
// Some providers might need cookie auth for the thumbnails fetched via companion
this.needsCookieAuth = false
this.allowLocalUrls = allowLocalUrls
this.providerGrantConfig = providerGrantConfig
return this
}
/**
* config to extend the grant config
* todo major: rename to getExtraGrantConfig
*/
static getExtraConfig () {
return {}
@ -85,13 +89,36 @@ class Provider {
}
/**
* Name of the OAuth provider. Return empty string if no OAuth provider is needed.
* @param {any} param0
* @returns {Promise<any>}
*/
// eslint-disable-next-line no-unused-vars, class-methods-use-this
async simpleAuth ({ requestBody }) {
throw new Error('method not implemented')
}
/**
* Name of the OAuth provider (passed to Grant). Return empty string if no OAuth provider is needed.
*
* @returns {string}
*/
// todo next major: rename authProvider to oauthProvider (we have other non-oauth auth types too now)
static get authProvider () {
return undefined
}
// eslint-disable-next-line no-unused-vars
static grantDynamicToUserSession ({ grantDynamic }) {
return {}
}
static get hasSimpleAuth () {
return false
}
static get authStateExpiry () {
return MAX_AGE_24H
}
}
module.exports = Provider

View file

@ -88,7 +88,7 @@ class Box extends Provider {
})
await prepareStream(stream)
return { stream }
return { stream, contentType: 'image/jpeg' }
})
}

View file

@ -83,17 +83,15 @@ exports.getCredentialsOverrideMiddleware = (providers, companionOptions) => {
return
}
const dynamicState = oAuthState.getDynamicStateFromRequest(req)
const grantDynamic = oAuthState.getGrantDynamicFromRequest(req)
// only use state via session object if user isn't making intial "connect" request.
// override param indicates subsequent requests from the oauth flow
const state = override ? dynamicState : req.query.state
const state = override ? grantDynamic.state : req.query.state
if (!state) {
next()
return
}
// pre auth token is companionKeysParams encoded and encrypted by companion before the oauth flow,
// I believe this has been done so that it cannot be modified by the client later.
const preAuthToken = oAuthState.getFromState(state, 'preAuthToken', companionOptions.secret)
if (!preAuthToken) {
next()

View file

@ -5,6 +5,7 @@ const logger = require('../../logger')
const { VIRTUAL_SHARED_DIR, adaptData, isShortcut, isGsuiteFile, getGsuiteExportType } = require('./adapter')
const { withProviderErrorHandling } = require('../providerErrors')
const { prepareStream } = require('../../helpers/utils')
const { MAX_AGE_REFRESH_TOKEN } = require('../../helpers/jwt')
const { ProviderAuthError } = require('../error')
@ -59,6 +60,10 @@ class Drive extends Provider {
return 'google'
}
static get authStateExpiry () {
return MAX_AGE_REFRESH_TOKEN
}
async list (options) {
return this.#withErrorHandling('provider.drive.list.error', async () => {
const directory = options.directory || 'root'

View file

@ -4,6 +4,7 @@ const Provider = require('../Provider')
const adaptData = require('./adapter')
const { withProviderErrorHandling } = require('../providerErrors')
const { prepareStream } = require('../../helpers/utils')
const { MAX_AGE_REFRESH_TOKEN } = require('../../helpers/jwt')
// From https://www.dropbox.com/developers/reference/json-encoding:
//
@ -63,6 +64,10 @@ class DropBox extends Provider {
return 'dropbox'
}
static get authStateExpiry () {
return MAX_AGE_REFRESH_TOKEN
}
/**
*
* @param {object} options
@ -100,13 +105,13 @@ class DropBox extends Provider {
return this.#withErrorHandling('provider.dropbox.thumbnail.error', async () => {
const stream = getClient({ token }).stream.post('files/get_thumbnail_v2', {
prefixUrl: 'https://content.dropboxapi.com/2',
headers: { 'Dropbox-API-Arg': httpHeaderSafeJson({ resource: { '.tag': 'path', path: `${id}` }, size: 'w256h256' }) },
headers: { 'Dropbox-API-Arg': httpHeaderSafeJson({ resource: { '.tag': 'path', path: `${id}` }, size: 'w256h256', format: 'jpeg' }) },
body: Buffer.alloc(0),
responseType: 'json',
})
await prepareStream(stream)
return { stream }
return { stream, contentType: 'image/jpeg' }
})
}

View file

@ -1,19 +0,0 @@
// We need explicit type declarations for `errors.js` because of a typescript bug when generating declaration files.
// I think it's this one:
// https://github.com/microsoft/TypeScript/issues/37832
//
// We could try removing this file when we upgrade to 4.1 :)
export class ProviderApiError extends Error {
constructor(message: string, statusCode: number)
}
export class ProviderAuthError extends ProviderApiError {
constructor()
}
export function errorToResponse(anyError: Error): {
code: number
message: string
}
export function respondWithError(anyError: Error, res: any): boolean

View file

@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
/**
* ProviderApiError is error returned when an adapter encounters
* an http error while communication with its corresponding provider
@ -7,7 +8,7 @@ class ProviderApiError extends Error {
* @param {string} message error message
* @param {number} statusCode the http status code from the provider api
*/
constructor (message, statusCode) {
constructor(message, statusCode) {
super(`HTTP ${statusCode}: ${message}`) // Include statusCode to make it easier to debug
this.name = 'ProviderApiError'
this.statusCode = statusCode
@ -15,12 +16,23 @@ class ProviderApiError extends Error {
}
}
class ProviderUserError extends ProviderApiError {
/**
* @param {object} json arbitrary JSON.stringify-able object that will be passed to the client
*/
constructor(json) {
super('User error', undefined)
this.name = 'ProviderUserError'
this.json = json
}
}
/**
* AuthError is error returned when an adapter encounters
* an authorization error while communication with its corresponding provider
*/
class ProviderAuthError extends ProviderApiError {
constructor () {
constructor() {
super('invalid access token detected by Provider', 401)
this.name = 'AuthError'
this.isAuthError = true
@ -32,37 +44,46 @@ class ProviderAuthError extends ProviderApiError {
*
* @param {Error | ProviderApiError} err the error instance to convert to an http json response
*/
function errorToResponse (err) {
if (err instanceof ProviderAuthError && err.isAuthError) {
return { code: 401, message: err.message }
function errorToResponse(err) {
// @ts-ignore
if (err?.isAuthError) {
return { code: 401, json: { message: err.message } }
}
if (err instanceof ProviderApiError) {
if (err?.name === 'ProviderUserError') {
// @ts-ignore
return { code: 400, json: err.json }
}
if (err?.name === 'ProviderApiError') {
// @ts-ignore
if (err.statusCode >= 500) {
// bad gateway i.e the provider APIs gateway
return { code: 502, message: err.message }
return { code: 502, json: { message: err.message } }
}
// @ts-ignore
if (err.statusCode === 429) {
return { code: 429, message: err.message }
}
// @ts-ignore
if (err.statusCode >= 400) {
// 424 Failed Dependency
return { code: 424, message: err.message }
return { code: 424, json: { message: err.message } }
}
}
return undefined
}
function respondWithError (err, res) {
function respondWithError(err, res) {
const errResp = errorToResponse(err)
if (errResp) {
res.status(errResp.code).json({ message: errResp.message })
res.status(errResp.code).json(errResp.json)
return true
}
return false
}
module.exports = { ProviderAuthError, ProviderApiError, errorToResponse, respondWithError }
module.exports = { ProviderAuthError, ProviderApiError, ProviderUserError, respondWithError }

View file

@ -36,7 +36,7 @@ const providerNameToAuthName = (name, options) => { // eslint-disable-line no-un
return (providers[name] || {}).authProvider
}
function getGrantConfigForProvider ({ providerName, companionOptions, grantConfig }) {
function getGrantConfigForProvider({ providerName, companionOptions, grantConfig }) {
const authProvider = providerNameToAuthName(providerName, companionOptions)
if (!isOAuthProvider(authProvider)) return undefined
@ -63,13 +63,17 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => {
const ProviderClass = providers[providerName]
if (ProviderClass && validOptions(req.companion.options)) {
const { allowLocalUrls } = req.companion.options
req.companion.provider = new ProviderClass({ providerName, allowLocalUrls })
req.companion.providerClass = ProviderClass
req.companion.providerGrantConfig = grantConfig[ProviderClass.authProvider]
const { authProvider } = ProviderClass
if (isOAuthProvider(ProviderClass.authProvider)) {
let providerGrantConfig
if (isOAuthProvider(authProvider)) {
req.companion.getProviderCredentials = getCredentialsResolver(providerName, req.companion.options, req)
providerGrantConfig = grantConfig[authProvider]
req.companion.providerGrantConfig = providerGrantConfig
}
req.companion.provider = new ProviderClass({ providerName, providerGrantConfig, allowLocalUrls })
req.companion.providerClass = ProviderClass
} else {
logger.warn('invalid provider options detected. Provider will not be loaded', 'provider.middleware.invalid', req.id)
}

View file

@ -1,29 +1,37 @@
const { HTTPError } = require('got').default
const logger = require('../logger')
const { ProviderApiError, ProviderAuthError } = require('./error')
const { StreamHttpJsonError } = require('../helpers/utils')
const { ProviderApiError, ProviderUserError, ProviderAuthError } = require('./error')
/**
*
* @param {{
* fn: () => any, tag: string, providerName: string, isAuthError: (a: { statusCode: number, body?: object }) => boolean,
* fn: () => any,
* tag: string,
* providerName: string,
* isAuthError?: (a: { statusCode: number, body?: object }) => boolean,
* isUserFacingError?: (a: { statusCode: number, body?: object }) => boolean,
* getJsonErrorMessage: (a: object) => string
* }} param0
* @returns
*/
async function withProviderErrorHandling ({ fn, tag, providerName, isAuthError = () => false, getJsonErrorMessage }) {
function getErrorMessage (response) {
if (typeof response.body === 'object') {
const message = getJsonErrorMessage(response.body)
async function withProviderErrorHandling({
fn,
tag,
providerName,
isAuthError = () => false,
isUserFacingError = () => false,
getJsonErrorMessage,
}) {
function getErrorMessage({ statusCode, body }) {
if (typeof body === 'object') {
const message = getJsonErrorMessage(body)
if (message != null) return message
}
if (typeof response.body === 'string') {
return response.body
if (typeof body === 'string') {
return body
}
return `request to ${providerName} returned ${response.statusCode}`
return `request to ${providerName} returned ${statusCode}`
}
try {
@ -32,21 +40,26 @@ async function withProviderErrorHandling ({ fn, tag, providerName, isAuthError =
let statusCode
let body
if (err instanceof HTTPError) {
if (err?.name === 'HTTPError') {
statusCode = err.response?.statusCode
body = err.response?.body
} else if (err instanceof StreamHttpJsonError) {
statusCode = err.statusCode
} else if (err?.name === 'StreamHttpJsonError') {
statusCode = err.statusCode
body = err.responseJson
}
if (statusCode != null) {
const err2 = isAuthError({ statusCode, body })
? new ProviderAuthError()
: new ProviderApiError(getErrorMessage(body), statusCode)
let knownErr
if (isAuthError({ statusCode, body })) {
knownErr = new ProviderAuthError()
} else if (isUserFacingError({ statusCode, body })) {
knownErr = new ProviderUserError({ message: getErrorMessage({ statusCode, body }) })
} else {
knownErr = new ProviderApiError(getErrorMessage({ statusCode, body }), statusCode)
}
logger.error(err2, tag)
throw err2
logger.error(knownErr, tag)
throw knownErr
}
logger.error(err, tag)

View file

@ -17,7 +17,7 @@ const { getCompanionOptions, generateSecret, buildHelpfulStartupMessage } = requ
*
* @returns {object}
*/
module.exports = function server (inputCompanionOptions) {
module.exports = function server(inputCompanionOptions) {
const companionOptions = getCompanionOptions(inputCompanionOptions)
companion.setLoggerProcessName(companionOptions)
@ -52,7 +52,7 @@ module.exports = function server (inputCompanionOptions) {
* censored: boolean
* }}
*/
function censorQuery (rawQuery) {
function censorQuery(rawQuery) {
/** @type {Record<string, any>} */
const query = {}
let censored = false
@ -172,7 +172,7 @@ module.exports = function server (inputCompanionOptions) {
if (app.get('env') === 'production') {
// if the error is a URIError from the requested URL we only log the error message
// to avoid uneccessary error alerts
if (err.status === 400 && err instanceof URIError) {
if (err.status === 400 && err.name === 'URIError') {
logger.error(err.message, 'root.error', req.id)
} else {
logger.error(err, 'root.error', req.id)

View file

@ -41,7 +41,8 @@ export default class BasePlugin {
}
i18nInit () {
const translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale])
const onMissingKey = (key) => this.uppy.log(`Missing i18n string: ${key}`, 'error')
const translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale], { onMissingKey })
this.i18n = translator.translate.bind(translator)
this.i18nArray = translator.translateArray.bind(translator)
this.setPluginState() // so that UI re-renders and we see the updated locale

View file

@ -204,7 +204,8 @@ class Uppy {
}
i18nInit () {
const translator = new Translator([this.defaultLocale, this.opts.locale])
const onMissingKey = (key) => this.log(`Missing i18n string: ${key}`, 'error')
const translator = new Translator([this.defaultLocale, this.opts.locale], { onMissingKey })
this.i18n = translator.translate.bind(translator)
this.i18nArray = translator.translateArray.bind(translator)
this.locale = translator.locale

View file

@ -27,6 +27,7 @@ export default class Dropbox extends UIPlugin {
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'dropbox',
pluginId: this.id,
supportsRefreshToken: true,
})
this.defaultLocale = locale

View file

@ -30,6 +30,7 @@ export default class Facebook extends UIPlugin {
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'facebook',
pluginId: this.id,
supportsRefreshToken: false,
})
this.defaultLocale = locale

View file

@ -41,6 +41,7 @@ export default class GoogleDrive extends UIPlugin {
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'drive',
pluginId: this.id,
supportsRefreshToken: true,
})
this.defaultLocale = locale

View file

@ -40,6 +40,7 @@ export default class Instagram extends UIPlugin {
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'instagram',
pluginId: this.id,
supportsRefreshToken: false,
})
this.onFirstRender = this.onFirstRender.bind(this)

View file

@ -32,6 +32,7 @@ export default class OneDrive extends UIPlugin {
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'onedrive',
pluginId: this.id,
supportsRefreshToken: false,
})
this.defaultLocale = locale

View file

@ -1,4 +1,5 @@
import { h } from 'preact'
import { useCallback } from 'preact/hooks'
function GoogleIcon () {
return (
@ -36,12 +37,47 @@ function GoogleIcon () {
)
}
function AuthView (props) {
const { pluginName, pluginIcon, i18nArray, handleAuth } = props
const DefaultForm = ({ pluginName, i18n, onAuth }) => {
// In order to comply with Google's brand we need to create a different button
// for the Google Drive plugin
const isGoogleDrive = pluginName === 'Google Drive'
const onSubmit = useCallback((e) => {
e.preventDefault()
onAuth()
}, [onAuth])
return (
<form onSubmit={onSubmit}>
{isGoogleDrive ? (
<button
type="submit"
className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn uppy-Provider-btn-google"
data-uppy-super-focusable
>
<GoogleIcon />
{i18n('signInWithGoogle')}
</button>
) : (
<button
type="submit"
className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn"
data-uppy-super-focusable
>
{i18n('authenticateWith', { pluginName })}
</button>
)}
</form>
)
}
const defaultRenderForm = ({ pluginName, i18n, onAuth }) => (
<DefaultForm pluginName={pluginName} i18n={i18n} onAuth={onAuth} />
)
function AuthView (props) {
const { loading, pluginName, pluginIcon, i18n, handleAuth, renderForm = defaultRenderForm } = props
const pluginNameComponent = (
<span className="uppy-Provider-authTitleName">
{pluginName}
@ -52,30 +88,14 @@ function AuthView (props) {
<div className="uppy-Provider-auth">
<div className="uppy-Provider-authIcon">{pluginIcon()}</div>
<div className="uppy-Provider-authTitle">
{i18nArray('authenticateWithTitle', {
{i18n('authenticateWithTitle', {
pluginName: pluginNameComponent,
})}
</div>
{isGoogleDrive ? (
<button
type="button"
className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn uppy-Provider-btn-google"
onClick={handleAuth}
data-uppy-super-focusable
>
<GoogleIcon />
{i18nArray('signInWithGoogle')}
</button>
) : (
<button
type="button"
className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-Provider-authBtn"
onClick={handleAuth}
data-uppy-super-focusable
>
{i18nArray('authenticateWith', { pluginName })}
</button>
)}
<div className="uppy-Provider-authForm">
{renderForm({ pluginName, i18n, loading, onAuth: handleAuth })}
</div>
</div>
)
}

View file

@ -6,7 +6,6 @@ import { getSafeFileId } from '@uppy/utils/lib/generateFileID'
import AuthView from './AuthView.jsx'
import Header from './Header.jsx'
import Browser from '../Browser.jsx'
import LoaderView from '../Loader.jsx'
import CloseWrapper from '../CloseWrapper.js'
import View from '../View.js'
@ -68,7 +67,7 @@ export default class ProviderView extends View {
// Set default state for the plugin
this.plugin.setPluginState({
authenticated: false,
authenticated: undefined, // we don't know yet
files: [],
folders: [],
breadcrumbs: [],
@ -186,6 +185,13 @@ export default class ProviderView extends View {
this.plugin.setPluginState({ folders, files, breadcrumbs, filterInput: '' })
})
} catch (err) {
// This is the first call that happens when the provider view loads, after auth, so it's probably nice to show any
// error occurring here to the user.
if (err?.name === 'UserFacingApiError') {
this.plugin.uppy.info({ message: this.plugin.uppy.i18n(err.message) }, 'warning', 5000)
return
}
this.handleError(err)
} finally {
this.setLoading(false)
@ -241,13 +247,23 @@ export default class ProviderView extends View {
this.plugin.setPluginState({ filterInput: '' })
}
async handleAuth () {
async handleAuth (authFormData) {
try {
await this.provider.login()
this.plugin.setPluginState({ authenticated: true })
this.preFirstRender()
} catch (e) {
this.plugin.uppy.log(`login failed: ${e.message}`)
await this.#withAbort(async (signal) => {
this.setLoading(true)
await this.provider.login({ authFormData, signal })
this.plugin.setPluginState({ authenticated: true })
this.preFirstRender()
})
} catch (err) {
if (err.name === 'UserFacingApiError') {
this.plugin.uppy.info({ message: this.plugin.uppy.i18n(err.message) }, 'warning', 5000)
return
}
this.plugin.uppy.log(`login failed: ${err.message}`)
} finally {
this.setLoading(false)
}
}
@ -429,7 +445,6 @@ export default class ProviderView extends View {
currentSelection,
files: hasInput ? filterItems(files) : files,
folders: hasInput ? filterItems(folders) : folders,
username: this.username,
getNextFolder: this.getNextFolder,
getFolder: this.getFolder,
loadAllFiles: this.opts.loadAllFiles,
@ -457,25 +472,19 @@ export default class ProviderView extends View {
i18n: this.plugin.uppy.i18n,
uppyFiles: this.plugin.uppy.getFiles(),
validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args),
isLoading: loading,
}
if (loading) {
return (
<CloseWrapper onUnmount={this.clearSelection}>
<LoaderView i18n={this.plugin.uppy.i18n} loading={loading} />
</CloseWrapper>
)
}
if (!authenticated) {
if (authenticated === false) {
return (
<CloseWrapper onUnmount={this.clearSelection}>
<AuthView
pluginName={this.plugin.title}
pluginIcon={pluginIcon}
handleAuth={this.handleAuth}
i18n={this.plugin.uppy.i18n}
i18nArray={this.plugin.uppy.i18nArray}
i18n={this.plugin.uppy.i18nArray}
renderForm={this.opts.renderAuthForm}
loading={loading}
/>
</CloseWrapper>
)

View file

@ -65,7 +65,8 @@
"./lib/FOCUSABLE_ELEMENTS.js": "./lib/FOCUSABLE_ELEMENTS.js",
"./lib/fileFilters": "./lib/fileFilters.js",
"./lib/VirtualList": "./lib/VirtualList.js",
"./src/microtip.scss": "./src/microtip.scss"
"./src/microtip.scss": "./src/microtip.scss",
"./lib/UserFacingApiError": "./lib/UserFacingApiError.js"
},
"dependencies": {
"lodash": "^4.17.21",

View file

@ -1,5 +1,3 @@
import has from './hasProperty.ts'
// We're using a generic because languages have different plural rules.
export interface Locale<T extends number = number> {
strings: Record<string, string | Record<T, string>>
@ -84,6 +82,10 @@ function interpolate(
return interpolated
}
const defaultOnMissingKey = (key: string): void => {
throw new Error(`missing string: ${key}`)
}
/**
* Translates strings with interpolation & pluralization support.
* Extensible with custom dictionaries and pluralization functions.
@ -98,7 +100,10 @@ function interpolate(
export default class Translator {
protected locale: Locale
constructor(locales: Locale | Locale[]) {
constructor(
locales: Locale | Locale[],
{ onMissingKey = defaultOnMissingKey } = {},
) {
this.locale = {
strings: {},
pluralize(n: number): 0 | 1 {
@ -114,8 +119,12 @@ export default class Translator {
} else {
this.#apply(locales)
}
this.#onMissingKey = onMissingKey
}
#onMissingKey
#apply(locale?: Locale): void {
if (!locale?.strings) {
return
@ -146,11 +155,11 @@ export default class Translator {
* @returns The translated and interpolated parts, in order.
*/
translateArray(key: string, options?: Options): Array<string | unknown> {
if (!has(this.locale.strings, key)) {
throw new Error(`missing string: ${key}`)
let string = this.locale.strings[key]
if (string == null) {
this.#onMissingKey(key)
string = key
}
const string = this.locale.strings[key]
const hasPluralForms = typeof string === 'object'
if (hasPluralForms) {
@ -163,6 +172,10 @@ export default class Translator {
)
}
if (typeof string !== 'string') {
throw new Error(`string was not a string`)
}
return interpolate(string, options)
}
}

View file

@ -0,0 +1,5 @@
class UserFacingApiError extends Error {
name = 'UserFacingApiError'
}
export default UserFacingApiError

View file

@ -28,6 +28,7 @@ export default class Zoom extends UIPlugin {
companionCookiesRule: this.opts.companionCookiesRule,
provider: 'zoom',
pluginId: this.id,
supportsRefreshToken: false,
})
this.defaultLocale = locale