Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3115,6 +3115,41 @@ describe('EditEmaEditorComponent', () => {
expect(mockEvent.preventDefault).toHaveBeenCalled();
});

it('should open a same-host PDF link in a new tab instead of loading it as a page', () => {
const pdfUrl = 'http://localhost:3000/application/files/report.pdf';
const mockEvent = createMockEvent(pdfUrl);

spectator.component.handleInternalNav(mockEvent);

expect(windowOpenSpy).toHaveBeenCalledWith(pdfUrl, '_blank');
expect(pageLoadSpy).not.toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});

it('should open a /dA/ asset link in a new tab instead of loading it as a page', () => {
const assetUrl = 'http://localhost:3000/dA/abc123/asset/report.pdf';
const mockEvent = createMockEvent(assetUrl);

spectator.component.handleInternalNav(mockEvent);

expect(windowOpenSpy).toHaveBeenCalledWith(assetUrl, '_blank');
expect(pageLoadSpy).not.toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});

it('should still load a page when the URL uses the page extension', () => {
const pageUrl = 'http://localhost:3000/test-page/index.html';
const mockEvent = createMockEvent(pageUrl);

spectator.component.handleInternalNav(mockEvent);

expect(windowOpenSpy).not.toHaveBeenCalled();
expect(pageLoadSpy).toHaveBeenCalledWith({
url: '/test-page/index.html'
});
expect(mockEvent.preventDefault).toHaveBeenCalled();
});

