From 0b3d6072b6d8d0aae710d342c93de1d8bb7b0aa7 Mon Sep 17 00:00:00 2001 From: Kire Mitrov Date: Wed, 25 Sep 2024 12:59:20 +0200 Subject: [PATCH 1/2] [IDM-174] - feat: use passport (#615) * feat(project): use passport service from access bridge * chore: remove unused prop * chore: update import paths * feat(project): remove passport on logout * feat(project): create access service and controller * feat(project): handle avod for anonymous passport generation * chore: update import paths in Access Controller * chore: revert env variables * feat(project): include expires in the passport stored data * feat(project): update init access controller * feat(project): move access bridge env variable to ini * feat(project): add refresh passport mechanism * feat(project): add getEntitledPlans service * feat(project): bind access stuff optionally * chore: naming typo * chore: register imports order * feat(project): add new way of handling media with passport * chore: nit in webapp.dev.ini * fix: add Mock for Access controller in cinema test * fix: make language as optional, to fix lint * feat(project): move entitled plans to the account controller * fix: add 0 as a port for access bridge tests * chore: revert port for tests * fix: query enable condition --- .../src/controllers/AccessController.ts | 178 ++++++++++++++++++ .../src/controllers/AccountController.ts | 36 +++- .../common/src/controllers/AppController.ts | 13 ++ packages/common/src/env.ts | 3 + .../functions/getApiAccessBridgeUrl.ts | 12 ++ packages/common/src/modules/register.ts | 10 +- packages/common/src/modules/types.ts | 2 + packages/common/src/services/AccessService.ts | 59 ++++++ packages/common/src/services/ApiService.ts | 31 ++- .../src/services/JWPEntitlementService.ts | 14 ++ .../common/src/services/SettingsService.ts | 1 + packages/common/src/stores/AccessStore.ts | 9 + packages/common/src/stores/AccountStore.ts | 3 + packages/common/src/utils/sources.ts | 45 ++++- packages/common/types/access.ts | 4 + packages/common/types/checkout.ts | 2 + packages/common/types/playlist.ts | 13 ++ packages/common/types/settings.ts | 1 + .../hooks-react/src/useContentProtection.ts | 7 +- packages/hooks-react/src/useMediaSources.ts | 4 +- packages/hooks-react/src/useProtectedMedia.ts | 21 ++- .../containers/AccountModal/forms/Login.tsx | 1 - .../src/containers/Cinema/Cinema.test.tsx | 2 + platforms/web/ini/.webapp.dev.ini | 2 + 24 files changed, 460 insertions(+), 13 deletions(-) create mode 100644 packages/common/src/controllers/AccessController.ts create mode 100644 packages/common/src/modules/functions/getApiAccessBridgeUrl.ts create mode 100644 packages/common/src/services/AccessService.ts create mode 100644 packages/common/src/stores/AccessStore.ts create mode 100644 packages/common/types/access.ts diff --git a/packages/common/src/controllers/AccessController.ts b/packages/common/src/controllers/AccessController.ts new file mode 100644 index 000000000..e10d1d52a --- /dev/null +++ b/packages/common/src/controllers/AccessController.ts @@ -0,0 +1,178 @@ +import { inject, injectable } from 'inversify'; + +import type { IntegrationType } from '../../types/config'; +import type { AccessTokens } from '../../types/access'; +import ApiService from '../services/ApiService'; +import AccessService from '../services/AccessService'; +import AccountService from '../services/integrations/AccountService'; +import StorageService from '../services/StorageService'; +import { useConfigStore } from '../stores/ConfigStore'; +import { INTEGRATION_TYPE } from '../modules/types'; +import { getNamedModule } from '../modules/container'; +import { useAccountStore } from '../stores/AccountStore'; +import { ApiError } from '../utils/api'; +import { useAccessStore } from '../stores/AccessStore'; + +const ACCESS_TOKENS = 'access_tokens'; + +@injectable() +export default class AccessController { + private readonly apiService: ApiService; + private readonly accessService: AccessService; + private readonly accountService: AccountService; + private readonly storageService: StorageService; + + private siteId: string = ''; + + constructor( + @inject(INTEGRATION_TYPE) integrationType: IntegrationType, + @inject(ApiService) apiService: ApiService, + @inject(StorageService) storageService: StorageService, + @inject(AccessService) accessService: AccessService, + ) { + this.apiService = apiService; + this.accessService = accessService; + this.storageService = storageService; + this.accountService = getNamedModule(AccountService, integrationType); + } + + initialize = async () => { + const { config, accessModel } = useConfigStore.getState(); + this.siteId = config.siteId; + + // For the AVOD access model, signing and DRM are not supported, so access tokens generation is skipped + if (accessModel === 'AVOD') { + return; + } + + // Not awaiting to avoid blocking the loading process, + // as the initial access tokens can be stored asynchronously without affecting the app's performance + void this.generateOrRefreshAccessTokens(); + }; + + /** + * Retrieves media by its ID using a passport token. + * If no access tokens exist, it attempts to generate them, if the passport token is expired, it attempts to refresh them. + * If an access token retrieval fails or the user is not entitled to the content, an error is thrown. + */ + getMediaById = async (mediaId: string) => { + const { entitledPlan } = useAccountStore.getState(); + + if (!this.siteId || !entitledPlan) { + return; + } + + try { + const accessTokens = await this.generateOrRefreshAccessTokens(); + if (!accessTokens?.passport) { + throw new Error('Failed to get / generate access tokens and retrieve media.'); + } + return await this.apiService.getMediaByIdWithPassport({ id: mediaId, siteId: this.siteId, planId: entitledPlan.id, passport: accessTokens.passport }); + } catch (error: unknown) { + if (error instanceof ApiError && error.code === 403) { + // If the passport is invalid or expired, refresh the access tokens and try to get the media again. + const accessTokens = await this.refreshAccessTokens(); + if (accessTokens?.passport) { + return await this.apiService.getMediaByIdWithPassport({ id: mediaId, siteId: this.siteId, planId: entitledPlan.id, passport: accessTokens.passport }); + } + + throw new Error('Failed to refresh access tokens and retrieve media.'); + } + throw error; + } + }; + + /** + * Generates or refreshes access tokens based on their current validity. + * If existing tokens are expired, they are refreshed; if no tokens exist, they are generated. + * If the existing tokens are valid, it retrieves them. + */ + generateOrRefreshAccessTokens = async (): Promise => { + const existingAccessTokens = await this.getAccessTokens(); + const shouldRefresh = existingAccessTokens && Date.now() > existingAccessTokens.expires; + + if (!existingAccessTokens) { + await this.generateAccessTokens(); + } + + if (shouldRefresh) { + return await this.refreshAccessTokens(); + } + + return existingAccessTokens; + }; + + /** + * Generates access tokens based on the viewer auth data. + * If the viewer is not authenticated it generates only access for free plans (if they are defined). + * Stores the access tokens in local storage. + */ + generateAccessTokens = async (): Promise => { + if (!this.siteId) { + return null; + } + + const auth = await this.accountService.getAuthData(); + + const accessTokens = await this.accessService.generateAccessTokens(this.siteId, auth?.jwt); + if (accessTokens) { + await this.setAccessTokens(accessTokens); + return accessTokens; + } + + return null; + }; + + /** + * Refreshes the access tokens using the refresh token if they exist. + * If no tokens are found, it cannot refresh and returns null. + * Updates the localstorage with the newly generated access tokens. + */ + refreshAccessTokens = async (): Promise => { + const existingAccessTokens = await this.getAccessTokens(); + // there is no access tokens stored, nothing to refresh + if (!existingAccessTokens) { + return null; + } + + const accessTokens = await this.accessService.refreshAccessTokens(this.siteId, existingAccessTokens.refresh_token); + if (accessTokens) { + await this.setAccessTokens(accessTokens); + return accessTokens; + } + + return null; + }; + + /** + * Stores the access tokens in local storage, adding an expiration timestamp of 1 hour (passport validity). + * The expiration timestamp helps determine when the passport token should be refreshed. + */ + setAccessTokens = async (accessTokens: AccessTokens) => { + useAccessStore.setState({ passport: accessTokens.passport }); + // Since the actual valid time for a passport token is 1 hour, set the expires to one hour from now. + // The expires field here is used as a helper to manage the passport's validity and refresh process. + const expires = new Date(Date.now() + 3600 * 1000).getTime(); + return await this.storageService.setItem(ACCESS_TOKENS, JSON.stringify({ ...accessTokens, expires }), true); + }; + + /** + * Retrieves the access tokens from local storage (if any) along with their expiration timestamp. + */ + getAccessTokens = async (): Promise<(AccessTokens & { expires: number }) | null> => { + const accessTokens = await this.storageService.getItem(ACCESS_TOKENS, true, true); + if (accessTokens) { + useAccessStore.setState({ passport: accessTokens.passport }); + } + + return accessTokens; + }; + + /** + * Removes the access tokens from local storage (if any). + */ + removeAccessTokens = async () => { + useAccessStore.setState({ passport: null }); + return await this.storageService.removeItem(ACCESS_TOKENS); + }; +} diff --git a/packages/common/src/controllers/AccountController.ts b/packages/common/src/controllers/AccountController.ts index 3e6ec3c30..bbb9e4953 100644 --- a/packages/common/src/controllers/AccountController.ts +++ b/packages/common/src/controllers/AccountController.ts @@ -6,7 +6,9 @@ import type { IntegrationType } from '../../types/config'; import CheckoutService from '../services/integrations/CheckoutService'; import AccountService, { type AccountServiceFeatures } from '../services/integrations/AccountService'; import SubscriptionService from '../services/integrations/SubscriptionService'; +import JWPEntitlementService from '../services/JWPEntitlementService'; import type { Offer } from '../../types/checkout'; +import type { Plan } from '../../types/plans'; import type { Capture, Customer, @@ -16,7 +18,7 @@ import type { GetCaptureStatusResponse, SubscribeToNotificationsPayload, } from '../../types/account'; -import { assertFeature, assertModuleMethod, getNamedModule } from '../modules/container'; +import { assertFeature, assertModuleMethod, getModule, getNamedModule } from '../modules/container'; import { INTEGRATION_TYPE } from '../modules/types'; import type { ServiceResponse } from '../../types/service'; import { useAccountStore } from '../stores/AccountStore'; @@ -26,12 +28,15 @@ import { logError } from '../logger'; import WatchHistoryController from './WatchHistoryController'; import FavoritesController from './FavoritesController'; +import AccessController from './AccessController'; @injectable() export default class AccountController { private readonly checkoutService: CheckoutService; private readonly accountService: AccountService; private readonly subscriptionService: SubscriptionService; + private readonly entitlementService: JWPEntitlementService; + private readonly accessController: AccessController; private readonly favoritesController: FavoritesController; private readonly watchHistoryController: WatchHistoryController; private readonly features: AccountServiceFeatures; @@ -41,14 +46,17 @@ export default class AccountController { constructor( @inject(INTEGRATION_TYPE) integrationType: IntegrationType, + accessController: AccessController, favoritesController: FavoritesController, watchHistoryController: WatchHistoryController, ) { this.checkoutService = getNamedModule(CheckoutService, integrationType); this.accountService = getNamedModule(AccountService, integrationType); this.subscriptionService = getNamedModule(SubscriptionService, integrationType); + this.entitlementService = getModule(JWPEntitlementService); // @TODO: Controllers shouldn't be depending on other controllers, but we've agreed to keep this as is for now + this.accessController = accessController; this.favoritesController = favoritesController; this.watchHistoryController = watchHistoryController; @@ -85,6 +93,7 @@ export default class AccountController { useConfigStore.setState({ accessModel: this.accountService.accessModel }); await this.loadUserData(); + await this.getEntitledPlans(); useAccountStore.setState({ loading: false }); }; @@ -163,6 +172,7 @@ export default class AccountController { const response = await this.accountService.login({ email, password, referrer }); if (response) { + await this.accessController?.generateAccessTokens(); await this.afterLogin(response.user, response.customerConsents); return; } @@ -180,6 +190,7 @@ export default class AccountController { logout = async () => { await this.accountService?.logout(); + await this.accessController?.removeAccessTokens(); await this.clearLoginState(); // let the application know to refresh all entitlements @@ -380,6 +391,29 @@ export default class AccountController { return !!responseData?.accessGranted; }; + // This currently supports only one plan, as the current usage for the media metadata requires only one plan_id provided. + // TODO: Support for multiple plans should be added. Revisit this logic once the dependency on plan_id is changed. + getEntitledPlans = async (): Promise => { + const { config, settings } = useConfigStore.getState(); + const siteId = config.siteId; + const isAccessBridgeEnabled = !!settings?.apiAccessBridgeUrl; + + // This should be only used when access bridge is defined, regardless of the integration type. + if (!isAccessBridgeEnabled) { + return null; + } + + const response = await this.entitlementService.getEntitledPlans({ siteId }); + if (response?.plans?.length) { + // Find the SVOD plan or fallback to the first available plan + const entitledPlan = response.plans.find((plan) => plan.metadata.access_model === 'svod') || response.plans[0]; + useAccountStore.setState({ entitledPlan }); + return entitledPlan; + } + + return null; + }; + reloadSubscriptions = async ( { delay, retry }: { delay?: number; retry?: number } = { delay: 0, diff --git a/packages/common/src/controllers/AppController.ts b/packages/common/src/controllers/AppController.ts index 474d04c86..803f5c867 100644 --- a/packages/common/src/controllers/AppController.ts +++ b/packages/common/src/controllers/AppController.ts @@ -15,6 +15,7 @@ import { logDebug } from '../logger'; import WatchHistoryController from './WatchHistoryController'; import FavoritesController from './FavoritesController'; import AccountController from './AccountController'; +import AccessController from './AccessController'; @injectable() export default class AppController { @@ -82,6 +83,11 @@ export default class AppController { await getModule(AccountController).initialize(url, refreshEntitlements); } + // when the apiAccessBridgeUrl is set up in the .ini file, we initialize the AccessController + if (settings?.apiAccessBridgeUrl) { + await getModule(AccessController).initialize(); + } + if (config.features?.continueWatchingList && config.content.some((el) => el.type === PersonalShelf.ContinueWatching)) { await getModule(WatchHistoryController).initialize(language); } @@ -116,4 +122,11 @@ export default class AppController { return configState.integrationType; }; + + getApiAccessBridgeUrl = (): string | undefined => { + const configState = useConfigStore.getState(); + + if (!configState.loaded) throw new Error('A call to `AppController#getApiAccessBridgeUrl()` was made before loading the config'); + return configState.settings?.apiAccessBridgeUrl || undefined; + }; } diff --git a/packages/common/src/env.ts b/packages/common/src/env.ts index 50c854c56..a791fea8e 100644 --- a/packages/common/src/env.ts +++ b/packages/common/src/env.ts @@ -1,6 +1,7 @@ export type Env = { APP_VERSION: string; APP_API_BASE_URL: string; + APP_API_ACCESS_BRIDGE_URL: string; APP_PLAYER_ID: string; APP_FOOTER_TEXT: string; APP_DEFAULT_LANGUAGE: string; @@ -15,6 +16,7 @@ export type Env = { const env: Env = { APP_VERSION: '', APP_API_BASE_URL: 'https://cdn.jwplayer.com', + APP_API_ACCESS_BRIDGE_URL: '', APP_PLAYER_ID: 'M4qoGvUk', APP_FOOTER_TEXT: '', APP_DEFAULT_LANGUAGE: 'en', @@ -23,6 +25,7 @@ const env: Env = { export const configureEnv = (options: Partial) => { env.APP_VERSION = options.APP_VERSION || env.APP_VERSION; env.APP_API_BASE_URL = options.APP_API_BASE_URL || env.APP_API_BASE_URL; + env.APP_API_ACCESS_BRIDGE_URL = options.APP_API_ACCESS_BRIDGE_URL || env.APP_API_ACCESS_BRIDGE_URL; env.APP_PLAYER_ID = options.APP_PLAYER_ID || env.APP_PLAYER_ID; env.APP_FOOTER_TEXT = options.APP_FOOTER_TEXT || env.APP_FOOTER_TEXT; env.APP_DEFAULT_LANGUAGE = options.APP_DEFAULT_LANGUAGE || env.APP_DEFAULT_LANGUAGE; diff --git a/packages/common/src/modules/functions/getApiAccessBridgeUrl.ts b/packages/common/src/modules/functions/getApiAccessBridgeUrl.ts new file mode 100644 index 000000000..ac33a02ff --- /dev/null +++ b/packages/common/src/modules/functions/getApiAccessBridgeUrl.ts @@ -0,0 +1,12 @@ +import type { interfaces } from 'inversify'; + +import AppController from '../../controllers/AppController'; + +/** + * Retrieves the access bridge URL from the AppController. + * If the access bridge URL is defined in the application's .ini configuration file, + * the function returns the URL. If the value is not defined, it returns `undefined`. + */ +export const getApiAccessBridgeUrl = (context: interfaces.Context) => { + return context.container.get(AppController).getApiAccessBridgeUrl(); +}; diff --git a/packages/common/src/modules/register.ts b/packages/common/src/modules/register.ts index 131d93bf8..8bad8ece3 100644 --- a/packages/common/src/modules/register.ts +++ b/packages/common/src/modules/register.ts @@ -3,7 +3,7 @@ import 'reflect-metadata'; // include once in the app for inversify (see: https://github.com/inversify/InversifyJS/blob/master/README.md#-installation) import { INTEGRATION, EPG_TYPE } from '../constants'; import { container } from './container'; -import { DETERMINE_INTEGRATION_TYPE, INTEGRATION_TYPE } from './types'; +import { API_ACCESS_BRIDGE_URL, DETERMINE_INTEGRATION_TYPE, INTEGRATION_TYPE } from './types'; import ApiService from '../services/ApiService'; import WatchHistoryService from '../services/WatchHistoryService'; @@ -16,6 +16,7 @@ import SettingsService from '../services/SettingsService'; import WatchHistoryController from '../controllers/WatchHistoryController'; import CheckoutController from '../controllers/CheckoutController'; import AccountController from '../controllers/AccountController'; +import AccessController from '../controllers/AccessController'; import FavoritesController from '../controllers/FavoritesController'; import AppController from '../controllers/AppController'; import EpgController from '../controllers/EpgController'; @@ -25,6 +26,10 @@ import EpgService from '../services/EpgService'; import ViewNexaEpgService from '../services/epg/ViewNexaEpgService'; import JWEpgService from '../services/epg/JWEpgService'; +// Access integration +import AccessService from '../services/AccessService'; +import { getApiAccessBridgeUrl } from './functions/getApiAccessBridgeUrl'; + // Integration interfaces import AccountService from '../services/integrations/AccountService'; import CheckoutService from '../services/integrations/CheckoutService'; @@ -51,6 +56,7 @@ container.bind(FavoriteService).toSelf(); container.bind(GenericEntitlementService).toSelf(); container.bind(ApiService).toSelf(); container.bind(SettingsService).toSelf(); +container.bind(AccessService).toSelf(); // Common controllers container.bind(AppController).toSelf(); @@ -61,6 +67,7 @@ container.bind(EpgController).toSelf(); // Integration controllers container.bind(AccountController).toSelf(); container.bind(CheckoutController).toSelf(); +container.bind(AccessController).toSelf(); // EPG services container.bind(EpgService).to(JWEpgService).whenTargetNamed(EPG_TYPE.jwp); @@ -68,6 +75,7 @@ container.bind(EpgService).to(ViewNexaEpgService).whenTargetNamed(EPG_TYPE.viewN // Functions container.bind(INTEGRATION_TYPE).toDynamicValue(getIntegrationType); +container.bind(API_ACCESS_BRIDGE_URL).toDynamicValue(getApiAccessBridgeUrl); // Cleeng integration container.bind(DETERMINE_INTEGRATION_TYPE).toConstantValue(isCleengIntegrationType); diff --git a/packages/common/src/modules/types.ts b/packages/common/src/modules/types.ts index c00c26851..0fb528923 100644 --- a/packages/common/src/modules/types.ts +++ b/packages/common/src/modules/types.ts @@ -3,3 +3,5 @@ export const INTEGRATION_TYPE = Symbol('INTEGRATION_TYPE'); export const DETERMINE_INTEGRATION_TYPE = Symbol('DETERMINE_INTEGRATION_TYPE'); export const GET_CUSTOMER_IP = Symbol('GET_CUSTOMER_IP'); + +export const API_ACCESS_BRIDGE_URL = Symbol('API_ACCESS_BRIDGE_URL'); diff --git a/packages/common/src/services/AccessService.ts b/packages/common/src/services/AccessService.ts new file mode 100644 index 000000000..4667bde58 --- /dev/null +++ b/packages/common/src/services/AccessService.ts @@ -0,0 +1,59 @@ +import { inject, injectable } from 'inversify'; + +import type { AccessTokens } from '../../types/access'; +import { logError } from '../logger'; +import { API_ACCESS_BRIDGE_URL } from '../modules/types'; + +@injectable() +export default class AccessService { + private readonly apiAccessBridgeUrl; + + constructor(@inject(API_ACCESS_BRIDGE_URL) apiAccessBridgeUrl: string) { + this.apiAccessBridgeUrl = apiAccessBridgeUrl; + } + + generateAccessTokens = async (siteId: string, jwt?: string): Promise => { + const url = `${this.apiAccessBridgeUrl}/v2/sites/${siteId}/access/generate`; + const response = await fetch(url, { + method: 'PUT', + headers: { + Authorization: jwt ? `Bearer ${jwt}` : '', + }, + }); + + if (!response.ok) { + logError('AccessService', 'Failed to generateAccessTokens', { + status: response.status, + error: response.json(), + }); + + return null; + } + + return (await response.json()) as AccessTokens; + }; + + refreshAccessTokens = async (siteId: string, refresh_token: string): Promise => { + const url = `${this.apiAccessBridgeUrl}/v2/sites/${siteId}/access/refresh`; + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + refresh_token, + }), + }); + + if (!response.ok) { + logError('AccessService', 'Failed to refreshAccessTokens', { + status: response.status, + error: response.json(), + }); + + return null; + } + + return (await response.json()) as AccessTokens; + }; +} diff --git a/packages/common/src/services/ApiService.ts b/packages/common/src/services/ApiService.ts index 0089768e2..0f36588c1 100644 --- a/packages/common/src/services/ApiService.ts +++ b/packages/common/src/services/ApiService.ts @@ -138,7 +138,7 @@ export default class ApiService { return transformedMediaItem; }; - private transformEpisodes = (episodesRes: EpisodesRes, language: string, seasonNumber?: number) => { + private transformEpisodes = (episodesRes: EpisodesRes, language?: string, seasonNumber?: number) => { const { episodes, page, page_limit, total } = episodesRes; // Adding images and keys for media items @@ -207,6 +207,33 @@ export default class ApiService { return this.transformMediaItem({ item: mediaItem, language }); }; + /** + * Get media by id with passport + */ + getMediaByIdWithPassport = async ({ + id, + siteId, + planId, + passport, + language, + }: { + id: string; + siteId: string; + planId: string; + passport: string; + language?: string; + }): Promise => { + const pathname = `/v2/sites/${siteId}/media/${id}/playback.json`; + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { passport, plan_id: planId }); + const response = await fetch(url); + const data = (await getDataOrThrow(response)) as Playlist; + const mediaItem = data.playlist[0]; + + if (!mediaItem) throw new Error('MediaItem not found'); + + return this.transformMediaItem({ item: mediaItem, language }); + }; + /** * Get series by id * @param {string} id @@ -249,7 +276,7 @@ export default class ApiService { pageOffset?: number; pageLimit?: number; afterId?: string; - language: string; + language?: string; }): Promise => { if (!seriesId) { throw new Error('Series ID is required'); diff --git a/packages/common/src/services/JWPEntitlementService.ts b/packages/common/src/services/JWPEntitlementService.ts index 75a9d1ec4..04003abbe 100644 --- a/packages/common/src/services/JWPEntitlementService.ts +++ b/packages/common/src/services/JWPEntitlementService.ts @@ -1,5 +1,8 @@ import { inject, injectable } from 'inversify'; +import type { GetEntitledPlans } from '../../types/checkout'; +import type { PlansResponse } from '../../types/plans'; + import type { SignedMediaResponse } from './integrations/jwp/types'; import JWPAPIService from './integrations/jwp/JWPAPIService'; @@ -29,4 +32,15 @@ export default class JWPEntitlementService { throw new Error('Unauthorized'); } }; + + getEntitledPlans: GetEntitledPlans = async ({ siteId }) => { + try { + const data = await this.apiService.get(`/v3/sites/${siteId}/entitlements`, { + withAuthentication: await this.apiService.isAuthenticated(), + }); + return data; + } catch { + throw new Error('Failed to fetch entitled plans'); + } + }; } diff --git a/packages/common/src/services/SettingsService.ts b/packages/common/src/services/SettingsService.ts index 2b964082b..b0ab6b8be 100644 --- a/packages/common/src/services/SettingsService.ts +++ b/packages/common/src/services/SettingsService.ts @@ -103,6 +103,7 @@ export default class SettingsService { settings.defaultConfigSource ||= env.APP_DEFAULT_CONFIG_SOURCE; settings.playerId ||= env.APP_PLAYER_ID || OTT_GLOBAL_PLAYER_ID; settings.playerLicenseKey ||= env.APP_PLAYER_LICENSE_KEY; + settings.apiAccessBridgeUrl ||= env.APP_API_ACCESS_BRIDGE_URL; // The player key should be set if using the global ott player if (settings.playerId === OTT_GLOBAL_PLAYER_ID && !settings.playerLicenseKey) { diff --git a/packages/common/src/stores/AccessStore.ts b/packages/common/src/stores/AccessStore.ts new file mode 100644 index 000000000..e98579e17 --- /dev/null +++ b/packages/common/src/stores/AccessStore.ts @@ -0,0 +1,9 @@ +import { createStore } from './utils'; + +type AccessStore = { + passport: string | null; +}; + +export const useAccessStore = createStore('AccessStore', () => ({ + passport: null, +})); diff --git a/packages/common/src/stores/AccountStore.ts b/packages/common/src/stores/AccountStore.ts index ad7aa3a39..5e830632a 100644 --- a/packages/common/src/stores/AccountStore.ts +++ b/packages/common/src/stores/AccountStore.ts @@ -1,3 +1,4 @@ +import type { Plan } from '../../types/plans'; import type { CustomFormField, Customer, CustomerConsent } from '../../types/account'; import type { Offer } from '../../types/checkout'; import type { PaymentDetail, Subscription, Transaction } from '../../types/subscription'; @@ -9,6 +10,7 @@ type AccountStore = { user: Customer | null; subscription: Subscription | null; transactions: Transaction[] | null; + entitledPlan: Plan | null; activePayment: PaymentDetail | null; customerConsents: CustomerConsent[] | null; publisherConsents: CustomFormField[] | null; @@ -23,6 +25,7 @@ export const useAccountStore = createStore('AccountStore', (set, g user: null, subscription: null, transactions: null, + entitledPlan: null, activePayment: null, customerConsents: null, publisherConsents: null, diff --git a/packages/common/src/utils/sources.ts b/packages/common/src/utils/sources.ts index c96a35022..0b666b47d 100644 --- a/packages/common/src/utils/sources.ts +++ b/packages/common/src/utils/sources.ts @@ -10,7 +10,19 @@ const isBCLManifestType = (sourceUrl: string, baseUrl: string, mediaId: string, return extensions.some((ext) => sourceUrl === `${baseUrl}/live/broadcast/${mediaId}.${ext}`); }; -export const getSources = ({ item, baseUrl, config, user }: { item: PlaylistItem; baseUrl: string; config: Config; user: Customer | null }) => { +export const getSources = ({ + item, + baseUrl, + config, + user, + passport, +}: { + item: PlaylistItem; + baseUrl: string; + config: Config; + user: Customer | null; + passport: string | null; +}) => { const { sources, mediaid } = item; const { adConfig, siteId, adDeliveryMethod } = config; @@ -36,8 +48,37 @@ export const getSources = ({ item, baseUrl, config, user }: { item: PlaylistItem url.searchParams.set('user_id', userId); } - source.file = url.toString(); + // Attach the passport in all the drm sources as it's needed for the licence request. + // Passport is only available if Access Bridge is in use. + if (passport) { + attachPassportToSourceWithDRM(source, passport); + } + source.file = url.toString(); return source; }); }; + +function attachPassportToSourceWithDRM(source: Source, passport: string): Source { + function updateUrl(urlString: string, passport: string): string { + const url = new URL(urlString); + if (!url.searchParams.has('token')) { + url.searchParams.set('passport', passport); + } + return url.toString(); + } + + if (source?.drm) { + if (source.drm?.playready?.url) { + source.drm.playready.url = updateUrl(source.drm.playready.url, passport); + } + if (source.drm?.widevine?.url) { + source.drm.widevine.url = updateUrl(source.drm.widevine.url, passport); + } + if (source.drm?.fairplay?.processSpcUrl) { + source.drm.fairplay.processSpcUrl = updateUrl(source.drm.fairplay.processSpcUrl, passport); + } + } + + return source; +} diff --git a/packages/common/types/access.ts b/packages/common/types/access.ts new file mode 100644 index 000000000..2a081dfbf --- /dev/null +++ b/packages/common/types/access.ts @@ -0,0 +1,4 @@ +export type AccessTokens = { + passport: string; + refresh_token: string; +}; diff --git a/packages/common/types/checkout.ts b/packages/common/types/checkout.ts index 89210f117..584dfc1eb 100644 --- a/packages/common/types/checkout.ts +++ b/packages/common/types/checkout.ts @@ -1,6 +1,7 @@ import type { PayloadWithIPOverride } from './account'; import type { PaymentDetail } from './subscription'; import type { EmptyEnvironmentServiceRequest, EnvironmentServiceRequest, PromiseRequest } from './service'; +import type { PlansResponse } from './plans'; export type Offer = { id: number | null; @@ -384,3 +385,4 @@ export type DeletePaymentMethod = EnvironmentServiceRequest; export type FinalizeAdyenPaymentDetails = EnvironmentServiceRequest; export type GetDirectPostCardPayment = (cardPaymentPayload: CardPaymentData, order: Order, referrer: string, returnUrl: string) => Promise; +export type GetEntitledPlans = PromiseRequest<{ siteId: string }, PlansResponse>; diff --git a/packages/common/types/playlist.ts b/packages/common/types/playlist.ts index 26c5731c3..5d08d9ca2 100644 --- a/packages/common/types/playlist.ts +++ b/packages/common/types/playlist.ts @@ -8,9 +8,22 @@ export type Image = { width: number; }; +export type DRM = { + playready?: { + url: string; + }; + widevine?: { + url: string; + }; + fairplay?: { + processSpcUrl: string; + }; +}; + export type Source = { file: string; type: string; + drm?: DRM; }; export type Track = { diff --git a/packages/common/types/settings.ts b/packages/common/types/settings.ts index 38eaab547..b030894c0 100644 --- a/packages/common/types/settings.ts +++ b/packages/common/types/settings.ts @@ -4,4 +4,5 @@ export type Settings = { playerLicenseKey?: string; additionalAllowedConfigSources?: string[]; UNSAFE_allowAnyConfigSource?: boolean; + apiAccessBridgeUrl?: string; }; diff --git a/packages/hooks-react/src/useContentProtection.ts b/packages/hooks-react/src/useContentProtection.ts index a3598af93..56580b69b 100644 --- a/packages/hooks-react/src/useContentProtection.ts +++ b/packages/hooks-react/src/useContentProtection.ts @@ -20,13 +20,15 @@ const useContentProtection = ( const genericEntitlementService = getModule(GenericEntitlementService); const jwpEntitlementService = getModule(JWPEntitlementService); - const { configId, signingConfig, contentProtection, jwp, urlSigning } = useConfigStore(({ config }) => ({ + const { configId, signingConfig, contentProtection, jwp, urlSigning, isAccessBridgeEnabled } = useConfigStore(({ config, settings }) => ({ configId: config.id, signingConfig: config.contentSigningService, contentProtection: config.contentProtection, jwp: config.integrations.jwp, urlSigning: isTruthyCustomParamValue(config?.custom?.urlSigning), + isAccessBridgeEnabled: !!settings?.apiAccessBridgeUrl, })); + const host = signingConfig?.host; const drmPolicyId = contentProtection?.drm?.defaultPolicyId ?? signingConfig?.drmPolicyId; const signingEnabled = !!urlSigning || !!host || (!!drmPolicyId && !host); @@ -42,12 +44,13 @@ const useContentProtection = ( return genericEntitlementService.getMediaToken(host, id, authData?.jwt, params, drmPolicyId); } + // if provider is JWP if (jwp && configId && !!id && signingEnabled) { return jwpEntitlementService.getJWPMediaToken(configId, id); } }, - { enabled: signingEnabled && enabled && !!id, keepPreviousData: false, staleTime: 15 * 60 * 1000 }, + { enabled: signingEnabled && enabled && !!id && !isAccessBridgeEnabled, keepPreviousData: false, staleTime: 15 * 60 * 1000 }, ); const queryResult = useQuery([type, id, params, token], async () => callback(token, drmPolicyId), { diff --git a/packages/hooks-react/src/useMediaSources.ts b/packages/hooks-react/src/useMediaSources.ts index 8f344f018..beca034f0 100644 --- a/packages/hooks-react/src/useMediaSources.ts +++ b/packages/hooks-react/src/useMediaSources.ts @@ -3,11 +3,13 @@ import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import type { PlaylistItem, Source } from '@jwp/ott-common/types/playlist'; import { getSources } from '@jwp/ott-common/src/utils/sources'; +import { useAccessStore } from '@jwp/ott-common/src/stores/AccessStore'; /** Modify manifest URLs to handle server ads and analytics params */ export const useMediaSources = ({ item, baseUrl }: { item: PlaylistItem; baseUrl: string }): Source[] => { const config = useConfigStore((s) => s.config); const user = useAccountStore((s) => s.user); + const passport = useAccessStore((s) => s.passport); - return useMemo(() => getSources({ item, baseUrl, config, user }), [item, baseUrl, config, user]); + return useMemo(() => getSources({ item, baseUrl, config, user, passport }), [item, baseUrl, config, user, passport]); }; diff --git a/packages/hooks-react/src/useProtectedMedia.ts b/packages/hooks-react/src/useProtectedMedia.ts index f034e3378..dda954fee 100644 --- a/packages/hooks-react/src/useProtectedMedia.ts +++ b/packages/hooks-react/src/useProtectedMedia.ts @@ -2,14 +2,29 @@ import { useQuery } from 'react-query'; import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; import ApiService from '@jwp/ott-common/src/services/ApiService'; import { getModule } from '@jwp/ott-common/src/modules/container'; +import AccessController from '@jwp/ott-common/src/controllers/AccessController'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import useContentProtection from './useContentProtection'; export default function useProtectedMedia(item: PlaylistItem) { const apiService = getModule(ApiService); - const contentProtectionQuery = useContentProtection('media', item.mediaid, (token, drmPolicyId) => - apiService.getMediaById({ id: item.mediaid, token, drmPolicyId }), - ); + const accessController = getModule(AccessController); + + const { isAccessBridgeEnabled } = useConfigStore(({ settings }) => ({ + isAccessBridgeEnabled: !!settings?.apiAccessBridgeUrl, + })); + + const contentProtectionQuery = useContentProtection('media', item.mediaid, async (token, drmPolicyId) => { + // If the Access Bridge is enabled, use it to retrieve media via access passport. + // This bypasses the need for a DRM token or policy and directly uses the access-controlled method. + if (isAccessBridgeEnabled) { + return accessController.getMediaById(item.mediaid); + } + + // If Access Bridge is not enabled, retrieve the media using the provided DRM token and policy ID. + return apiService.getMediaById({ id: item.mediaid, token, drmPolicyId }); + }); const { isLoading, data: isGeoBlocked } = useQuery( ['media', 'geo', item.mediaid], diff --git a/packages/ui-react/src/containers/AccountModal/forms/Login.tsx b/packages/ui-react/src/containers/AccountModal/forms/Login.tsx index 7d016a11b..010866901 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/Login.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/Login.tsx @@ -35,7 +35,6 @@ const Login = () => { onSubmit: ({ email, password }) => accountController.login(email, password, window.location.href), onSubmitSuccess: () => { announce(t('login.sign_in_success'), 'success'); - navigate(modalURLFromLocation(location, null)); }, onSubmitError: ({ resetValue }) => resetValue('password'), diff --git a/packages/ui-react/src/containers/Cinema/Cinema.test.tsx b/packages/ui-react/src/containers/Cinema/Cinema.test.tsx index 1711f49e4..d93b72fea 100644 --- a/packages/ui-react/src/containers/Cinema/Cinema.test.tsx +++ b/packages/ui-react/src/containers/Cinema/Cinema.test.tsx @@ -3,6 +3,7 @@ import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; import { beforeEach } from 'vitest'; import { mockService } from '@jwp/ott-common/test/mockService'; import ApiService from '@jwp/ott-common/src/services/ApiService'; +import AccessController from '@jwp/ott-common/src/controllers/AccessController'; import GenericEntitlementService from '@jwp/ott-common/src/services/GenericEntitlementService'; import JWPEntitlementService from '@jwp/ott-common/src/services/JWPEntitlementService'; import WatchHistoryController from '@jwp/ott-common/src/controllers/WatchHistoryController'; @@ -18,6 +19,7 @@ describe('', () => { mockService(GenericEntitlementService, {}); mockService(JWPEntitlementService, {}); mockService(WatchHistoryController, {}); + mockService(AccessController, {}); }); test('renders and matches snapshot', async () => { diff --git a/platforms/web/ini/.webapp.dev.ini b/platforms/web/ini/.webapp.dev.ini index 01fd5196a..850dc82fd 100644 --- a/platforms/web/ini/.webapp.dev.ini +++ b/platforms/web/ini/.webapp.dev.ini @@ -2,3 +2,5 @@ defaultConfigSource = gnnuzabk ; When developing, switching between configs is useful for test and debug UNSAFE_allowAnyConfigSource = true +; Access Bridge service API url host +apiAccessBridgeUrl = From 4ffb84923424de05f1f62455c7822354c0842173 Mon Sep 17 00:00:00 2001 From: Carina Dragan <92930790+CarinaDraganJW@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:45:50 +0200 Subject: [PATCH 2/2] feat(menu): fix support for media menu item (#621) --- packages/common/src/utils/configSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/utils/configSchema.ts b/packages/common/src/utils/configSchema.ts index 43c2d60af..37c3dd62d 100644 --- a/packages/common/src/utils/configSchema.ts +++ b/packages/common/src/utils/configSchema.ts @@ -14,7 +14,7 @@ const menuSchema: SchemaOf = object().shape({ label: string().defined(), contentId: string().defined(), filterTags: string().notRequired(), - type: mixed().oneOf(['playlist', 'content_list']).notRequired(), + type: mixed().oneOf(['playlist', 'content_list', 'media']).notRequired(), }); const featuresSchema: SchemaOf = object({