/*
 * 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.wicket.core.request.mapper;

import java.util.Iterator;
import java.util.List;
import java.util.function.Supplier;

import org.apache.wicket.Application;
import org.apache.wicket.core.request.handler.RequestSettingRequestHandler;
import org.apache.wicket.protocol.http.PageExpiredException;
import org.apache.wicket.request.IRequestHandler;
import org.apache.wicket.request.IRequestMapper;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.Url;
import org.apache.wicket.request.mapper.IRequestMapperDelegate;
import org.apache.wicket.request.mapper.info.PageComponentInfo;
import org.apache.wicket.util.crypt.ICrypt;
import org.apache.wicket.util.crypt.ICryptFactory;
import org.apache.wicket.util.lang.Args;
import org.apache.wicket.util.string.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * <p>
 * A request mapper that encrypts URLs generated by another mapper. This mapper encrypts the segments
 * and query parameters of URLs starting with {@link IMapperContext#getNamespace()}, and just the
 * {@link PageComponentInfo} parameter for mounted URLs.
 * </p>
 *
 * <p>
 * <strong>Important</strong>: for better security it is recommended to use
 * {@link org.apache.wicket.core.request.mapper.CryptoMapper#CryptoMapper(IRequestMapper, Supplier)}
 * constructor with {@link org.apache.wicket.util.crypt.ICrypt} implementation that generates a
 * separate key for each user. {@link org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory} provides such an
 * implementation that stores the key in the HTTP session.
 * </p>
 * 
 * <p>
 * This mapper can be mounted before or after mounting other pages, but will only encrypt URLs for
 * pages mounted before the {@link CryptoMapper}. If required, multiple {@link CryptoMapper}s may be
 * installed in an {@link Application}.
 * </p>
 * 
 * <p>
 * When encrypting URLs in the Wicket namespace (starting with {@link IMapperContext#getNamespace()}), the entire URL,
 * including segments and parameters, is encrypted, with the encrypted form stored in the first segment of the encrypted URL.
 * </p>
 * 
 * <p>
 * To be able to handle relative URLs, like for image URLs in a CSS file, checksum segments are appended to the
 * encrypted URL until the encrypted URL has the same number of segments as the original URL had.
 * Each checksum segment has a precise 5 character value, calculated using a checksum. This helps in calculating
 * the relative distance from the original URL. When a URL is returned by the browser, we iterate through these
 * checksummed placeholder URL segments. If the segment matches the expected checksum, then the segment is deemed
 * to be the corresponding segment in the original URL. If the segment does not match the expected checksum, then
 * the segment is deemed a plain text sibling of the corresponding segment in the original URL, and all subsequent
 * segments are considered plain text children of the current segment.
 * </p>
 * 
 * <p>
 * When encrypting mounted URLs, we look for the {@link PageComponentInfo} parameter, and encrypt only that parameter.
 * </p>
 * 
 * <p>
 * {@link CryptoMapper} can be configured to mark encrypted URLs as encrypted, and throw a {@link PageExpiredException}
 * exception if a encrypted URL cannot be decrypted. This can occur when using {@code KeyInSessionSunJceCryptFactory}, and
 * the session has expired.
 * </p>
 * 
 * @author igor.vaynberg
 * @author Jesse Long
 * @author svenmeier
 * @see org.apache.wicket.settings.SecuritySettings#setCryptFactory(org.apache.wicket.util.crypt.ICryptFactory)
 * @see org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory
 * @see org.apache.wicket.util.crypt.SunJceCrypt
 */
public class CryptoMapper implements IRequestMapperDelegate
{
	private static final Logger log = LoggerFactory.getLogger(CryptoMapper.class);

	/**
	 * Name of the parameter which contains encrypted page component info.
	 */
	private static final String ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER = "wicket-crypt";

	private static final String ENCRYPTED_URL_MARKER_PREFIX = "crypt.";

