/* * Copyright (C) 2021 LibreCCM Foundation. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * MA 02110-1301 USA */ package org.librecms.pages; import com.arsdigita.kernel.KernelConfig; import org.libreccm.categorization.Category; import org.libreccm.categorization.CategoryManager; import org.libreccm.categorization.CategoryRepository; import org.libreccm.configuration.ConfigurationManager; import org.libreccm.l10n.GlobalizationHelper; import org.libreccm.sites.Site; import org.libreccm.sites.SiteRepository; import org.libreccm.theming.ThemeInfo; import org.libreccm.theming.ThemeVersion; import org.libreccm.theming.Themes; import org.libreccm.theming.mvc.ThemesMvc; import org.librecms.contentsection.ContentItem; import org.librecms.contentsection.ContentItemL10NManager; import org.librecms.contentsection.ContentItemManager; import org.librecms.contentsection.ContentItemVersion; import org.librecms.pages.models.CategoryModel; import org.librecms.pages.models.ContentItemModel; import org.librecms.pages.models.PagePropertiesModel; import org.librecms.pages.models.PageUrlModel; import org.librecms.pages.models.SiteInfoModel; import java.net.URI; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; import javax.annotation.PostConstruct; import javax.enterprise.context.RequestScoped; import javax.inject.Inject; import javax.mvc.Controller; import javax.transaction.Transactional; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.NotFoundException; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; /** * Controller for the pages MVC application responsible for initializing the * models used by themes for displaying the pages. * * This controller is the main entry point for the Pages application, which is * the primary entry point for most public sites powered by LibreCMS. Based on * the provided path it looks up the matching category and initalizes the models * used by the themes to create the public pages. The application is ,as most * other frontends of LibreCCM/LibreCMS, based on the EE MVC framework. * * In the following description, {placeholder} stands for a * placeholder for a parameter. The placeholder {path} stands for * the requested path. The path might be empty, in this case the root category * of the category system associated with the instance of the pages application * is used. * * Depending on the provided path in the URL this controller does the following: * * * * In the last two cases this controller will initialize the basic models to * provide soem data for displaying theme, and delegate to the theme by calling * {@link ThemesMvc#getMvcTemplate(javax.ws.rs.core.UriInfo, java.lang.String, java.lang.String)}. * {@link ThemesMvc} will determine the theme to use from the requested URL. For * details please refer to the documentation of {@link ThemesMvc}. * * The following models are initialized by this controller: * * * The language to use is determined using the following algorithm: *
    *
  1. * Check if one of the languages sent by the user agent in the * Accept-Language header is a supported language (see * {@link KernelConfig#supportedLanguages}. If not fall back to the default * language (see {@link KernelConfig#defaultLanguage}. *
  2. *
  3. * Check if the requested item is available in the selected language, or if a * category without an index item is requested, if the category is avaiable for * the selected language. If yes use the selected language otherwise fallback to * the default default configured in {@link KernelConfig#defaultLanguage}. *
  4. *
* * @author Jens Pelzetter */ @Controller @RequestScoped @Path("/") public class PagesController { @Inject private CategoryManager categoryManager; @Inject private CategoryModel categoryModel; @Inject private CategoryRepository categoryRepo; @Inject private GlobalizationHelper globalizationHelper; @Inject private ConfigurationManager confManager; @Inject private ContentItemManager contentItemManager; @Inject private ContentItemL10NManager contentItemL10NManager; @Inject private ContentItemModel contentItemModel; @Inject private PageManager pageManager; @Inject private PagePropertiesModel pagePropertiesModel; @Inject private PageUrlModel pageUrlModel; @Inject private PagesRepository pagesRepo; @Inject private PagesService pagesService; @Inject private SiteInfoModel siteInfoModel; @Inject private SiteRepository siteRepo; @Inject private Themes themes; @Inject private ThemesMvc themesMvc; private Locale defaultLocale; @PostConstruct private void init() { final KernelConfig kernelConfig = confManager .findConfiguration(KernelConfig.class); defaultLocale = kernelConfig.getDefaultLocale(); } @GET @Path("/") @Transactional(Transactional.TxType.REQUIRED) public Response redirectToIndexPage( @Context final UriInfo uriInfo, @QueryParam("theme") @DefaultValue(ThemesMvc.DEFAULT_THEME_PARAM) final String theme, @QueryParam("preview") @DefaultValue("") final String preview ) { final String domain = uriInfo.getBaseUri().getHost(); final Pages pages = getPages(domain); final Category category = getCategory(domain, pages, "/"); final Versions versions = generateFromPreviewParam(preview); final String language = determineLanguage(category, versions); categoryModel.init( pages.getCategoryDomain(), category, generateFromPreviewParam(preview).getContentItemVersion() ); pageUrlModel.init(uriInfo); final String indexPage = String.format( "/index.%s.html%s", language, buildQueryParamsStr(preview, theme) ); final URI uri = uriInfo.getBaseUriBuilder().path(indexPage).build(); return Response.temporaryRedirect(uri).build(); } @GET @Path("/{name:[\\w\\-]+}") @Transactional(Transactional.TxType.REQUIRED) public Response getRootPage( @Context final UriInfo uriInfo, @PathParam("name") final String itemName, @QueryParam("theme") @DefaultValue(ThemesMvc.DEFAULT_THEME_PARAM) final String theme, @QueryParam("preview") @DefaultValue("") final String preview ) { final String domain = uriInfo.getBaseUri().getHost(); final Pages pages = getPages(domain); final Category category = getCategory(domain, pages, "/"); final Versions versions = generateFromPreviewParam(preview); final String language = determineLanguage(category, versions); categoryModel.init( pages.getCategoryDomain(), category, generateFromPreviewParam(preview).getContentItemVersion() ); pageUrlModel.init(uriInfo); final String itemPage = String.format( "/%s.%s.html%s", itemName, language, buildQueryParamsStr(preview, theme) ); final URI uri = uriInfo.getBaseUriBuilder().path(itemPage).build(); return Response.temporaryRedirect(uri).build(); } @GET @Path("/{name:[\\w\\-]+}.html") @Transactional(Transactional.TxType.REQUIRED) public Response getRootPageAsHtml( @Context final UriInfo uriInfo, @QueryParam("theme") @DefaultValue(ThemesMvc.DEFAULT_THEME_PARAM) final String theme, @QueryParam("preview") @DefaultValue("") final String preview, @PathParam("name") final String itemName ) { final String domain = uriInfo.getBaseUri().getHost(); final Pages pages = getPages(domain); final Category category = getCategory(domain, pages, "/"); final Versions versions = generateFromPreviewParam(preview); final String language = determineLanguage(category, itemName, versions); categoryModel.init( pages.getCategoryDomain(), category, generateFromPreviewParam(preview).getContentItemVersion() ); pageUrlModel.init(uriInfo); final String itemPage = String.format( "/%s.%s.html", itemName, language ); final String path = uriInfo .getPath() .replace(String.format("%s.html", itemName), itemPage); final URI uri = uriInfo.getBaseUriBuilder().path(path).build(); return Response.temporaryRedirect(uri).build(); } @GET @Path("/{name:[\\w\\-]+}.{lang:\\w+}.html") @Produces("text/html") @Transactional(Transactional.TxType.REQUIRED) public String getRootPageAsHtml( @Context final UriInfo uriInfo, @PathParam("name") final String itemName, @PathParam("lang") final String language, @QueryParam("theme") @DefaultValue("--DEFAULT--") final String theme, @QueryParam("preview") @DefaultValue("") final String preview ) { // final Versions versions = generateFromPreviewParam(preview); // // globalizationHelper.setSelectedLocale(new Locale(language)); // // contentItemModel.setItemName(itemName); // contentItemModel.setItemVersion(versions.getContentItemVersion()); // // final String domain = uriInfo.getBaseUri().getHost(); // final Pages pages = getPages(domain); // final Site site = pages.getSite(); // siteInfoModel.setAvailableLanguages( // confManager // .findConfiguration(KernelConfig.class) // .getSupportedLanguages() // .stream() // .sorted() // .collect(Collectors.toList()) // ); // siteInfoModel.setDomain(site.getDomainOfSite()); // siteInfoModel.setHost(domain); // siteInfoModel.setName( // Optional // .ofNullable(site.getDisplayName()) // .orElse("") // ); // final Category category = getCategory(domain, pages, "/"); // categoryModel.init(pages.getCategoryDomain(), category); // final Page page = pageManager.findPageForCategory(category); // pagePropertiesModel.setProperties(page.getProperties()); // return themesMvc.getMvcTemplate( // uriInfo, "pages", page.getDisplayName() // ); return getPageAsHtml( uriInfo, "/", itemName, language, theme, preview ); } /** * Retrieve the item page for a category and the content item associated * with the category and identified by {@code itemName}. * * Redirects to * {@link #getPageAsHtml(javax.ws.rs.core.UriInfo, java.lang.String, java.lang.String)}. * * @param uriInfo * @param page * @param itemName * @param theme * @param preview * * @return */ @GET @Path("/{page:[\\w\\-/]+}/{name:[\\w\\-]+}") @Transactional(Transactional.TxType.REQUIRED) public Response getPage( @Context final UriInfo uriInfo, @PathParam("page") final String page, @PathParam("name") final String itemName, @QueryParam("theme") @DefaultValue("--DEFAULT--") final String theme, @QueryParam("preview") @DefaultValue("") final String preview ) { final String domain = uriInfo.getBaseUri().getHost(); final Pages pages = getPages(domain); final Category category = getCategory(domain, pages, page); final Versions versions = generateFromPreviewParam(preview); final String language = determineLanguage(category, versions); pageUrlModel.init(uriInfo); final String redirectTo; if (uriInfo.getPath().endsWith("/")) { redirectTo = String.format( "%sindex.%s.html", uriInfo.getPath(), language ); } else { final String itemPath = String.format( "%s.%s.html%s", itemName, language, buildQueryParamsStr(preview, theme) ); redirectTo = uriInfo.getPath().replace(itemName, itemPath); } final URI uri = uriInfo.getBaseUriBuilder().path(redirectTo).build(); return Response.temporaryRedirect(uri).build(); } /** * Retrieve the item page for a category and the content item associated * with the category and identified by {@code itemName}. Redirects to * {@link #getPageAsHtml(javax.ws.rs.core.UriInfo, java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String)}. * * @param uriInfo * @param page * @param itemName * @param theme * @param preview * * @return */ @GET @Path("/{page:[\\w\\-/]+}/{name:[\\w\\-]+}.html") @Transactional(Transactional.TxType.REQUIRED) public Response getPageAsHtml( @Context final UriInfo uriInfo, @PathParam("page") final String page, @PathParam("name") final String itemName, @QueryParam("theme") @DefaultValue("--DEFAULT--") final String theme, @QueryParam("preview") @DefaultValue("") final String preview ) { //ToDo Check! final String domain = uriInfo.getBaseUri().getHost(); final Pages pages = getPages(domain); final Category category = getCategory(domain, pages, page); final Versions versions = generateFromPreviewParam(preview); final String language = determineLanguage(category, versions); pageUrlModel.init(uriInfo); final String redirectTo; if (uriInfo.getPath().endsWith("/")) { redirectTo = String.format( "%sindex.%s.html%s", uriInfo.getPath(), language, buildQueryParamsStr(preview, theme) ); } else { final String itemPath = String.format( "%s.%s.html%s", itemName, language, buildQueryParamsStr(preview, theme) ); redirectTo = uriInfo.getPath().replace(itemName, itemPath); } final URI uri = uriInfo.getBaseUriBuilder().path(redirectTo).build(); return Response.temporaryRedirect(uri).build(); } /** * Retrieve the item page as HTML for a category and the content item * associated with the category and identified by {@code itemName}. * * @param uriInfo * @param pagePath * @param itemName * @param language * @param theme * @param preview * * @return */ @GET @Path("/{pagePath:[\\w\\-/]+}/{name:[\\w\\-]+}.{lang:\\w+}.html") @Produces("text/html") @Transactional(Transactional.TxType.REQUIRED) public String getPageAsHtml( @Context final UriInfo uriInfo, @PathParam("pagePath") final String pagePath, @PathParam("name") final String itemName, @PathParam("lang") final String language, @QueryParam("theme") @DefaultValue("--DEFAULT--") final String theme, @QueryParam("preview") @DefaultValue("") final String preview ) { final Versions versions = generateFromPreviewParam(preview); globalizationHelper.setSelectedLocale(new Locale(language)); contentItemModel.setItemName(itemName); final ContentItemVersion version = versions.getContentItemVersion(); contentItemModel.setItemVersion(versions.getContentItemVersion()); final String domain = uriInfo.getBaseUri().getHost(); final Pages pages = getPages(domain); final Site site = pages.getSite(); siteInfoModel.setAvailableLanguages( confManager .findConfiguration(KernelConfig.class) .getSupportedLanguages() .stream() .sorted() .collect(Collectors.toList()) ); siteInfoModel.setDomain(site.getDomainOfSite()); siteInfoModel.setHost(domain); siteInfoModel.setName( Optional .ofNullable(site.getDisplayName()) .orElse("") ); final Category category = getCategory(domain, pages, pagePath); categoryModel.init(pages.getCategoryDomain(), category, version); pageUrlModel.init(uriInfo); final Page page = pageManager.findPageForCategory(category); pagePropertiesModel.setProperties(page.getProperties()); if (itemName.equals("index") || itemName.isBlank()) { return themesMvc.getMvcTemplate( uriInfo, "pages", page.getDisplayName() ); } else { return themesMvc.getMvcTemplate( uriInfo, "pages", "item-page" ); } } private void initSiteInfoModel( final Site site, final String host ) { siteInfoModel.setAvailableLanguages( confManager .findConfiguration(KernelConfig.class) .getSupportedLanguages() .stream() .sorted() .collect(Collectors.toList()) ); siteInfoModel.setDomain(site.getDomainOfSite()); siteInfoModel.setHost(host); siteInfoModel.setName( Optional .ofNullable(site.getDisplayName()) .orElse("") ); } private Site getSite(final UriInfo uriInfo) { final String domain = Objects .requireNonNull(uriInfo) .getBaseUri() .getHost(); final Site site; if (siteRepo.hasSiteForDomain(domain)) { site = siteRepo.findByDomain(domain).get(); } else { site = siteRepo .findDefaultSite() .orElseThrow( () -> new NotFoundException( "No matching Site and no default Site." ) ); } return site; } private Pages getPages(final String domain) { return pagesRepo .findPagesForSite(domain) .orElseThrow( () -> new NotFoundException( String.format( "No Pages for domain \"%s\" available.", domain ) ) ); } private Category getCategory( final String domain, final Pages pages, final String pagePath ) { return categoryRepo .findByPath(pages.getCategoryDomain(), pagePath) .orElseThrow( () -> new NotFoundException( String.format( "No Page for path \"%s\" in site \"%s\"", pagePath, domain ) ) ); } private String determineLanguage( final Category category, final Versions versions ) { final Locale negoiatedLocale = globalizationHelper .getNegotiatedLocale(); if (categoryManager.hasIndexObject(category)) { final ContentItem indexItem = getIndexObject(category, versions) .get(); if (contentItemL10NManager.hasLanguage(indexItem, negoiatedLocale)) { return negoiatedLocale.toString(); } else { return confManager .findConfiguration(KernelConfig.class) .getDefaultLanguage(); } } else { if (category.getTitle().hasValue(negoiatedLocale)) { return negoiatedLocale.toString(); } else { return defaultLocale.toString(); } } } private String determineLanguage( final Category category, final String itemName, final Versions versions ) { final Locale negoiatedLocale = globalizationHelper .getNegotiatedLocale(); final ContentItem contentItem = pagesService.findCategorizedItem( category, itemName, versions.getContentItemVersion() ) .orElseThrow( () -> new NotFoundException( String.format( "No item %s found in category %s.", itemName, categoryManager.getCategoryPath(category) ) ) ); final KernelConfig kernelConfig = confManager.findConfiguration( KernelConfig.class ); if (contentItemL10NManager.hasLanguage(contentItem, negoiatedLocale)) { return negoiatedLocale.toString(); } else if (contentItemL10NManager.hasLanguage(contentItem, kernelConfig .getDefaultLocale())) { return kernelConfig.getDefaultLanguage(); } else { throw new NotFoundException( String.format( "No item %s found in category %s.", itemName, categoryManager.getCategoryPath(category) ) ); } } private String buildQueryParamsStr( final String previewParam, final String themeParam ) { final String queryString = List .of( Optional .of(themeParam) .filter(String::isBlank) .map(param -> String.format("theme=%s", param)) .orElse(""), Optional .of(previewParam) .filter(String::isBlank) .map(param -> String.format("preview=%s", param)) .orElse("") ) .stream() .filter(String::isBlank) .collect(Collectors.joining("&", "?", "")); if (queryString.length() <= 1) { return ""; } return queryString; } private ThemeInfo getTheme( final Site site, final String theme, final ThemeVersion themeVersion ) { if ("--DEFAULT--".equals(theme)) { return themes .getTheme(site.getDefaultTheme(), themeVersion) .orElseThrow( () -> new WebApplicationException( String.format( "The configured default theme \"%s\" for " + "site \"%s\" is not available.", site.getDefaultTheme(), site.getDomainOfSite() ), Response.Status.INTERNAL_SERVER_ERROR ) ); } else { return themes.getTheme(theme, themeVersion) .orElseThrow( () -> new WebApplicationException( String.format( "The theme \"%s\" is not available.", theme ), Response.Status.BAD_REQUEST ) ); } } private Optional getIndexObject( final Category category, final Versions versions ) { final Predicate filter; if (versions.getContentItemVersion() == ContentItemVersion.DRAFT) { filter = (ContentItem item) -> !contentItemManager.isLive(item); } else { filter = (ContentItem item) -> contentItemManager.isLive(item); } return categoryManager .getIndexObject(category) .stream() .filter(object -> object instanceof ContentItem) .map(object -> (ContentItem) object) .filter(filter) .findFirst(); } /** * Parse the value of the {@code preview} query parameter. * * @param value The value of the {@code preview} query parameter to parse. * * @return If the provided value is {@code all} a {@link Versions} object * with all versions set to the draft versions is created and * returned. If the provided value is {@code null} or empty a * {@link Versions} object with fields set the the live versions is * returned. Otherwise the values is split into tokens (separated by * commas). The values of the returned {@link Versions} depend on * presence of certain tokens. At the moment to following tokens are * recognised: *
*
{@code content}
*
{@link Versions#contentVersion} is set to * {@link ContentItemVersion#DRAFT}
*
{@code theme}
*
{@link Versions#themeVersion} is set to * {@link ThemeVersion#DRAFT}.
*
* */ private Versions generateFromPreviewParam(final String value) { if (value == null || value.isEmpty() || value.matches("\\s*")) { return new Versions(ContentItemVersion.LIVE, ThemeVersion.LIVE); } else if ("all".equals(value.toLowerCase(Locale.ROOT))) { return new Versions(ContentItemVersion.DRAFT, ThemeVersion.DRAFT); } else { final Set values = new HashSet<>(); Collections.addAll(values, value.toLowerCase(Locale.ROOT).split(",")); final Versions result = new Versions(); if (values.contains("content")) { result.setContentVersion(ContentItemVersion.DRAFT); } if (values.contains("theme")) { result.setThemeVersion(ThemeVersion.DRAFT); } return result; } } // private void pageUrlModel.init(final UriInfo uriInfo) { // pageUrlModel.setBasePath(uriInfo.getBaseUri().toString()); // pageUrlModel.setBaseUri(uriInfo.getBaseUri()); // pageUrlModel.setHost(uriInfo.getRequestUri().getHost()); // // final List pathSegments = uriInfo.getPathSegments(); // if (pathSegments.isEmpty()) { // throw new IllegalArgumentException("No page segements available."); // } // pageUrlModel.setPath( // pathSegments // .subList(0, pathSegments.size()) // .stream() // .map(PathSegment::getPath) // .collect(Collectors.joining("/")) // ); // final String pageSegment = pathSegments // .get(pathSegments.size() - 1) // .getPath(); // final String[] pageTokens = pageSegment.split("\\."); // if (pageTokens.length != 3) { // throw new IllegalArgumentException( // String.format( // "Unexpected number of tokens for page segement of path." // + "Expected 3 tokens, separated by '.', but got %d " // + "tokens.", // pageTokens.length // ) // ); // } // final String pageName = pageTokens[0]; // final String pageLocale = pageTokens[1]; // final String pageFormat = pageTokens[2]; // // pageUrlModel.setPageName(pageName); // pageUrlModel.setPageLocale(pageLocale); // pageUrlModel.setPageFormat(pageFormat); // // pageUrlModel.setPath(uriInfo.getPath()); // pageUrlModel.setPort(uriInfo.getRequestUri().getPort()); // pageUrlModel.setProtocol(uriInfo.getRequestUri().getScheme()); // pageUrlModel.setQueryParameters( // uriInfo // .getQueryParameters() // .entrySet() // .stream() // .collect( // Collectors.toMap( // entry -> entry.getKey(), // entry -> entry.getValue().get(0) // ) // ) // ); // } /** * Encapsulate the result of converting the value of the {@code preview} * query parameter. */ private class Versions { /** * Version of content to use */ private ContentItemVersion contentVersion; /** * Version of theme to use. */ private ThemeVersion themeVersion; /** * Creates a new {@code Versions} object with all fields set to * {@code live} versions. */ public Versions() { this.contentVersion = ContentItemVersion.LIVE; this.themeVersion = ThemeVersion.LIVE; } /** * Create a new {@code Versions} object with the provided parameters. * * @param contentVersion * @param themeVersion */ public Versions( final ContentItemVersion contentVersion, final ThemeVersion themeVersion ) { this.contentVersion = contentVersion; this.themeVersion = themeVersion; } public ContentItemVersion getContentItemVersion() { return contentVersion; } public void setContentVersion(final ContentItemVersion contentVersion) { this.contentVersion = contentVersion; } public ThemeVersion getThemeVersion() { return themeVersion; } public void setThemeVersion(final ThemeVersion themeVersion) { this.themeVersion = themeVersion; } } }