Skip to content
Merged
2 changes: 1 addition & 1 deletion build-dev/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '2e6029161561cf03b75b');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'e6c20842b642780c13e2');
269 changes: 158 additions & 111 deletions build-dev/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build-dev/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '89e6f0b1ba0650424eeb');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '60929e0f22c74b6d0c3a');
16 changes: 8 additions & 8 deletions build/index.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion resources/js/components/molecules/LegacyLicenseBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useSelect } from '@wordpress/data';
import { AlertTriangle } from 'lucide-react';
import { store as harborStore } from '@/store';
import { getHarborDataValue } from '@/lib/harbor-data';
import { webUrl } from '@/lib/web-url';

/**
* @since 1.3.0 Read subscriptionsUrl through the getHarborDataValue helper.
Expand All @@ -23,7 +24,7 @@ export function LegacyLicenseBanner() {
[]
);

const portalUrl = getHarborDataValue( 'subscriptionsUrl' );
const portalUrl = webUrl( getHarborDataValue( 'subscriptionsUrl' ) );
if ( ! hasLegacy || ! portalUrl ) {
return null;
}
Expand Down
6 changes: 3 additions & 3 deletions resources/js/components/molecules/LicenseProductCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function LicenseProductCard( {
{ tiers.map( ( lp ) => {
const expiryStatus = getExpiryStatus( lp.expires );
const isActivatedHere = lp.is_valid && lp.activated_here === true;
const showActivate = ! isActivatedHere && !! activationUrl;
const activateHref = isActivatedHere || ! activationUrl ? undefined : buildActivationUrl( activationUrl, lp.product_slug, lp.tier );

return (
<div
Expand All @@ -123,10 +123,10 @@ export function LicenseProductCard( {
{ formatDate( lp.expires ) }
</span>
</div>
{ showActivate && (
{ activateHref && (
<Button variant="outline" size="xs" asChild className="shrink-0 ml-2">
<a
href={ buildActivationUrl( activationUrl, lp.product_slug, lp.tier ) }
href={ activateHref }
target="_blank"
rel="noopener noreferrer"
>
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/molecules/NotActivatedBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useSelect } from '@wordpress/data';
import { Info } from 'lucide-react';
import { store as harborStore } from '@/store';
import { getHarborDataValue } from '@/lib/harbor-data';
import { webUrl } from '@/lib/web-url';

/**
* @since 1.3.0 Read activationUrl through the getHarborDataValue helper.
Expand All @@ -27,7 +28,7 @@ export function NotActivatedBanner() {
[]
);

const activationUrl = getHarborDataValue( 'activationUrl' );
const activationUrl = webUrl( getHarborDataValue( 'activationUrl' ) );
if ( ! allNotActivated || ! licenseKey || ! activationUrl ) return null;

return (
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/organisms/LicenseSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { groupLicenseProducts } from '@/lib/group-license-products';
import type { LicenseProduct } from '@/types/api';
import type HarborError from '@/errors/harbor-error';
import { getHarborDataValue } from '@/lib/harbor-data';
import { webUrl } from '@/lib/web-url';

interface LicenseSectionProps {
licenseKey: string | null;
Expand Down Expand Up @@ -84,7 +85,7 @@ export function LicenseSection( {
const [ isEditing, setIsEditing ] = useState( false );

const hasLicense = licenseKey !== null;
const manageUrl = getHarborDataValue( 'subscriptionsUrl' );
const manageUrl = webUrl( getHarborDataValue( 'subscriptionsUrl' ) );

const handleRemove = async (): Promise<HarborError | null> => {
const error = await onRemove();
Expand Down
7 changes: 4 additions & 3 deletions resources/js/components/organisms/ProductSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useFilter } from '@/context/filter-context';
import { useProductFeatureGroups } from '@/hooks/useProductFeatureGroups';
import { buildUpgradeUrl } from '@/lib/upgrade-url';
import { buildActivationUrl } from '@/lib/activation-url';
import { webUrl } from '@/lib/web-url';
import { getHarborDataValue } from '@/lib/harbor-data';
import type { Product } from '@/types/api';

Expand Down Expand Up @@ -104,7 +105,7 @@ export function ProductSection( { product, hideActivation = false }: ProductSect

// Owned-but-unactivated products keep their activation action,
// falling back to the unactivated product record when no tier is active here.
const activationUrl = getHarborDataValue( 'activationUrl' );
const activationUrl = webUrl( getHarborDataValue( 'activationUrl' ) );
const effectiveLicenseProduct = licenseProduct ?? unactivatedLicenseProduct;
const showHeaderActivate = isNotActivated && !! activationUrl && !! effectiveLicenseProduct;

Expand Down Expand Up @@ -273,8 +274,8 @@ export function ProductSection( { product, hideActivation = false }: ProductSect

const effectiveLicenseProduct = licenseProduct ?? unactivatedLicenseProduct;
const buttonHref = effectiveLicenseProduct
? ( tier.upgrade_url ? buildUpgradeUrl( tier.upgrade_url, getHarborDataValue( 'domain' ) ) : undefined )
: ( tier.purchase_url || undefined );
? buildUpgradeUrl( tier.upgrade_url, getHarborDataValue( 'domain' ) )
: webUrl( tier.purchase_url );

return (
<TierGroup
Expand Down
15 changes: 8 additions & 7 deletions resources/js/lib/activation-url.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parseWebUrl } from '@/lib/web-url';

/**
* Appends product and tier params to the base activation URL supplied by the
* API, producing a product-scoped URL the Nexcess portal can use to
Expand All @@ -23,12 +25,11 @@ export function buildActivationUrl(
baseUrl: string,
productSlug: string,
tier: string,
): string {
try {
const url = new URL( baseUrl );
url.searchParams.set( 'sku', `${ productSlug }:${ tier }` );
return url.toString();
} catch {
return baseUrl;
): string | undefined {
const url = parseWebUrl( baseUrl );
Comment thread
defunctl marked this conversation as resolved.
if ( ! url ) {
return undefined;
Comment thread
defunctl marked this conversation as resolved.
}
url.searchParams.set( 'sku', `${ productSlug }:${ tier }` );
return url.toString();
}
21 changes: 11 additions & 10 deletions resources/js/lib/upgrade-url.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parseWebUrl, webUrl } from '@/lib/web-url';

/**
* Appends portal tracking parameters to a catalog upgrade URL.
*
Expand All @@ -19,16 +21,15 @@
export function buildUpgradeUrl(
baseUrl: string,
domain: string | null,
): string {
if ( ! domain ) {
return baseUrl;
): string | undefined {
const url = parseWebUrl( baseUrl );
if ( ! url ) {
return undefined;
}
try {
const url = new URL( baseUrl );
url.searchParams.set( 'domain', domain );
url.searchParams.set( 'portal-referral', 'plugin' );
return url.toString();
} catch {
return baseUrl;
if ( ! domain ) {
return webUrl( baseUrl );
}
url.searchParams.set( 'domain', domain );
url.searchParams.set( 'portal-referral', 'plugin' );
return url.toString();
}
13 changes: 2 additions & 11 deletions resources/js/lib/upsell-offers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { __, sprintf } from '@wordpress/i18n';
import { PRODUCTS } from '@/data/products';
import { isFreeFeature } from '@/lib/feature-utils';
import { webUrl } from '@/lib/web-url';
import type { CatalogFeature, CatalogTier, LicenseProduct, Product, ProductCatalog } from '@/types/api';

export interface UpsellOffer {
Expand Down Expand Up @@ -45,17 +46,7 @@ function paidFeatures( catalog: ProductCatalog, tier: CatalogTier ): CatalogFeat
* Accept an explicit web purchase destination without inventing a fallback URL.
*/
function purchaseUrl( tier: CatalogTier | undefined ): string | undefined {
const href = tier?.purchase_url?.trim();
if ( ! href ) {
return undefined;
}

try {
const url = new URL( href );
return url.protocol === 'https:' || url.protocol === 'http:' ? href : undefined;
} catch {
return undefined;
}
return webUrl( tier?.purchase_url );
}

