blob: 7ce83543b08aa78fd523fae7bca092d392e8d7fe [file] [log] [blame]
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
******************************************************************************/
package org.apache.sling.scripting.sightly.impl.engine.extension;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.request.RequestPathInfo;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.scripting.sightly.SightlyException;
import org.apache.sling.scripting.sightly.extension.RuntimeExtension;
import org.apache.sling.scripting.sightly.render.RenderContext;
import org.apache.sling.scripting.sightly.render.RuntimeObjectModel;
import org.jetbrains.annotations.NotNull;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Component(
service = RuntimeExtension.class,
property = {
RuntimeExtension.NAME + "=" + RuntimeExtension.URI_MANIPULATION
}
)
public class URIManipulationFilterExtension implements RuntimeExtension {
public static final String SCHEME = "scheme";
public static final String DOMAIN = "domain";
public static final String PATH = "path";
public static final String APPEND_PATH = "appendPath";
public static final String PREPEND_PATH = "prependPath";
public static final String SELECTORS = "selectors";
public static final String ADD_SELECTORS = "addSelectors";
public static final String REMOVE_SELECTORS = "removeSelectors";
public static final String EXTENSION = "extension";
public static final String SUFFIX = "suffix";
public static final String PREPEND_SUFFIX = "prependSuffix";
public static final String APPEND_SUFFIX = "appendSuffix";
public static final String FRAGMENT = "fragment";
public static final String QUERY = "query";
public static final String ADD_QUERY = "addQuery";
public static final String REMOVE_QUERY = "removeQuery";
private static final Logger LOG = LoggerFactory.getLogger(URIManipulationFilterExtension.class);
@Override
@SuppressWarnings("unchecked")
public Object call(RenderContext renderContext, Object... arguments) {
ExtensionUtils.checkArgumentCount(RuntimeExtension.URI_MANIPULATION, arguments, 2);
RuntimeObjectModel runtimeObjectModel = renderContext.getObjectModel();
String uriString = runtimeObjectModel.toString(arguments[0]);
Map<String, Object> options = runtimeObjectModel.toMap(arguments[1]);
try {
URI originalUri = new URI(uriString);
// read in https://docs.oracle.com/javase/7/docs/api/java/net/URI.html in section "Identities"
// which constructors to use for which use case
final URI transformedUri;
final String scheme = getOption(SCHEME, options, originalUri.getScheme(), true);
final String fragment = getOption(FRAGMENT, options, originalUri.getFragment(), false);
// first check which type of URI
if (originalUri.isOpaque()) {
// only scheme and fragment is relevant
transformedUri = new URI(scheme, originalUri.getSchemeSpecificPart(), fragment);
return transformedUri.toString();
} else {
// only server-based authorities are supported
try {
originalUri = originalUri.parseServerAuthority();
} catch (URISyntaxException e) {
LOG.warn("Only server-based authorities are supported for non-opaque URLs");
throw e;
}
final String host = getOption(DOMAIN, options, originalUri.getHost(), true);
final String path = getPath(runtimeObjectModel, originalUri.getPath(), options, scheme != null || host != null);
final String escapedQuery = getEscapedQuery(runtimeObjectModel, originalUri.getRawQuery(), options);
// the URI constructor will escape the % in the query part again, we must revert that
transformedUri = new URI(scheme, originalUri.getUserInfo(), host, originalUri.getPort(), path, escapedQuery,
fragment);
return unescapePercentInQuery(transformedUri.toString());
}
} catch (URISyntaxException e) {
LOG.warn("Cannot manipulate invalid URI '{}'", uriString, e);
}
return uriString;
}
/**
* Decodes the encoding of the percent character itself within the given uri's query i.e. reverts the conversion of {@code %} to
* {@code %25}.
*
* @return the uri with the query partly decoded ({@code %25} decoded to {@code %})
* @see <a href="https://blog.stackhunter.com/2014/03/31/encode-special-characters-java-net-uri/">How to Encode Special Characters in
* Java’s URI Class</a>
* @see <a href="https://stackoverflow.com/q/19917079/5155923">java.net.URI and percent in query parameter value</a>
*/
static String unescapePercentInQuery(String uri) {
String[] parts = uri.split("\\?", 2);
if (parts.length != 2) {
return uri;
}
// separate fragment
String[] suffixParts = parts[1].split("#", 2);
final String suffix;
final String query;
if (suffixParts.length == 2) {
query = suffixParts[0];
suffix = "#" + suffixParts[1];
} else {
query = parts[1];
suffix = "";
}
return parts[0] + "?" + query.replaceAll("%25", "%") + suffix;
}
/**
* Returns a value from a map.
*
* @param key the option name
* @param options the options map
* @param defaultValue the default value to return
* @param useDefaultIfEmpty if set to {@code true}, the {@code defaultValue} will be returned when the map contains the required
* option but its value is the empty string or null
* @return either the value from the map for entry with name or {@code defaultValue}; in case {@code useDefaultIfEmpty} is set to
* {@code false}, returns null in case the entry present in the map but has an empty string or null value
*/
private String getOption(String key, Map<String, Object> options, String defaultValue, boolean useDefaultIfEmpty) {
String value = (String) options.get(key);
if (StringUtils.isNotEmpty(value)) {
return value;
}
if (options.containsKey(key)) {
if (useDefaultIfEmpty) {
return defaultValue;
} else {
return null;
}
}
return defaultValue;
}
static String concatenateWithSlashes(String... pathParts) {
StringBuilder sb = new StringBuilder();
for (String pathPart : pathParts) {
if (StringUtils.isNotBlank(pathPart)) {
if (sb.length() > 0 && !pathPart.startsWith("/") && !sb.toString().endsWith("/")) {
sb.append("/");
}
if (sb.toString().endsWith("/") && pathPart.startsWith("/")) {
sb.append(pathPart.substring(1));
} else {
sb.append(pathPart);
}
}
}
return sb.toString();
}
private String getPath(RuntimeObjectModel runtimeObjectModel, String originalPath, Map<String, Object> options, boolean isAbsolute) {
ModifiableRequestPathInfo requestPathInfo = new ModifiableRequestPathInfo(originalPath);
final String prependPath = getOption(PREPEND_PATH, options, StringUtils.EMPTY, true);
final String path =
getOption(PATH, options, requestPathInfo.getResourcePath(), true); // empty path option should not remove existing path!
final String appendPath = getOption(APPEND_PATH, options, StringUtils.EMPTY, true);
if (!options.containsKey(PATH) && StringUtils.isEmpty(path)) {
// no not prepend/append if path is neither set initially nor through option
LOG.debug("Do not modify path because original path was empty and not set through an option either!");
// dealing with selectors, extension or suffix is not allowed then either
return requestPathInfo.toString();
} else {
String newPath = concatenateWithSlashes(prependPath, path, appendPath);
// do we need to make the path absolute?
if (isAbsolute && !newPath.startsWith("/")) {
newPath = '/' + newPath;
}
// modify resource path
requestPathInfo.setResourcePath(newPath);
}
handleSelectors(runtimeObjectModel, requestPathInfo, options);
// handle extension
String extension = getOption(EXTENSION, options, requestPathInfo.getExtension(), false);
requestPathInfo.setExtension(extension);
// modify suffix!
String prependSuffix = getOption(PREPEND_SUFFIX, options, StringUtils.EMPTY, true);
// remove suffix if option is empty
String suffix = getOption(SUFFIX, options, requestPathInfo.getSuffix(), false);
String appendSuffix = getOption(APPEND_SUFFIX, options, StringUtils.EMPTY, true);
String newSuffix = concatenateWithSlashes(prependSuffix, suffix, appendSuffix);
if (StringUtils.isNotEmpty(newSuffix)) {
// make sure it starts with a slash
if (!newSuffix.startsWith("/")) {
newSuffix = '/' + newSuffix;
}
}
requestPathInfo.setSuffix(newSuffix);
return requestPathInfo.toString();
}
private String getEscapedQuery(RuntimeObjectModel runtimeObjectModel, String originalQuery, Map<String, Object> options) {
// parse parameters
Map<String, Collection<String>> parameters = new LinkedHashMap<>();
if (StringUtils.isNotEmpty(originalQuery)) {
String[] keyValuePairs = originalQuery.split("&");
for (String keyValuePair : keyValuePairs) {
String[] pair = keyValuePair.split("=");
if (pair.length == 2) {
String param;
try {
param = URLDecoder.decode(pair[0], StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
LOG.warn("Could not decode parameter key '{}'", pair[0], e);
continue;
}
String value;
try {
value = URLDecoder.decode(pair[1], StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
LOG.warn("Could not decode parameter value of parameter '{}': '{}'", param, pair[1], e);
continue;
}
Collection<String> values = parameters.get(param);
if (values == null) {
values = new ArrayList<>();
parameters.put(param, values);
}
values.add(value);
}
}
}
if (handleParameters(runtimeObjectModel, parameters, options)) {
if (!parameters.isEmpty()) {
try {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Collection<String>> entry : parameters.entrySet()) {
for (String value : entry.getValue()) {
sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8.name())).append("=")
.append(URLEncoder.encode(value, StandardCharsets.UTF_8.name())).append("&");
}
}
// delete the last &
sb.deleteCharAt(sb.length() - 1);
return sb.toString();
} catch (UnsupportedEncodingException e) {
throw new SightlyException("Could not encode the parameter values/keys", e);
}
} else {
return null;
}
}
return originalQuery;
}
private void handleSelectors(RuntimeObjectModel runtimeObjectModel, ModifiableRequestPathInfo requestPathInfo,
Map<String, Object> options) {
if (options.containsKey(SELECTORS)) {
Object selectorsOption = options.get(SELECTORS);
if (selectorsOption == null) {
// we want to remove all selectors
requestPathInfo.clearSelectors();
} else if (selectorsOption instanceof String) {
String selectorString = (String) selectorsOption;
String[] selectorsArray = selectorString.split("\\.");
requestPathInfo.replaceSelectors(selectorsArray);
} else if (selectorsOption instanceof Object[]) {
Object[] selectorsURIArray = (Object[]) selectorsOption;
String[] selectorsArray = new String[selectorsURIArray.length];
int index = 0;
for (Object selector : selectorsURIArray) {
selectorsArray[index++] = runtimeObjectModel.toString(selector);
}
requestPathInfo.replaceSelectors(selectorsArray);
}
}
Object addSelectorsOption = options.get(ADD_SELECTORS);
if (addSelectorsOption instanceof String) {
String selectorString = (String) addSelectorsOption;
String[] selectorsArray = selectorString.split("\\.");
requestPathInfo.addSelectors(selectorsArray);
} else if (addSelectorsOption instanceof Object[]) {
Object[] selectorsURIArray = (Object[]) addSelectorsOption;
String[] selectorsArray = new String[selectorsURIArray.length];
int index = 0;
for (Object selector : selectorsURIArray) {
selectorsArray[index++] = runtimeObjectModel.toString(selector);
}
requestPathInfo.addSelectors(selectorsArray);
}
Object removeSelectorsOption = options.get(REMOVE_SELECTORS);
if (removeSelectorsOption instanceof String) {
String selectorString = (String) removeSelectorsOption;
String[] selectorsArray = selectorString.split("\\.");
requestPathInfo.removeSelectors(selectorsArray);
} else if (removeSelectorsOption instanceof Object[]) {
Object[] selectorsURIArray = (Object[]) removeSelectorsOption;
String[] selectorsArray = new String[selectorsURIArray.length];
int index = 0;
for (Object selector : selectorsURIArray) {
selectorsArray[index++] = runtimeObjectModel.toString(selector);
}
requestPathInfo.removeSelectors(selectorsArray);
}
}
@SuppressWarnings("unchecked")
private boolean handleParameters(RuntimeObjectModel runtimeObjectModel, Map<String, Collection<String>> parameters,
Map<String, Object> options) {
boolean hasModifiedParameters = false;
if (options.containsKey(QUERY)) {
Object queryOption = options.get(QUERY);
parameters.clear();
Map<String, Object> queryParameters = runtimeObjectModel.toMap(queryOption);
addQueryParameters(runtimeObjectModel, parameters, queryParameters);
hasModifiedParameters = true;
}
Object addQueryOption = options.get(ADD_QUERY);
if (addQueryOption != null) {
Map<String, Object> addParams = runtimeObjectModel.toMap(addQueryOption);
addQueryParameters(runtimeObjectModel, parameters, addParams);
hasModifiedParameters = true;
}
Object removeQueryOption = options.get(REMOVE_QUERY);
if (removeQueryOption != null) {
if (removeQueryOption instanceof String) {
parameters.remove(removeQueryOption);
} else if (removeQueryOption instanceof Object[]) {
Object[] removeQueryParamArray = (Object[]) removeQueryOption;
for (Object param : removeQueryParamArray) {
String paramString = runtimeObjectModel.toString(param);
if (paramString != null) {
parameters.remove(paramString);
}
}
}
hasModifiedParameters = true;
}
return hasModifiedParameters;
}
private void addQueryParameters(RuntimeObjectModel runtimeObjectModel, Map<String, Collection<String>> parameters,
Map<String, Object> queryParameters) {
for (Map.Entry<String, Object> entry : queryParameters.entrySet()) {
Object entryValue = entry.getValue();
if (runtimeObjectModel.isCollection(entryValue)) {
Collection<Object> collection = runtimeObjectModel.toCollection(entryValue);
Collection<String> values = new ArrayList<>(collection.size());
for (Object o : collection) {
values.add(runtimeObjectModel.toString(o));
}
parameters.put(entry.getKey(), values);
} else {
Collection<String> values = new ArrayList<>(1);
values.add(runtimeObjectModel.toString(entryValue));
parameters.put(entry.getKey(), values);
}
}
}
static class ModifiableRequestPathInfo implements RequestPathInfo {
private String resourcePath;
private List<String> selectors;
private String extension;
private String suffix;
/**
* Creates the {@link RequestPathInfo} object based on a request path only.
* This utility does not take this into account any underlying repository structure and
* just uses the first dot to split resource path from the rest!
* {@code org.apache.sling.servlets.resolver.internal.DecomposedUrl} uses the same logic!
*
* @param path the full normalized path (no '.', '..', or double slashes8) of the request, including the query parameters
* @throws NullPointerException if the supplied {@code path} is null
*/
ModifiableRequestPathInfo(String path) {
if (path == null) {
throw new NullPointerException("The path parameter cannot be null.");
}
selectors = new LinkedList<>();
// get relevant parts
int firstDot = path.indexOf('.');
// the extra path in the request URI
final String pathToParse;
if (firstDot >= 0) {
pathToParse = path.substring(firstDot);
resourcePath = path.substring(0, firstDot);
} else {
pathToParse = "";
resourcePath = path;
}
// use logic from org.apache.sling.engine.impl.request.SlingRequestPathInfo
// separate selectors/ext from the suffix
int firstSlash = pathToParse.indexOf('/');
String pathToSplit;
if (firstSlash < 0) {
pathToSplit = pathToParse;
suffix = null;
} else {
pathToSplit = pathToParse.substring(0, firstSlash);
suffix = pathToParse.substring(firstSlash);
}
int lastDot = pathToSplit.lastIndexOf('.');
if (lastDot > 1) {
// no selectors if splitting would give an empty array
String tmpSel = pathToSplit.substring(1, lastDot);
selectors.addAll(Arrays.asList(tmpSel.split("\\.")));
}
// extension only if lastDot is not trailing
extension = (lastDot + 1 < pathToSplit.length())
? pathToSplit.substring(lastDot + 1) : null;
}
void setExtension(String extension) {
this.extension = extension;
}
void setSuffix(String suffix) {
this.suffix = suffix;
}
@Override
@NotNull
public String getResourcePath() {
return resourcePath;
}
void setResourcePath(String path) {
this.resourcePath = path;
}
/**
* Returns the extension.
*
* @return the extension, if one exists, otherwise {@code null}
*/
@Override
public String getExtension() {
return extension;
}
/**
* Returns the selector string.
*
* @return the selector string, if the path has selectors, otherwise {@code null}
*/
@Override
public String getSelectorString() {
throw new UnsupportedOperationException();
}
/**
* Returns the suffix appended to the path. The suffix represents the path segment between the path's extension and the path's
* fragment.
*
* @return the suffix if the path contains one, {@code null} otherwise
*/
@Override
public String getSuffix() {
return suffix;
}
@Override
@NotNull
public String[] getSelectors() {
return selectors.toArray(new String[]{});
}
@Override
public Resource getSuffixResource() {
throw new UnsupportedOperationException();
}
@Override
public String toString() {
// resourcePath + selectors + extension + suffix
StringBuilder sb = new StringBuilder(getResourcePath());
if (getSelectors().length > 0) {
for (String selector : selectors) {
if (StringUtils.isNotBlank(selector) && !selector.contains(" ")) {
// make sure not to append empty or invalid selectors
sb.append(".").append(selector);
}
}
}
if (StringUtils.isNotEmpty(getExtension())) {
sb.append('.').append(getExtension());
}
if (StringUtils.isNotEmpty(getSuffix())) {
sb.append(getSuffix());
}
return sb.toString();
}
void replaceSelectors(String[] selectorsArray) {
selectors.clear();
selectors.addAll(Arrays.asList(selectorsArray));
}
void addSelectors(String[] selectorsArray) {
selectors.addAll(Arrays.asList(selectorsArray));
}
void removeSelectors(String[] selectorsArray) {
selectors.removeAll(Arrays.asList(selectorsArray));
}
void clearSelectors() {
selectors.clear();
}
}
}