From ac888c6bf09924bb5a6d92aa1a7fb0b55604b0c4 Mon Sep 17 00:00:00 2001 From: Anatol Sialitski Date: Mon, 9 Dec 2024 14:09:11 +0100 Subject: [PATCH] Make static serviceUrls in Java instead of the js assetUrl hack #454 --- build.gradle | 2 + .../lib/react4xp/url/ContentResolver.java | 154 ++++++++++ .../react4xp/url/ContentResolverResult.java | 72 +++++ .../lib/react4xp/url/ServiceUrlBuilder.java | 279 ++++++++++++++++++ .../dependencies/initServiceUrlRoot.ts | 36 ++- .../react4xp/url/ServiceUrlBuilderTest.java | 147 +++++++++ 6 files changed, 675 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/enonic/lib/react4xp/url/ContentResolver.java create mode 100644 src/main/java/com/enonic/lib/react4xp/url/ContentResolverResult.java create mode 100644 src/main/java/com/enonic/lib/react4xp/url/ServiceUrlBuilder.java create mode 100644 src/test/java/com/enonic/lib/react4xp/url/ServiceUrlBuilderTest.java diff --git a/build.gradle b/build.gradle index 958d710c..76ecf567 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,7 @@ repositories { dependencies { compileOnly "com.enonic.xp:core-api:${xpVersion}" + compileOnly "com.enonic.xp:portal-api:${xpVersion}" compileOnly "com.enonic.xp:script-api:${xpVersion}" implementation "com.enonic.xp:lib-io:${xpVersion}" @@ -29,6 +30,7 @@ dependencies { testImplementation "com.enonic.xp:testing:${xpVersion}" testImplementation "org.junit.jupiter:junit-jupiter-api:5.11.3" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.11.3" + testImplementation 'org.mockito:mockito-core:5.12.0' } node { diff --git a/src/main/java/com/enonic/lib/react4xp/url/ContentResolver.java b/src/main/java/com/enonic/lib/react4xp/url/ContentResolver.java new file mode 100644 index 00000000..86be1a9b --- /dev/null +++ b/src/main/java/com/enonic/lib/react4xp/url/ContentResolver.java @@ -0,0 +1,154 @@ +package com.enonic.lib.react4xp.url; + +import java.util.concurrent.Callable; + +import com.enonic.xp.content.Content; +import com.enonic.xp.content.ContentId; +import com.enonic.xp.content.ContentNotFoundException; +import com.enonic.xp.content.ContentPath; +import com.enonic.xp.content.ContentService; +import com.enonic.xp.context.Context; +import com.enonic.xp.context.ContextAccessor; +import com.enonic.xp.context.ContextBuilder; +import com.enonic.xp.portal.PortalRequest; +import com.enonic.xp.portal.RenderMode; +import com.enonic.xp.security.RoleKeys; +import com.enonic.xp.security.acl.Permission; +import com.enonic.xp.security.auth.AuthenticationInfo; +import com.enonic.xp.site.Site; + +public class ContentResolver +{ + private final ContentService contentService; + + public ContentResolver( final ContentService contentService ) + { + this.contentService = contentService; + } + + public ContentResolverResult resolve( final PortalRequest request ) + { + final ContentPath contentPath = request.getContentPath(); + + if ( contentPath.isRoot() ) + { + return new ContentResolverResult( null, false, null, "/", contentPath.toString() ); + } + + if ( request.getMode() == RenderMode.EDIT ) + { + return resolveInEditMode( contentPath ); + } + else + { + return resolveInNonEditMode( contentPath ); + } + } + + private ContentResolverResult resolveInNonEditMode( final ContentPath contentPath ) + { + final Content content = callAsContentAdmin( () -> getContentByPath( contentPath ) ); + + final Site site = content != null && content.isSite() + ? (Site) content + : callAsContentAdmin( () -> this.contentService.findNearestSiteByPath( contentPath ) ); + + final String siteRelativePath = siteRelativePath( site, contentPath ); + return new ContentResolverResult( visibleContent( content ), content != null, site, siteRelativePath, contentPath.toString() ); + } + + private ContentResolverResult resolveInEditMode( final ContentPath contentPath ) + { + final String contentPathString = contentPath.toString(); + + final ContentId contentId = tryConvertToContentId( contentPathString ); + + final Content contentById = contentId != null ? callAsContentAdmin( () -> getContentById( contentId ) ) : null; + + final Content content = contentById != null ? contentById : callAsContentAdmin( () -> this.getContentByPath( contentPath ) ); + + if ( content == null ) + { + return new ContentResolverResult( null, false, null, contentPathString, contentPathString ); + } + + if ( content.getPath().isRoot() ) + { + return new ContentResolverResult( null, false, null, "/", contentPathString ); + } + + final Site site = + content.isSite() ? (Site) content : callAsContentAdmin( () -> this.contentService.getNearestSite( content.getId() ) ); + + final String siteRelativePath = siteRelativePath( site, content.getPath() ); + return new ContentResolverResult( visibleContent( content ), true, site, siteRelativePath, contentPathString ); + } + + private static ContentId tryConvertToContentId( final String contentPathString ) + { + try + { + return ContentId.from( contentPathString.substring( 1 ) ); + } + catch ( Exception e ) + { + return null; + } + } + + private Content visibleContent( final Content content ) + { + return content == null || content.getPath().isRoot() || + !content.getPermissions().isAllowedFor( ContextAccessor.current().getAuthInfo().getPrincipals(), Permission.READ ) + ? null + : content; + } + + private Content getContentById( final ContentId contentId ) + { + try + { + return this.contentService.getById( contentId ); + } + catch ( final ContentNotFoundException e ) + { + return null; + } + } + + private Content getContentByPath( final ContentPath contentPath ) + { + try + { + return this.contentService.getByPath( contentPath ); + } + catch ( final ContentNotFoundException e ) + { + return null; + } + } + + private static T callAsContentAdmin( final Callable callable ) + { + final Context context = ContextAccessor.current(); + return ContextBuilder.from( context ).authInfo( + AuthenticationInfo.copyOf( context.getAuthInfo() ).principals( RoleKeys.CONTENT_MANAGER_ADMIN ).build() ).build().callWith( + callable ); + } + + private static String siteRelativePath( final Site site, final ContentPath contentPath ) + { + if ( site == null ) + { + return contentPath.toString(); + } + else if ( site.getPath().equals( contentPath ) ) + { + return "/"; + } + else + { + return contentPath.toString().substring( site.getPath().toString().length() ); + } + } +} diff --git a/src/main/java/com/enonic/lib/react4xp/url/ContentResolverResult.java b/src/main/java/com/enonic/lib/react4xp/url/ContentResolverResult.java new file mode 100644 index 00000000..f47a0a31 --- /dev/null +++ b/src/main/java/com/enonic/lib/react4xp/url/ContentResolverResult.java @@ -0,0 +1,72 @@ +package com.enonic.lib.react4xp.url; + +import com.enonic.xp.content.Content; +import com.enonic.xp.site.Site; +import com.enonic.xp.web.WebException; + +public final class ContentResolverResult +{ + private final Content content; + + private final Site nearestSite; + + private final String siteRelativePath; + + private final String notFoundHint; + + private final boolean contentExists; + + ContentResolverResult( final Content content, final boolean contentExists, final Site nearestSite, final String siteRelativePath, + final String notFoundHint ) + { + this.content = content; + this.nearestSite = nearestSite; + this.siteRelativePath = siteRelativePath; + this.notFoundHint = notFoundHint; + this.contentExists = contentExists; + } + + public Content getContent() + { + return content; + } + + public Content getContentOrElseThrow() + { + if ( content != null ) + { + return content; + } + else if ( contentExists ) + { + throw WebException.forbidden( String.format( "You don't have permission to access [%s]", notFoundHint ) ); + } + else + { + throw WebException.notFound( String.format( "Page [%s] not found", notFoundHint ) ); + } + + } + + public Site getNearestSite() + { + return nearestSite; + } + + public Site getNearestSiteOrElseThrow() + { + if ( nearestSite != null ) + { + return nearestSite; + } + else + { + throw WebException.notFound( String.format( "Site for [%s] not found", notFoundHint ) ); + } + } + + public String getSiteRelativePath() + { + return siteRelativePath; + } +} diff --git a/src/main/java/com/enonic/lib/react4xp/url/ServiceUrlBuilder.java b/src/main/java/com/enonic/lib/react4xp/url/ServiceUrlBuilder.java new file mode 100644 index 00000000..d00147be --- /dev/null +++ b/src/main/java/com/enonic/lib/react4xp/url/ServiceUrlBuilder.java @@ -0,0 +1,279 @@ +package com.enonic.lib.react4xp.url; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import com.google.common.base.Splitter; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.common.net.UrlEscapers; + +import com.enonic.xp.content.ContentService; +import com.enonic.xp.portal.PortalRequest; +import com.enonic.xp.portal.url.UrlTypeConstants; +import com.enonic.xp.script.ScriptValue; +import com.enonic.xp.script.bean.BeanContext; +import com.enonic.xp.script.bean.ScriptBean; +import com.enonic.xp.site.Site; +import com.enonic.xp.web.servlet.ServletRequestUrlHelper; +import com.enonic.xp.web.servlet.UriRewritingResult; + +import static com.google.common.base.Strings.isNullOrEmpty; + +public class ServiceUrlBuilder + implements ScriptBean +{ + private static final Pattern ADMIN_SITE_CTX_PATTERN = + Pattern.compile( "^(?edit|preview|admin|inline)/(?[^/]+)/(?[^/]+)" ); + + private static final Pattern SITE_CTX_PATTERN = Pattern.compile( "^(?[^/]+)/(?[^/]+)" ); + + private static final String ADMIN_SITE_PREFIX = "/admin/site/"; + + private static final String SITE_PREFIX = "/site/"; + + private Supplier requestSupplier; + + private Supplier contentServiceSupplier; + + private String application; + + private String serviceName; + + private Multimap params; + + private String type; + + private String path; + + @Override + public void initialize( final BeanContext beanContext ) + { + this.requestSupplier = beanContext.getBinding( PortalRequest.class ); + this.contentServiceSupplier = beanContext.getService( ContentService.class ); + } + + public void setApplication( final String application ) + { + this.application = application; + } + + public void setServiceName( final String serviceName ) + { + this.serviceName = serviceName; + } + + public void setParams( final ScriptValue params ) + { + this.params = resolveParams( params == null ? new HashMap<>() : params.getMap() ); + } + + public void setType( final String type ) + { + this.type = type; + } + + public void setPath( final String path ) + { + this.path = path; + } + + public String createUrl() + { + Objects.requireNonNull( serviceName, "Service name is required" ); + + final StringBuilder url = new StringBuilder(); + + final PortalRequest portalRequest = requestSupplier.get(); + final String rawPath = portalRequest.getRawPath(); + + if ( rawPath.startsWith( ADMIN_SITE_PREFIX ) ) + { + processSite( url, rawPath, true ); + } + else if ( rawPath.startsWith( SITE_PREFIX ) ) + { + processSite( url, rawPath, false ); + } + else + { + throw new IllegalArgumentException( String.format( "Invalid path: \"%s\"", rawPath ) ); + } + + appendPart( url, "_" ); + appendPart( url, "service" ); + + appendPart( url, Objects.requireNonNullElseGet( application, () -> portalRequest.getApplicationKey().toString() ) ); + appendPart( url, serviceName ); + + if ( !isNullOrEmpty( path ) ) + { + appendPart( url, path.replaceAll( "/$", "" ) ); + } + + if ( params != null && !params.isEmpty() ) + { + appendParams( url, params.entries() ); + } + + final String targetUri = url.toString(); + final UriRewritingResult rewritingResult = ServletRequestUrlHelper.rewriteUri( portalRequest.getRawRequest(), targetUri ); + + if ( rewritingResult.isOutOfScope() ) + { + throw new IllegalStateException( "URI out of scope" ); + } + + final String uri = rewritingResult.getRewrittenUri(); + + if ( UrlTypeConstants.ABSOLUTE.equals( type ) ) + { + return ServletRequestUrlHelper.getServerUrl( portalRequest.getRawRequest() ) + uri; + } + else + { + return uri; + } + } + + private void processSite( final StringBuilder url, final String requestURI, final boolean isSiteAdmin ) + { + final String sitePrefix = isSiteAdmin ? ADMIN_SITE_PREFIX : SITE_PREFIX; + final String subPath = subPath( requestURI, sitePrefix ); + final Pattern pattern = isSiteAdmin ? ADMIN_SITE_CTX_PATTERN : SITE_CTX_PATTERN; + final Matcher matcher = pattern.matcher( subPath ); + if ( matcher.find() ) + { + if ( isSiteAdmin ) + { + appendPart( url, "admin" ); + } + appendPart( url, "site" ); + if ( isSiteAdmin ) + { + final String mode = matcher.group( "mode" ); + appendPart( url, "edit".equals( mode ) ? "preview" : mode ); + } + appendPart( url, matcher.group( "project" ) ); + appendPart( url, matcher.group( "branch" ) ); + + final ContentResolverResult contentResolverResult = + new ContentResolver( contentServiceSupplier.get() ).resolve( requestSupplier.get() ); + final Site site = contentResolverResult.getNearestSite(); + if ( site != null ) + { + appendPart( url, site.getPath().toString() ); + } + } + else + { + throw new IllegalArgumentException( String.format( "Invalid site context: %s", subPath ) ); + } + } + + private String subPath( final String requestURI, final String prefix ) + { + final int endpoint = requestURI.indexOf( "/_/" ); + final int endIndex = endpoint == -1 ? requestURI.length() : endpoint + 1; + return requestURI.substring( prefix.length(), endIndex ); + } + + private void appendPart( final StringBuilder str, final String urlPart ) + { + if ( isNullOrEmpty( urlPart ) ) + { + return; + } + + final boolean endsWithSlash = str.length() > 0 && str.charAt( str.length() - 1 ) == '/'; + final String normalized = normalizePath( urlPart ); + + if ( !endsWithSlash ) + { + str.append( "/" ); + } + + str.append( normalized ); + } + + private String normalizePath( final String value ) + { + if ( !value.contains( "/" ) ) + { + return urlEncodePathSegment( value ); + } + + return StreamSupport.stream( Splitter.on( '/' ).trimResults().omitEmptyStrings().split( value ).spliterator(), false ).map( + this::urlEncodePathSegment ).collect( Collectors.joining( "/" ) ); + } + + private String urlEncodePathSegment( final String value ) + { + return UrlEscapers.urlPathSegmentEscaper().escape( value ); + } + + private Multimap resolveParams( final Object params ) + { + final Multimap result = HashMultimap.create(); + + if ( params instanceof Map ) + { + for ( final Map.Entry entry : ( (Map) params ).entrySet() ) + { + final String key = entry.getKey().toString(); + final Object value = entry.getValue(); + if ( value instanceof Iterable ) + { + for ( final Object v : (Iterable) value ) + { + result.put( key, v.toString() ); + } + } + else + { + result.put( key, value.toString() ); + } + } + } + return result; + } + + private void appendParams( final StringBuilder str, final Collection> params ) + { + if ( params.isEmpty() ) + { + return; + } + str.append( "?" ); + final Iterator> it = params.iterator(); + appendParam( str, it.next() ); + while ( it.hasNext() ) + { + str.append( "&" ); + appendParam( str, it.next() ); + } + } + + private void appendParam( final StringBuilder str, final Map.Entry param ) + { + final String value = param.getValue(); + str.append( urlEncode( param.getKey() ) ); + if ( value != null ) + { + str.append( "=" ).append( urlEncode( value ) ); + } + } + + public static String urlEncode( final String value ) + { + return UrlEscapers.urlFormParameterEscaper().escape( value ); + } +} diff --git a/src/main/resources/lib/enonic/react4xp/dependencies/initServiceUrlRoot.ts b/src/main/resources/lib/enonic/react4xp/dependencies/initServiceUrlRoot.ts index d1cc6adb..02bb7c6b 100644 --- a/src/main/resources/lib/enonic/react4xp/dependencies/initServiceUrlRoot.ts +++ b/src/main/resources/lib/enonic/react4xp/dependencies/initServiceUrlRoot.ts @@ -1,12 +1,21 @@ import type { UrlType } from '/types'; - -import { assetUrl as getAssetUrl } from '/lib/xp/portal'; import { getUrlType } from '/lib/enonic/react4xp/React4xp/utils/getUrlType'; +interface ServiceUrlBuilder { + setApplication(value: string): void; + + setPath(value: string): void; + + setType(value: string): void; + + setServiceName(value: string): void; + + createUrl(): string; +} + /* -* Asseturl should work in any context. -* Hack until lib-static generates perfect static asset urls. +* Initialize the root path of a service URL for a site mount. */ export function initServiceUrlRoot({ serviceName = '', @@ -15,15 +24,12 @@ export function initServiceUrlRoot({ serviceName?: string, urlType?: UrlType } = {}) { - const assetUrl = getAssetUrl({ - path:'/', - type: getUrlType(urlType) - }); - // log.debug('initServiceUrlRoot(%s) assetUrl:%s', serviceName, assetUrl); - const serviceUrlRoot = assetUrl - .replace(/\/edit\/([^\/]+)\/([^\/]+)\/_\/asset/,'/preview/$1/$2/_/asset') // Fix BUG Assets give 404 in edit mode #476 - .replace(/\/_\/asset\/.*$/, `/_/service/${app.name}/${serviceName}/`) - .replace(/\/{2,}$/, '/'); - // log.debug('initServiceUrlRoot(%s) -> %s', serviceName, serviceUrlRoot); - return serviceUrlRoot; + const bean: ServiceUrlBuilder = __.newBean('com.enonic.lib.react4xp.url.ServiceUrlBuilder'); + + bean.setApplication(app.name); + bean.setPath('/'); + bean.setType(getUrlType(urlType)); + bean.setServiceName(serviceName); + + return bean.createUrl(); } diff --git a/src/test/java/com/enonic/lib/react4xp/url/ServiceUrlBuilderTest.java b/src/test/java/com/enonic/lib/react4xp/url/ServiceUrlBuilderTest.java new file mode 100644 index 00000000..ed81583f --- /dev/null +++ b/src/test/java/com/enonic/lib/react4xp/url/ServiceUrlBuilderTest.java @@ -0,0 +1,147 @@ +package com.enonic.lib.react4xp.url; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.enonic.xp.content.ContentPath; +import com.enonic.xp.content.ContentService; +import com.enonic.xp.portal.PortalRequest; +import com.enonic.xp.script.bean.BeanContext; +import com.enonic.xp.site.Site; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ServiceUrlBuilderTest +{ + private PortalRequest portalRequest; + + private ContentService contentService; + + private BeanContext beanContext; + + ServiceUrlBuilder instance; + + @BeforeEach + void setUp() + { + portalRequest = mock( PortalRequest.class ); + contentService = mock( ContentService.class ); + beanContext = mock( BeanContext.class ); + instance = new ServiceUrlBuilder(); + + when( beanContext.getBinding( PortalRequest.class ) ).thenReturn( () -> portalRequest ); + when( beanContext.getService( ContentService.class ) ).thenReturn( () -> contentService ); + + instance = new ServiceUrlBuilder(); + instance.initialize( beanContext ); + } + + @Test + void testOnAdminSiteMount() + { + when( portalRequest.getRawPath() ).thenReturn( "/admin/site/preview/myproject/master/mysite/content" ); + + final ContentPath contentPath = ContentPath.from( "/mysite/content" ); + when( portalRequest.getContentPath() ).thenReturn( contentPath ); + + final Site site = mock( Site.class ); + when( site.getPath() ).thenReturn( ContentPath.from( "/mysite" ) ); + + when( contentService.getByPath( eq( contentPath ) ) ).thenReturn( null ); + when( contentService.findNearestSiteByPath( contentPath ) ).thenReturn( site ); + + instance.setApplication( "myapp" ); + instance.setServiceName( "myservice" ); + instance.setPath( "/mypath" ); + instance.setType( "server" ); + + final String result = instance.createUrl(); + + assertEquals( "/admin/site/preview/myproject/master/mysite/_/service/myapp/myservice/mypath", result ); + } + + @Test + void testOnAdminSiteInlineMount() + { + when( portalRequest.getRawPath() ).thenReturn( "/admin/site/inline/myproject/master/mysite/content" ); + + final ContentPath contentPath = ContentPath.from( "/mysite/content" ); + when( portalRequest.getContentPath() ).thenReturn( contentPath ); + + final Site site = mock( Site.class ); + when( site.getPath() ).thenReturn( ContentPath.from( "/mysite" ) ); + + when( contentService.getByPath( eq( contentPath ) ) ).thenReturn( null ); + when( contentService.findNearestSiteByPath( contentPath ) ).thenReturn( site ); + + instance.setApplication( "myapp" ); + instance.setServiceName( "myservice" ); + instance.setPath( "/mypath" ); + instance.setType( "server" ); + + final String result = instance.createUrl(); + + assertEquals( "/admin/site/inline/myproject/master/mysite/_/service/myapp/myservice/mypath", result ); + } + + @Test + void testOnAdminMount() + { + when( portalRequest.getRawPath() ).thenReturn( "/site/myproject/master/mysite/content" ); + + final ContentPath contentPath = ContentPath.from( "/mysite/content" ); + when( portalRequest.getContentPath() ).thenReturn( contentPath ); + + final Site site = mock( Site.class ); + when( site.getPath() ).thenReturn( ContentPath.from( "/mysite" ) ); + + when( contentService.getByPath( eq( contentPath ) ) ).thenReturn( null ); + when( contentService.findNearestSiteByPath( contentPath ) ).thenReturn( site ); + + instance.setApplication( "myapp" ); + instance.setServiceName( "myservice" ); + instance.setPath( "/mypath" ); + instance.setType( "server" ); + + final String result = instance.createUrl(); + + assertEquals( "/site/myproject/master/mysite/_/service/myapp/myservice/mypath", result ); + } + + @Test + void testInvalidContext() + { + when( portalRequest.getRawPath() ).thenReturn( "/admin/tool/myapp/toolname" ); + + final ServiceUrlBuilder serviceUrlBuilder = new ServiceUrlBuilder(); + serviceUrlBuilder.initialize( beanContext ); + + serviceUrlBuilder.setApplication( "myapp" ); + serviceUrlBuilder.setServiceName( "myservice" ); + serviceUrlBuilder.setPath( "/mypath" ); + serviceUrlBuilder.setType( "server" ); + + IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, serviceUrlBuilder::createUrl ); + assertEquals( "Invalid path: \"/admin/tool/myapp/toolname\"", ex.getMessage() ); + } + + @Test + void testEmptyServiceName() + { + when( portalRequest.getRawPath() ).thenReturn( "/admin/tool/myapp/toolname" ); + + final ServiceUrlBuilder serviceUrlBuilder = new ServiceUrlBuilder(); + serviceUrlBuilder.initialize( beanContext ); + + serviceUrlBuilder.setApplication( "myapp" ); + serviceUrlBuilder.setPath( "/mypath" ); + serviceUrlBuilder.setType( "server" ); + + NullPointerException ex = assertThrows( NullPointerException.class, serviceUrlBuilder::createUrl ); + assertEquals( "Service name is required", ex.getMessage() ); + } +}