Skip to content
Closed
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,15 +1,52 @@
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';

import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router';
import {
ActivatedRouteSnapshot,
CanActivate,
PRIMARY_OUTLET,
Params,
Router,
RouterStateSnapshot,
UrlTree
} from '@angular/router';

import { map } from 'rxjs/operators';
import { catchError, map, switchMap } from 'rxjs/operators';

import { DotRouterService, DotSessionStorageService } from '@dotcms/data-access';
import {
DotContentletService,
DotHttpErrorManagerService,
DotRouterService,
DotSessionStorageService,
DotSiteService
} from '@dotcms/data-access';
import { GlobalStore } from '@dotcms/store';
import { mapParamsFromEditContentlet } from '@dotcms/utils';

import { DotNavigationService } from '../../../view/components/dot-navigation/services/dot-navigation.service';
import { DotMenuService } from '../dot-menu.service';

const CONTENT_DRIVE_PORTLET_ID = 'content-drive';
const CONTENT_DRIVE_URL = `/${CONTENT_DRIVE_PORTLET_ID}`;

/**
* Portlets whose listing Content Drive replaces. Their bare listing URL (`/c/<id>`) is redirected
* to Content Drive when the portlet is missing from the menu.
*/
const CONTENT_DRIVE_REPLACES = ['content', 'site-browser'];

/**
* The Content Search URLs the guard knows a Content Drive equivalent for:
* - `listing`: `/c/content` or `/c/site-browser`
* - `edit`: `/c/content/<inode>`
* - `create`: `/c/content/new` and `/c/content/new/<content type variable>`
*/
type ReplacedUrl =
| { kind: 'listing'; portletId: string }
| { kind: 'edit'; inode: string }
| { kind: 'create'; contentType?: string };