it('should extract and pass query parameters from URL', () => {
const urlWithParams =
'http://localhost:3000/test-page?param1=value1&param2=value2';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import {
deleteContentletFromContainer,
getTargetUrl,
insertContentletInContainer,
isAssetPath,
isSamePageNavigation,
measureCanvasAvailableSize,
shouldNavigate
Expand Down Expand Up @@ -768,6 +769,16 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
return;
}

// Files (PDFs, images, docs…) are not pages: the Page API cannot resolve
// them and the editor would show "Page not found". Open them in a new tab
// so the author can verify the link without leaving the editor.
if (isAssetPath(url.pathname)) {
this.window.open(href, '_blank');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch decision is made on url.pathname, which is resolved against window.location.origin, but the open uses the unresolved href. Those diverge when the click target is a child of the anchor:

<a href="files/report.pdf"><span>Download</span></a>

e.target is the span, so target.href is undefined and href falls back to rawHref, the raw relative attribute. new URL('files/report.pdf', origin) correctly yields /files/report.pdf so the asset branch is entered, but window.open('files/report.pdf') resolves against the admin document (/dotAdmin/...) and opens a 404 tab. url.href is already computed a few lines above and is exactly what the decision was based on.

Separately, the guard above is url.hostname !== window.location.hostname, hostname only, so this branch can still be cross-origin on a different scheme or port. Since this adds a new window.open, noopener closes reverse-tabnabbing for the cost of one argument. (The external branch has the same gap, which you already called out as out of scope.)

Suggested change
this.window.open(href, '_blank');
this.window.open(url.href, '_blank', 'noopener');

e.preventDefault();

return;
}

// Same pathname (any hash/query): let the browser handle it (anchors, query-driven UI)
if (isSamePageNavigation(href, this.uveStore.pageParams()?.url)) {
return;
Expand Down
54 changes: 54 additions & 0 deletions core-web/libs/portlets/edit-ema/portlet/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,3 +1168,57 @@ export const isSamePageNavigation = (incomingUrl: string, currentUrl: string): b

return target.pathname === current.pathname;
};

/** dotCMS path prefixes that stream a binary asset instead of rendering a page. */
const ASSET_PATH_PREFIXES = ['/dA/', '/dotAsset/', '/contentAsset/'];

/**
* Extensions that still resolve to an HTMLPage. `html` is the default
* `VELOCITY_PAGE_EXTENSION`; `dot` is the legacy fallback the backend uses
* (see `Identifier#setURI`).
*/
const PAGE_PATH_EXTENSIONS = new Set(['html', 'htm', 'dot']);
Comment on lines +1175 to +1180

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

htm is not a dotCMS page extension. VELOCITY_PAGE_EXTENSION = html in dotmarketing-config.properties:91, and dot is the code-level fallback in Identifier#setURI (Config.getStringProperty("VELOCITY_PAGE_EXTENSION", "dot")). The doc comment right above the Set documents only those two, so the comment and the code already disagree.

The consequence is not cosmetic: a .htm file uploaded as a file asset still gets handed to pageLoad, which is the exact bug this PR fixes. There is no page case on the other side of the trade to pay for it. The ['/about-us/index.htm', false] case in utils.spec.ts should flip to true with this.

Suggested change
/**
* Extensions that still resolve to an HTMLPage. `html` is the default
* `VELOCITY_PAGE_EXTENSION`; `dot` is the legacy fallback the backend uses
* (see `Identifier#setURI`).
*/
const PAGE_PATH_EXTENSIONS = new Set(['html', 'htm', 'dot']);
/**
* Extensions that still resolve to an HTMLPage. `html` is the default
* `VELOCITY_PAGE_EXTENSION`; `dot` is the legacy fallback the backend uses
* (see `Identifier#setURI`).
*/
const PAGE_PATH_EXTENSIONS = new Set(['html', 'dot']);

Worth a comment noting that VELOCITY_PAGE_EXTENSION is configurable, so a site that overrides it will see page links open in a new tab. Not fixable client-side without plumbing the value through, but it should be written down.


/**
* Matches a plausible file extension: letter-initial, up to 8 alphanumerics.
* Guards URL-map slugs such as `/blog/release-v1.2`, whose trailing `2` must
* not be mistaken for a file extension.
*/
const FILE_EXTENSION_PATTERN = /^[a-z][a-z0-9]{0,7}$/;
Comment on lines +1182 to +1187

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The letter-initial rule drops real extensions that start with a digit: 7z, 3gp, 3ds. I ran the function standalone and /files/archive.7z returns false, so a .7z download still hits "Page not found".

The thing actually protecting /blog/release-v1.2 and /news/2024.10 is that their trailing tokens are all digits, not that they start with one. Requiring at least one letter anywhere keeps both guards and fixes the digit-initial case. Verified against the full case list in this PR plus 7z/3gp: 2024.10 and release-v1.2 stay pages, 7z becomes an asset, nothing else changes.

Suggested change
/**
* Matches a plausible file extension: letter-initial, up to 8 alphanumerics.
* Guards URL-map slugs such as `/blog/release-v1.2`, whose trailing `2` must
* not be mistaken for a file extension.
*/
const FILE_EXTENSION_PATTERN = /^[a-z][a-z0-9]{0,7}$/;
/**
* Matches a plausible file extension: 1-8 alphanumerics containing at least
* one letter. The letter requirement is what guards URL-map slugs such as
* `/blog/release-v1.2` and `/news/2024.10`, whose all-digit trailing token
* must not be mistaken for a file extension.
*/
const FILE_EXTENSION_PATTERN = /^(?=.*[a-z])[a-z0-9]{1,8}$/;

Worth adding ['/files/archive.7z', true] to the asset cases so the rule is pinned.


/**
* Checks whether a pathname targets a file asset rather than an HTMLPage.
*
* Mirrors the backend's own extension heuristic: no extension (or the page
* extension) means a page; any other real extension means a file.
*
* @param {string} pathname - The pathname to check (query and hash excluded)
* @returns {boolean} True when the pathname points at a file asset
*
* @example
* isAssetPath('/application/files/doc.pdf') // true
* isAssetPath('/dA/abc123/asset/doc.pdf') // true
* isAssetPath('/about-us/index') // false
* isAssetPath('/about-us/index.html') // false
* isAssetPath('/blog/release-v1.2') // false
Comment on lines +1198 to +1203

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description lists two known limitations but not this one, and it is the most reachable of the three: a page whose last segment contains a dot followed by a short alpha token is classified as an asset. Both /store/product.detail and /pages/about.us return true and would open a new tab instead of navigating, since URL-map slugs are author-controlled and can contain dots.

No code change requested, it is inherent to an extension heuristic without a backend round-trip. But it belongs in this JSDoc next to the release-v1.2 example so the next reader knows it was a considered trade rather than an oversight.

*/
export const isAssetPath = (pathname: string): boolean => {
if (!pathname) {
return false;
}

if (ASSET_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return true;
}

const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);
Comment thread
zJaaal marked this conversation as resolved.
const dotIndex = lastSegment.lastIndexOf('.');

if (dotIndex === -1) {
return false;
}

const extension = lastSegment.slice(dotIndex + 1).toLowerCase();

return FILE_EXTENSION_PATTERN.test(extension) && !PAGE_PATH_EXTENSIONS.has(extension);
};
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import {
normalizeQueryParams,
convertUTCToLocalTime,
escapeHtmlAttributeValue,
isSamePageNavigation
isSamePageNavigation,
isAssetPath
} from '.';

import { DEFAULT_PERSONA, PERSONA_KEY } from '../shared/consts';
Expand Down Expand Up @@ -1648,4 +1649,41 @@ describe('utils functions', () => {
expect(result.getHours()).toBe(12);
});
});