	private final IRequestMapper wrappedMapper;
	private final Supplier<ICrypt> cryptProvider;

	/**
	 * Whether or not to mark encrypted URLs as encrypted.
	 */
	private boolean markEncryptedUrls = false;

	/**
	 * Encrypt with {@link org.apache.wicket.settings.SecuritySettings#getCryptFactory()}.
	 * <p>
	 * <strong>Important</strong>: For better security it is recommended to use
	 * {@link CryptoMapper#CryptoMapper(IRequestMapper, Supplier)} with a specific {@link ICrypt} implementation
	 * that generates a separate key for each user.
	 * {@link org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory} provides such an implementation that stores the
	 * key in the HTTP session.
	 * </p>
	 *
	 * @param wrappedMapper
	 *            the non-crypted request mapper
	 * @param application
	 *            the current application
	 * @see org.apache.wicket.util.crypt.SunJceCrypt
	 */
	public CryptoMapper(final IRequestMapper wrappedMapper, final Application application)
	{
		this(wrappedMapper, () -> application.getSecuritySettings().getCryptFactory().newCrypt());
	}

	/**
	 * Construct.
	 * 
	 * @param wrappedMapper
	 *            the non-crypted request mapper
	 * @param cryptProvider
	 *            the custom crypt provider
	 */
	public CryptoMapper(final IRequestMapper wrappedMapper, final Supplier<ICrypt> cryptProvider)
	{
		this.wrappedMapper = Args.notNull(wrappedMapper, "wrappedMapper");
		this.cryptProvider = Args.notNull(cryptProvider, "cryptProvider");
	}

	/**
	 * Whether or not to mark encrypted URLs as encrypted. If set, a {@link PageExpiredException} is thrown when
	 * a encrypted URL can no longer be decrypted.
	 * 
	 * @return whether or not to mark encrypted URLs as encrypted.
	 */
	public boolean getMarkEncryptedUrls()
	{
		return markEncryptedUrls;
	}

	/**
	 * Sets whether or not to mark encrypted URLs as encrypted. If set, a {@link PageExpiredException} is thrown when
	 * a encrypted URL can no longer be decrypted.
	 * 
	 * @param markEncryptedUrls
	 *		whether or not to mark encrypted URLs as encrypted.
	 * 
	 * @return {@code this}, for chaining.
	 */
	public CryptoMapper setMarkEncryptedUrls(boolean markEncryptedUrls)
	{
		this.markEncryptedUrls = markEncryptedUrls;
		return this;
	}

	/**
	 * {@inheritDoc}
	 * <p>
	 * This implementation decrypts the URL and passes the decrypted URL to the wrapped mapper.
	 * </p>
	 * @param request
	 *		The request for which to get a compatibility score.
	 * 
	 * @return The compatibility score.
	 */
	@Override
	public int getCompatibilityScore(final Request request)
	{
		Url decryptedUrl = decryptUrl(request, request.getUrl());

		if (decryptedUrl == null)
		{
			return 0;
		}

		Request decryptedRequest = request.cloneWithUrl(decryptedUrl);

		return wrappedMapper.getCompatibilityScore(decryptedRequest);
	}

	@Override
	public Url mapHandler(final IRequestHandler requestHandler)
	{
		final Url url = wrappedMapper.mapHandler(requestHandler);

		if (url == null)
		{
			return null;
		}

		if (url.isFull())
		{
			// do not encrypt full urls
			return url;
		}

		return encryptUrl(url);
	}

	@Override
	public IRequestHandler mapRequest(final Request request)
	{
		Url url = decryptUrl(request, request.getUrl());

		if (url == null)
		{
			return null;
		}

		Request decryptedRequest = request.cloneWithUrl(url);

		IRequestHandler handler = wrappedMapper.mapRequest(decryptedRequest);

		if (handler != null)
		{
			handler = new RequestSettingRequestHandler(decryptedRequest, handler);
		}

		return handler;
	}

