libreccm/ccm-cms/src/main/java/org/librecms/dispatcher/MultilingualItemResolver.java

800 lines
29 KiB
Java

/*
* Copyright (C) 2017 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.dispatcher;
import com.arsdigita.bebop.PageState;
import com.arsdigita.cms.CMS;
import com.arsdigita.cms.dispatcher.CMSDispatcher;
import com.arsdigita.cms.dispatcher.CMSPage;
import com.arsdigita.cms.dispatcher.MasterPage;
import com.arsdigita.cms.ui.ContentItemPage;
import com.arsdigita.kernel.KernelConfig;
import com.arsdigita.util.Assert;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.libreccm.core.CcmObject;
import org.librecms.CmsConstants;
import org.librecms.contentsection.ContentItem;
import org.librecms.contentsection.ContentItemManager;
import org.librecms.contentsection.ContentItemRepository;
import org.librecms.contentsection.ContentItemVersion;
import org.librecms.contentsection.ContentSection;
import org.librecms.contentsection.Folder;
import org.librecms.contentsection.FolderManager;
import org.librecms.contentsection.FolderRepository;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.StringTokenizer;
import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.transaction.Transactional;
/**
* Resolves items to URLs and URLs to items for multiple language variants.
*
* For version 7.0.0 this call has been moved from the
* {@code com.arsdigita.cms.dispatcher} package to the
* {@code org.librecms.dispatcher} package and refactored to an CDI bean. This
* was necessary to avoid several problems when accessing the entity beans for
* {@link Category} and {@link ContentItem}, primarily the infamous
* {@code LazyInitializationException}. Also several checks for null parameters
* were added to avoid {@code NullPointerExcpetions}.
*
* <strong>
* AS of version 7.0.0 this class not longer part of the public API. It is left
* here to keep the changes to the UI classes as minimal as possible. For new
* code other methods, for example from the {@link ContentItemManager} or the
* {@link ContentItemRepository} should be used. Because this class is no longer
* part of the public API the will might be removed or changed in future
* releases without prior warning.
* </strong>
*
*
*
* @author <a href="mailto:mhanisch@redhat.com">Michael Hanisch</a>
* @author <a href="mailto:jens.pelzetter@googlemail.com">Jens Pelzetter</a>
*/
@RequestScoped
public class MultilingualItemResolver implements ItemResolver {
private static final Logger LOGGER = LogManager.getLogger(
MultilingualItemResolver.class);
private static final String ADMIN_PREFIX = "admin";
/**
* The string identifying an item's ID in the query string of a URL.
*/
protected static final String ITEM_ID = "item_id";
/**
* The separator used in URL query strings; should be either "&" or ";".
*/
protected static final String SEPARATOR = "&";
@Inject
private FolderRepository folderRepo;
@Inject
private FolderManager folderManager;
@Inject
private ContentItemRepository itemRepo;
@Inject
private ContentItemManager itemManager;
/**
* Returns a content item based on section, url, and use context.
*
* @param section The current content section
* @param itemUrl The section-relative URL
* @param context The use context, e.g. <code>ContentItem.LIVE</code>,
* <code>CMSDispatcher.PREVIEW</code> or
* <code>ContentItem.DRAFT</code>. See {@link
* #getCurrentContext}.
*
* @return The content item, or null if no such item exists
*/
@Transactional(Transactional.TxType.REQUIRED)
@Override
public ContentItem getItem(final ContentSection section,
final String itemUrl,
final String context) {
if (section == null) {
throw new IllegalArgumentException(
"Can't get item from section null.");
}
if (itemUrl == null) {
throw new IllegalArgumentException("Can't get item for URL null.");
}
if (context == null) {
throw new IllegalArgumentException(
"Can't get item for context null.");
}
LOGGER.debug("Resolving the item in content section \"{}\" at URL "
+ "\"{}\" for context \"{}\"...",
section.getLabel(),
itemUrl,
context);
final Folder rootFolder = section.getRootDocumentsFolder();
String url = stripTemplateFromURL(itemUrl);
if (rootFolder == null) {
// nothing to do, if root folder is null
LOGGER.debug("The root folder is null; returning no item");
} else {
LOGGER.debug("Using root folder {}...",
Objects.toString(rootFolder));
if (ContentItemVersion.LIVE.toString().equals(context)) {
LOGGER.debug("The use context is 'live'");
LOGGER.debug("The root folder has a live version; recursing");
final String prefix = String.join(
"",
section.getPrimaryUrl(),
folderManager.getFolderPath(rootFolder));
if (url.startsWith(prefix)) {
LOGGER.
debug("The starts with prefix \"{}\"; removing it...",
prefix);
url = url.substring(prefix.length());
}
final ContentItem item = getItemFromLiveURL(url, rootFolder);
LOGGER.debug("Resolved URL \"{}\" to item {}...",
url,
Objects.toString(item));
return item;
} else if (ContentItemVersion.DRAFT.toString().equals(context)) {
LOGGER.debug("The use context is 'draft'");
// For 'draft' items, 'generateUrl()' method returns
// URL like this
// '/acs/wcms/admin/item.jsp?item_id=10201&set_tab=1'
// Check if URL contains any match of string
// 'item_id', then try to instanciate item_id value
// and return FIXME: Please hack this if there is
// more graceful solution. [aavetyan]
if (Assert.isEnabled()) {
Assert.isTrue(url.contains(ITEM_ID),
String.format("url must contain parameter %s",
ITEM_ID));
}
final ContentItem item = getItemFromDraftURL(url);
LOGGER.debug("Resolved URL \"{}\" to item {}.",
url,
Objects.toString(item));
return item;
} else if (CMSDispatcher.PREVIEW.equals(context)) {
LOGGER.debug("The use context is 'preview'");
final String prefix = CMSDispatcher.PREVIEW + "/";
if (url.startsWith(prefix)) {
LOGGER.debug(
"The URL starts with prefix \"{}\"; removing it",
prefix);
url = url.substring(prefix.length());
}
final ContentItem item = getItemFromLiveURL(url, rootFolder);
LOGGER.debug("Resolved URL \"{}\" to item {}.",
url,
Objects.toString(item));
return item;
} else {
throw new IllegalArgumentException(String.format(
"Invalid item resolver context \"%s\".", context));
}
}
LOGGER.debug("No item resolved; returning null");
return null;
}
/**
* Fetches the current context based on the page state.
*
* @param state the current page state
*
* @return the context of the current URL, such as
* <code>ContentItem.LIVE</code> or <code>ContentItem.DRAFT</code>
*
* @see ContentItem#LIVE
* @see ContentItem#DRAFT
*
* ToDo: Refactor to use the {@link ContentItemVersion} directly.
*/
@Transactional(Transactional.TxType.REQUIRED)
@Override
public String getCurrentContext(final PageState state) {
LOGGER.debug("Getting the current context");
// XXX need to use Web.getWebContext().getRequestURL() here.
String url = state.getRequest().getRequestURI();
final ContentSection section = CMS.getContext().getContentSection();
// If this page is associated with a content section,
// transform the URL so that it is relative to the content
// section site node.
if (section != null) {
final String sectionURL = section.getPrimaryUrl();
if (url.startsWith(sectionURL)) {
url = url.substring(sectionURL.length());
}
}
// Remove any template-specific URL components (will only work
// if they're first in the URL at this point; verify). XXX but
// we don't actually verify?
url = stripTemplateFromURL(url);
// Determine if we are under the admin UI.
if (url.startsWith(ADMIN_PREFIX)
|| url.startsWith(CmsConstants.CONTENT_CENTER_URL)) {
return ContentItemVersion.DRAFT.toString();
} else {
return ContentItemVersion.LIVE.toString();
}
}
/**
* Generates a URL for a content item.
*
* @param itemId The item ID
* @param name The name of the content page
* @param state The page state
* @param section the content section to which the item belongs
* @param context the context of the URL, such as "live" or "admin"
*
* @return The URL of the item
*
* @see #getCurrentContext
*/
@Transactional(Transactional.TxType.REQUIRED)
@Override
public String generateItemURL(final PageState state,
final Long itemId,
final String name,
final ContentSection section,
final String context) {
return generateItemURL(state, itemId, name, section, context, null);
}
/**
* Generates a URL for a content item.
*
* @param itemId The item ID
* @param name The name of the content page
* @param state The page state
* @param section the content section to which the item belongs
* @param context the context of the URL, such as "live" or "admin"
* @param templateContext the context for the URL, such as "public"
*
* @return The URL of the item
*
* @see #getCurrentContext
*/
@Transactional(Transactional.TxType.REQUIRED)
@Override
public String generateItemURL(final PageState state,
final Long itemId,
final String name,
final ContentSection section,
final String context,
final String templateContext) {
if (itemId == null) {
throw new IllegalArgumentException(
"Can't generate item URL for item id null.");
}
if (context == null) {
throw new IllegalArgumentException(
"Can't generate item URL for context null.");
}
if (section == null) {
throw new IllegalArgumentException(
"Can't generate item URL for section null.");
}
LOGGER.debug("Generating an item URL for item id {}, section \"{}\" "
+ "and context \"{}\" with name \"{}\"...",
itemId,
section.getLabel(),
context,
name);
if (ContentItemVersion.DRAFT.toString().equals(context)) {
// No template context here.
return generateDraftURL(section, itemId);
} else if (CMSDispatcher.PREVIEW.equals(context)) {
final ContentItem item = itemRepo.findById(itemId).get();
return generatePreviewURL(section, item, templateContext);
} else if (ContentItemVersion.LIVE.toString().equals(context)) {
final ContentItem item = itemRepo.findById(itemId).get();
return generateLiveURL(section, item, templateContext);
} else {
throw new IllegalArgumentException("Unknown context '" + context
+ "'");
}
}
/**
* Generates a URL for a content item.
*
* @param item The item
* @param state The page state
* @param section the content section to which the item belongs
* @param context the context of the URL, such as "live" or "admin"
*
* @return The URL of the item
*
* @see #getCurrentContext
*/
@Transactional(Transactional.TxType.REQUIRED)
@Override
public String generateItemURL(final PageState state,
final ContentItem item,
final ContentSection section,
final String context) {
return generateItemURL(state, item, section, context, null);
}
/**
* Generates a URL for a content item.
*
* @param item The item
* @param state The page state
* @param section the content section to which the item belongs
* @param context the context of the URL, such as "live" or "admin"
* @param templateContext the context for the URL, such as "public"
*
* @return The URL of the item
*
* @see #getCurrentContext
*/
@Transactional(Transactional.TxType.REQUIRED)
@Override
public String generateItemURL(final PageState state,
final ContentItem item,
final ContentSection section,
final String context,
final String templateContext) {
if (item == null) {
throw new IllegalArgumentException(
"Can't generate URL for item null.");
}
if (context == null) {
throw new IllegalArgumentException(
"Can't generate URL for context null.");
}
final ContentSection contentSection;
if (section == null) {
contentSection = item.getContentType().getContentSection();
} else {
contentSection = section;
}
LOGGER.debug("Generating an item URL for item \"{}\", section \"{}\" "
+ "and context \"{}\".",
item.getDisplayName(),
contentSection.getLabel(),
context);
if (ContentItemVersion.DRAFT.toString().equals(context)) {
return generateDraftURL(section, item.getObjectId());
} else if (CMSDispatcher.PREVIEW.equals(context)) {
return generatePreviewURL(section, item, templateContext);
} else if (ContentItemVersion.LIVE.toString().equals(context)) {
return generateLiveURL(contentSection, item, templateContext);
} else {
throw new IllegalArgumentException(String.format(
"Unknown context \"%s\".", context));
}
}
@Transactional(Transactional.TxType.REQUIRED)
@Override
public CMSPage getMasterPage(final ContentItem item,
final HttpServletRequest request)
throws ServletException {
LOGGER.debug("Getting the master page for item {}",
item.getDisplayName());
final MasterPage masterPage = new MasterPage();
masterPage.init();
return masterPage;
}
/**
* Returns content item's draft version URL
*
* @param section The content section to which the item belongs
* @param itemId The content item's ID
*
* @return generated URL string
*/
protected String generateDraftURL(final ContentSection section,
final Long itemId) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Generating draft URL for item ID " + itemId
+ " and section " + section);
}
if (Assert.isEnabled()) {
Assert.isTrue(section != null && itemId != null,
"get draft url: neither secion nor item "
+ "can be null");
}
final String url = ContentItemPage.getItemURL(
String.format("%s/", section.getPrimaryUrl()),
itemId,
ContentItemPage.AUTHORING_TAB);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Generated draft URL " + url);
}
return url;
}
/**
* Generate a <em>language-independent</em> URL to the <code>item</code> in
* the given section.<p>
* When a client retrieves this URL, the URL is resolved to point to a
* specific language instance of the item referenced here, i.e. this URL
* will be resolved to a <em>language-specific</em> URL internally.
*
* @param section the <code>ContentSection</code> that contains this
* item
* @param item <code>ContentItem</code> for which a URL should be
* constructed.
* @param templateContext template context; will be ignored if
* <code>null</code>
*
* @return a <em>language-independent</em> URL to the <code>item</code> in
* the given <code>section</code>, which will be presented within
* the given <code>templateContext</code>
*/
protected String generateLiveURL(final ContentSection section,
final ContentItem item,
final String templateContext) {
LOGGER.debug("Generating live URL for item {} in section {}",
Objects.toString(item),
Objects.toString(section));
/*
* URL = URL of section + templateContext + path to the ContentBundle
* which contains the item
*/
final StringBuffer url = new StringBuffer(400);
//url.append(section.getURL());
url
.append(section.getPrimaryUrl())
.append("/");
/*
* add template context, if one is given
*/
// This is breaking URL's...not sure why it's here. XXX
// this should work with the appropriate logic. trying again.
if (!(templateContext == null || templateContext.length() == 0)) {
url
.append(TEMPLATE_CONTEXT_PREFIX)
.append(templateContext)
.append("/");
}
url.append(itemManager.getItemPath(item));
return url.toString();
}
/**
* Generate a URL which can be used to preview the <code>item</code>, using
* the given <code>templateContext</code>.<p>
* Only a specific language instance can be previewed, meaning there
* <em>no</em> language negotiation is involved when a request is made to a
* URL that has been generated by this method.
*
* @param section The <code>ContentSection</code> which contains the
* <code>item</code>
* @param item The <code>ContentItem</code> for which a URL
* should be generated.
* @param templateContext the context that determines which template should
* render the item when it is previewed; ignored if
* the argument given here is <code>null</code>
*
* @return a URL which can be used to preview the given <code>item</code>
*/
protected String generatePreviewURL(final ContentSection section,
final ContentItem item,
final String templateContext) {
Assert.exists(section, "ContentSection section");
Assert.exists(item, "ContentItem item");
final StringBuffer url = new StringBuffer(100);
url
.append(section.getPrimaryUrl())
.append("/")
.append(CMSDispatcher.PREVIEW)
.append("/");
/*
* add template context, if one is given
*/
// This is breaking URL's...not sure why it's here. XXX
// this should work with the appropriate logic. trying again.
if (!(templateContext == null || templateContext.length() == 0)) {
url
.append(TEMPLATE_CONTEXT_PREFIX)
.append(templateContext)
.append("/");
}
url.append(itemManager.getItemPath(item));
return url.toString();
}
/**
* Retrieves <code>ITEM_ID</code> parameter from URL and instantiates item
* according to this ID.
*
* @param url URL that indicates which item should be retrieved; must
* contain the <code>ITEM_ID</code> parameter
*
* @return the <code>ContentItem</code> the given <code>url</code> points
* to, or <code>null</code> if no ID has been found in the
* <code>url</code>
*/
protected ContentItem getItemFromDraftURL(final String url) {
LOGGER.debug("Looking up the item from draft URL ", url);
int pos = url.indexOf(ITEM_ID);
String item_id = url.substring(pos); // item_id == ITEM_ID=.... ?
pos = item_id.indexOf("="); // should be exactly after the ITEM_ID string
if (pos != ITEM_ID.length()) {
// item_id seems to be something like ITEM_IDFOO=
LOGGER.debug("No suitable item_id parameter found; returning null");
return null; // no ID found
}
pos++; // skip the "="
// ID is the string between the equal (at pos) and the next separator
int i = item_id.indexOf(SEPARATOR);
item_id = item_id.substring(pos, Math.min(i, item_id.length() - 1));
LOGGER.debug("Looking up item using item ID {}", item_id);
final Optional<ContentItem> item = itemRepo.findById(Long.parseLong(
item_id));
if (item.isPresent()) {
LOGGER.debug("Returning item {}", Objects.toString(item));
return item.get();
} else {
return null;
}
}
/**
* Returns a content item based on URL relative to the root folder.
*
* @param url The content item url
* @param parentFolder The parent folder object, url must be relevant to it
*
* @return The Content Item instance, it can also be either Bundle or Folder
* objects, depending on URL and file language extension
*/
protected ContentItem getItemFromLiveURL(final String url,
final Folder parentFolder) {
if (parentFolder == null || url == null || url.equals("")) {
LOGGER.debug("The url is null or parent folder was null "
+ "or something else is wrong, so just return "
+ "null.");
return null;
}
LOGGER.debug("Resolving the item for live URL {}"
+ " and parent folder {}...",
url,
parentFolder.getName());
int len = url.length();
int index = url.indexOf('/');
if (index >= 0) {
LOGGER.debug("The URL starts with a slash; paring off the first "
+ "URL element and recursing");
final String liveUrl = index + 1 < len ? url.substring(index + 1)
: "";
return getItemFromLiveURL(liveUrl, parentFolder);
} else {
LOGGER.debug("Found a file element in the URL");
final String[] nameAndLang = getNameAndLangFromURLFrag(url);
final String name = nameAndLang[0];
final Optional<ContentItem> item = itemRepo.findByNameInFolder(
parentFolder, name);
if (item.isPresent()) {
return item.get();
} else {
return null;
}
}
}
/**
* Returns an array containing the the item's name and lang based on the URL
* fragment.
*
* @param url
*
* @return a two-element string array, the first element containing the
* bundle name, and the second element containing the lang string
*/
protected String[] getNameAndLangFromURLFrag(final String url) {
String name;
String lang = null;
/*
* Try to find out if there's an extension with the language code
* 1 Get a list of all "extensions", i.e. parts of the url
* which are separated by colons
* 2 If one or more extensions have been found, compare them against
* the list of known language codes
* 2a if a match is found, this language is used to retrieve an instance
* from a bundle
* 2b if no match is found
*/
final List<String> exts = new ArrayList<>(5);
final StringTokenizer tok = new StringTokenizer(url, ".");
while (tok.hasMoreTokens()) {
exts.add(tok.nextToken());
}
if (exts.size() > 0) {
LOGGER.debug("Found some file extensions to look at; they "
+ "are {}",
exts);
/*
* We have found at least one extension, so we can
* proceed. Now try to find out if one of the
* extensions looks like a language code (we only
* support 2-letter language codes!).
* If so, use this as the language to look for.
*/
/*
* First element is the NAME of the item, not an extension!
*/
name = exts.get(0);
String ext;
final Collection<String> supportedLangs = KernelConfig.getConfig()
.getSupportedLanguages();
Iterator<String> supportedLangIt;
for (int i = 1; i < exts.size(); i++) {
ext = exts.get(i);
LOGGER.debug("Examining extension {}", ext);
/*
* Loop through all extensions, but discard the
* first one, which is the name of the item.
*/
if (ext != null && ext.length() == 2) {
/* Only check extensions consisting of 2
* characters.
*
* Compare current extension with known
* languages; if it matches, we've found the
* language we should use!
*/
supportedLangIt = supportedLangs.iterator();
while (supportedLangIt.hasNext()) {
if (ext.equals(supportedLangIt.next())) {
lang = ext;
LOGGER.debug("Found a match; using "
+ "language {}", lang);
break;
}
}
} else {
LOGGER.debug("Discarding extension {}; it is too short",
ext);
}
}
} else {
LOGGER.debug("The file has no extensions; no language was encoded");
name = url; // no extension, so we just have a name here
lang = null; // no extension, so we cannot guess the language
}
LOGGER.debug("File name resolved to {}", name);
LOGGER.debug("File language resolved to {}", lang);
final String[] returnArray = new String[2];
returnArray[0] = name;
returnArray[1] = lang;
return returnArray;
}
/**
* Finds a language instance of a content item given the bundle, name, and
* lang string. Note: Because there not ContentBundles anymore this method
* simply returns the provided item.
*
* @param lang The lang string from the URL
* @param item The content bundle
*
* @return The negotiated lang instance for the current request.
*/
protected ContentItem getItemFromLangAndBundle(final String lang,
final ContentItem item) {
return item;
}
}