/*
 * 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.markup.html;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Pattern;

import org.apache.wicket.util.collections.ReverseListIterator;
import org.apache.wicket.util.string.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * This is a resource guard which by default denies access to all resources and thus is more secure.
 * <p/>
 * All pattern are executed in the order they were provided. All pattern are executed to determine
 * if access can be granted or not.
 * <p/>
 * Note that access to the config data such as get/setPattern() and acceptXXX() is not synchronized.
 * It is assumed that configuration has finished before the first request gets executed.
 * <p/>
 * The rules are fairly simple. Each pattern must start with either "+" (include) or "-" (exclude).
 * "*" is a placeholder for zero, one or more characters within a file or directory name. "**" is a
 * placeholder for zero, one or more sub-directories.
 * <p/>
 * Examples:
 * <table border="0">
 * <caption>Examples</caption>
 * <tr>
 * <td>+*.gif</td>
 * <td>All gif files in all directories</td>
 * </tr>
 * <tr>
 * <td>+test*.*</td>
 * <td>All files in all directories starting with "test"</td>
 * </tr>
 * <tr>
 * <td>+mydir&#47;*&#47;*.gif</td>
 * <td>All gif files two levels below the mydir directory. E.g. mydir&#47;dir2&#47;test.gif</td>
 * </tr>
 * <tr>
 * <td>+mydir&#47;**&#47;*.gif</td>
 * <td>All gif files in all directories below mydir. E.g. mydir&#47;test.gif or
 * mydir&#47;dir2&#47;dir3&#47;test.gif</td>
 * </tr>
 * </table>
 * 
 * @see IPackageResourceGuard
 * @see org.apache.wicket.settings.ResourceSettings#getPackageResourceGuard
 * @see PackageResourceGuard
 * 
 * @author Juergen Donnerstag
 */
public class SecurePackageResourceGuard extends PackageResourceGuard
{
	/** Log. */
	private static final Logger log = LoggerFactory.getLogger(SecurePackageResourceGuard.class);

	/** The path separator used */
	private static final char PATH_SEPARATOR = '/';

	/** The list of pattern. Note that the order is important, hence a list */
	private List<SearchPattern> pattern = new ArrayList<>();

	/** A cache to speed up the checks */
	private final ConcurrentMap<String, Boolean> cache;

	/**
	 * Constructor.
	 */
	public SecurePackageResourceGuard()
	{
		this(new SimpleCache(100));
	}

	/**
	 * Constructor.
	 * 
	 * @param cache
	 *            the internal cache that will hold the results for all already checked resources.
	 *            Use {@code null} to disable caching.
	 */
	public SecurePackageResourceGuard(final ConcurrentMap<String, Boolean> cache)
	{
		this.cache = cache;

		// the order is important for better performance
		// first add the most commonly used
		addPattern("+*.js");
		addPattern("+*.css");
		addPattern("+*.png");
		addPattern("+*.jpg");
		addPattern("+*.jpeg");
		addPattern("+*.gif");
		addPattern("+*.ico");
		addPattern("+*.cur");
		addPattern("+*.map");

		// WICKET-208 non page templates may be served
		addPattern("+*.html");

		addPattern("+*.txt");
		addPattern("+*.swf");
		addPattern("+*.bmp");
		addPattern("+*.svg");
		addPattern("+*.avif");

		// allow web fonts
		addPattern("+*.eot");
		addPattern("+*.ttf");
		addPattern("+*.woff");
		addPattern("+*.woff2");

	}

	/**
	 * 
	 */
	public void clearCache()
	{
		if (cache != null)
		{
			cache.clear();
		}
	}

	/**
	 * Whether the provided absolute path is accepted.
	 * 
	 * @param path
	 *            The absolute path, starting from the class root (packages are separated with
	 *            forward slashes instead of dots).
	 * @return True if accepted, false otherwise.
	 */
	@Override
	public boolean accept(String path)
	{
		// First check the cache
		if (cache != null)
		{
			Boolean rtn = cache.get(path);
			if (rtn != null)
			{
				return rtn;
			}
		}

		// Check typical files such as log4j.xml etc.
		if (super.accept(path) == false)
		{
			return false;
		}

		// Check against the pattern
		boolean hit = false;
		for (SearchPattern pattern : new ReverseListIterator<>(this.pattern))
		{
			if ((pattern != null) && pattern.isActive())
			{
				if (pattern.matches(path))
				{
					hit = pattern.isInclude();
					break;
				}
			}
		}

		if (cache != null)
		{
			// Do not use putIfAbsent(). See newCache()
			cache.put(path, (hit ? Boolean.TRUE : Boolean.FALSE));
		}

		if (hit == false)
		{
			log.warn("Access denied to shared (static) resource: " + path);
		}

		return hit;
	}

	/**
	 * Gets the current list of pattern. Please invoke clearCache() or setPattern(List) when
	 * finished in order to clear the cache of previous checks.
	 * 
	 * @return pattern
	 */
	public List<SearchPattern> getPattern()
	{
		clearCache();
		return pattern;
	}

	/**
	 * Sets pattern.
	 * 
	 * @param pattern
	 *            pattern
	 */
	public void setPattern(List<SearchPattern> pattern)
	{
		this.pattern = pattern;
		clearCache();
	}