	/**
	 * @return the {@link ICrypt} implementation that may be used to encrypt/decrypt {@link Url}'s
	 *         segments and/or query string
	 */
	protected final ICrypt getCrypt()
	{
		return cryptProvider.get();
	}

	/**
	 * @return the wrapped root request mapper
	 */
	@Override
	public final IRequestMapper getDelegateMapper()
	{
		return wrappedMapper;
	}

	/**
	 * Returns the applications {@link IMapperContext}.
	 *
	 * @return The applications {@link IMapperContext}.
	 */
	protected IMapperContext getContext()
	{
		return Application.get().getMapperContext();
	}

	/**
	 * Encrypts a URL. This method should return a new, encrypted instance of the URL. If the URL starts with {@code /wicket/},
	 * the entire URL is encrypted.
	 * 
	 * @param url
	 *		The URL to encrypt.
	 * 
	 * @return A new, encrypted version of the URL.
	 */
	protected Url encryptUrl(final Url url)
	{
		if (url.getSegments().size() > 0
			&& url.getSegments().get(0).equals(getContext().getNamespace()))
		{
			return encryptEntireUrl(url);
		}
		else
		{
			return encryptRequestListenerParameter(url);
		}
	}

	/**
	 * Encrypts an entire URL, segments and query parameters.
	 * 
	 * @param url
	 *		The URL to encrypt.
	 * 
	 * @return An encrypted form of the URL.
	 */
	protected Url encryptEntireUrl(final Url url)
	{
		String encryptedUrlString = getCrypt().encryptUrlSafe(url.toString());

		Url encryptedUrl = new Url(url.getCharset());

		if (getMarkEncryptedUrls())
		{
			encryptedUrl.getSegments().add(ENCRYPTED_URL_MARKER_PREFIX + encryptedUrlString);
		}
		else
		{
			encryptedUrl.getSegments().add(encryptedUrlString);
		}

		int numberOfSegments = url.getSegments().size() - 1;
		HashedSegmentGenerator generator = new HashedSegmentGenerator(encryptedUrlString);
		for (int segNo = 0; segNo < numberOfSegments; segNo++)
		{
			encryptedUrl.getSegments().add(generator.next());
		}
		return encryptedUrl;
	}

	/**
	 * Encrypts the {@link PageComponentInfo} query parameter in the URL, if any is found.
	 * 
	 * @param url
	 *		The URL to encrypt.
	 * 
	 * @return An encrypted form of the URL.
	 */
	protected Url encryptRequestListenerParameter(final Url url)
	{
		Url encryptedUrl = new Url(url);
		boolean encrypted = false;

		for (Iterator<Url.QueryParameter> it = encryptedUrl.getQueryParameters().iterator(); it.hasNext();)
		{
			Url.QueryParameter qp = it.next();

			if (MapperUtils.parsePageComponentInfoParameter(qp) != null)
			{
				it.remove();
				String encryptedParameterValue = getCrypt().encryptUrlSafe(qp.getName());
				Url.QueryParameter encryptedParameter
					= new Url.QueryParameter(ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER, encryptedParameterValue);
				encryptedUrl.getQueryParameters().add(0, encryptedParameter);
				encrypted = true;
				break;
			}
		}

		if (encrypted)
		{
			return encryptedUrl;
		}
		else
		{
			return url;
		}
	}

