Skip to content

Commit

Permalink
feat: fix merge conflicts
Browse files Browse the repository at this point in the history
  • Loading branch information
CarinaDraganJW committed Sep 25, 2024
2 parents aa713b7 + 4ffb849 commit fb1a8f1
Show file tree
Hide file tree
Showing 25 changed files with 461 additions and 14 deletions.
178 changes: 178 additions & 0 deletions packages/common/src/controllers/AccessController.ts
Original file line number Diff line number Diff line change
@@ -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<AccessTokens | null> => {
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<AccessTokens | null> => {
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<AccessTokens | null> => {
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<AccessTokens & { expires: number }>(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);
};
}
36 changes: 35 additions & 1 deletion packages/common/src/controllers/AccountController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -85,6 +93,7 @@ export default class AccountController {
useConfigStore.setState({ accessModel: this.accountService.accessModel });

await this.loadUserData();
await this.getEntitledPlans();

useAccountStore.setState({ loading: false });
};
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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<Plan | null> => {
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,
Expand Down
13 changes: 13 additions & 0 deletions packages/common/src/controllers/AppController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
};
}
3 changes: 3 additions & 0 deletions packages/common/src/env.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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',
Expand All @@ -23,6 +25,7 @@ const env: Env = {
export const configureEnv = (options: Partial<Env>) => {
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;
Expand Down
12 changes: 12 additions & 0 deletions packages/common/src/modules/functions/getApiAccessBridgeUrl.ts
Original file line number Diff line number Diff line change
@@ -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();
};
Loading

0 comments on commit fb1a8f1

Please sign in to comment.