	/**
	 * @param pattern
	 */
	public void addPattern(String pattern)
	{
		this.pattern.add(new SearchPattern(pattern));
		clearCache();
	}

	/**
	 * 
	 */
	public static class SearchPattern
	{
		private String pattern;

		private Pattern regex;

		private boolean include;

		private boolean active = true;

		private boolean fileOnly;

		/**
		 * Construct.
		 * 
		 * @param pattern
		 */
		public SearchPattern(final String pattern)
		{
			setPattern(pattern);
		}

		/**
		 * 
		 * @param pattern
		 * @return Regex pattern
		 */
		private Pattern convertToRegex(final String pattern)
		{
			String regex = Strings.replaceAll(pattern, ".", "#dot#").toString();

			// If path starts with "*/" or "**/"
			regex = regex.replaceAll("^\\*" + PATH_SEPARATOR, "[^" + PATH_SEPARATOR + "]+" +
				PATH_SEPARATOR);
			regex = regex.replaceAll("^[\\*]{2,}" + PATH_SEPARATOR, "([^" + PATH_SEPARATOR +
				"].#star#" + PATH_SEPARATOR + ")?");

			// Handle "/*/" and "/**/"
			regex = regex.replaceAll(PATH_SEPARATOR + "\\*" + PATH_SEPARATOR, PATH_SEPARATOR +
				"[^" + PATH_SEPARATOR + "]+" + PATH_SEPARATOR);
			regex = regex.replaceAll(PATH_SEPARATOR + "[\\*]{2,}" + PATH_SEPARATOR, "(" +
				PATH_SEPARATOR + "|" + PATH_SEPARATOR + ".+" + PATH_SEPARATOR + ")");

			// Handle "*" within dir or file names
			regex = regex.replaceAll("\\*+", "[^" + PATH_SEPARATOR + "]*");

			// replace placeholder
			regex = Strings.replaceAll(regex, "#dot#", "\\.").toString();
			regex = Strings.replaceAll(regex, "#star#", "*").toString();

			return Pattern.compile(regex);
		}

		/**
		 * Gets pattern.
		 * 
		 * @return pattern
		 */
		public String getPattern()
		{
			return pattern;
		}

		/**
		 * Gets regex.
		 * 
		 * @return regex
		 */
		public Pattern getRegex()
		{
			return regex;
		}

		/**
		 * Sets pattern.
		 * 
		 * @param pattern
		 *            pattern
		 */
		public void setPattern(String pattern)
		{
			if (Strings.isEmpty(pattern))
			{
				throw new IllegalArgumentException(
					"Parameter 'pattern' can not be null or an empty string");
			}

			if (pattern.charAt(0) == '+')
			{
				include = true;
			}
			else if (pattern.charAt(0) == '-')
			{
				include = false;
			}
			else
			{
				throw new IllegalArgumentException(
					"Parameter 'pattern' must start with either '+' or '-'. pattern='" + pattern +
						"'");
			}

			this.pattern = pattern;
			regex = convertToRegex(pattern.substring(1));

			fileOnly = (pattern.indexOf(PATH_SEPARATOR) == -1);
		}

		/**
		 * 
		 * @param path
		 * @return True if 'path' matches the pattern
		 */
		public boolean matches(String path)
		{
			if (fileOnly)
			{
				path = Strings.lastPathComponent(path, PATH_SEPARATOR);
			}
			return regex.matcher(path).matches();
		}

		/**
		 * Gets include.
		 * 
		 * @return include
		 */
		public boolean isInclude()
		{
			return include;
		}

		/**
		 * Sets include.
		 * 
		 * @param include
		 *            include
		 */
		public void setInclude(boolean include)
		{
			this.include = include;
		}

		/**
		 * Gets active.
		 * 
		 * @return active
		 */
		public boolean isActive()
		{
			return active;
		}

		/**
		 * Sets active.
		 * 
		 * @param active
		 *            active
		 */
		public void setActive(boolean active)
		{
			this.active = active;
		}

		@Override
		public String toString()
		{
			return "Pattern: " + pattern + ", Regex: " + regex + ", include:" + include +
				", fileOnly:" + fileOnly + ", active:" + active;
		}
	}

	/**
	 * A very simple cache
	 */
	public static class SimpleCache extends ConcurrentHashMap<String, Boolean>
	{
		private static final long serialVersionUID = 1L;

		private final ConcurrentLinkedQueue<String> fifo = new ConcurrentLinkedQueue<>();

		private final int maxSize;

		/**
		 * Construct.
		 * 
		 * @param maxSize
		 */
		public SimpleCache(int maxSize)
		{
			this.maxSize = maxSize;
		}

		/**
		 * @see java.util.concurrent.ConcurrentHashMap#put(java.lang.Object, java.lang.Object)
		 */
		@Override
		public Boolean put(String key, Boolean value)
		{
			// add the key to the hash map. Do not replace existing once
			Boolean rtn = super.putIfAbsent(key, value);

			// If found, than remove it from the fifo list and ...
			if (rtn != null)
			{
				fifo.remove(key);
			}

			// append it at the end of the list
			fifo.add(key);

			// remove all "outdated" cache entries
			while (fifo.size() > maxSize)
			{
				remove(fifo.poll());
			}
			return rtn;
		}
	}
}