	/**
	 * Decrypts a {@link Url}. This method should return {@code null} if the URL is not decryptable, or if the
	 * URL should have been encrypted but was not. Returning {@code null} results in a 404 error.
	 * 
	 * @param request
	 *		The {@link Request}.
	 * @param encryptedUrl
	 *		The encrypted {@link Url}.
	 * 
	 * @return Returns a decrypted {@link Url}.
	 */
	protected Url decryptUrl(final Request request, final Url encryptedUrl)
	{
		Url url = decryptEntireUrl(request, encryptedUrl);

		if (url == null)
		{
			if (encryptedUrl.getSegments().size() > 0
				&& encryptedUrl.getSegments().get(0).equals(getContext().getNamespace()))
			{
				/*
				 * This URL should have been encrypted, but was not. We should refuse to handle this, except when
				 * there is more than one CryptoMapper installed, and the request was decrypted by some other
				 * CryptoMapper.
				 */
				if (request.getOriginalUrl().getSegments().size() > 0
					&& request.getOriginalUrl().getSegments().get(0).equals(getContext().getNamespace()))
				{
					return null;
				}
				else
				{
					return encryptedUrl;
				}
			}
		}

		if (url == null)
		{
			url = decryptRequestListenerParameter(request, encryptedUrl);
		}

		log.debug("Url '{}' has been decrypted to '{}'", encryptedUrl, url);

		return url;
	}

	/**
	 * Decrypts an entire URL, which was previously encrypted by {@link #encryptEntireUrl(org.apache.wicket.request.Url)}.
	 * This method should return {@code null} if the URL is not decryptable.
	 * 
	 * @param request
	 *		The request that was made.
	 * @param encryptedUrl
	 *		The encrypted URL.
	 * 
	 * @return A decrypted form of the URL, or {@code null} if the URL is not decryptable.
	 */
	protected Url decryptEntireUrl(final Request request, final Url encryptedUrl)
	{
		Url url = new Url(request.getCharset());

		List<String> encryptedSegments = encryptedUrl.getSegments();

		if (encryptedSegments.isEmpty())
		{
			return null;
		}

		/*
		 * The first encrypted segment contains an encrypted version of the entire plain text url.
		 */
		String encryptedUrlString = encryptedSegments.get(0);
		if (Strings.isEmpty(encryptedUrlString))
		{
			return null;
		}

		if (getMarkEncryptedUrls())
		{
			if (encryptedUrlString.startsWith(ENCRYPTED_URL_MARKER_PREFIX))
			{
				encryptedUrlString = encryptedUrlString.substring(ENCRYPTED_URL_MARKER_PREFIX.length());
			}
			else
			{
				return null;
			}
		}

		String decryptedUrl;
		try
		{
			decryptedUrl = getCrypt().decryptUrlSafe(encryptedUrlString);
		}
		catch (Exception e)
		{
			log.error("Error decrypting URL", e);
			return null;
		}

		if (decryptedUrl == null)
		{
			if (getMarkEncryptedUrls())
			{
				throw new PageExpiredException("Encrypted URL is no longer decryptable");
			}
			else
			{
				return null;
			}
		}

		Url originalUrl = Url.parse(decryptedUrl, request.getCharset());

		int originalNumberOfSegments = originalUrl.getSegments().size();
		int encryptedNumberOfSegments = encryptedUrl.getSegments().size();

		if (originalNumberOfSegments > 0)
		{
			/*
			 * This should always be true. Home page URLs are the only ones without
			 * segments, and we don't encrypt those with this method.
			 * 
			 * We always add the first segment of the URL, because we encrypt a URL like:
			 *	/path/to/something
			 * to:
			 *	/encrypted_full/hash/hash
			 * 
			 * Notice the consistent number of segments. If we applied the following relative URL:
			 *	../../something
			 * then the resultant URL would be:
			 *	/something
			 * 
			 * Hence, the mere existence of the first, encrypted version of complete URL, segment
			 * tells us that the first segment of the original URL is still to be used.
			 */
			url.getSegments().add(originalUrl.getSegments().get(0));
		}

		HashedSegmentGenerator generator = new HashedSegmentGenerator(encryptedUrlString);
		int segNo = 1;
		for (; segNo < encryptedNumberOfSegments; segNo++)
		{
			if (segNo >= originalNumberOfSegments)
			{
				break;
			}

			String next = generator.next();
			String encryptedSegment = encryptedSegments.get(segNo);
			if (!next.equals(encryptedSegment))
			{
				/*
				 * This segment received from the browser is not the same as the expected segment generated
				 * by the HashSegmentGenerator. Hence it, and all subsequent segments are considered plain
				 * text siblings of the original encrypted url.
				 */
				break;
			}

			/*
			 * This segments matches the expected checksum, so we add the corresponding segment from the
			 * original URL.
			 */
			url.getSegments().add(originalUrl.getSegments().get(segNo));
		}
		/*
		 * Add all remaining segments from the encrypted url as plain text segments.
		 */
		for (; segNo < encryptedNumberOfSegments; segNo++)
		{
			// modified or additional segment
			url.getSegments().add(encryptedUrl.getSegments().get(segNo));
		}

		url.getQueryParameters().addAll(originalUrl.getQueryParameters());
		// WICKET-4923 additional parameters
		url.getQueryParameters().addAll(encryptedUrl.getQueryParameters());

		return url;
	}

