diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/AWSS3EndPointPublisher.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/AWSS3EndPointPublisher.java index 06d109c8d468..b947a4df1582 100644 --- a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/AWSS3EndPointPublisher.java +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/AWSS3EndPointPublisher.java @@ -87,6 +87,16 @@ public void shutdownTransferManager(){ this.storage.shutdownTransferManager(); } + /** + * Indica se il file viene gestito dal publisher S3. + * + * @param file file da verificare + * @return true se il file puo essere pubblicato o rimosso + */ + public boolean acceptsFile(final File file) { + return awss3FileFilter.accept(file); + } + /** * Implementation of {@link EndPointPublisher#checkConnectSuccessfully(String)}. * @@ -127,13 +137,7 @@ public void deleteFilesFromEndpoint(final String bucketName, try{ //We want to ignore these extensions because they weren't pushed. if (awss3FileFilter.accept(new File(filePath))) { - String filePathInBucket = filePath; - if (filePathInBucket.startsWith(File.separator)){ - filePathInBucket = filePathInBucket.substring(1); - } - if (UtilMethods.isSet(bucketRootPrefix)){ - filePathInBucket = bucketRootPrefix + File.separator + filePathInBucket; - } + final String filePathInBucket = getCompleteFileKey(bucketRootPrefix, filePath); Logger.debug(this, "Deleting file named: " + filePathInBucket + " from bucket: " + bucketName); this.storage.deleteFile(bucketName, filePathInBucket); } @@ -144,6 +148,84 @@ public void deleteFilesFromEndpoint(final String bucketName, } } // deleteFilesFromEndpoint + /** + * Pubblica un file usando una key S3 esplicita. + * + * @param bucketName nome bucket + * @param region regione bucket + * @param bucketRootPrefix prefisso bucket + * @param filePath key S3 relativa o assoluta + * @param file file fisico da inviare + * @throws DotPublishingException se la pubblicazione fallisce + */ + public void pushFileToEndpoint(final String bucketName, final String region, final String bucketRootPrefix, + final String filePath, final File file) throws DotPublishingException { + final int secondsToSleep = Config.getIntProperty("STATIC_PUSH_SLEEP_ON_ERROR_SECONDS", 10); + final int pushRetries = Config.getIntProperty("STATIC_PUSH_RETRY_ATTEMPTS", 3); + + boolean success = false; + int retriesCount = 0; + String errorMessages = ""; + + while (!success && retriesCount <= pushRetries) { + try { + Logger.info(this, "Pushing File with explicit key: " + filePath + ", retries: " + retriesCount); + this.pushExactFile(bucketName, bucketRootPrefix, filePath, file); + success = true; + } catch (final Exception e) { + errorMessages += "\n Retry #: " + retriesCount + ", Error: " + e.getMessage(); + retriesCount++; + try { + Logger.info(this, "Sleeping before next push try, seconds: " + secondsToSleep); + Thread.sleep(secondsToSleep * 1000); + } catch (InterruptedException ie) { + Logger.error(this, "Can't Sleep before retry file: " + file.getAbsolutePath()); + } + } + } + + if (!success) { + Logger.error(this, "Can't push file: " + file.getAbsolutePath() + ", reasons : " + errorMessages); + throw new DotPublishingException("Can't push file: " + file.getAbsolutePath() + ", reasons : " + errorMessages); + } + } + + /** + * Pubblica un file usando la key esplicita fornita. + * + * @param bucketName nome bucket + * @param bucketRootPrefix prefisso bucket + * @param filePath key S3 + * @param file file fisico da inviare + * @throws IOException in caso di errore I/O + * @throws DecoderException in caso di errore MD5 + * @throws InterruptedException in caso di attesa interrotta + */ + private void pushExactFile(final String bucketName, final String bucketRootPrefix, + final String filePath, final File file) + throws IOException, DecoderException, InterruptedException { + if (!awss3FileFilter.accept(file)) { + return; + } + + Logger.debug(this, "Pushing explicit File: " + file); + + final String completeFileKey = getCompleteFileKey(bucketRootPrefix, filePath); + ObjectMetadata objectMetadata = new ObjectMetadata(); + setMD5Based64(file, objectMetadata); + setContentType(file, objectMetadata); + try (InputStream is = Files.newInputStream(file.toPath())) { + objectMetadata.setContentLength(file.length()); + PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, completeFileKey, is, objectMetadata); + final Upload upload = this.storage.uploadFile(putObjectRequest); + + if (null != upload && this.isWaitForCompletionNeeded()) { + final UploadResult result = upload.waitForUploadResult(); + Logger.debug(this, "File: " + file + " has been uploaded, result: " + result); + } + } + } + @Override public void pushBundleToEndpoint(final String bucketName, final String region, final String bucketRootPrefix, final String filePath, final File file) throws DotPublishingException { @@ -332,6 +414,32 @@ protected String getFolderPath(final String bucketRootPrefix, return folderPath; } // getFolderPath. + /** + * Costruisce la key S3 completa a partire da un path relativo. + * + * @param bucketRootPrefix prefisso bucket + * @param filePath path relativo o assoluto + * @return key S3 normalizzata + */ + protected String getCompleteFileKey(final String bucketRootPrefix, final String filePath) { + String completeFileKey = filePath; + if (completeFileKey.startsWith(File.separator)) { + completeFileKey = completeFileKey.substring(1); + } + + if (UtilMethods.isSet(bucketRootPrefix)) { + if (bucketRootPrefix.endsWith(File.separator) && completeFileKey.startsWith(File.separator)) { + completeFileKey = bucketRootPrefix + completeFileKey.substring(1); + } else if (bucketRootPrefix.endsWith(File.separator) || completeFileKey.startsWith(File.separator)) { + completeFileKey = bucketRootPrefix + completeFileKey; + } else { + completeFileKey = bucketRootPrefix + File.separator + completeFileKey; + } + } + + return completeFileKey; + } + public void createBucket(final String bucketName, final String region) throws DotPublishingException { if (!this.exists (bucketName)) { diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/AWSS3Publisher.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/AWSS3Publisher.java index 9f964e84df57..15a1b2ce439f 100644 --- a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/AWSS3Publisher.java +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/AWSS3Publisher.java @@ -19,6 +19,7 @@ import com.dotcms.publisher.environment.bean.Environment; import com.dotcms.publisher.environment.business.EnvironmentAPI; import com.dotcms.publisher.pusher.PushUtils; +import com.dotcms.publisher.util.PusheableAsset; import com.dotcms.publishing.*; import com.amazonaws.auth.DefaultAWSCredentialsProviderChain; import org.apache.commons.io.FileUtils; @@ -26,10 +27,16 @@ import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.business.ContentletAPI; +import com.dotmarketing.portlets.contentlet.business.DotContentletStateException; import com.dotmarketing.portlets.contentlet.business.HostAPI; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.languagesmanager.business.LanguageAPI; import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.util.*; import com.google.common.annotations.VisibleForTesting; +import io.vavr.Lazy; import org.apache.logging.log4j.ThreadContext; import org.jetbrains.annotations.NotNull; @@ -59,12 +66,17 @@ public class AWSS3Publisher extends Publisher { public static final String DEFAULT_BUCKET_NAME = "dot-bucket-default"; private static final String CREATED_BUCKETS = "createdBuckets"; + private static final Lazy STATIC_PUSH_S3_VANITY_ALIAS_ENABLED = + Lazy.of(() -> Config.getBooleanProperty("STATIC_PUSH_S3_VANITY_ALIAS_ENABLED", false)); private final HostAPI hostAPI; private final PublishAuditAPI publishAuditAPI; private final EnvironmentAPI environmentAPI; private final PublishingEndPointAPI publisherEndPointAPI; private final PushedAssetsAPI pushedAssetsAPI; + private final S3VanityAliasService vanityAliasService; + private final ContentletAPI contentletAPI; + private final LanguageAPI languageAPI; /** * Class constructor. @@ -75,6 +87,9 @@ public AWSS3Publisher() { this.environmentAPI = APILocator.getEnvironmentAPI(); this.publisherEndPointAPI = APILocator.getPublisherEndPointAPI(); this.pushedAssetsAPI = APILocator.getPushedAssetsAPI(); + this.vanityAliasService = new S3VanityAliasService(); + this.contentletAPI = APILocator.getContentletAPI(); + this.languageAPI = APILocator.getLanguageAPI(); } /** @@ -84,6 +99,7 @@ public AWSS3Publisher() { * @param environmentAPI * @param publisherEndPointAPI * @param pushedAssetsAPI + * @param vanityAliasService */ @VisibleForTesting public AWSS3Publisher(final HostAPI hostAPI, @@ -91,11 +107,34 @@ public AWSS3Publisher(final HostAPI hostAPI, final EnvironmentAPI environmentAPI, final PublishingEndPointAPI publisherEndPointAPI, final PushedAssetsAPI pushedAssetsAPI) { + this(hostAPI, publishAuditAPI, environmentAPI, publisherEndPointAPI, pushedAssetsAPI, + new S3VanityAliasService()); + } + + /** + * Test driven constructor + * @param hostAPI + * @param publishAuditAPI + * @param environmentAPI + * @param publisherEndPointAPI + * @param pushedAssetsAPI + * @param vanityAliasService + */ + @VisibleForTesting + public AWSS3Publisher(final HostAPI hostAPI, + final PublishAuditAPI publishAuditAPI, + final EnvironmentAPI environmentAPI, + final PublishingEndPointAPI publisherEndPointAPI, + final PushedAssetsAPI pushedAssetsAPI, + final S3VanityAliasService vanityAliasService) { this.hostAPI = hostAPI; this.publishAuditAPI = publishAuditAPI; this.environmentAPI = environmentAPI; this.publisherEndPointAPI = publisherEndPointAPI; this.pushedAssetsAPI = pushedAssetsAPI; + this.vanityAliasService = vanityAliasService; + this.contentletAPI = APILocator.getContentletAPI(); + this.languageAPI = APILocator.getLanguageAPI(); } /** @@ -335,13 +374,7 @@ public PublisherConfig process(final PublishStatus status) throws DotPublishingE : FileUtils.listFiles(languageFolder.getLanguageFolder(), TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE); for (File file : listFiles) { - String filePath = file.getAbsolutePath().replace(bundleRoot.getAbsolutePath()+ LIVE_FOLDER, ""); - - //Always remove the /hostName/ i.e. /demo.dotcms.com/ - filePath = filePath.substring(filePath.indexOf(File.separator, filePath.indexOf(File.separator)+1)); - - //Always remove the /languageId/ i.e. /1/ - filePath = filePath.substring(filePath.indexOf(File.separator, filePath.indexOf(File.separator)+1)); + final String filePath = getStaticFilePath(bundleRoot, file); try { if (amIPublishing) { @@ -355,9 +388,16 @@ public PublisherConfig process(final PublishStatus status) throws DotPublishingE Logger.error(this.getClass(), error, e); } } + publishVanityAliasesForCanonicalFilesIfEnabled(endPointPublisher, endpoint, + host, language, bucketName, bucketRegion, bucketPrefix, bundleRoot, + languageFolder.getLanguageFolder()); } } } + publishVanityAliasesForBundleAssetsIfEnabled(endPointPublisher, bucketRegion, + bucketPrefixProp, endpoint); + unpublishVanityAliasesForBundleAssetsIfEnabled(new S3VanityAliasCleanupContext( + endpoint.getId(), endPointPublisher)); } catch(Exception e) { @@ -506,6 +546,304 @@ protected String normalizeBucketName (final String bucketName) { return normalizedBucketName.toLowerCase(); } //normalizeBucketName + /** + * Publishes vanity clones for Vanity URL contentlets included in the bundle. + * + * @param endPointPublisher S3 publisher + * @param bucketRegion bucket region + * @param bucketPrefixProp bucket prefix property before interpolation + * @param endpoint current endpoint + * @throws DotPublishingException when vanity clone publishing fails + */ + private void publishVanityAliasesForBundleAssetsIfEnabled(final AWSS3EndPointPublisher endPointPublisher, + final String bucketRegion, + final String bucketPrefixProp, + final PublishingEndPoint endpoint) + throws DotPublishingException { + if (!isS3VanityAliasEnabled() || !PublisherConfig.Operation.PUBLISH.equals(config.getOperation())) { + return; + } + + final List assets = config.getAssets(); + if (!UtilMethods.isSet(assets)) { + return; + } + + for (final PublishQueueElement asset : assets) { + publishVanityAliasForQueueElement(endPointPublisher, bucketRegion, bucketPrefixProp, endpoint, asset); + } + } + + /** + * Refreshes existing vanity aliases when their canonical static files are published. + * + * @param endPointPublisher S3 publisher + * @param endpoint current endpoint + * @param host current host + * @param language current language + * @param bucketName bucket name + * @param bucketRegion bucket region + * @param bucketPrefix bucket prefix + * @param bundleRoot bundle root + * @param languageFolder folder containing the static files for the current language + * @throws DotPublishingException when alias refresh fails + */ + private void publishVanityAliasesForCanonicalFilesIfEnabled(final AWSS3EndPointPublisher endPointPublisher, + final PublishingEndPoint endpoint, + final Host host, + final Language language, + final String bucketName, + final String bucketRegion, + final String bucketPrefix, + final File bundleRoot, + final File languageFolder) + throws DotPublishingException { + if (!isS3VanityAliasEnabled() || !PublisherConfig.Operation.PUBLISH.equals(config.getOperation())) { + return; + } + + for (final File file : FileUtils.listFiles(languageFolder, TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE)) { + if (!endPointPublisher.acceptsFile(file)) { + continue; + } + + final String canonicalPath = getStaticFilePath(bundleRoot, file); + try { + vanityAliasService.publishAliases(new S3VanityAliasContext( + new S3VanityAliasLookup(endpoint.getId(), host.getIdentifier(), language.getId(), + canonicalPath), + bucketName, bucketRegion, bucketPrefix, host, language, file, endPointPublisher)); + } catch (final DotDataException e) { + throw new DotPublishingException(e.getMessage(), e); + } + } + } + + /** + * Handles one bundle element as a possible published Vanity URL. + * + * @param endPointPublisher S3 publisher + * @param bucketRegion bucket region + * @param bucketPrefixProp bucket prefix property before interpolation + * @param endpoint current endpoint + * @param asset element from the publishing queue + * @throws DotPublishingException when vanity clone publishing fails + */ + private void publishVanityAliasForQueueElement(final AWSS3EndPointPublisher endPointPublisher, + final String bucketRegion, + final String bucketPrefixProp, + final PublishingEndPoint endpoint, + final PublishQueueElement asset) + throws DotPublishingException { + if (!isPublishedContentletAsset(asset)) { + return; + } + + try { + publishLiveVanityContentlet(endPointPublisher, bucketRegion, bucketPrefixProp, endpoint, asset); + } catch (final DotDataException | DotSecurityException e) { + throw new DotPublishingException(e.getMessage(), e); + } + } + + /** + * Publishes one live Vanity URL contentlet as a static S3 clone. + * + * @param endPointPublisher S3 publisher + * @param bucketRegion bucket region + * @param bucketPrefixProp bucket prefix property before interpolation + * @param endpoint current endpoint + * @param asset element from the publishing queue + * @throws DotDataException when content or mapping reads fail + * @throws DotSecurityException when system user cannot read the contentlet + * @throws DotPublishingException when S3 publishing fails + */ + private void publishLiveVanityContentlet(final AWSS3EndPointPublisher endPointPublisher, + final String bucketRegion, + final String bucketPrefixProp, + final PublishingEndPoint endpoint, + final PublishQueueElement asset) + throws DotDataException, DotSecurityException, DotPublishingException { + final Optional vanityContentlet = findLiveVanityContentlet(asset); + if (vanityContentlet.isEmpty()) { + return; + } + + final Host host = hostAPI.find(vanityContentlet.get().getHost(), APILocator.getUserAPI().getSystemUser(), false); + final Language language = languageAPI.getLanguage(vanityContentlet.get().getLanguageId()); + if (host == null || language == null) { + Logger.warn(this, "Skipping Vanity URL because its site or language cannot be resolved: " + + vanityContentlet.get().getIdentifier()); + return; + } + + config.put(CURRENT_HOST, host); + config.put(CURRENT_LANGUAGE, Long.toString(language.getId())); + final String bucketName = getBucketName(config); + final String bucketPrefix = getBucketPrefix(bucketPrefixProp, config); + ensureBucketExists(endPointPublisher, bucketName, bucketRegion); + vanityAliasService.publishAliasForVanityUrl(new S3VanityAliasPublishContext(endpoint.getId(), + bucketName, bucketRegion, bucketPrefix, host, language, endPointPublisher), vanityContentlet.get()); + } + + /** + * Finds the live Vanity URL contentlet represented by a publishing queue element. + * + * @param asset element from the publishing queue + * @return live Vanity URL contentlet when the asset represents one + * @throws DotDataException when the contentlet cannot be read + * @throws DotSecurityException when the system user cannot read the contentlet + */ + private Optional findLiveVanityContentlet(final PublishQueueElement asset) + throws DotDataException, DotSecurityException { + final Optional languageSpecificContentlet = findLiveVanityContentletForLanguage(asset); + if (languageSpecificContentlet.isPresent()) { + return languageSpecificContentlet; + } + + final List contentlets = contentletAPI.search("+identifier:" + asset.getAsset() + " +live:true", + 0, 0, null, APILocator.getUserAPI().getSystemUser(), false); + return contentlets.stream().filter(Contentlet::isVanityUrl).findFirst(); + } + + /** + * Finds the live Vanity URL contentlet for the queue language when present. + * + * @param asset element from the publishing queue + * @return language-specific live Vanity URL when the queue carries a language + * @throws DotDataException when the contentlet cannot be read + * @throws DotSecurityException when the system user cannot read the contentlet + */ + private Optional findLiveVanityContentletForLanguage(final PublishQueueElement asset) + throws DotDataException, DotSecurityException { + if (asset.getLanguageId() == null || asset.getLanguageId() <= 0) { + return Optional.empty(); + } + + try { + final Contentlet contentlet = contentletAPI.findContentletByIdentifier(asset.getAsset(), true, + asset.getLanguageId(), APILocator.getUserAPI().getSystemUser(), false); + return contentlet != null && contentlet.isVanityUrl() ? Optional.of(contentlet) : Optional.empty(); + } catch (final DotContentletStateException e) { + Logger.warn(this, "Unable to find live Vanity URL for language: " + asset.getLanguageId()); + return Optional.empty(); + } + } + + /** + * Creates the bucket once per endpoint execution when needed. + * + * @param endPointPublisher S3 publisher + * @param bucketName bucket name + * @param bucketRegion bucket region + * @throws DotPublishingException when bucket creation fails + */ + private void ensureBucketExists(final AWSS3EndPointPublisher endPointPublisher, + final String bucketName, + final String bucketRegion) throws DotPublishingException { + if (!((Set) config.get(CREATED_BUCKETS)).contains(bucketName)) { + endPointPublisher.createBucket(bucketName, bucketRegion); + ((Set) config.get(CREATED_BUCKETS)).add(bucketName); + } + } + + /** + * Removes vanity clones when the bundle directly contains a deleted or + * unpublished Vanity URL. + * + * @param context minimal cleanup context for the S3 endpoint + * @throws DotPublishingException when vanity clone removal fails + */ + private void unpublishVanityAliasesForBundleAssetsIfEnabled(final S3VanityAliasCleanupContext context) + throws DotPublishingException { + if (!isS3VanityAliasEnabled() || PublisherConfig.Operation.PUBLISH.equals(config.getOperation())) { + return; + } + + final List assets = config.getAssets(); + if (!UtilMethods.isSet(assets)) { + return; + } + + for (final PublishQueueElement asset : assets) { + unpublishVanityAliasForQueueElement(context, asset); + } + } + + /** + * Handles one bundle element as a possible removed Vanity URL. + * + * @param context minimal cleanup context for the S3 endpoint + * @param asset element from the publishing queue + * @throws DotPublishingException when vanity clone removal fails + */ + private void unpublishVanityAliasForQueueElement(final S3VanityAliasCleanupContext context, + final PublishQueueElement asset) + throws DotPublishingException { + if (!isDeletedContentletAsset(asset)) { + return; + } + + try { + final long languageId = asset.getLanguageId() == null ? -1L : asset.getLanguageId().longValue(); + vanityAliasService.unpublishAliasesByVanityUrl(context, languageId, asset.getAsset()); + } catch (final DotDataException e) { + throw new DotPublishingException(e.getMessage(), e); + } + } + + /** + * Checks whether the asset represents a deleted contentlet. + * + * @param asset publishing queue element + * @return true when the asset can be a removed Vanity URL + */ + private boolean isDeletedContentletAsset(final PublishQueueElement asset) { + return asset != null + && asset.getOperation() != null + && asset.getOperation().longValue() == com.dotcms.publisher.business.PublisherAPI.DELETE_ELEMENT + && PusheableAsset.CONTENTLET.getType().equals(asset.getType()); + } + + /** + * Checks whether the asset represents a published contentlet. + * + * @param asset publishing queue element + * @return true when the asset can be a published Vanity URL + */ + private boolean isPublishedContentletAsset(final PublishQueueElement asset) { + return asset != null + && asset.getOperation() != null + && asset.getOperation().longValue() == com.dotcms.publisher.business.PublisherAPI.ADD_OR_UPDATE_ELEMENT + && PusheableAsset.CONTENTLET.getType().equals(asset.getType()); + } + + /** + * Converts a bundle file into the static path used as canonical key. + * + * @param bundleRoot bundle root + * @param file file or directory to convert + * @return static path relative to host and language + */ + private String getStaticFilePath(final File bundleRoot, final File file) { + String filePath = file.getAbsolutePath().replace(bundleRoot.getAbsolutePath() + LIVE_FOLDER, ""); + + //Always remove the /hostName/ i.e. /demo.dotcms.com/ + filePath = filePath.substring(filePath.indexOf(File.separator, filePath.indexOf(File.separator) + 1)); + + //Always remove the /languageId/ i.e. /1/ + return filePath.substring(filePath.indexOf(File.separator, filePath.indexOf(File.separator) + 1)); + } + + /** + * Checks whether S3 vanity alias support is enabled. + * + * @return true when vanity alias support is active + */ + private boolean isS3VanityAliasEnabled() { + return STATIC_PUSH_S3_VANITY_ALIAS_ENABLED.get(); + } + @Override diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/DotAsset.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/DotAsset.java new file mode 100644 index 000000000000..7d035f2436f0 --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/DotAsset.java @@ -0,0 +1,11 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +/** + * Static publishing target types supported by S3 Vanity URL clones. + */ +public enum DotAsset { + PAGE, + PAGE_INDEX, + PAGE_URL_MAP, + FILE_ASSET +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAlias.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAlias.java new file mode 100644 index 000000000000..578a13931be3 --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAlias.java @@ -0,0 +1,44 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +/** + * Represents a vanity alias that has been materialized on a static endpoint. + */ +public final class S3VanityAlias { + + final String endpointId; + final String hostId; + final long languageId; + final String canonicalPath; + final String vanityPath; + final String vanityUrlId; + final String bucketName; + final String bucketRegion; + final String bucketPrefix; + + /** + * Creates an immutable vanity alias mapping. + * + * @param endpointId publishing endpoint identifier + * @param hostId host identifier + * @param languageId language identifier + * @param canonicalPath canonical S3 key + * @param vanityPath vanity S3 key + * @param vanityUrlId source Vanity URL identifier + * @param bucketName bucket where the alias has been materialized + * @param bucketRegion bucket region used during publishing + * @param bucketPrefix bucket prefix used for the vanity key + */ + public S3VanityAlias(final String endpointId, final String hostId, final long languageId, + final String canonicalPath, final String vanityPath, final String vanityUrlId, + final String bucketName, final String bucketRegion, final String bucketPrefix) { + this.endpointId = endpointId; + this.hostId = hostId; + this.languageId = languageId; + this.canonicalPath = canonicalPath; + this.vanityPath = vanityPath; + this.vanityUrlId = vanityUrlId; + this.bucketName = bucketName; + this.bucketRegion = bucketRegion; + this.bucketPrefix = bucketPrefix; + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasCleanupContext.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasCleanupContext.java new file mode 100644 index 000000000000..696227470148 --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasCleanupContext.java @@ -0,0 +1,22 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +/** + * Minimal context required to remove vanity aliases already materialized on S3. + */ +public final class S3VanityAliasCleanupContext { + + final String endpointId; + final AWSS3EndPointPublisher endpointPublisher; + + /** + * Creates the cleanup context for a static endpoint. + * + * @param endpointId static endpoint identifier + * @param endpointPublisher concrete S3 adapter + */ + public S3VanityAliasCleanupContext(final String endpointId, + final AWSS3EndPointPublisher endpointPublisher) { + this.endpointId = endpointId; + this.endpointPublisher = endpointPublisher; + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasContext.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasContext.java new file mode 100644 index 000000000000..a88434e62745 --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasContext.java @@ -0,0 +1,47 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.portlets.languagesmanager.model.Language; + +import java.io.File; + +/** + * Operational context required to publish or remove vanity aliases. + */ +public final class S3VanityAliasContext { + + final S3VanityAliasLookup lookup; + final String bucketName; + final String bucketRegion; + final String bucketPrefix; + final Host host; + final Language language; + final File file; + final AWSS3EndPointPublisher endpointPublisher; + + /** + * Creates the operational context for vanity publish or unpublish. + * + * @param lookup logical key of the persisted mapping + * @param bucketName S3 bucket name + * @param bucketRegion S3 bucket region + * @param bucketPrefix S3 bucket prefix + * @param host page or static resource host + * @param language page or static resource language + * @param file physical file to publish or remove + * @param endpointPublisher concrete S3 adapter + */ + public S3VanityAliasContext(final S3VanityAliasLookup lookup, final String bucketName, + final String bucketRegion, final String bucketPrefix, final Host host, + final Language language, final File file, + final AWSS3EndPointPublisher endpointPublisher) { + this.lookup = lookup; + this.bucketName = bucketName; + this.bucketRegion = bucketRegion; + this.bucketPrefix = bucketPrefix; + this.host = host; + this.language = language; + this.file = file; + this.endpointPublisher = endpointPublisher; + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasLookup.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasLookup.java new file mode 100644 index 000000000000..d5faf7a91e92 --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasLookup.java @@ -0,0 +1,28 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +/** + * Uniquely identifies the vanity mapping associated with a static resource. + */ +public final class S3VanityAliasLookup { + + final String endpointId; + final String hostId; + final long languageId; + final String canonicalPath; + + /** + * Creates an immutable lookup key for the vanity mapping. + * + * @param endpointId publishing endpoint identifier + * @param hostId host identifier + * @param languageId language identifier + * @param canonicalPath canonical resource path + */ + public S3VanityAliasLookup(final String endpointId, final String hostId, + final long languageId, final String canonicalPath) { + this.endpointId = endpointId; + this.hostId = hostId; + this.languageId = languageId; + this.canonicalPath = canonicalPath; + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasPublishContext.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasPublishContext.java new file mode 100644 index 000000000000..aaba8cbc4d6c --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasPublishContext.java @@ -0,0 +1,42 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.portlets.languagesmanager.model.Language; + +/** + * Operational context required to publish a Vanity URL clone. + */ +public final class S3VanityAliasPublishContext { + + final String endpointId; + final String bucketName; + final String bucketRegion; + final String bucketPrefix; + final Host host; + final Language language; + final AWSS3EndPointPublisher endpointPublisher; + + /** + * Creates the operational context for Vanity URL clone publishing. + * + * @param endpointId publishing endpoint identifier + * @param bucketName S3 bucket name + * @param bucketRegion S3 bucket region + * @param bucketPrefix S3 bucket prefix + * @param host Vanity URL site + * @param language Vanity URL language + * @param endpointPublisher concrete S3 adapter + */ + public S3VanityAliasPublishContext(final String endpointId, final String bucketName, + final String bucketRegion, final String bucketPrefix, + final Host host, final Language language, + final AWSS3EndPointPublisher endpointPublisher) { + this.endpointId = endpointId; + this.bucketName = bucketName; + this.bucketRegion = bucketRegion; + this.bucketPrefix = bucketPrefix; + this.host = host; + this.language = language; + this.endpointPublisher = endpointPublisher; + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasRepository.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasRepository.java new file mode 100644 index 000000000000..796d1ecf3ddd --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasRepository.java @@ -0,0 +1,306 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotcms.business.CloseDBIfOpened; +import com.dotcms.business.WrapInTransaction; +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.exception.DotDataException; +import org.apache.commons.codec.digest.DigestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Manages operational persistence for vanity aliases published on S3. + */ +public class S3VanityAliasRepository { + + private static final String TABLE_NAME = "static_s3_vanity_mapping"; + + private static final String SELECT_BY_LOOKUP = + "SELECT endpoint_id, host_id, language_id, canonical_path, vanity_path, vanity_url_id, " + + "bucket_name, bucket_region, bucket_prefix " + + "FROM static_s3_vanity_mapping " + + "WHERE endpoint_id = ? AND host_id = ? AND language_id = ? " + + "AND canonical_path_hash = ? AND canonical_path = ?"; + private static final String SELECT_BY_VANITY_URL_ID = + "SELECT endpoint_id, host_id, language_id, canonical_path, vanity_path, vanity_url_id, " + + "bucket_name, bucket_region, bucket_prefix " + + "FROM static_s3_vanity_mapping " + + "WHERE endpoint_id = ? AND language_id = ? AND vanity_url_id = ?"; + private static final String SELECT_BY_VANITY_URL_ID_ANY_LANGUAGE = + "SELECT endpoint_id, host_id, language_id, canonical_path, vanity_path, vanity_url_id, " + + "bucket_name, bucket_region, bucket_prefix " + + "FROM static_s3_vanity_mapping " + + "WHERE endpoint_id = ? AND vanity_url_id = ?"; + private static final String DELETE_BY_LOOKUP = + "DELETE FROM static_s3_vanity_mapping " + + "WHERE endpoint_id = ? AND host_id = ? AND language_id = ? " + + "AND canonical_path_hash = ? AND canonical_path = ?"; + private static final String DELETE_ALIAS = + "DELETE FROM static_s3_vanity_mapping " + + "WHERE endpoint_id = ? AND host_id = ? AND language_id = ? " + + "AND canonical_path_hash = ? AND canonical_path = ? " + + "AND vanity_path_hash = ? AND vanity_path = ?"; + private static final String DELETE_BY_VANITY_URL_ID = + "DELETE FROM static_s3_vanity_mapping WHERE endpoint_id = ? AND language_id = ? AND vanity_url_id = ?"; + private static final String INSERT_ALIAS = + "INSERT INTO static_s3_vanity_mapping " + + "(endpoint_id, host_id, language_id, canonical_path, canonical_path_hash, " + + "vanity_path, vanity_path_hash, vanity_url_id, bucket_name, bucket_region, " + + "bucket_prefix, mod_date) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"; + + /** + * Finds operational mappings associated with the canonical page. + * + * @param lookup logical mapping key + * @return persisted mappings + * @throws DotDataException when reading mappings fails + */ + @CloseDBIfOpened + public List findByLookup(final S3VanityAliasLookup lookup) throws DotDataException { + final List> rows = new DotConnect() + .setSQL(SELECT_BY_LOOKUP) + .addParam(lookup.endpointId) + .addParam(lookup.hostId) + .addParam(lookup.languageId) + .addParam(pathHash(lookup.canonicalPath)) + .addParam(lookup.canonicalPath) + .loadObjectResults(); + + return aliasesFromRows(rows); + } + + /** + * Finds operational mappings associated with one Vanity URL. + * + * @param endpointId static endpoint identifier + * @param languageId Vanity URL language identifier + * @param vanityUrlId Vanity URL identifier + * @return persisted mappings for the Vanity URL + * @throws DotDataException when reading mappings fails + */ + @CloseDBIfOpened + public List findByVanityUrlId(final String endpointId, + final long languageId, + final String vanityUrlId) throws DotDataException { + final List> rows = new DotConnect() + .setSQL(SELECT_BY_VANITY_URL_ID) + .addParam(endpointId) + .addParam(languageId) + .addParam(vanityUrlId) + .loadObjectResults(); + + return aliasesFromRows(rows); + } + + /** + * Finds operational mappings associated with one Vanity URL in any language. + * + * @param endpointId static endpoint identifier + * @param vanityUrlId Vanity URL identifier + * @return persisted mappings for the Vanity URL + * @throws DotDataException when reading mappings fails + */ + @CloseDBIfOpened + public List findByVanityUrlId(final String endpointId, + final String vanityUrlId) throws DotDataException { + final List> rows = new DotConnect() + .setSQL(SELECT_BY_VANITY_URL_ID_ANY_LANGUAGE) + .addParam(endpointId) + .addParam(vanityUrlId) + .loadObjectResults(); + + return aliasesFromRows(rows); + } + + /** + * Transactionally replaces all mappings associated with the lookup key. + * + * @param lookup logical mapping key + * @param aliases mappings to save + * @throws DotDataException when writing mappings fails + */ + @WrapInTransaction + public void replaceMappings(final S3VanityAliasLookup lookup, + final List aliases) throws DotDataException { + deleteByLookupInternal(lookup); + for (final S3VanityAlias alias : aliases) { + insertAlias(alias); + } + } + + /** + * Transactionally replaces all mappings associated with one Vanity URL. + * + * @param endpointId static endpoint identifier + * @param languageId Vanity URL language identifier + * @param vanityUrlId Vanity URL identifier + * @param aliases mappings to save + * @throws DotDataException when writing mappings fails + */ + @WrapInTransaction + public void replaceMappingsByVanityUrlId(final String endpointId, final long languageId, + final String vanityUrlId, + final List aliases) throws DotDataException { + deleteByVanityUrlIdInternal(endpointId, languageId, vanityUrlId); + for (final S3VanityAlias alias : aliases) { + insertAlias(alias); + } + } + + /** + * Removes one materialized mapping. + * + * @param alias alias to remove from the table + * @throws DotDataException when deleting the mapping fails + */ + @WrapInTransaction + public void deleteAlias(final S3VanityAlias alias) throws DotDataException { + new DotConnect().executeUpdate(DELETE_ALIAS, false, + alias.endpointId, + alias.hostId, + alias.languageId, + pathHash(alias.canonicalPath), + alias.canonicalPath, + pathHash(alias.vanityPath), + alias.vanityPath); + } + + /** + * Removes all mappings associated with the lookup key. + * + * @param lookup logical mapping key + * @throws DotDataException when deleting mappings fails + */ + @WrapInTransaction + public void deleteByLookup(final S3VanityAliasLookup lookup) throws DotDataException { + deleteByLookupInternal(lookup); + } + + /** + * Removes all mappings associated with the lookup key. + * + * @param lookup logical mapping key + * @throws DotDataException when deleting mappings fails + */ + private void deleteByLookupInternal(final S3VanityAliasLookup lookup) throws DotDataException { + new DotConnect().executeUpdate(DELETE_BY_LOOKUP, false, + lookup.endpointId, + lookup.hostId, + lookup.languageId, + pathHash(lookup.canonicalPath), + lookup.canonicalPath); + } + + /** + * Removes all mappings associated with one Vanity URL. + * + * @param endpointId static endpoint identifier + * @param languageId Vanity URL language identifier + * @param vanityUrlId Vanity URL identifier + * @throws DotDataException when deleting mappings fails + */ + private void deleteByVanityUrlIdInternal(final String endpointId, final long languageId, + final String vanityUrlId) + throws DotDataException { + new DotConnect().executeUpdate(DELETE_BY_VANITY_URL_ID, false, endpointId, languageId, vanityUrlId); + } + + /** + * Inserts one materialized mapping. + * + * @param alias mapping to save + * @throws DotDataException when writing the mapping fails + */ + private void insertAlias(final S3VanityAlias alias) throws DotDataException { + new DotConnect().executeUpdate(INSERT_ALIAS, false, + alias.endpointId, + alias.hostId, + alias.languageId, + alias.canonicalPath, + pathHash(alias.canonicalPath), + alias.vanityPath, + pathHash(alias.vanityPath), + alias.vanityUrlId, + alias.bucketName, + alias.bucketRegion, + alias.bucketPrefix); + } + + /** + * Returns the mapping table name. + * + * @return table name + */ + public String tableName() { + return TABLE_NAME; + } + + /** + * Converts SQL rows into domain mappings. + * + * @param rows rows read from the database + * @return matching vanity mappings + */ + private List aliasesFromRows(final List> rows) { + final List aliases = new ArrayList<>(); + for (final Map row : rows) { + aliases.add(aliasFromRow(row)); + } + return aliases; + } + + /** + * Converts one SQL row into a domain mapping. + * + * @param row row read from the database + * @return matching vanity mapping + */ + private S3VanityAlias aliasFromRow(final Map row) { + return new S3VanityAlias( + stringValue(row.get("endpoint_id")), + stringValue(row.get("host_id")), + longValue(row.get("language_id")), + stringValue(row.get("canonical_path")), + stringValue(row.get("vanity_path")), + stringValue(row.get("vanity_url_id")), + stringValue(row.get("bucket_name")), + stringValue(row.get("bucket_region")), + stringValue(row.get("bucket_prefix"))); + } + + /** + * Converts a SQL value into a string. + * + * @param value SQL value + * @return string value + */ + private String stringValue(final Object value) { + return value == null ? null : String.valueOf(value); + } + + /** + * Converts a SQL value into a long. + * + * @param value SQL value + * @return numeric value + */ + private long longValue(final Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return Long.parseLong(String.valueOf(value)); + } + + /** + * Calculates a deterministic short key for potentially long paths. + * + * @param path path to normalize + * @return hexadecimal SHA-256 hash + */ + private String pathHash(final String path) { + return DigestUtils.sha256Hex(path); + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasService.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasService.java new file mode 100644 index 000000000000..35f06be38d32 --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasService.java @@ -0,0 +1,709 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotcms.vanityurl.business.VanityUrlAPI; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotcms.publishing.DotPublishingException; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.DotStateException; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.htmlpageasset.business.HTMLPageAssetAPI; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.util.Constants; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.model.User; +import org.apache.http.HttpStatus; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Coordinates publishing and removal of static vanity aliases. + */ +public class S3VanityAliasService { + + private final VanityUrlAPI vanityUrlAPI; + private final S3VanityAliasSupport aliasSupport; + private final S3VanityAliasRepository repository; + private final HTMLPageAssetAPI htmlPageAssetAPI; + private final S3VanityTargetResolver targetResolver; + + /** + * Creates the service with system dependencies. + */ + public S3VanityAliasService() { + this(APILocator.getVanityUrlAPI(), new S3VanityAliasSupport(), new S3VanityAliasRepository(), + APILocator.getHTMLPageAssetAPI(), new S3VanityTargetResolver()); + } + + /** + * Creates the service with explicit dependencies for tests. + * + * @param vanityUrlAPI Vanity URL API + * @param aliasSupport alias support component + * @param repository alias mapping repository + */ + public S3VanityAliasService(final VanityUrlAPI vanityUrlAPI, final S3VanityAliasSupport aliasSupport, + final S3VanityAliasRepository repository) { + this(vanityUrlAPI, aliasSupport, repository, APILocator.getHTMLPageAssetAPI(), + new S3VanityTargetResolver()); + } + + /** + * Creates the service with explicit dependencies for tests. + * + * @param vanityUrlAPI Vanity URL API + * @param aliasSupport alias support component + * @param repository alias mapping repository + * @param htmlPageAssetAPI HTML page rendering API + * @param targetResolver dotCMS target resolver + */ + public S3VanityAliasService(final VanityUrlAPI vanityUrlAPI, final S3VanityAliasSupport aliasSupport, + final S3VanityAliasRepository repository, + final HTMLPageAssetAPI htmlPageAssetAPI, + final S3VanityTargetResolver targetResolver) { + this.vanityUrlAPI = vanityUrlAPI; + this.aliasSupport = aliasSupport; + this.repository = repository; + this.htmlPageAssetAPI = htmlPageAssetAPI; + this.targetResolver = targetResolver; + } + + /** + * Publishes one Vanity URL clone by rendering its live forward target. + * + * @param context Vanity URL publishing context + * @param vanityContentlet live Vanity URL contentlet + * @throws DotDataException when persistence or S3 operations fail + */ + public void publishAliasForVanityUrl(final S3VanityAliasPublishContext context, + final Contentlet vanityContentlet) throws DotDataException { + if (!aliasSupport.isSupportedVanityUrl(vanityContentlet)) { + Logger.warn(this, "Skipping unsupported Vanity URL: " + vanityContentlet); + if (vanityContentlet != null) { + unpublishAliasesByVanityUrl(new S3VanityAliasCleanupContext(context.endpointId, + context.endpointPublisher), vanityContentlet.getLanguageId(), + vanityContentlet.getIdentifier()); + } + return; + } + + final User systemUser = APILocator.getUserAPI().getSystemUser(); + final Optional canonicalPath = aliasSupport.normalizeCanonicalPath( + aliasSupport.getForwardTo(vanityContentlet)); + final Optional target = resolveTarget(context, canonicalPath.get(), systemUser); + if (target.isEmpty()) { + unpublishAliasesByVanityUrl(new S3VanityAliasCleanupContext(context.endpointId, + context.endpointPublisher), vanityContentlet.getLanguageId(), + vanityContentlet.getIdentifier()); + return; + } + + final Optional alias = buildAlias(context, vanityContentlet, target.get()); + final Optional renderedFile = renderTarget(context, target.get(), systemUser); + if (renderedFile.isEmpty()) { + unpublishAliasesByVanityUrl(new S3VanityAliasCleanupContext(context.endpointId, + context.endpointPublisher), vanityContentlet.getLanguageId(), + vanityContentlet.getIdentifier()); + return; + } + + try { + publishMaterializedAlias(context, alias.get(), renderedFile.get()); + } finally { + cleanupMaterializedFile(target.get(), renderedFile.get()); + } + } + + /** + * Builds the operational alias from a supported Vanity URL contentlet. + * + * @param context Vanity URL publishing context + * @param vanityContentlet live Vanity URL contentlet + * @return alias to materialize when the contentlet is supported + */ + private Optional buildAlias(final S3VanityAliasPublishContext context, + final Contentlet vanityContentlet, + final S3VanityResolvedTarget target) { + final String canonicalPath = target.canonicalPath; + final String vanityPath = aliasSupport.materializeVanityPath( + aliasSupport.getUri(vanityContentlet), target.type).get(); + return Optional.of(new S3VanityAlias(context.endpointId, context.host.getIdentifier(), + context.language.getId(), canonicalPath, vanityPath, vanityContentlet.getIdentifier(), + context.bucketName, context.bucketRegion, context.bucketPrefix)); + } + + /** + * Resolves a canonical path with dotCMS URL semantics. + * + * @param context Vanity URL publishing context + * @param canonicalPath normalized forward target path + * @param systemUser system user used for resolution + * @return resolved target when the path can be static-published + * @throws DotDataException when resolution fails unexpectedly + */ + private Optional resolveTarget(final S3VanityAliasPublishContext context, + final String canonicalPath, + final User systemUser) + throws DotDataException { + try { + final Optional target = targetResolver.resolve(canonicalPath, context, systemUser); + if (target.isEmpty()) { + Logger.warn(this, "Skipping Vanity URL because canonical target is not static-publishable: " + + canonicalPath); + } + return target; + } catch (final DotSecurityException e) { + Logger.warn(this, "Skipping Vanity URL because canonical target cannot be resolved: " + + canonicalPath + ". " + e.getMessage()); + return Optional.empty(); + } + } + + /** + * Renders the resolved target and stores it in a temporary HTML file. + * + * @param context Vanity URL publishing context + * @param target resolved target to render + * @param systemUser system user used for rendering + * @return temporary file when rendering produced HTML + * @throws DotDataException when rendering fails unexpectedly + */ + private Optional renderTarget(final S3VanityAliasPublishContext context, + final S3VanityResolvedTarget target, + final User systemUser) throws DotDataException { + if (DotAsset.FILE_ASSET.equals(target.type)) { + return target.physicalFile(); + } + + try { + final String html = renderTargetHtml(context, target, systemUser); + if (!UtilMethods.isSet(html)) { + Logger.warn(this, "Skipping Vanity URL because canonical target rendered empty: " + + target.canonicalPath); + return Optional.empty(); + } + return Optional.of(writeHtmlTempFile(html)); + } catch (final DotStateException | DotSecurityException | IOException e) { + Logger.warn(this, "Skipping Vanity URL because canonical target cannot be rendered: " + + target.canonicalPath + ". " + e.getMessage()); + return Optional.empty(); + } + } + + /** + * Renders the resolved target using the matching dotCMS rendering API. + * + * @param context Vanity URL publishing context + * @param target resolved target to render + * @param systemUser system user used for rendering + * @return rendered HTML + * @throws DotStateException when render state is invalid + * @throws DotDataException when data access fails + * @throws DotSecurityException when permissions prevent rendering + */ + private String renderTargetHtml(final S3VanityAliasPublishContext context, + final S3VanityResolvedTarget target, + final User systemUser) + throws DotStateException, DotDataException, DotSecurityException { + final String contentletInode = target.contentletInode().orElse(null); + return htmlPageAssetAPI.getHTML(target.htmlPage, true, contentletInode, systemUser, + context.language.getId(), Constants.USER_AGENT_DOTCMS_PUSH_PUBLISH); + } + + /** + * Writes rendered HTML into a temporary file accepted by the S3 publisher. + * + * @param html rendered HTML + * @return temporary HTML file + * @throws IOException when the temporary file cannot be written + */ + private File writeHtmlTempFile(final String html) throws IOException { + final File tempFile = File.createTempFile("s3-vanity-alias-", ".html"); + Files.write(tempFile.toPath(), html.getBytes(StandardCharsets.UTF_8)); + return tempFile; + } + + /** + * Publishes the current alias, removes obsolete S3 clones, and realigns persistence. + * + * @param context Vanity URL publishing context + * @param alias alias to publish + * @param file rendered canonical HTML file + * @throws DotDataException when persistence or S3 operations fail + */ + private void publishMaterializedAlias(final S3VanityAliasPublishContext context, + final S3VanityAlias alias, + final File file) throws DotDataException { + try { + final List persistedAliases = + repository.findByVanityUrlId(context.endpointId, context.language.getId(), alias.vanityUrlId); + publishAlias(context, alias, file); + deleteObsoleteAliases(context, persistedAliases, alias); + repository.replaceMappingsByVanityUrlId(context.endpointId, context.language.getId(), alias.vanityUrlId, + Collections.singletonList(alias)); + } catch (final Exception e) { + throw wrapAsDotDataException(e); + } + } + + /** + * Deletes materialized aliases no longer represented by the current Vanity URL. + * + * @param context Vanity URL publishing context + * @param persistedAliases previously persisted aliases + * @param currentAlias current alias + * @throws DotDataException when S3 cleanup fails + * @throws DotPublishingException when S3 cleanup fails + */ + private void deleteObsoleteAliases(final S3VanityAliasPublishContext context, + final List persistedAliases, + final S3VanityAlias currentAlias) + throws DotDataException, DotPublishingException { + for (final S3VanityAlias alias : persistedAliases) { + if (!storageLocation(alias).equals(storageLocation(currentAlias))) { + deleteAlias(context.endpointPublisher, alias); + } + } + } + + /** + * Publishes one rendered Vanity URL clone. + * + * @param context Vanity URL publishing context + * @param alias alias to publish + * @param file rendered canonical HTML file + * @throws DotPublishingException when S3 publishing fails + */ + private void publishAlias(final S3VanityAliasPublishContext context, + final S3VanityAlias alias, + final File file) throws DotPublishingException { + context.endpointPublisher.pushFileToEndpoint(alias.bucketName, alias.bucketRegion, + alias.bucketPrefix, alias.vanityPath, file); + } + + /** + * Removes a materialized file created for Vanity URL publishing. + * + * File Assets are backed by dotCMS-managed binaries and must not be removed + * after the S3 upload. + * + * @param target resolved target + * @param file materialized file + */ + private void cleanupMaterializedFile(final S3VanityResolvedTarget target, final File file) { + if (DotAsset.FILE_ASSET.equals(target.type)) { + return; + } + + try { + Files.deleteIfExists(file.toPath()); + } catch (final IOException e) { + Logger.warn(this, "Unable to delete temporary Vanity URL file: " + file.getAbsolutePath()); + } + } + + /** + * Publishes current vanity aliases and realigns the persisted mapping. + * + * @param context publishing context + * @throws DotDataException when persistence or S3 operations fail + */ + public void publishAliases(final S3VanityAliasContext context) throws DotDataException { + final List currentAliases = loadCurrentAliases(context); + final List persistedAliases = repository.findByLookup(context.lookup); + final Map currentByLocation = indexByStorageLocation(currentAliases); + final List aliasesToRefresh = filterExisting(currentAliases, + indexByStorageLocation(persistedAliases)); + final List aliasesToDelete = filterMissing(persistedAliases, currentByLocation); + final List deletedNow = new ArrayList<>(); + + try { + publishAliases(context, aliasesToRefresh, alias -> { }); + deleteAliases(context, aliasesToDelete, deletedNow::add); + repository.replaceMappings(context.lookup, aliasesToRefresh); + } catch (final Exception e) { + restoreAliases(context, deletedNow); + throw wrapAsDotDataException(e); + } + } + + /** + * Removes vanity aliases materialized by a deleted or unpublished Vanity + * URL. + * + * @param context minimal S3 cleanup context + * @param languageId Vanity URL language identifier + * @param vanityUrlId source Vanity URL identifier + * @throws DotDataException when persistence or S3 cleanup fails + */ + public void unpublishAliasesByVanityUrl(final S3VanityAliasCleanupContext context, + final long languageId, + final String vanityUrlId) throws DotDataException { + if (!UtilMethods.isSet(vanityUrlId)) { + return; + } + + final List persistedAliases = languageId > 0 + ? repository.findByVanityUrlId(context.endpointId, languageId, vanityUrlId) + : repository.findByVanityUrlId(context.endpointId, vanityUrlId); + try { + for (final S3VanityAlias alias : persistedAliases) { + removeMaterializedAlias(context, alias); + repository.deleteAlias(alias); + } + } catch (final Exception e) { + throw wrapAsDotDataException(e); + } + } + + /** + * Removes a materialized Vanity URL key, restoring the live resource when + * the Vanity URL was obscuring an existing dotCMS resource. + * + * @param context cleanup context + * @param alias persisted alias to remove + * @throws DotDataException when resolving the live resource fails + * @throws DotSecurityException when live resource resolution is denied + * @throws DotPublishingException when S3 publishing or deletion fails + */ + private void removeMaterializedAlias(final S3VanityAliasCleanupContext context, + final S3VanityAlias alias) + throws DotDataException, DotSecurityException, DotPublishingException { + final Optional restoreResult = restoreLiveResource(context, alias); + if (restoreResult.isEmpty()) { + deleteAlias(context.endpointPublisher, alias); + } + } + + /** + * Restores a live dotCMS resource that is currently obscured by a Vanity + * URL clone. + * + * @param context cleanup context + * @param alias persisted alias to evaluate + * @return restore result when the vanity path resolves to a live resource + * @throws DotDataException when resolving or rendering the live resource fails + * @throws DotSecurityException when live resource resolution is denied + * @throws DotPublishingException when S3 publishing fails + */ + private Optional restoreLiveResource(final S3VanityAliasCleanupContext context, + final S3VanityAlias alias) + throws DotDataException, DotSecurityException, DotPublishingException { + final User systemUser = APILocator.getUserAPI().getSystemUser(); + final Optional restoreContext = + buildRestoreContext(context, alias, systemUser); + if (restoreContext.isEmpty()) { + return Optional.empty(); + } + + final Optional target = + resolveObscuredLiveTarget(restoreContext.get(), alias, systemUser); + if (target.isEmpty()) { + return Optional.empty(); + } + + final S3VanityRestoreResult result = materializeRestoreTarget(restoreContext.get(), target.get(), systemUser); + try { + publishRestoredResource(restoreContext.get(), alias, result.file); + } finally { + cleanupMaterializedFile(result.target, result.file); + } + return Optional.of(result); + } + + /** + * Builds a publishing context from a persisted alias row. + * + * @param context cleanup context + * @param alias persisted alias + * @param systemUser system user used for host lookup + * @return restore context when host and language are still available + * @throws DotDataException when host lookup fails + * @throws DotSecurityException when host lookup is denied + */ + private Optional buildRestoreContext(final S3VanityAliasCleanupContext context, + final S3VanityAlias alias, + final User systemUser) + throws DotDataException, DotSecurityException { + final Host host = APILocator.getHostAPI().find(alias.hostId, systemUser, false); + final Language language = APILocator.getLanguageAPI().getLanguage(alias.languageId); + if (host == null || language == null) { + return Optional.empty(); + } + + return Optional.of(new S3VanityAliasPublishContext(alias.endpointId, alias.bucketName, + alias.bucketRegion, alias.bucketPrefix, host, language, context.endpointPublisher)); + } + + /** + * Resolves the vanity path as a possible live resource obscured by the + * removed Vanity URL. + * + * @param context restore publishing context + * @param alias persisted alias + * @param systemUser system user used for resolution + * @return resolved live target when one exists + * @throws DotDataException when target resolution fails + * @throws DotSecurityException when target resolution is denied + */ + private Optional resolveObscuredLiveTarget(final S3VanityAliasPublishContext context, + final S3VanityAlias alias, + final User systemUser) + throws DotDataException, DotSecurityException { + return targetResolver.resolve(alias.vanityPath, context, systemUser); + } + + /** + * Materializes a resolved live resource for S3 restore. + * + * @param context restore publishing context + * @param target resolved live target + * @param systemUser system user used for rendering + * @return restore result with the materialized file + * @throws DotDataException when rendering fails + * @throws DotPublishingException when the live resource cannot be materialized + */ + private S3VanityRestoreResult materializeRestoreTarget(final S3VanityAliasPublishContext context, + final S3VanityResolvedTarget target, + final User systemUser) + throws DotDataException, DotPublishingException { + final Optional file = renderTarget(context, target, systemUser); + if (file.isEmpty()) { + throw new DotPublishingException("Unable to restore live resource for Vanity URL path: " + + target.canonicalPath); + } + return new S3VanityRestoreResult(target, file.get()); + } + + /** + * Publishes the restored live resource on the obscured Vanity URL key. + * + * @param context restore publishing context + * @param alias persisted alias being removed + * @param file live resource file + * @throws DotPublishingException when S3 publishing fails + */ + private void publishRestoredResource(final S3VanityAliasPublishContext context, + final S3VanityAlias alias, + final File file) throws DotPublishingException { + context.endpointPublisher.pushFileToEndpoint(alias.bucketName, alias.bucketRegion, + alias.bucketPrefix, alias.vanityPath, file); + } + + /** + * Removes the vanity aliases materialized for the current mapping. + * + * @param context publishing context + * @throws DotDataException when persistence or S3 cleanup fails + */ + public void unpublishAliases(final S3VanityAliasContext context) throws DotDataException { + final List persistedAliases = repository.findByLookup(context.lookup); + final List deletedNow = new ArrayList<>(); + + try { + deleteAliases(context, persistedAliases, deletedNow::add); + repository.deleteByLookup(context.lookup); + } catch (final Exception e) { + restoreAliases(context, deletedNow); + throw wrapAsDotDataException(e); + } + } + + /** + * Loads and normalizes the current vanity aliases. + * + * @param context publishing context + * @return supported current aliases + */ + private List loadCurrentAliases(final S3VanityAliasContext context) { + final List vanityUrls = vanityUrlAPI.findByForward( + context.host, context.language, context.lookup.canonicalPath, HttpStatus.SC_OK, true); + return aliasSupport.toAliasMappings(context, vanityUrls).stream() + .sorted(Comparator.comparing(alias -> alias.vanityPath)) + .collect(Collectors.toList()); + } + + /** + * Publishes the provided alias list. + * + * @param context publishing context + * @param aliases aliases to publish + * @param publishedConsumer success consumer + * @throws DotDataException when S3 publishing fails + */ + private void publishAliases(final S3VanityAliasContext context, final List aliases, + final Consumer publishedConsumer) + throws DotDataException, DotPublishingException { + for (final S3VanityAlias alias : aliases) { + publishAlias(context, alias); + publishedConsumer.accept(alias); + } + } + + /** + * Removes the provided alias list. + * + * @param context publishing context + * @param aliases aliases to remove + * @param deletedConsumer success consumer + * @throws DotDataException when S3 cleanup fails + */ + private void deleteAliases(final S3VanityAliasContext context, final List aliases, + final Consumer deletedConsumer) + throws DotDataException, DotPublishingException { + for (final S3VanityAlias alias : aliases) { + deleteAlias(context, alias); + deletedConsumer.accept(alias); + } + } + + /** + * Publishes one vanity alias on S3. + * + * @param context publishing context + * @param alias vanity alias to publish + * @throws DotDataException when S3 publishing fails + */ + private void publishAlias(final S3VanityAliasContext context, final S3VanityAlias alias) + throws DotDataException, DotPublishingException { + context.endpointPublisher.pushFileToEndpoint(alias.bucketName, alias.bucketRegion, + alias.bucketPrefix, alias.vanityPath, context.file); + } + + /** + * Removes one vanity alias from S3. + * + * @param context publishing context + * @param alias vanity alias to remove + * @throws DotDataException when S3 cleanup fails + */ + private void deleteAlias(final S3VanityAliasContext context, final S3VanityAlias alias) + throws DotDataException, DotPublishingException { + deleteAlias(context.endpointPublisher, alias); + } + + /** + * Removes one vanity alias from the S3 location stored in the mapping. + * + * @param endpointPublisher concrete S3 adapter + * @param alias vanity alias to remove + * @throws DotDataException when S3 cleanup fails + */ + private void deleteAlias(final AWSS3EndPointPublisher endpointPublisher, final S3VanityAlias alias) + throws DotDataException, DotPublishingException { + endpointPublisher.deleteFilesFromEndpoint(alias.bucketName, alias.bucketPrefix, alias.vanityPath); + } + + /** + * Restores aliases already removed when a later step fails. + * + * @param context publishing context + * @param aliases aliases to restore + */ + private void restoreAliases(final S3VanityAliasContext context, final List aliases) { + for (final S3VanityAlias alias : aliases) { + try { + publishAlias(context, alias); + } catch (final Exception e) { + Logger.error(this, "Unable to restore vanity alias " + alias.vanityPath, e); + } + } + } + + /** + * Rolls back aliases published during the current attempt. + * + * @param context publishing context + * @param aliases aliases to remove + */ + private void rollbackAliases(final S3VanityAliasContext context, final List aliases) { + for (final S3VanityAlias alias : aliases) { + try { + deleteAlias(context, alias); + } catch (final Exception e) { + Logger.error(this, "Unable to rollback vanity alias " + alias.vanityPath, e); + } + } + } + + /** + * Indexes aliases by materialized S3 location. + * + * @param aliases aliases to index + * @return map keyed by S3 location + */ + private Map indexByStorageLocation(final List aliases) { + return aliases.stream().collect(Collectors.toMap(this::storageLocation, alias -> alias, (left, right) -> left)); + } + + /** + * Filters aliases that are not present in the comparison map. + * + * @param aliases source aliases + * @param comparison comparison map + * @return aliases missing from the comparison map + */ + private List filterMissing(final List aliases, + final Map comparison) { + return aliases.stream().filter(alias -> !comparison.containsKey(storageLocation(alias))).collect(Collectors.toList()); + } + + /** + * Filters aliases that are present in the comparison map. + * + * @param aliases source aliases + * @param comparison comparison map + * @return aliases present in the comparison map + */ + private List filterExisting(final List aliases, + final Map comparison) { + return aliases.stream().filter(alias -> comparison.containsKey(storageLocation(alias))).collect(Collectors.toList()); + } + + /** + * Calculates the logical key for the materialized S3 location. + * + * @param alias alias to index + * @return key composed of bucket, prefix, and vanity path + */ + private String storageLocation(final S3VanityAlias alias) { + return String.join("|", nullSafe(alias.bucketName), nullSafe(alias.bucketPrefix), alias.vanityPath); + } + + /** + * Normalizes null values used in comparison keys. + * + * @param value value to normalize + * @return empty string when the value is not set + */ + private String nullSafe(final String value) { + return value == null ? "" : value; + } + + /** + * Converts a generic error into the type expected by the publisher. + * + * @param error original error + * @return domain exception + */ + private DotDataException wrapAsDotDataException(final Exception error) { + if (error instanceof DotDataException) { + return (DotDataException) error; + } + return new DotDataException(error); + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasSupport.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasSupport.java new file mode 100644 index 000000000000..8574e42f5c18 --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasSupport.java @@ -0,0 +1,202 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotcms.contenttype.model.type.VanityUrlContentType; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.util.UtilMethods; +import com.liferay.util.StringPool; +import org.apache.http.HttpStatus; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Normalizes and filters vanity aliases supported by S3 publishing. + */ +public class S3VanityAliasSupport { + + private static final String UNSUPPORTED_CHARS = "*?[](){}|^$\\+"; + + /** + * Converts current Vanity URLs into persistable mappings. + * + * @param context operational mapping context + * @param vanityUrls current Vanity URLs found for the content + * @return normalized and deduplicated mappings + */ + public List toAliasMappings(final S3VanityAliasContext context, + final List vanityUrls) { + final Map aliasesByPath = new LinkedHashMap<>(); + + if (vanityUrls == null) { + return new ArrayList<>(); + } + + for (final CachedVanityUrl vanityUrl : vanityUrls) { + normalizeMaterializedVanityPath(vanityUrl).ifPresent(vanityPath -> + aliasesByPath.putIfAbsent(vanityPath, + new S3VanityAlias(context.lookup.endpointId, context.lookup.hostId, + context.lookup.languageId, context.lookup.canonicalPath, vanityPath, + vanityUrl.vanityUrlId, context.bucketName, context.bucketRegion, + context.bucketPrefix))); + } + + return new ArrayList<>(aliasesByPath.values()); + } + + /** + * Normalizes a Vanity URL into a publishable S3 key. + * + * @param vanityUrl source Vanity URL + * @return normalized path when supported + */ + public Optional normalizeVanityPath(final CachedVanityUrl vanityUrl) { + return vanityUrl == null ? Optional.empty() : normalizeVanityPath(vanityUrl.url); + } + + /** + * Normalizes a Vanity URL into the concrete S3 key that will be published. + * + * @param vanityUrl source Vanity URL + * @return normalized path when supported + */ + public Optional normalizeMaterializedVanityPath(final CachedVanityUrl vanityUrl) { + return vanityUrl == null ? Optional.empty() : normalizeMaterializedVanityPath(vanityUrl.url); + } + + /** + * Normalizes a vanity path into the concrete S3 key that will be published. + * + * @param vanityPath source vanity path + * @return normalized path when supported + */ + public Optional normalizeMaterializedVanityPath(final String vanityPath) { + return materializeVanityPath(vanityPath, DotAsset.PAGE); + } + + /** + * Converts a Vanity URL path into the S3 key used for the clone. + * The resolved source type only decides what dotCMS renders; the Vanity URL + * path remains the literal S3 key to materialize. + * + * @param vanityPath source vanity path + * @param targetType resolved dotCMS target type + * @return materialized S3 key when supported + */ + public Optional materializeVanityPath(final String vanityPath, final DotAsset targetType) { + return normalizeVanityPath(vanityPath); + } + + /** + * Normalizes a vanity path into a publishable S3 key. + * + * @param vanityPath source vanity path + * @return normalized path when supported + */ + public Optional normalizeVanityPath(final String vanityPath) { + if (!UtilMethods.isSet(vanityPath)) { + return Optional.empty(); + } + + final String normalized = vanityPath.trim().replace('\\', '/'); + if (!normalized.startsWith(StringPool.FORWARD_SLASH) || containsUnsupportedChars(normalized)) { + return Optional.empty(); + } + + return Optional.of(normalized.replaceAll("/{2,}", "/")); + } + + /** + * Checks whether the path is compatible with a static S3 key. + * + * @param vanityPath vanity path to evaluate + * @return true when the path is supported + */ + public boolean isSupportedVanityPath(final String vanityPath) { + return normalizeVanityPath(vanityPath).isPresent(); + } + + /** + * Checks whether the Vanity URL contentlet can be materialized as a static clone. + * + * @param vanityContentlet Vanity URL contentlet + * @return true when the contentlet is a supported 200-forward Vanity URL + */ + public boolean isSupportedVanityUrl(final Contentlet vanityContentlet) { + return vanityContentlet != null + && vanityContentlet.isVanityUrl() + && isForwardAction(vanityContentlet) + && normalizeVanityPath(getUri(vanityContentlet)).isPresent() + && normalizeCanonicalPath(getForwardTo(vanityContentlet)).isPresent(); + } + + /** + * Normalizes a Vanity URL forward target into a renderable canonical path. + * + * @param forwardTo Vanity URL forward target + * @return normalized path when the target is internal and supported + */ + public Optional normalizeCanonicalPath(final String forwardTo) { + if (!UtilMethods.isSet(forwardTo)) { + return Optional.empty(); + } + + final String path = forwardTo.trim().replace('\\', '/'); + if (!path.startsWith(StringPool.FORWARD_SLASH) || path.startsWith("//") || path.contains("://")) { + return Optional.empty(); + } + + return Optional.of(path.replaceAll("/{2,}", "/")); + } + + /** + * Checks whether the Vanity URL is a 200-forward. + * + * @param vanityContentlet Vanity URL contentlet + * @return true when the action is 200 + */ + public boolean isForwardAction(final Contentlet vanityContentlet) { + return vanityContentlet != null + && vanityContentlet.getLongProperty(VanityUrlContentType.ACTION_FIELD_VAR) == HttpStatus.SC_OK; + } + + /** + * Reads the Vanity URL URI field. + * + * @param vanityContentlet Vanity URL contentlet + * @return URI value + */ + public String getUri(final Contentlet vanityContentlet) { + return vanityContentlet.getStringProperty(VanityUrlContentType.URI_FIELD_VAR); + } + + /** + * Reads the Vanity URL forward target field. + * + * @param vanityContentlet Vanity URL contentlet + * @return forward target value + */ + public String getForwardTo(final Contentlet vanityContentlet) { + return vanityContentlet.getStringProperty(VanityUrlContentType.FORWARD_TO_FIELD_VAR); + } + + /** + * Checks whether the path contains unsupported regular expression + * metacharacters. + * + * @param vanityPath path to evaluate + * @return true when the path contains regex metacharacters + */ + private boolean containsUnsupportedChars(final String vanityPath) { + for (final char character : vanityPath.toCharArray()) { + if (UNSUPPORTED_CHARS.indexOf(character) >= 0) { + return true; + } + } + return false; + } + +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityResolvedTarget.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityResolvedTarget.java new file mode 100644 index 000000000000..616674c19903 --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityResolvedTarget.java @@ -0,0 +1,70 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotmarketing.cms.urlmap.URLMapInfo; +import com.dotmarketing.portlets.fileassets.business.FileAsset; +import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; + +import java.io.File; +import java.util.Optional; + +/** + * Holds the dotCMS resource resolved from a Vanity URL forward target. + */ +public final class S3VanityResolvedTarget { + + final DotAsset type; + final String canonicalPath; + final IHTMLPage htmlPage; + final URLMapInfo urlMapInfo; + final FileAsset fileAsset; + + /** + * Creates a resolved static publishing target. + * + * @param type dotCMS target type + * @param canonicalPath normalized forward target path + * @param htmlPage page to render + * @param urlMapInfo URL Map information when the target is URL mapped + */ + public S3VanityResolvedTarget(final DotAsset type, final String canonicalPath, + final IHTMLPage htmlPage, final URLMapInfo urlMapInfo) { + this(type, canonicalPath, htmlPage, urlMapInfo, null); + } + + /** + * Creates a resolved static publishing target. + * + * @param type dotCMS target type + * @param canonicalPath normalized forward target path + * @param htmlPage page to render + * @param urlMapInfo URL Map information when the target is URL mapped + * @param fileAsset file asset when the target is a binary asset + */ + public S3VanityResolvedTarget(final DotAsset type, final String canonicalPath, + final IHTMLPage htmlPage, final URLMapInfo urlMapInfo, + final FileAsset fileAsset) { + this.type = type; + this.canonicalPath = canonicalPath; + this.htmlPage = htmlPage; + this.urlMapInfo = urlMapInfo; + this.fileAsset = fileAsset; + } + + /** + * Returns the contentlet inode required to render URL mapped pages. + * + * @return contentlet inode when available + */ + public Optional contentletInode() { + return urlMapInfo == null ? Optional.empty() : Optional.of(urlMapInfo.getContentlet().getInode()); + } + + /** + * Returns the physical file backing a resolved File Asset. + * + * @return physical file when this target is a File Asset + */ + public Optional physicalFile() { + return fileAsset == null ? Optional.empty() : Optional.of(fileAsset.getFileAsset()); + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityRestoreResult.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityRestoreResult.java new file mode 100644 index 000000000000..d3d0b0f447ff --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityRestoreResult.java @@ -0,0 +1,23 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import java.io.File; + +/** + * Holds a live dotCMS resource materialized for restoring an obscured S3 key. + */ +public final class S3VanityRestoreResult { + + final S3VanityResolvedTarget target; + final File file; + + /** + * Creates a restore result for a resolved live resource. + * + * @param target resolved live resource + * @param file file to publish on S3 + */ + public S3VanityRestoreResult(final S3VanityResolvedTarget target, final File file) { + this.target = target; + this.file = file; + } +} diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityTargetResolver.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityTargetResolver.java new file mode 100644 index 000000000000..746caa2b84db --- /dev/null +++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityTargetResolver.java @@ -0,0 +1,199 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.beans.Identifier; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.cms.urlmap.URLMapAPI; +import com.dotmarketing.cms.urlmap.URLMapInfo; +import com.dotmarketing.cms.urlmap.UrlMapContextBuilder; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.filters.CMSFilter.IAm; +import com.dotmarketing.filters.CMSFilter.IAmSubType; +import com.dotmarketing.filters.CMSUrlUtil; +import com.dotmarketing.portlets.contentlet.business.ContentletAPI; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.fileassets.business.FileAsset; +import com.dotmarketing.portlets.fileassets.business.FileAssetAPI; +import com.dotmarketing.portlets.htmlpageasset.business.HTMLPageAssetAPI; +import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; +import com.dotmarketing.util.PageMode; +import com.liferay.portal.model.User; +import io.vavr.Tuple2; + +import java.util.Optional; + +/** + * Resolves Vanity URL forward targets using dotCMS URL semantics. + */ +public class S3VanityTargetResolver { + + private final CMSUrlUtil cmsUrlUtil; + private final HTMLPageAssetAPI htmlPageAssetAPI; + private final URLMapAPI urlMapAPI; + private final ContentletAPI contentletAPI; + private final FileAssetAPI fileAssetAPI; + + /** + * Creates a resolver with system dependencies. + */ + public S3VanityTargetResolver() { + this(CMSUrlUtil.getInstance(), APILocator.getHTMLPageAssetAPI(), APILocator.getURLMapAPI(), + APILocator.getContentletAPI(), APILocator.getFileAssetAPI()); + } + + /** + * Creates a resolver with explicit dependencies for tests. + * + * @param cmsUrlUtil CMS URL resolver + * @param htmlPageAssetAPI HTML page API + * @param urlMapAPI URL Map API + * @param contentletAPI Contentlet API + * @param fileAssetAPI File Asset API + */ + public S3VanityTargetResolver(final CMSUrlUtil cmsUrlUtil, final HTMLPageAssetAPI htmlPageAssetAPI, + final URLMapAPI urlMapAPI, final ContentletAPI contentletAPI, + final FileAssetAPI fileAssetAPI) { + this.cmsUrlUtil = cmsUrlUtil; + this.htmlPageAssetAPI = htmlPageAssetAPI; + this.urlMapAPI = urlMapAPI; + this.contentletAPI = contentletAPI; + this.fileAssetAPI = fileAssetAPI; + } + + /** + * Resolves a forward target into a supported static publishing target. + * + * @param canonicalPath normalized Vanity URL forward target + * @param context Vanity URL publishing context + * @param user user used for dotCMS resolution + * @return resolved target when dotCMS can render it as static HTML + * @throws DotDataException when dotCMS data access fails + * @throws DotSecurityException when permissions prevent target resolution + */ + public Optional resolve(final String canonicalPath, + final S3VanityAliasPublishContext context, + final User user) + throws DotDataException, DotSecurityException { + final Optional page = resolvePage(canonicalPath, context, DotAsset.PAGE); + if (page.isPresent()) { + return page; + } + + final Tuple2 resourceType = cmsUrlUtil.resolveResourceType(IAm.NOTHING_IN_THE_CMS, + canonicalPath, context.host, context.language.getId()); + if (IAm.FILE.equals(resourceType._1())) { + return resolveFileAsset(canonicalPath, context, user); + } + if (!IAm.PAGE.equals(resourceType._1())) { + return Optional.empty(); + } + return resolvePageSubtype(resourceType._2(), canonicalPath, context, user); + } + + /** + * Resolves a File Asset target using the same URL classification used by dotCMS. + * + * @param canonicalPath normalized forward target path + * @param context Vanity URL publishing context + * @param user user used for content lookup + * @return resolved File Asset when found + * @throws DotDataException when dotCMS data access fails + * @throws DotSecurityException when permissions prevent target resolution + */ + private Optional resolveFileAsset(final String canonicalPath, + final S3VanityAliasPublishContext context, + final User user) + throws DotDataException, DotSecurityException { + final Identifier identifier = APILocator.getIdentifierAPI().find(context.host, canonicalPath); + if (identifier == null || !identifier.exists()) { + return Optional.empty(); + } + + final Optional contentlet = contentletAPI.findContentletByIdentifierOrFallback(identifier.getId(), + true, context.language.getId(), user, false); + if (contentlet.isEmpty() || !fileAssetAPI.isFileAsset(contentlet.get())) { + return Optional.empty(); + } + + final FileAsset fileAsset = fileAssetAPI.fromContentlet(contentlet.get()); + return Optional.of(new S3VanityResolvedTarget(DotAsset.FILE_ASSET, canonicalPath, + null, null, fileAsset)); + } + + /** + * Resolves the specific page subtype reported by dotCMS. + * + * @param subType dotCMS page subtype + * @param canonicalPath normalized forward target path + * @param context Vanity URL publishing context + * @param user user used for URL Map resolution + * @return resolved target when supported + * @throws DotDataException when dotCMS data access fails + * @throws DotSecurityException when permissions prevent target resolution + */ + private Optional resolvePageSubtype(final IAmSubType subType, + final String canonicalPath, + final S3VanityAliasPublishContext context, + final User user) + throws DotDataException, DotSecurityException { + if (IAmSubType.PAGE_URL_MAP.equals(subType)) { + return resolveUrlMap(canonicalPath, context, user); + } + final DotAsset targetType = IAmSubType.PAGE_INDEX.equals(subType) + ? DotAsset.PAGE_INDEX : DotAsset.PAGE; + return resolvePage(canonicalPath, context, targetType); + } + + /** + * Resolves a regular page or folder index page. + * + * @param canonicalPath normalized forward target path + * @param context Vanity URL publishing context + * @param targetType target type to assign + * @return resolved page target when found + * @throws DotDataException when dotCMS data access fails + * @throws DotSecurityException when permissions prevent target resolution + */ + private Optional resolvePage(final String canonicalPath, + final S3VanityAliasPublishContext context, + final DotAsset targetType) + throws DotDataException, DotSecurityException { + final IHTMLPage htmlPage = htmlPageAssetAPI.getPageByPath(canonicalPath, context.host, + context.language.getId(), true); + return htmlPage == null ? Optional.empty() + : Optional.of(new S3VanityResolvedTarget(targetType, canonicalPath, htmlPage, null)); + } + + /** + * Resolves a URL mapped target into its detail page and contentlet. + * + * @param canonicalPath normalized forward target path + * @param context Vanity URL publishing context + * @param user user used for URL Map resolution + * @return resolved URL mapped target when found + * @throws DotDataException when dotCMS data access fails + * @throws DotSecurityException when permissions prevent target resolution + */ + private Optional resolveUrlMap(final String canonicalPath, + final S3VanityAliasPublishContext context, + final User user) + throws DotDataException, DotSecurityException { + final Optional urlMapInfo = urlMapAPI.processURLMap(UrlMapContextBuilder.builder() + .setHost(context.host) + .setLanguageId(context.language.getId()) + .setMode(PageMode.LIVE) + .setUri(canonicalPath) + .setUser(user) + .build()); + if (urlMapInfo.isEmpty()) { + return Optional.empty(); + } + + final IHTMLPage detailPage = htmlPageAssetAPI.getPageByPath(urlMapInfo.get().getDetailtPageUri(), + context.host, context.language.getId(), true); + return detailPage == null ? Optional.empty() + : Optional.of(new S3VanityResolvedTarget(DotAsset.PAGE_URL_MAP, canonicalPath, + detailPage, urlMapInfo.get())); + } +} diff --git a/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260408CreateS3VanityAliasTable.java b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260408CreateS3VanityAliasTable.java new file mode 100644 index 000000000000..33ff8ad4c3b1 --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260408CreateS3VanityAliasTable.java @@ -0,0 +1,66 @@ +package com.dotmarketing.startup.runonce; + +import com.dotmarketing.common.db.DotDatabaseMetaData; +import com.dotmarketing.db.DbConnectionFactory; +import com.dotmarketing.startup.AbstractJDBCStartupTask; +import com.dotmarketing.util.Logger; + +import java.sql.SQLException; + +/** + * Creates the operational table that stores vanity aliases published on S3. + */ +public class Task260408CreateS3VanityAliasTable extends AbstractJDBCStartupTask { + + private static final String TABLE_NAME = "static_s3_vanity_mapping"; + + /** + * Checks whether the table already exists in the database. + * + * @return true when the task must run + */ + @Override + public boolean forceRun() { + try { + return !new DotDatabaseMetaData().tableExists(DbConnectionFactory.getConnection(), TABLE_NAME); + } catch (final SQLException e) { + Logger.error(this, e.getMessage(), e); + return false; + } + } + + /** + * Returns the PostgreSQL script that creates the mapping table. + * + * @return table DDL + */ + @Override + public String getPostgresScript() { + return getScript(); + } + + /** + * Returns the PostgreSQL DDL for the mapping table. + * + * @return table DDL + */ + private String getScript() { + return "CREATE TABLE IF NOT EXISTS static_s3_vanity_mapping (" + + " endpoint_id varchar not null," + + " host_id varchar not null," + + " language_id bigint not null," + + " canonical_path varchar not null," + + " canonical_path_hash varchar not null," + + " vanity_path varchar not null," + + " vanity_path_hash varchar not null," + + " vanity_url_id varchar," + + " bucket_name varchar not null," + + " bucket_region varchar," + + " bucket_prefix varchar," + + " mod_date timestamptz not null," + + " primary key (endpoint_id, host_id, language_id, canonical_path_hash, vanity_path_hash)" + + ");" + + "CREATE INDEX IF NOT EXISTS idx_static_s3_vanity_mapping_vurl " + + "ON static_s3_vanity_mapping (endpoint_id, vanity_url_id)"; + } +} diff --git a/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java b/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java index 8780ba01a64e..c89b5a6aab8b 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java @@ -266,6 +266,7 @@ import com.dotmarketing.startup.runonce.Task260403SetLz4CompressionOnTextColumns; import com.dotmarketing.startup.runonce.Task260403SetPermissionReferenceUnlogged; import com.dotmarketing.startup.runonce.Task260407AddBaseTypeColumnToIdentifier; +import com.dotmarketing.startup.runonce.Task260408CreateS3VanityAliasTable; import com.dotmarketing.startup.runonce.Task260505AddPluginsPortletToMenu; import com.google.common.collect.ImmutableList; @@ -607,6 +608,7 @@ public static List> getStartupRunOnceTaskClasses() { .add(Task260403SetLz4CompressionOnTextColumns.class) .add(Task260403SetPermissionReferenceUnlogged.class) .add(Task260407AddBaseTypeColumnToIdentifier.class) + .add(Task260408CreateS3VanityAliasTable.class) .add(Task260505AddPluginsPortletToMenu.class) .build(); diff --git a/dotCMS/src/main/resources/dotmarketing-config.properties b/dotCMS/src/main/resources/dotmarketing-config.properties index 06d920eb4f02..690aca98d0b6 100644 --- a/dotCMS/src/main/resources/dotmarketing-config.properties +++ b/dotCMS/src/main/resources/dotmarketing-config.properties @@ -700,6 +700,7 @@ DEFAULT_REST_PAGE_COUNT=20 #STATIC_PUSH_BUCKET_NAME_REGEX=[,!:;&?$\\\\*\\/\\[\\]=\\|#_@\\(\\)<>\\s\\+"]+ STATIC_PUSH_SLEEP_ON_ERROR_SECONDS=10 STATIC_PUSH_RETRY_ATTEMPTS=3 +STATIC_PUSH_S3_VANITY_ALIAS_ENABLED=false # Allow HTTP Authentication @@ -936,4 +937,3 @@ telemetry.metric.slow.threshold.ms=500 # Set to ERROR to surface shadow failures in dashboards/alerts. # Set to DEBUG to suppress them during steady-state migration. #DOTCMS_SHADOW_WRITE_LOG_LEVEL=WARN - diff --git a/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasServiceTest.java b/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasServiceTest.java new file mode 100644 index 000000000000..443a6abff332 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasServiceTest.java @@ -0,0 +1,172 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotcms.publishing.DotPublishingException; +import com.dotcms.vanityurl.business.VanityUrlAPI; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotmarketing.beans.Host; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.portlets.htmlpageasset.business.HTMLPageAssetAPI; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import org.apache.http.HttpStatus; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.io.File; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link S3VanityAliasService}. + */ +public class S3VanityAliasServiceTest { + + private static final String ENDPOINT_ID = "endpoint"; + private static final String HOST_ID = "host"; + private static final long LANGUAGE_ID = 1L; + private static final String CANONICAL_PATH = "/canonical"; + private static final String BUCKET = "bucket"; + private static final String REGION = "region"; + private static final String PREFIX = "prefix"; + + private final VanityUrlAPI vanityUrlAPI = mock(VanityUrlAPI.class); + private final S3VanityAliasRepository repository = mock(S3VanityAliasRepository.class); + private final AWSS3EndPointPublisher endpointPublisher = mock(AWSS3EndPointPublisher.class); + private final Host host = mock(Host.class); + private final Language language = mock(Language.class); + private final File canonicalFile = new File("canonical.html"); + private final S3VanityAliasLookup lookup = + new S3VanityAliasLookup(ENDPOINT_ID, HOST_ID, LANGUAGE_ID, CANONICAL_PATH); + private final S3VanityAliasContext context = + new S3VanityAliasContext(lookup, BUCKET, REGION, PREFIX, host, language, + canonicalFile, endpointPublisher); + private final S3VanityAliasService service = + new S3VanityAliasService(vanityUrlAPI, new S3VanityAliasSupport(), repository, + mock(HTMLPageAssetAPI.class), mock(S3VanityTargetResolver.class)); + + @Test + public void publishAliasesShouldReturnVoidAndRefreshOnlyPersistedAliases() throws Exception { + final S3VanityAlias persistedAlias = alias("/promo", "vanity-1"); + whenCurrentVanities(vanityUrl("/promo", "vanity-1")); + when(repository.findByLookup(lookup)).thenReturn(List.of(persistedAlias)); + + final Void result = invokeVoid(() -> service.publishAliases(context)); + + Assert.assertNull(result); + verify(endpointPublisher).pushFileToEndpoint(BUCKET, REGION, PREFIX, "/promo", canonicalFile); + final List mappings = replacedMappings(); + Assert.assertEquals(1, mappings.size()); + Assert.assertEquals("/promo", mappings.get(0).vanityPath); + } + + @Test + public void publishAliasesShouldReturnVoidAndAvoidCreatingNewAliasesDuringCanonicalRepublish() + throws Exception { + whenCurrentVanities(vanityUrl("/new-promo", "vanity-1")); + when(repository.findByLookup(lookup)).thenReturn(List.of()); + + final Void result = invokeVoid(() -> service.publishAliases(context)); + + Assert.assertNull(result); + verify(endpointPublisher, never()).pushFileToEndpoint(any(), any(), any(), any(), any()); + final List mappings = replacedMappings(); + Assert.assertTrue(mappings.isEmpty()); + } + + @Test + public void publishAliasesShouldReturnVoidAndDeleteObsoletePersistedAliases() throws Exception { + final S3VanityAlias obsoleteAlias = alias("/old-promo", "vanity-1"); + whenCurrentVanities(); + when(repository.findByLookup(lookup)).thenReturn(List.of(obsoleteAlias)); + + final Void result = invokeVoid(() -> service.publishAliases(context)); + + Assert.assertNull(result); + verify(endpointPublisher).deleteFilesFromEndpoint(BUCKET, PREFIX, "/old-promo"); + final List mappings = replacedMappings(); + Assert.assertTrue(mappings.isEmpty()); + } + + @Test + public void publishAliasesShouldReturnDotDataExceptionAndRestoreDeletedAliasWhenRepositoryFails() + throws Exception { + final S3VanityAlias obsoleteAlias = alias("/old-promo", "vanity-1"); + whenCurrentVanities(); + when(repository.findByLookup(lookup)).thenReturn(List.of(obsoleteAlias)); + doThrow(new DotDataException("replace failed")).when(repository).replaceMappings(eq(lookup), any()); + + final DotDataException result = Assert.assertThrows(DotDataException.class, + () -> service.publishAliases(context)); + + Assert.assertNotNull(result); + verify(endpointPublisher).deleteFilesFromEndpoint(BUCKET, PREFIX, "/old-promo"); + verify(endpointPublisher).pushFileToEndpoint(BUCKET, REGION, PREFIX, "/old-promo", canonicalFile); + } + + @Test + public void unpublishAliasesShouldReturnVoidAndDeletePersistedAliases() throws Exception { + final S3VanityAlias firstAlias = alias("/promo", "vanity-1"); + final S3VanityAlias secondAlias = alias("/landing", "vanity-2"); + when(repository.findByLookup(lookup)).thenReturn(List.of(firstAlias, secondAlias)); + + final Void result = invokeVoid(() -> service.unpublishAliases(context)); + + Assert.assertNull(result); + verify(endpointPublisher).deleteFilesFromEndpoint(BUCKET, PREFIX, "/promo"); + verify(endpointPublisher).deleteFilesFromEndpoint(BUCKET, PREFIX, "/landing"); + verify(repository).deleteByLookup(lookup); + } + + @Test + public void unpublishAliasesShouldReturnDotDataExceptionAndKeepMappingWhenS3DeleteFails() + throws Exception { + final S3VanityAlias persistedAlias = alias("/promo", "vanity-1"); + when(repository.findByLookup(lookup)).thenReturn(List.of(persistedAlias)); + doThrow(new DotPublishingException("delete failed")) + .when(endpointPublisher).deleteFilesFromEndpoint(BUCKET, PREFIX, "/promo"); + + final DotDataException result = Assert.assertThrows(DotDataException.class, + () -> service.unpublishAliases(context)); + + Assert.assertNotNull(result); + verify(repository, never()).deleteByLookup(lookup); + } + + private Void invokeVoid(final VoidMethod method) throws Exception { + method.invoke(); + return null; + } + + private void whenCurrentVanities(final CachedVanityUrl... vanityUrls) { + when(vanityUrlAPI.findByForward(host, language, CANONICAL_PATH, HttpStatus.SC_OK, true)) + .thenReturn(List.of(vanityUrls)); + } + + private CachedVanityUrl vanityUrl(final String uri, final String vanityUrlId) { + return new CachedVanityUrl(vanityUrlId, uri, LANGUAGE_ID, HOST_ID, CANONICAL_PATH, + HttpStatus.SC_OK, 0); + } + + private S3VanityAlias alias(final String vanityPath, final String vanityUrlId) { + return new S3VanityAlias(ENDPOINT_ID, HOST_ID, LANGUAGE_ID, CANONICAL_PATH, vanityPath, + vanityUrlId, BUCKET, REGION, PREFIX); + } + + private List replacedMappings() throws DotDataException { + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(repository).replaceMappings(eq(lookup), captor.capture()); + return captor.getValue(); + } + + @FunctionalInterface + private interface VoidMethod { + void invoke() throws Exception; + } +} diff --git a/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasSupportTest.java b/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasSupportTest.java new file mode 100644 index 000000000000..8649a5804cb9 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityAliasSupportTest.java @@ -0,0 +1,136 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotcms.contenttype.model.type.VanityUrlContentType; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotmarketing.beans.Host; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import org.apache.http.HttpStatus; +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; +import java.util.List; +import java.util.Optional; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link S3VanityAliasSupport}. + */ +public class S3VanityAliasSupportTest { + + private final S3VanityAliasSupport support = new S3VanityAliasSupport(); + + @Test + public void normalizeVanityPathShouldReturnOptionalWithStaticSafePath() { + final String vanityPath = " /promo\\area//landing "; + + final Optional result = support.normalizeVanityPath(vanityPath); + + Assert.assertTrue(result.isPresent()); + Assert.assertEquals("/promo/area/landing", result.get()); + } + + @Test + public void normalizeVanityPathShouldReturnEmptyOptionalForRegexLikePath() { + final String vanityPath = "/promo/(.*)"; + + final Optional result = support.normalizeVanityPath(vanityPath); + + Assert.assertFalse(result.isPresent()); + } + + @Test + public void normalizeCanonicalPathShouldReturnOptionalWithInternalPath() { + final String forwardTo = " /content\\pages//home "; + + final Optional result = support.normalizeCanonicalPath(forwardTo); + + Assert.assertTrue(result.isPresent()); + Assert.assertEquals("/content/pages/home", result.get()); + } + + @Test + public void normalizeCanonicalPathShouldReturnEmptyOptionalForExternalUrl() { + final String forwardTo = "https://example.com/content/pages/home"; + + final Optional result = support.normalizeCanonicalPath(forwardTo); + + Assert.assertFalse(result.isPresent()); + } + + @Test + public void isForwardActionShouldReturnTrueForHttpOkVanityAction() { + final Contentlet vanityContentlet = vanityContentlet("/promo", "/home", HttpStatus.SC_OK, true); + + final boolean result = support.isForwardAction(vanityContentlet); + + Assert.assertTrue(result); + } + + @Test + public void isSupportedVanityUrlShouldReturnFalseForExternalForwardTarget() { + final Contentlet vanityContentlet = vanityContentlet("/promo", "https://example.com/home", + HttpStatus.SC_OK, true); + + final boolean result = support.isSupportedVanityUrl(vanityContentlet); + + Assert.assertFalse(result); + } + + @Test + public void toAliasMappingsShouldReturnDeduplicatedListByMaterializedVanityPath() { + final S3VanityAliasContext context = context("/home"); + final List vanityUrls = List.of( + vanityUrl("first", "/promo", "/home"), + vanityUrl("second", "/promo", "/home")); + + final List result = support.toAliasMappings(context, vanityUrls); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals("first", result.get(0).vanityUrlId); + Assert.assertEquals("/promo", result.get(0).vanityPath); + } + + @Test + public void toAliasMappingsShouldReturnOnlyAliasesWithSupportedStaticVanityPath() { + final S3VanityAliasContext context = context("/home"); + final List vanityUrls = List.of( + vanityUrl("unsupported", "/promo/(.*)", "/home"), + vanityUrl("supported", "/promo", "/home")); + + final List result = support.toAliasMappings(context, vanityUrls); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals("supported", result.get(0).vanityUrlId); + Assert.assertEquals("/promo", result.get(0).vanityPath); + } + + private Contentlet vanityContentlet(final String uri, final String forwardTo, + final int action, final boolean vanityUrl) { + final Contentlet contentlet = mock(Contentlet.class); + when(contentlet.isVanityUrl()).thenReturn(vanityUrl); + when(contentlet.getLongProperty(VanityUrlContentType.ACTION_FIELD_VAR)).thenReturn((long) action); + when(contentlet.getStringProperty(VanityUrlContentType.URI_FIELD_VAR)).thenReturn(uri); + when(contentlet.getStringProperty(VanityUrlContentType.FORWARD_TO_FIELD_VAR)).thenReturn(forwardTo); + return contentlet; + } + + private S3VanityAliasContext context(final String canonicalPath) { + return new S3VanityAliasContext( + new S3VanityAliasLookup("endpoint", "host", 1L, canonicalPath), + "bucket", + "region", + "prefix", + mock(Host.class), + mock(Language.class), + new File("page.html"), + mock(AWSS3EndPointPublisher.class)); + } + + private CachedVanityUrl vanityUrl(final String id, final String uri, final String forwardTo) { + return new CachedVanityUrl(id, uri, 1L, "host", forwardTo, HttpStatus.SC_OK, 0); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityTargetResolverTest.java b/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityTargetResolverTest.java new file mode 100644 index 000000000000..8361e7895035 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityTargetResolverTest.java @@ -0,0 +1,112 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.cms.urlmap.URLMapAPI; +import com.dotmarketing.cms.urlmap.URLMapInfo; +import com.dotmarketing.cms.urlmap.UrlMapContext; +import com.dotmarketing.filters.CMSFilter.IAm; +import com.dotmarketing.filters.CMSFilter.IAmSubType; +import com.dotmarketing.filters.CMSUrlUtil; +import com.dotmarketing.portlets.contentlet.business.ContentletAPI; +import com.dotmarketing.portlets.fileassets.business.FileAssetAPI; +import com.dotmarketing.portlets.htmlpageasset.business.HTMLPageAssetAPI; +import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.liferay.portal.model.User; +import io.vavr.Tuple; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link S3VanityTargetResolver}. + */ +public class S3VanityTargetResolverTest { + + private static final String CANONICAL_PATH = "/home"; + private static final long LANGUAGE_ID = 1L; + + private final CMSUrlUtil cmsUrlUtil = mock(CMSUrlUtil.class); + private final HTMLPageAssetAPI htmlPageAssetAPI = mock(HTMLPageAssetAPI.class); + private final URLMapAPI urlMapAPI = mock(URLMapAPI.class); + private final Host host = mock(Host.class); + private final Language language = mock(Language.class); + private final User user = null; + private final S3VanityAliasPublishContext context = new S3VanityAliasPublishContext( + "endpoint", "bucket", "region", "prefix", host, language, mock(AWSS3EndPointPublisher.class)); + private final S3VanityTargetResolver resolver = new S3VanityTargetResolver(cmsUrlUtil, + htmlPageAssetAPI, urlMapAPI, mock(ContentletAPI.class), mock(FileAssetAPI.class)); + + @Test + public void resolveShouldReturnOptionalWithPageTargetWhenPathResolvesDirectlyToHtmlPage() + throws Exception { + final IHTMLPage htmlPage = mock(IHTMLPage.class); + when(language.getId()).thenReturn(LANGUAGE_ID); + when(htmlPageAssetAPI.getPageByPath(CANONICAL_PATH, host, LANGUAGE_ID, true)).thenReturn(htmlPage); + + final Optional result = resolver.resolve(CANONICAL_PATH, context, user); + + Assert.assertTrue(result.isPresent()); + Assert.assertEquals(DotAsset.PAGE, result.get().type); + Assert.assertEquals(CANONICAL_PATH, result.get().canonicalPath); + Assert.assertEquals(htmlPage, result.get().htmlPage); + } + + @Test + public void resolveShouldReturnOptionalWithIndexPageTargetWhenDotCmsClassifiesPathAsPageIndex() + throws Exception { + final IHTMLPage indexPage = mock(IHTMLPage.class); + when(language.getId()).thenReturn(LANGUAGE_ID); + when(htmlPageAssetAPI.getPageByPath(CANONICAL_PATH, host, LANGUAGE_ID, true)) + .thenReturn(null, indexPage); + when(cmsUrlUtil.resolveResourceType(IAm.NOTHING_IN_THE_CMS, CANONICAL_PATH, host, LANGUAGE_ID)) + .thenReturn(Tuple.of(IAm.PAGE, IAmSubType.PAGE_INDEX)); + + final Optional result = resolver.resolve(CANONICAL_PATH, context, user); + + Assert.assertTrue(result.isPresent()); + Assert.assertEquals(DotAsset.PAGE_INDEX, result.get().type); + Assert.assertEquals(CANONICAL_PATH, result.get().canonicalPath); + Assert.assertEquals(indexPage, result.get().htmlPage); + } + + @Test + public void resolveShouldReturnOptionalWithUrlMapTargetWhenDotCmsClassifiesPathAsUrlMap() + throws Exception { + final URLMapInfo urlMapInfo = mock(URLMapInfo.class); + final IHTMLPage detailPage = mock(IHTMLPage.class); + when(language.getId()).thenReturn(LANGUAGE_ID); + when(htmlPageAssetAPI.getPageByPath(CANONICAL_PATH, host, LANGUAGE_ID, true)).thenReturn(null); + when(cmsUrlUtil.resolveResourceType(IAm.NOTHING_IN_THE_CMS, CANONICAL_PATH, host, LANGUAGE_ID)) + .thenReturn(Tuple.of(IAm.PAGE, IAmSubType.PAGE_URL_MAP)); + when(urlMapInfo.getDetailtPageUri()).thenReturn("/detail"); + when(urlMapAPI.processURLMap(any(UrlMapContext.class))).thenReturn(Optional.of(urlMapInfo)); + when(htmlPageAssetAPI.getPageByPath("/detail", host, LANGUAGE_ID, true)).thenReturn(detailPage); + + final Optional result = resolver.resolve(CANONICAL_PATH, context, user); + + Assert.assertTrue(result.isPresent()); + Assert.assertEquals(DotAsset.PAGE_URL_MAP, result.get().type); + Assert.assertEquals(CANONICAL_PATH, result.get().canonicalPath); + Assert.assertEquals(detailPage, result.get().htmlPage); + Assert.assertEquals(urlMapInfo, result.get().urlMapInfo); + } + + @Test + public void resolveShouldReturnEmptyOptionalWhenDotCmsCannotResolveStaticTarget() + throws Exception { + when(language.getId()).thenReturn(LANGUAGE_ID); + when(htmlPageAssetAPI.getPageByPath(CANONICAL_PATH, host, LANGUAGE_ID, true)).thenReturn(null); + when(cmsUrlUtil.resolveResourceType(IAm.NOTHING_IN_THE_CMS, CANONICAL_PATH, host, LANGUAGE_ID)) + .thenReturn(Tuple.of(IAm.NOTHING_IN_THE_CMS, IAmSubType.NONE)); + + final Optional result = resolver.resolve(CANONICAL_PATH, context, user); + + Assert.assertFalse(result.isPresent()); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityStaticPublishingIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityStaticPublishingIntegrationTest.java new file mode 100644 index 000000000000..a73efcd472eb --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/enterprise/publishing/staticpublishing/S3VanityStaticPublishingIntegrationTest.java @@ -0,0 +1,361 @@ +package com.dotcms.enterprise.publishing.staticpublishing; + +import static com.dotcms.contenttype.model.type.VanityUrlContentType.URI_FIELD_VAR; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.field.TextField; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.ContainerDataGen; +import com.dotcms.datagen.ContentTypeDataGen; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FieldDataGen; +import com.dotcms.datagen.HTMLPageDataGen; +import com.dotcms.datagen.MultiTreeDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.TemplateDataGen; +import com.dotcms.datagen.VanityUrlDataGen; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.portlets.containers.model.Container; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.startup.runonce.Task260408CreateS3VanityAliasTable; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class S3VanityStaticPublishingIntegrationTest { + + private static final String BUCKET_NAME = "static-vanity-test"; + private static final String BUCKET_REGION = "us-east-1"; + private static final String BUCKET_PREFIX = "root"; + + private S3VanityAliasRepository repository; + private S3VanityAliasService service; + private AWSS3EndPointPublisher endpointPublisher; + private String endpointId; + private List pushedPaths; + private List deletedPaths; + private Map pushedContentByPath; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + new Task260408CreateS3VanityAliasTable().executeUpgrade(); + } + + @Before + public void prepareTest() throws Exception { + repository = new S3VanityAliasRepository(); + service = new S3VanityAliasService(); + endpointPublisher = mock(AWSS3EndPointPublisher.class); + endpointId = "s3-vanity-it-" + UUID.randomUUID(); + pushedPaths = new ArrayList<>(); + deletedPaths = new ArrayList<>(); + pushedContentByPath = new LinkedHashMap<>(); + recordEndpointCalls(); + } + + /** + * Method to Test: {@link S3VanityAliasService#publishAliasForVanityUrl(S3VanityAliasPublishContext, Contentlet)} + * Given Scenario: A live Vanity URL points to a live dotCMS page + * ExpectedResult: The service should push the vanity clone and persist the mapping row. + */ + @Test + public void publishAliasForVanityUrlShouldPublishCloneAndPersistMapping() throws Exception { + final PageFixture source = createLivePage("source", "published source content"); + final Contentlet vanity = createLiveVanity(source, "/alias", source.path); + + service.publishAliasForVanityUrl(context(source), vanity); + + verify(endpointPublisher).pushFileToEndpoint(eq(BUCKET_NAME), eq(BUCKET_REGION), + eq(BUCKET_PREFIX), eq("/alias"), any(File.class)); + assertEquals(List.of("/alias"), pushedPaths); + assertTrue(pushedContentByPath.get("/alias").contains("published source content")); + + final List aliases = repository.findByVanityUrlId(endpointId, + source.language.getId(), vanity.getIdentifier()); + assertEquals(1, aliases.size()); + assertAlias(aliases.get(0), source, source.path, "/alias", vanity.getIdentifier()); + } + + /** + * Method to Test: {@link S3VanityAliasService#publishAliasForVanityUrl(S3VanityAliasPublishContext, Contentlet)} + * Given Scenario: An already materialized Vanity URL changes its URI + * ExpectedResult: The old S3 clone should be deleted and the mapping should point to the new URI. + */ + @Test + public void publishAliasForVanityUrlShouldDeleteOldCloneWhenVanityPathChanges() throws Exception { + final PageFixture source = createLivePage("source-change", "changed source content"); + Contentlet vanity = createLiveVanity(source, "/old-alias", source.path); + service.publishAliasForVanityUrl(context(source), vanity); + + vanity = updateVanityUri(vanity, "/new-alias"); + service.publishAliasForVanityUrl(context(source), vanity); + + verify(endpointPublisher).deleteFilesFromEndpoint(eq(BUCKET_NAME), eq(BUCKET_PREFIX), + eq("/old-alias")); + verify(endpointPublisher).pushFileToEndpoint(eq(BUCKET_NAME), eq(BUCKET_REGION), + eq(BUCKET_PREFIX), eq("/new-alias"), any(File.class)); + assertTrue(deletedPaths.contains("/old-alias")); + assertTrue(pushedPaths.contains("/new-alias")); + + final List aliases = repository.findByVanityUrlId(endpointId, + source.language.getId(), vanity.getIdentifier()); + assertEquals(1, aliases.size()); + assertAlias(aliases.get(0), source, source.path, "/new-alias", vanity.getIdentifier()); + } + + /** + * Method to Test: {@link S3VanityAliasService#unpublishAliasesByVanityUrl(S3VanityAliasCleanupContext, long, String)} + * Given Scenario: A materialized Vanity URL is removed and its URI does not hide a live resource + * ExpectedResult: The clone should be deleted and the mapping row should be removed. + */ + @Test + public void unpublishAliasesByVanityUrlShouldDeleteCloneAndMappingWhenNoResourceIsShadowed() + throws Exception { + final PageFixture source = createLivePage("source-delete", "source to delete"); + final Contentlet vanity = createLiveVanity(source, "/orphan-alias", source.path); + service.publishAliasForVanityUrl(context(source), vanity); + + service.unpublishAliasesByVanityUrl(cleanupContext(), source.language.getId(), vanity.getIdentifier()); + + verify(endpointPublisher).deleteFilesFromEndpoint(eq(BUCKET_NAME), eq(BUCKET_PREFIX), + eq("/orphan-alias")); + assertTrue(deletedPaths.contains("/orphan-alias")); + assertTrue(repository.findByVanityUrlId(endpointId, source.language.getId(), + vanity.getIdentifier()).isEmpty()); + } + + /** + * Method to Test: {@link S3VanityAliasService#unpublishAliasesByVanityUrl(S3VanityAliasCleanupContext, long, String)} + * Given Scenario: A materialized Vanity URL is removed and its URI shadows a live dotCMS page + * ExpectedResult: The live page should be restored on the same S3 key and the mapping should be removed. + */ + @Test + public void unpublishAliasesByVanityUrlShouldRestoreShadowedLiveResourceAndRemoveMapping() + throws Exception { + final Host host = new SiteDataGen().nextPersisted(); + final Language language = APILocator.getLanguageAPI().getDefaultLanguage(); + final PageFixture source = createLivePage(host, language, "source-shadow", + "vanity source content"); + final PageFixture shadowed = createLivePage(host, language, "shadowed-alias", + "shadowed original content"); + final Contentlet vanity = createLiveVanity(source, shadowed.path, source.path); + service.publishAliasForVanityUrl(context(source), vanity); + pushedPaths.clear(); + pushedContentByPath.clear(); + clearInvocations(endpointPublisher); + + service.unpublishAliasesByVanityUrl(cleanupContext(), source.language.getId(), vanity.getIdentifier()); + + verify(endpointPublisher, never()).deleteFilesFromEndpoint(eq(BUCKET_NAME), eq(BUCKET_PREFIX), + eq(shadowed.path)); + verify(endpointPublisher).pushFileToEndpoint(eq(BUCKET_NAME), eq(BUCKET_REGION), + eq(BUCKET_PREFIX), eq(shadowed.path), any(File.class)); + assertEquals(List.of(shadowed.path), pushedPaths); + assertTrue(pushedContentByPath.get(shadowed.path).contains("shadowed original content")); + assertTrue(repository.findByVanityUrlId(endpointId, source.language.getId(), + vanity.getIdentifier()).isEmpty()); + } + + /** + * Method to Test: {@link S3VanityAliasService#publishAliases(S3VanityAliasContext)} + * Given Scenario: A canonical page with an existing materialized Vanity URL is republished + * ExpectedResult: The existing vanity clone should be refreshed without creating new mappings. + */ + @Test + public void publishAliasesShouldRefreshExistingCloneWhenCanonicalContentIsRepublished() + throws Exception { + final PageFixture source = createLivePage("source-republish", "initial source content"); + final Contentlet vanity = createLiveVanity(source, "/republished-alias", source.path); + service.publishAliasForVanityUrl(context(source), vanity); + pushedPaths.clear(); + pushedContentByPath.clear(); + + final File republishedFile = temporaryStaticFile("updated static content"); + service.publishAliases(aliasContext(source, republishedFile)); + + verify(endpointPublisher).pushFileToEndpoint(eq(BUCKET_NAME), eq(BUCKET_REGION), + eq(BUCKET_PREFIX), eq("/republished-alias"), eq(republishedFile)); + assertEquals(List.of("/republished-alias"), pushedPaths); + assertEquals("updated static content", pushedContentByPath.get("/republished-alias")); + assertEquals(1, repository.findByLookup(lookup(source)).size()); + } + + /** + * Method to Test: {@link S3VanityAliasService#unpublishAliases(S3VanityAliasContext)} + * Given Scenario: A canonical page with an existing materialized Vanity URL is unpublished + * ExpectedResult: The vanity clone and its persisted mapping should be removed. + */ + @Test + public void unpublishAliasesShouldDeleteCloneAndMappingWhenCanonicalContentIsRemoved() + throws Exception { + final PageFixture source = createLivePage("source-unpublish", "source to unpublish"); + final Contentlet vanity = createLiveVanity(source, "/canonical-removed-alias", source.path); + service.publishAliasForVanityUrl(context(source), vanity); + + service.unpublishAliases(aliasContext(source, temporaryStaticFile("unused"))); + + verify(endpointPublisher).deleteFilesFromEndpoint(eq(BUCKET_NAME), eq(BUCKET_PREFIX), + eq("/canonical-removed-alias")); + assertTrue(deletedPaths.contains("/canonical-removed-alias")); + assertTrue(repository.findByLookup(lookup(source)).isEmpty()); + } + + private void recordEndpointCalls() throws Exception { + doAnswer(invocation -> { + final String path = invocation.getArgument(3); + final File file = invocation.getArgument(4); + pushedPaths.add(path); + pushedContentByPath.put(path, Files.readString(file.toPath(), StandardCharsets.UTF_8)); + return null; + }).when(endpointPublisher).pushFileToEndpoint(anyString(), anyString(), any(), anyString(), + any(File.class)); + + doAnswer(invocation -> { + deletedPaths.add(invocation.getArgument(2)); + return null; + }).when(endpointPublisher).deleteFilesFromEndpoint(anyString(), any(), anyString()); + } + + private PageFixture createLivePage(final String path, final String content) throws Exception { + final Host host = new SiteDataGen().nextPersisted(); + final Language language = APILocator.getLanguageAPI().getDefaultLanguage(); + return createLivePage(host, language, path, content); + } + + private PageFixture createLivePage(final Host host, final Language language, final String path, + final String content) throws Exception { + final Field textField = new FieldDataGen().type(TextField.class).next(); + final ContentType contentType = new ContentTypeDataGen().field(textField).nextPersisted(); + final Container container = new ContainerDataGen() + .site(host) + .withContentType(contentType, "$!{" + textField.variable() + "}") + .nextPersisted(); + final Template template = new TemplateDataGen() + .site(host) + .withContainer(container.getIdentifier()) + .nextPersisted(); + final Contentlet contentlet = new ContentletDataGen(contentType) + .host(host) + .languageId(language.getId()) + .setProperty(textField.variable(), content) + .nextPersisted(); + final HTMLPageAsset page = new HTMLPageDataGen(host, template) + .host(host) + .languageId(language.getId()) + .pageURL(path) + .title(path) + .nextPersisted(); + + new MultiTreeDataGen() + .setContainer(container) + .setPage(page) + .setContentlet(contentlet) + .nextPersisted(); + + ContentletDataGen.publish(contentlet); + ContainerDataGen.publish(container); + TemplateDataGen.publish(template); + HTMLPageDataGen.publish(page); + + return new PageFixture(host, language, page, "/" + path); + } + + private Contentlet createLiveVanity(final PageFixture source, final String uri, + final String forwardTo) { + final Contentlet vanity = new VanityUrlDataGen() + .title("Vanity " + System.currentTimeMillis()) + .uri(uri) + .forwardTo(forwardTo) + .action(200) + .order(0) + .languageId(source.language.getId()) + .host(source.host) + .nextPersisted(); + return ContentletDataGen.publish(vanity); + } + + private Contentlet updateVanityUri(final Contentlet vanity, final String uri) { + final Contentlet checkedOut = ContentletDataGen.checkout(vanity); + checkedOut.setProperty(URI_FIELD_VAR, uri); + return ContentletDataGen.publish(ContentletDataGen.checkin(checkedOut)); + } + + private S3VanityAliasPublishContext context(final PageFixture source) { + return new S3VanityAliasPublishContext(endpointId, BUCKET_NAME, BUCKET_REGION, + BUCKET_PREFIX, source.host, source.language, endpointPublisher); + } + + private S3VanityAliasCleanupContext cleanupContext() { + return new S3VanityAliasCleanupContext(endpointId, endpointPublisher); + } + + private S3VanityAliasContext aliasContext(final PageFixture source, final File file) { + return new S3VanityAliasContext(lookup(source), BUCKET_NAME, BUCKET_REGION, + BUCKET_PREFIX, source.host, source.language, file, endpointPublisher); + } + + private S3VanityAliasLookup lookup(final PageFixture source) { + return new S3VanityAliasLookup(endpointId, source.host.getIdentifier(), + source.language.getId(), source.path); + } + + private File temporaryStaticFile(final String content) throws Exception { + final File file = File.createTempFile("s3-vanity-it-", ".html"); + Files.writeString(file.toPath(), content, StandardCharsets.UTF_8); + return file; + } + + private void assertAlias(final S3VanityAlias alias, final PageFixture source, + final String canonicalPath, final String vanityPath, + final String vanityUrlId) { + assertEquals(endpointId, alias.endpointId); + assertEquals(source.host.getIdentifier(), alias.hostId); + assertEquals(source.language.getId(), alias.languageId); + assertEquals(canonicalPath, alias.canonicalPath); + assertEquals(vanityPath, alias.vanityPath); + assertEquals(vanityUrlId, alias.vanityUrlId); + assertEquals(BUCKET_NAME, alias.bucketName); + assertEquals(BUCKET_REGION, alias.bucketRegion); + assertEquals(BUCKET_PREFIX, alias.bucketPrefix); + } + + private static class PageFixture { + + private final Host host; + private final Language language; + private final HTMLPageAsset page; + private final String path; + + private PageFixture(final Host host, final Language language, + final HTMLPageAsset page, final String path) { + this.host = host; + this.language = language; + this.page = page; + this.path = path; + } + } +}