/**
* Route Guard that checks if a User have access to the specified Menu portlet.
*/
Expand All @@ -19,37 +56,229 @@ export class MenuGuardService implements CanActivate {
private dotRouterService = inject(DotRouterService);
private dotNavigationService = inject(DotNavigationService);
private dotSessionStorageService = inject(DotSessionStorageService);
private dotContentletService = inject(DotContentletService);
private dotHttpErrorManagerService = inject(DotHttpErrorManagerService);
private dotSiteService = inject(DotSiteService);
private globalStore = inject(GlobalStore);
private router = inject(Router);

canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
canActivate(
_route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean | UrlTree> {
return this.canAccessPortlet(state.url);
}

canActivateChild(
_route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> {
): Observable<boolean | UrlTree> {
return this.canAccessPortlet(state.url);
}

/**
* Check if User has access to the requested route (url) based on the Menu, otherwise go to the 'First Portlet' and return false.
* Checks the requested portlet against the user's menu. When it is missing, a Content Search
* or Site Browser URL goes to its Content Drive equivalent if the user has Content Drive;
* anything else goes to the first portlet in the menu.
*
* The variant id is cleared whenever the requested portlet is rejected, since the user leaves
* it either way.
*
* @param string url
* @returns boolean
* @param url the requested route
* @returns true to allow the route, a UrlTree to redirect, or false when the navigation stops,
* after going to the first portlet or because an error handler already navigated
*/
private canAccessPortlet(url: string): Observable<boolean> {
private canAccessPortlet(url: string): Observable<boolean | UrlTree> {
const id = this.dotRouterService.getPortletId(url);
const checkJSPPortlet = this.dotRouterService.isJSPPortletURL(url);

return this.dotMenuService.isPortletInMenu(id, checkJSPPortlet).pipe(
map((isValidPortlet) => {
if (!isValidPortlet) {
this.dotSessionStorageService.removeVariantId();
switchMap(
(isValidPortlet): Observable<boolean | UrlTree | null> =>
isValidPortlet ? of(true) : this.getContentDriveRedirect(url)
),
map((result) => {
if (result === true) {
return true;
}

this.dotSessionStorageService.removeVariantId();

if (result === null) {
this.dotNavigationService.goToFirstPortlet();

return false;
}

return isValidPortlet;
return result;
})
);
}

/**
* Builds the Content Drive equivalent of a Content Search or Site Browser URL, when the user
* has Content Drive in their menu.
*
* Edit links coming from Content Drive itself are left alone: Content Drive sends content with
* the legacy editor to `/c/content/<inode>`, and opening `editContent` would send it there
* again, so the redirect would loop. Create links are still redirected: Content Drive also
* sends legacy creates to `/c/content/new/<type>`, but nothing reads `createContent` yet, so
* there is nothing to loop on. Whatever reads it must open the create in Content Drive rather
* than navigate there.
*
* @param url the rejected route
* @returns the Content Drive UrlTree, false when an error handler already navigated away, or
* null when there is no Content Drive equivalent and the user goes to the first portlet
*/
private getContentDriveRedirect(url: string): Observable<UrlTree | false | null> {
const urlTree = this.router.parseUrl(url);
const replaced = this.parseReplacedUrl(urlTree);
const isLeavingContentDrive = this.router.url.startsWith(CONTENT_DRIVE_URL);

if (!replaced || (replaced.kind === 'edit' && isLeavingContentDrive)) {
return of(null);
}

return this.dotMenuService
.isPortletInMenu(CONTENT_DRIVE_PORTLET_ID)
.pipe(
switchMap(
(hasContentDrive): Observable<UrlTree | false | null> =>
hasContentDrive
? this.getContentDriveUrl(replaced, urlTree.queryParams)
: of(null)
)
);
}

/**
* Reads which Content Search or Site Browser URL was requested.
*
* @param urlTree the rejected route
* @returns the replaced URL, or null when Content Drive has no equivalent for it
*/
private parseReplacedUrl(urlTree: UrlTree): ReplacedUrl | null {
const [root, portletId, asset, contentType, ...rest] = (
urlTree.root.children[PRIMARY_OUTLET]?.segments ?? []
).map(({ path }) => path);

if (root !== 'c' || !CONTENT_DRIVE_REPLACES.includes(portletId) || rest.length) {
return null;
}

if (!asset) {
return { kind: 'listing', portletId };
}

if (portletId !== 'content') {
return null;
}

if (asset === 'new') {
return { kind: 'create', contentType };
}

return contentType ? null : { kind: 'edit', inode: asset };
}

/**
* Builds the Content Drive URL for a replaced URL.
*
* - The Content Search `filter` param holds a content type variable, which Content Drive takes
* as its `contentType` filter. The Content Types chip fills in the base type it belongs to.
* - The Site Browser `path` param holds the folder to open (see {@link getFolderPath}).
* - Edit links carry an inode, while Content Drive opens content by identifier and language,
* so the content is looked up first. If the lookup fails, the error is reported and the user
* lands on Content Drive.
* - Create links pass the content type as `createContent`.
* - Edit and create links restore the `CD_`-prefixed params Content Drive adds when it sends
* the user to the legacy editor, so they land back on their folder and filters. Other params
* have no Content Drive equivalent and are dropped.
*
* @param replaced the replaced URL
* @param queryParams the rejected route's query params
* @returns the Content Drive UrlTree, or false when the error handler already navigated away
*/
private getContentDriveUrl(
replaced: ReplacedUrl,
queryParams: Params
): Observable<UrlTree | false> {
const toUrlTree = (params: Params) =>
this.router.createUrlTree([CONTENT_DRIVE_URL], { queryParams: params });

if (replaced.kind === 'listing' && replaced.portletId === 'site-browser') {
const folder = queryParams['path'];

return folder
? this.getFolderPath(folder).pipe(map((path) => toUrlTree(path ? { path } : {})))
: of(toUrlTree({}));
}

if (replaced.kind === 'listing') {
const contentType = queryParams['filter'];

return of(toUrlTree(contentType ? { filters: `contentType:${contentType}` } : {}));
}

const restored = mapParamsFromEditContentlet(new URLSearchParams(queryParams));

if (replaced.kind === 'create') {
const createContent = replaced.contentType
? { createContent: replaced.contentType }
: {};

return of(toUrlTree({ ...restored, ...createContent }));
}

return this.dotContentletService.getContentletByInode(replaced.inode).pipe(
map(({ identifier, languageId }) =>
toUrlTree({ ...restored, editContent: identifier, editContentLang: languageId })
),
catchError((error: HttpErrorResponse) =>
this.dotHttpErrorManagerService
.handle(error)
.pipe(map(({ redirected }) => (redirected ? false : toUrlTree(restored))))
)
);
}

/**
* Turns a Site Browser folder into Content Drive's `path` param, which is a folder path inside
* the current site.
*
* Files stored in the Site Browser (containers, templates) give their folder with the host,
* as `//<host>/<folder path>/`. The host is stripped. When it is not the current site, the
* guard switches to that site first: Content Drive loads again when the site changes, and it
* reads the folder from the URL each time, so it lands on the folder in the right site. A path
* without a host is a folder in the current site and is passed as is.
*
* @param folder the Site Browser folder
* @returns the folder path in the current site, or null when its host cannot be found
*/
private getFolderPath(folder: string): Observable<string | null> {
const [, hostname, path = '/'] = folder.match(/^\/\/([^/]+)(\/.*)?$/) ?? [];

if (!hostname) {
return of(folder);
}

if (hostname === this.globalStore.siteDetails()?.hostname) {
return of(path);
}

return this.dotSiteService.getSites({ filter: hostname }).pipe(
map(({ sites }) => {
const site = sites.find((site) => site.hostname === hostname);

if (!site) {
return null;
}

this.globalStore.switchCurrentSite(site.identifier);

return path;
}),
catchError(() => of(null))
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@ describe('ContainerListComponent', () => {
fileContainer.pathName
);
expect(store['dotSiteBrowserService'].setSelectedFolder).toHaveBeenCalledTimes(1);
expect(store['dotRouterService'].goToSiteBrowser).toHaveBeenCalledWith(
fileContainer?.pathName
);
expect(store['dotRouterService'].goToSiteBrowser).toHaveBeenCalledTimes(1);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ export class DotContainerListStore extends ComponentStore<DotContainerListState>
.setSelectedFolder(container.pathName)
.pipe(take(1))
.subscribe(() => {
this.dotRouterService.goToSiteBrowser();
this.dotRouterService.goToSiteBrowser(container.pathName);
})
: this.dotRouterService.goToEditContainer(container.identifier);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ describe('DotTemplateListComponent', () => {
link.nativeElement.click();

expect(mockGoToFolder).toHaveBeenCalledWith(expect.any(Event), 'test');
expect(dotRouterService.goToSiteBrowser).toHaveBeenCalledWith('test');
}));

it("should render 'System Theme' when the theme is SYSTEM_THEME", fakeAsync(() => {
Expand Down Expand Up @@ -817,6 +818,9 @@ describe('DotTemplateListComponent', () => {
expect(dotSiteBrowserService.setSelectedFolder).toHaveBeenCalledWith(
templatesMock[4].identifier
);
expect(dotRouterService.goToSiteBrowser).toHaveBeenCalledWith(
templatesMock[4].identifier
);
expect(dotRouterService.goToSiteBrowser).toHaveBeenCalledTimes(1);
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export class DotTemplateListComponent implements OnInit {
editTemplate(template: DotTemplate): void {
this.isTemplateAsFile(template)
? this.dotSiteBrowserService.setSelectedFolder(template.identifier).subscribe(() => {
this.dotRouterService.goToSiteBrowser();
this.dotRouterService.goToSiteBrowser(template.identifier);
})
: this.dotRouterService.goToEditTemplate(template.identifier);
}
Expand Down Expand Up @@ -314,7 +314,7 @@ export class DotTemplateListComponent implements OnInit {
event.stopPropagation();

this.dotSiteBrowserService.setSelectedFolder(path).subscribe(() => {
this.dotRouterService.goToSiteBrowser();
this.dotRouterService.goToSiteBrowser(path);
}); // This takes one under the hood
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ describe('DotRouterService', () => {
expect(router.navigate).toHaveBeenCalledWith(['/c/content']);
});

it('should go to Site Browser', () => {
service.goToSiteBrowser();
expect(router.navigate).toHaveBeenCalledWith(['/c/site-browser'], {});
});

it('should go to Site Browser with the folder path', () => {
service.goToSiteBrowser('//demo.dotcms.com/application/containers/default/');
expect(router.navigate).toHaveBeenCalledWith(['/c/site-browser'], {
queryParams: { path: '//demo.dotcms.com/application/containers/default/' }
});
});

it('should go to edit page', () => {
service.goToEditTemplate('123');
expect(router.navigate).toHaveBeenCalledWith(['/templates/edit/123']);
Expand Down
14 changes: 12 additions & 2 deletions core-web/libs/data-access/src/lib/dot-router/dot-router.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,18 @@ export class DotRouterService {
this.router.navigate(['/public/login'], this.addCacheBusting(navExtras));
}

goToSiteBrowser(): void {
this.router.navigate(['/c/site-browser']);
/**
* Redirects to the Site Browser.
*
* The legacy Site Browser opens the folder saved with `DotSiteBrowserService.setSelectedFolder`,
* so callers still call that first. The `path` query param is for users without Site Browser:
* the menu guard reads it to open the same folder in Content Drive.
*
* @param path the folder to open, as `//<host>/<folder path>/` or a path in the current site
* @memberof DotRouterService
*/
goToSiteBrowser(path?: string): void {
this.router.navigate(['/c/site-browser'], path ? { queryParams: { path } } : {});
}

/**
Expand Down
Loading
Loading