	/**
	 * Decrypts a URL which may contain an encrypted {@link PageComponentInfo} query parameter.
	 * 
	 * @param request
	 *		The request that was made.
	 * @param encryptedUrl
	 *		The (potentially) encrypted URL.
	 * 
	 * @return A decrypted form of the URL.
	 */
	protected Url decryptRequestListenerParameter(final Request request, Url encryptedUrl)
	{
		Url url = new Url(encryptedUrl);

		url.getQueryParameters().clear();

		for (Url.QueryParameter qp : encryptedUrl.getQueryParameters())
		{
			if (MapperUtils.parsePageComponentInfoParameter(qp) != null)
			{
				/*
				 * Plain text request listener parameter found. This should have been encrypted, so we
				 * refuse to map the request unless the original URL did not include this parameter, which
				 * case there are likely to be multiple cryptomappers installed.
				 */
				if (request.getOriginalUrl().getQueryParameter(qp.getName()) == null)
				{
					url.getQueryParameters().add(qp);
				}
				else
				{
					return null;
				}
			}
			else if (ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER.equals(qp.getName()))
			{
				String encryptedValue = qp.getValue();

				if (Strings.isEmpty(encryptedValue))
				{
					url.getQueryParameters().add(qp);
				}
				else
				{
					String decryptedValue = null;

					try
					{
						decryptedValue = getCrypt().decryptUrlSafe(encryptedValue);
					}
					catch (Exception e)
					{
						log.error("Error decrypting encrypted request listener query parameter", e);
					}

					if (Strings.isEmpty(decryptedValue))
					{
						url.getQueryParameters().add(qp);
					}
					else
					{
						Url.QueryParameter decryptedParamter = new Url.QueryParameter(decryptedValue, "");
						url.getQueryParameters().add(0, decryptedParamter);
					}
				}
			}
			else
			{
				url.getQueryParameters().add(qp);
			}
		}

		return url;
	}

	/**
	 * A generator of hashed segments.
	 */
	public static class HashedSegmentGenerator
	{
		private char[] characters;

		private int hash = 0;

		public HashedSegmentGenerator(String string)
		{
			characters = string.toCharArray();
		}

		/**
		 * Generate the next segment
		 * 
		 * @return segment
		 */
		public String next()
		{
			char a = characters[Math.abs(hash % characters.length)];
			hash++;
			char b = characters[Math.abs(hash % characters.length)];
			hash++;
			char c = characters[Math.abs(hash % characters.length)];

			String segment = "" + a + b + c;
			hash = hashString(segment);

			segment += String.format("%02x", Math.abs(hash % 256));
			hash = hashString(segment);

			return segment;
		}

		public int hashString(final String str)
		{
			int hash = 97;

			for (char c : str.toCharArray())
			{
				int i = c;
				hash = 47 * hash + i;
			}

			return hash;
		}
	}
}