describe('isAssetPath', () => {
it.each([
['/dA/abc123/asset/report.pdf', true],
['/dA/abc123/asset/no-extension', true],
['/dotAsset/abc123', true],
['/contentAsset/raw-data/abc123/asset', true],
['/application/files/report.pdf', true],
['/files/quarterly.docx', true],
['/media/promo.mp4', true],
['/backups/site.tar.gz', true],
['/files/REPORT.PDF', true]
])('should treat %s as a file asset', (pathname, expected) => {
expect(isAssetPath(pathname as string)).toBe(expected);
});
Comment on lines +1654 to +1666

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the expected column is constant within each block, and carrying it is what forces the pathname as string cast (the mixed tuple infers as (string | boolean)[]). Dropping it removes the cast and makes the block's intent read off the title.

Suggested change
it.each([
['/dA/abc123/asset/report.pdf', true],
['/dA/abc123/asset/no-extension', true],
['/dotAsset/abc123', true],
['/contentAsset/raw-data/abc123/asset', true],
['/application/files/report.pdf', true],
['/files/quarterly.docx', true],
['/media/promo.mp4', true],
['/backups/site.tar.gz', true],
['/files/REPORT.PDF', true]
])('should treat %s as a file asset', (pathname, expected) => {
expect(isAssetPath(pathname as string)).toBe(expected);
});
it.each([
'/dA/abc123/asset/report.pdf',
'/dA/abc123/asset/no-extension',
'/dotAsset/abc123',
'/contentAsset/raw-data/abc123/asset',
'/application/files/report.pdf',
'/files/quarterly.docx',
'/media/promo.mp4',
'/backups/site.tar.gz',
'/files/REPORT.PDF'
])('should treat %s as a file asset', (pathname) => {
expect(isAssetPath(pathname)).toBe(true);
});

Same shape applies to the page block below (.toBe(false)).


it.each([
['/about-us/index', false],
['/about-us/index.html', false],
['/about-us/index.htm', false],
['/legacy/index.dot', false],
['/blog/', false],
['/', false],
['/blog/release-v1.2', false],
['/news/2024.10', false]
])('should treat %s as a page', (pathname, expected) => {
expect(isAssetPath(pathname as string)).toBe(expected);
});

it('should return false for an empty pathname', () => {
expect(isAssetPath('')).toBe(false);
});

it('should return false for a nullish pathname', () => {
expect(isAssetPath(undefined as unknown as string)).toBe(false);
});
});
});
Loading