/**
Expand Down
29 changes: 29 additions & 0 deletions resources/js/lib/web-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Catalog and activation links, accepted only as http(s) so Portal data never renders as a script link.
*
* @package LiquidWeb\Harbor
*/

/**
* Parse a link, or return undefined when it is missing or not http(s).
*/
export function parseWebUrl( href: string | null | undefined ): URL | undefined {
const trimmed = href?.trim();
if ( ! trimmed ) {
return undefined;
}

try {
const url = new URL( trimmed );
return url.protocol === 'https:' || url.protocol === 'http:' ? url : undefined;
} catch {
return undefined;
}
}

/**
* The trimmed link when it is http(s), otherwise undefined.
*/
export function webUrl( href: string | null | undefined ): string | undefined {
return parseWebUrl( href ) ? href?.trim() : undefined;
}
25 changes: 23 additions & 2 deletions src/Harbor/Portal/Results/Catalog_Tier.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,33 @@ public static function from_array( array $data ): self {
'herald_slugs' => isset( $data['herald_slugs'] ) && is_array( $data['herald_slugs'] )
? array_map( [ Cast::class, 'to_string' ], array_values( $data['herald_slugs'] ) )
: [],
'purchase_url' => Cast::to_string( $data['purchase_url'] ?? '' ),
'upgrade_url' => Cast::to_string( $data['upgrade_url'] ?? '' ),
'purchase_url' => self::web_url( $data['purchase_url'] ?? '' ),
'upgrade_url' => self::web_url( $data['upgrade_url'] ?? '' ),
]
);
}

/**
* Keeps a catalog link only when it is http(s), sanitized for storage.
*
* The scheme is checked first because esc_url_raw() prepends one to input
* without a scheme, which would invent a destination the catalog never gave.
*
* @param mixed $value The raw catalog value.
*
* @return string The sanitized URL, or an empty string.
*/
private static function web_url( $value ): string {
$url = trim( Cast::to_string( $value ) );
$scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );

if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) {
return '';
}

return esc_url_raw( $url, [ 'http', 'https' ] );
}

/**
* Converts the tier to an associative array.
*
Expand Down
29 changes: 29 additions & 0 deletions tests/js/components/LegacyLicenseBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import { useSelect } from '@wordpress/data';
import { LegacyLicenseBanner } from '@/components/molecules/LegacyLicenseBanner';

jest.mock( '@wordpress/data', () => ( { useSelect: jest.fn() } ) );
jest.mock( '@/store', () => ( { store: { name: 'harbor' } } ) );

let mockSubscriptionsUrl: string | null = 'https://portal.example/subscriptions/';
jest.mock( '@/lib/harbor-data', () => ( {
getHarborDataValue: () => mockSubscriptionsUrl,
} ) );

describe( 'LegacyLicenseBanner', () => {
beforeEach( () => {
( useSelect as jest.Mock ).mockReturnValue( true );
} );

it( 'links to the subscriptions page', () => {
mockSubscriptionsUrl = 'https://portal.example/subscriptions/';
render( <LegacyLicenseBanner /> );
expect( screen.getByRole( 'link', { name: 'switching to a unified license' } ).getAttribute( 'href' ) ).toBe( 'https://portal.example/subscriptions/' );
} );

it.each( [ 'javascript:alert(1)//', 'not a url', null ] )( 'renders nothing for a rejected subscriptions URL %p', ( url ) => {
mockSubscriptionsUrl = url;
const { container } = render( <LegacyLicenseBanner /> );
expect( container.innerHTML ).toBe( '' );
} );
} );
41 changes: 41 additions & 0 deletions tests/js/components/LicenseProductCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import { LicenseProductCard } from '@/components/molecules/LicenseProductCard';
import type { LicenseProduct } from '@/types/api';

const unactivated = {
product_slug: 'kadence',
tier: 'pro',
status: 'active',
expires: '2099-12-31 23:59:59',
activations: { site_limit: 1, active_count: 0, over_limit: false, domains: [] },
capabilities: [],
activated_here: false,
validation_status: 'not_activated',
is_valid: false,
} as LicenseProduct;

function renderCard( activationUrl: string | null ) {
render(
<LicenseProductCard
productSlug="kadence"
productName="Kadence"
tiers={ [ unactivated ] }
tierNameMap={ { pro: 'Pro' } }
activationUrl={ activationUrl }
/>
);
}

describe( 'LicenseProductCard activation', () => {
it( 'links an unactivated tier to the activation URL with its SKU', () => {
renderCard( 'https://portal.example/activate' );
const url = new URL( screen.getByRole( 'link', { name: 'Activate' } ).getAttribute( 'href' )! );
expect( url.searchParams.get( 'sku' ) ).toBe( 'kadence:pro' );
} );

it.each( [ 'javascript:alert(1)//', 'not a url', null ] )( 'hides Activate for a rejected activation URL %p', ( url ) => {
renderCard( url );
expect( screen.queryByRole( 'link', { name: 'Activate' } ) ).toBeNull();
expect( screen.queryByText( 'Activate' ) ).toBeNull();
} );
} );
43 changes: 43 additions & 0 deletions tests/js/components/LicenseSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import { LicenseSection } from '@/components/organisms/LicenseSection';
import type { LicenseProduct } from '@/types/api';

jest.mock( '@/components/molecules/LicenseKeyInput', () => ( { LicenseKeyInput: () => null } ) );
jest.mock( '@/components/molecules/LicenseProductCard', () => ( { LicenseProductCard: () => null } ) );

let mockSubscriptionsUrl: string | null = 'https://portal.example/subscriptions/';
jest.mock( '@/lib/harbor-data', () => ( {
getHarborDataValue: () => mockSubscriptionsUrl,
} ) );

const purchase = { product_slug: 'kadence', tier: 'pro', activated_here: true, is_valid: true } as LicenseProduct;

function renderSection() {
render(
<LicenseSection
licenseKey="LWSW-TEST"
licenseProducts={ [ purchase ] }
tierNameMap={ { pro: 'Pro' } }
tierRankMap={ { pro: 2 } }
onRemove={ async () => null }
onRefresh={ async () => {} }
isRefreshing={ false }
isLoading={ false }
activationUrl={ null }
/>
);
}

describe( 'LicenseSection manage link', () => {
it( 'links to the subscriptions page', () => {
mockSubscriptionsUrl = 'https://portal.example/subscriptions/';
renderSection();
expect( screen.getByRole( 'link', { name: 'Manage license in Nexcess' } ).getAttribute( 'href' ) ).toBe( 'https://portal.example/subscriptions/' );
} );

it.each( [ 'javascript:alert(1)//', 'not a url', null ] )( 'hides the manage link for a rejected subscriptions URL %p', ( url ) => {
mockSubscriptionsUrl = url;
renderSection();
expect( screen.queryByText( 'Manage license in Nexcess' ) ).toBeNull();
} );
} );
Loading
Loading