diff --git a/src/lib/Preview.js b/src/lib/Preview.js index 031087f8e..ffb50cfe6 100644 --- a/src/lib/Preview.js +++ b/src/lib/Preview.js @@ -109,9 +109,6 @@ class Preview extends EventEmitter { /** @property {Object} - Map of disabled viewer names */ disabledViewers = {}; - /** @property {string} - Access token */ - token = ''; - /** @property {Object} - Current viewer instance */ viewer; @@ -1002,6 +999,7 @@ class Preview extends EventEmitter { location: this.location, cache: this.cache, ui: this.ui, + refreshToken: this.refreshToken, }); } @@ -1882,6 +1880,21 @@ class Preview extends EventEmitter { const fileId = typeof fileIdOrFile === 'string' ? fileIdOrFile : fileIdOrFile.id; return getProp(this.previewOptions, `fileOptions.${fileId}.${optionName}`); } + + /** + * Refresh the access token + * + * @private + * @return {Promise} + */ + refreshToken = () => { + if (typeof this.previewOptions.token !== 'function') { + return Promise.reject(new Error('Token is not a function and cannot be refreshed.')); + } + return getTokens(this.file.id, this.previewOptions.token).then( + tokenOrTokenMap => tokenOrTokenMap[this.file.id], + ); + }; } global.Box = global.Box || {}; diff --git a/src/lib/__tests__/Preview-test.js b/src/lib/__tests__/Preview-test.js index b0db3fe09..d5b7df861 100644 --- a/src/lib/__tests__/Preview-test.js +++ b/src/lib/__tests__/Preview-test.js @@ -62,7 +62,6 @@ describe('lib/Preview', () => { expect(preview.file).to.deep.equal({}); expect(preview.options).to.deep.equal({}); expect(preview.disabledViewers).to.deep.equal({ Office: 1 }); - expect(preview.token).to.equal(''); expect(preview.loaders).to.equal(loaders); expect(preview.location.hostname).to.equal('localhost'); }); @@ -2837,5 +2836,26 @@ describe('lib/Preview', () => { expect(preview.getFileOption('123', 'fileVersionId')).to.equal(undefined); }); }); + + describe('refreshToken()', () => { + it('should return a new token if the previewOptions.token is a function', done => { + preview.file = { + id: 'file_123', + }; + preview.previewOptions.token = id => Promise.resolve({ [id]: 'new_token' }); + preview.refreshToken().then(token => { + expect(token).to.equal('new_token'); + done(); + }); + }); + + it('should reject if previewOptions.token is not a function', done => { + preview.previewOptions.token = 'token'; + preview.refreshToken().catch(error => { + expect(error.message).to.equal('Token is not a function and cannot be refreshed.'); + done(); + }); + }); + }); }); /* eslint-enable no-unused-expressions */ diff --git a/src/lib/events.js b/src/lib/events.js index bcfc7c46b..d7a3a592a 100644 --- a/src/lib/events.js +++ b/src/lib/events.js @@ -46,6 +46,7 @@ export const ERROR_CODE = { PREFETCH_FILE: 'error_prefetch_file', RATE_LIMIT: 'error_rate_limit', SHAKA: 'error_shaka', + TOKEN_NOT_VALID: 'error_token_function_not_valid', UNSUPPORTED_FILE_TYPE: 'error_unsupported_file_type', VIEWER_LOAD_TIMEOUT: 'error_viewer_load_timeout', }; diff --git a/src/lib/viewers/media/DashViewer.js b/src/lib/viewers/media/DashViewer.js index 592a2b539..13bf53126 100644 --- a/src/lib/viewers/media/DashViewer.js +++ b/src/lib/viewers/media/DashViewer.js @@ -40,6 +40,7 @@ class DashViewer extends VideoBaseViewer { this.loadeddataHandler = this.loadeddataHandler.bind(this); this.requestFilter = this.requestFilter.bind(this); this.shakaErrorHandler = this.shakaErrorHandler.bind(this); + this.restartPlayback = this.restartPlayback.bind(this); } /** @@ -477,6 +478,32 @@ class DashViewer extends VideoBaseViewer { this.hideLoadingIcon(); } + /** + * Determain whether is an expired token error + * + * @private + * @param {Object} details - error details + * @return {bool} + */ + isExpiredTokenError({ details }) { + // unauthorized error may be caused by token expired + return details.code === shaka.util.Error.Code.BAD_HTTP_STATUS && details.data[1] === 401; + } + + /** + * Restart playback using new token + * + * @private + * @param {string} newToken - new token + * @return {void} + */ + restartPlayback(newToken) { + this.options.token = newToken; + if (this.player.retryStreaming()) { + this.retryTokenCount = 0; + } + } + /** * Handles errors thrown by shaka player. See https://shaka-player-demo.appspot.com/docs/api/shaka.util.Error.html * @@ -491,6 +518,7 @@ class DashViewer extends VideoBaseViewer { __('error_refresh'), { code: normalizedShakaError.code, + data: normalizedShakaError.data, severity: normalizedShakaError.severity, }, `Shaka error. Code = ${normalizedShakaError.code}, Category = ${ @@ -498,6 +526,10 @@ class DashViewer extends VideoBaseViewer { }, Severity = ${normalizedShakaError.severity}, Data = ${normalizedShakaError.data.toString()}`, ); + if (this.handleExpiredTokenError(error)) { + return; + } + if (normalizedShakaError.severity > SHAKA_CODE_ERROR_RECOVERABLE) { // Anything greater than a recoverable error should be critical if (normalizedShakaError.code === shaka.util.Error.Code.HTTP_ERROR) { @@ -505,6 +537,7 @@ class DashViewer extends VideoBaseViewer { this.handleDownloadError(error, downloadURL); return; } + // critical error this.triggerError(error); } diff --git a/src/lib/viewers/media/MediaBaseViewer.js b/src/lib/viewers/media/MediaBaseViewer.js index 0eef859ab..73b2b80b3 100644 --- a/src/lib/viewers/media/MediaBaseViewer.js +++ b/src/lib/viewers/media/MediaBaseViewer.js @@ -1,4 +1,5 @@ import debounce from 'lodash/debounce'; +import isEmpty from 'lodash/isEmpty'; import BaseViewer from '../BaseViewer'; import Browser from '../../Browser'; import MediaControls from './MediaControls'; @@ -21,6 +22,8 @@ const INITIAL_TIME_IN_SECONDS = 0; const ONE_MINUTE_IN_SECONDS = 60; const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS; const PLAY_PROMISE_NOT_SUPPORTED = 'play_promise_not_supported'; +const MEDIA_TOKEN_EXPIRE_ERROR = 'PIPELINE_ERROR_READ'; +const MAX_RETRY_TOKEN = 3; // number of times to retry refreshing token for unauthorized error class MediaBaseViewer extends BaseViewer { /** @property {Object} - Keeps track of the different media metrics */ @@ -33,6 +36,9 @@ class MediaBaseViewer extends BaseViewer { [MEDIA_METRIC.watchLength]: 0, }; + /** @property {number} - Number of times refreshing token has been retried for unauthorized error */ + retryTokenCount = 0; + /** * @inheritdoc */ @@ -61,6 +67,7 @@ class MediaBaseViewer extends BaseViewer { this.toggleMute = this.toggleMute.bind(this); this.togglePlay = this.togglePlay.bind(this); this.updateVolumeIcon = this.updateVolumeIcon.bind(this); + this.restartPlayback = this.restartPlayback.bind(this); window.addEventListener('beforeunload', this.processMetrics); } @@ -229,6 +236,14 @@ class MediaBaseViewer extends BaseViewer { return; } + // If it's already loaded, this handler should be triggered by refreshing token, + // so we want to continue playing from the previous time, and don't need to load UI again. + if (this.loaded) { + this.play(this.currentTime); + this.retryTokenCount = 0; + return; + } + this.loadUI(); if (this.isAutoplayEnabled()) { @@ -260,6 +275,73 @@ class MediaBaseViewer extends BaseViewer { this.wrapperEl.classList.add(CLASS_IS_VISIBLE); } + /** + * Determain whether is an expired token error + * + * @protected + * @param {Object} details - error details + * @return {bool} + */ + isExpiredTokenError({ details }) { + return ( + !isEmpty(details) && + details.error_code === MediaError.MEDIA_ERR_NETWORK && + details.error_message.includes(MEDIA_TOKEN_EXPIRE_ERROR) + ); + } + + /** + * Restart playback using new token + * + * @protected + * @param {string} newToken - new token + * @return {void} + */ + restartPlayback(newToken) { + const { currentTime } = this.mediaEl; + this.currentTime = currentTime; + this.options.token = newToken; + this.mediaUrl = this.createContentUrlWithAuthParams(this.options.representation.content.url_template); + this.mediaEl.src = this.mediaUrl; + } + + /** + * Handle expired token error + * + * @protected + * @param {PreviewError} error + * @return {boolean} True if it is a token error and is handled + */ + handleExpiredTokenError(error) { + if (this.isExpiredTokenError(error)) { + if (this.retryTokenCount >= MAX_RETRY_TOKEN) { + const tokenError = new PreviewError( + ERROR_CODE.TOKEN_NOT_VALID, + null, + { silent: true }, + 'Reach refreshing token limit for unauthorized error.', + ); + this.triggerError(tokenError); + } else { + this.options + .refreshToken() + .then(this.restartPlayback) + .catch(e => { + const tokenError = new PreviewError( + ERROR_CODE.TOKEN_NOT_VALID, + null, + { silent: true }, + e.message, + ); + this.triggerError(tokenError); + }); + this.retryTokenCount += 1; + } + return true; + } + return false; + } + /** * Handles media element loading errors. * @@ -273,10 +355,14 @@ class MediaBaseViewer extends BaseViewer { console.error(err); const errorCode = getProp(err, 'target.error.code'); - const errorDetails = errorCode ? { error_code: errorCode } : {}; - + const errorMessage = getProp(err, 'target.error.message'); + const errorDetails = errorCode ? { error_code: errorCode, error_message: errorMessage } : {}; const error = new PreviewError(ERROR_CODE.LOAD_MEDIA, __('error_refresh'), errorDetails); + if (this.handleExpiredTokenError(error)) { + return; + } + if (!this.isLoaded()) { this.handleDownloadError(error, this.mediaUrl); } else { diff --git a/src/lib/viewers/media/__tests__/MediaBaseViewer-test.js b/src/lib/viewers/media/__tests__/MediaBaseViewer-test.js index c3e53285e..abe34fc2b 100644 --- a/src/lib/viewers/media/__tests__/MediaBaseViewer-test.js +++ b/src/lib/viewers/media/__tests__/MediaBaseViewer-test.js @@ -4,7 +4,10 @@ import MediaBaseViewer from '../MediaBaseViewer'; import BaseViewer from '../../BaseViewer'; import Timer from '../../../Timer'; import { CLASS_ELEM_KEYBOARD_FOCUS } from '../../../constants'; -import { VIEWER_EVENT } from '../../../events'; +import { ERROR_CODE, VIEWER_EVENT } from '../../../events'; +import PreviewError from '../../../PreviewError'; + +const MAX_RETRY_TOKEN = 3; // number of times to retry refreshing token for unauthorized error let media; let stubs; @@ -185,12 +188,42 @@ describe('lib/viewers/media/MediaBaseViewer', () => { }); }); + describe('handleExpiredTokenError()', () => { + it('should not trigger error if is not an ExpiredTokenError', () => { + sandbox.stub(media, 'isExpiredTokenError').returns(false); + sandbox.stub(media, 'triggerError'); + const error = new PreviewError(ERROR_CODE.LOAD_MEDIA); + media.handleExpiredTokenError(error); + expect(media.triggerError).to.not.be.called; + }); + + it('should trigger error if retry token count reaches max retry limit', () => { + media.retryTokenCount = MAX_RETRY_TOKEN + 1; + sandbox.stub(media, 'isExpiredTokenError').returns(true); + sandbox.stub(media, 'triggerError'); + const error = new PreviewError(ERROR_CODE.LOAD_MEDIA); + media.handleExpiredTokenError(error); + expect(media.triggerError).to.be.calledWith(sinon.match.has('code', ERROR_CODE.TOKEN_NOT_VALID)); + }); + + it('should call refreshToken if retry token count did not reach max retry limit', () => { + media.retryTokenCount = 0; + sandbox.stub(media, 'isExpiredTokenError').returns(true); + media.options.refreshToken = sandbox.stub().returns(Promise.resolve()); + const error = new PreviewError(ERROR_CODE.LOAD_MEDIA); + media.handleExpiredTokenError(error); + + expect(media.options.refreshToken).to.be.called; + expect(media.retryTokenCount).to.equal(1); + }); + }); + describe('errorHandler()', () => { it('should handle download error if the viewer was not yet loaded', () => { + const err = new Error(); media.mediaUrl = 'foo'; sandbox.stub(media, 'isLoaded').returns(false); sandbox.stub(media, 'handleDownloadError'); - const err = new Error(); media.errorHandler(err); @@ -198,9 +231,9 @@ describe('lib/viewers/media/MediaBaseViewer', () => { }); it('should trigger an error if Preview is already loaded', () => { + const err = new Error(); sandbox.stub(media, 'isLoaded').returns(true); sandbox.stub(media, 'triggerError'); - const err = new Error(); media.errorHandler(err);