-
Notifications
You must be signed in to change notification settings - Fork 481
fix(uve): open file-asset links in a new tab instead of loading them as pages (#35504) #36925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The consequence is not cosmetic: a
Suggested change
Worth a comment noting that |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The letter-initial rule drops real extensions that start with a digit: The thing actually protecting
Suggested change
Worth adding |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||
|
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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -31,7 +31,8 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| normalizeQueryParams, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| convertUTCToLocalTime, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| escapeHtmlAttributeValue, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isSamePageNavigation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isSamePageNavigation, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isAssetPath | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '.'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { DEFAULT_PERSONA, PERSONA_KEY } from '../shared/consts'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the
Suggested change
Same shape applies to the page block below ( |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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 againstwindow.location.origin, but the open uses the unresolvedhref. Those diverge when the click target is a child of the anchor:e.targetis the span, sotarget.hrefisundefinedandhreffalls back torawHref, the raw relative attribute.new URL('files/report.pdf', origin)correctly yields/files/report.pdfso the asset branch is entered, butwindow.open('files/report.pdf')resolves against the admin document (/dotAdmin/...) and opens a 404 tab.url.hrefis 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 newwindow.open,noopenercloses reverse-tabnabbing for the cost of one argument. (The external branch has the same gap, which you already called out as out of scope.)