Jump to content
  • 0

Acis Caparso's bots prevention


Question

Posted

Hello everyone! I'm using latest free aCis files. I added Capraso's bots prevention to the server (

But when I'm trying to test it I'm getting this: "WARNING    33    net.sf.l2j.commons.concurrent.ThreadPool    Exception in a Runnable execution:java.lang.IllegalArgumentException: bound must be positive" . Bots prevention doesn't work and it also causes to stay some mosters alive with 0 hp (they are not respawning). Any ideas to fix this? Below I'm pasting some *.java

 

My BotsPreventionManager.java

package net.sf.l2j.gameserver.instancemanager;

import java.io.File;
import java.io.RandomAccessFile;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Future;

import net.sf.l2j.Config;
import net.sf.l2j.L2DatabaseFactory;
import net.sf.l2j.commons.lang.StringUtil;
import net.sf.l2j.commons.concurrent.ThreadPool;
import net.sf.l2j.gameserver.datatables.MapRegionTable;
import net.sf.l2j.gameserver.model.actor.L2Character;
import net.sf.l2j.gameserver.model.actor.instance.L2MonsterInstance;
import net.sf.l2j.gameserver.model.actor.instance.L2PcInstance;
import net.sf.l2j.gameserver.network.serverpackets.NpcHtmlMessage;
import net.sf.l2j.gameserver.network.serverpackets.PledgeCrest;

public class BotsPreventionManager
{
	private class PlayerData
	{
		public PlayerData()
		{
			firstWindow = true;
		}
		
		public int mainpattern;
		public List<Integer> options = new ArrayList<>();
		public boolean firstWindow;
		public int patternid;
	}
	
	protected Random _randomize;
	protected static Map<Integer, Integer> _monsterscounter;
	protected static Map<Integer, Future<?>> _beginvalidation;
	protected static Map<Integer, PlayerData> _validation;
	protected static Map<Integer, byte[]> _images;
	protected int WINDOW_DELAY = 3; //delay used to generate new window if previous have been closed.
	protected int VALIDATION_TIME = Config.VALIDATION_TIME * 1000;
	
	public static final BotsPreventionManager getInstance()
	{
		return SingletonHolder._instance;
	}
	
	BotsPreventionManager()
	{
		_randomize = new Random();
		_monsterscounter = new HashMap<>();
		_beginvalidation = new HashMap<>();
		_validation = new HashMap<>();
		_images = new HashMap<>();
		_beginvalidation = new HashMap<>();
		
		getimages();
	}
	
	public void updatecounter(L2Character player, L2Character monster)
	{
		if ((player instanceof L2PcInstance) && (monster instanceof L2MonsterInstance))
		{
			L2PcInstance killer = (L2PcInstance) player;
			
			if (_validation.get(killer.getObjectId()) != null)
			{
				return;
			}
			
			int count = 1;
			if (_monsterscounter.get(killer.getObjectId()) != null)
			{
				count = _monsterscounter.get(killer.getObjectId()) + 1;
			}
			
			int next = _randomize.nextInt(Config.KILLS_COUNTER_RANDOMIZATION);
			if (Config.KILLS_COUNTER + next < count)
			{
				validationtasks(killer);
				_monsterscounter.remove(killer.getObjectId());
			}
			else
			{
				_monsterscounter.put(killer.getObjectId(), count);
			}
		}
	}
	
	private static void getimages()
	{
		String CRESTS_DIR = "data/html/mods/prevention";
		
		final File directory = new File(CRESTS_DIR);
		directory.mkdirs();
		
		int i = 0;
		for (File file : directory.listFiles())
		{
			if (!file.getName().endsWith(".dds"))
				continue;
			
			byte[] data;
			
			try (RandomAccessFile f = new RandomAccessFile(file, "r"))
			{
				data = new byte[(int) f.length()];
				f.readFully(data);
			}
			catch (Exception e)
			{
				continue;
			}
			_images.put(i, data);
			i++;
		}
	}
	
	public void prevalidationwindow(L2PcInstance player)
	{
		NpcHtmlMessage html = new NpcHtmlMessage(1);
		StringBuilder tb = new StringBuilder();
		StringUtil.append(tb, "<html>");
		StringUtil.append(tb, "<title>Bots prevention</title>");
		StringUtil.append(tb, "<body><center><br><br><img src=\"L2UI_CH3.herotower_deco\" width=\"256\" height=\"32\">");
		StringUtil.append(tb, "<br><br><font color=\"a2a0a2\">if such window appears it means server suspect,<br1>that you may using cheating software.</font>");
		StringUtil.append(tb, "<br><br><font color=\"b09979\">if given answer results are incorrect or no action is made<br1>server is going to punish character instantly.</font>");
		StringUtil.append(tb, "<br><br><button value=\"CONTINUE\" action=\"bypass report_continue\" width=\"75\" height=\"21\" back=\"L2UI_CH3.Btn1_normal\" fore=\"L2UI_CH3.Btn1_normal\">");
		StringUtil.append(tb, "</center></body>");
		StringUtil.append(tb, "</html>");
		html.setHtml(tb.toString());
		player.sendPacket(html);
	}
	
	private static void validationwindow(L2PcInstance player)
	{
		PlayerData container = _validation.get(player.getObjectId());
		NpcHtmlMessage html = new NpcHtmlMessage(1);
		
		StringBuilder tb = new StringBuilder();
		StringUtil.append(tb, "<html>");
		StringUtil.append(tb, "<title>Bots prevention</title>");
		StringUtil.append(tb, "<body><center><br><br><img src=\"L2UI_CH3.herotower_deco\" width=\"256\" height=\"32\">");
		StringUtil.append(tb, "<br><br><font color=\"a2a0a2\">in order to prove you are a human being<br1>you've to</font> <font color=\"b09979\">match colours within generated pattern:</font>");
		
		// generated main pattern.
		StringUtil.append(tb, "<br><br><img src=\"Crest.crest_" + Config.SERVER_ID + "_" + (_validation.get(player.getObjectId()).patternid) + "\" width=\"32\" height=\"32\"></td></tr>");
		StringUtil.append(tb, "<br><br><font color=b09979>click-on pattern of your choice beneath:</font>");
		
		// generate random colours.
		StringUtil.append(tb, "<table><tr>");
		for (int i = 0; i < container.options.size(); i++)
		{
			StringUtil.append(tb, "<td><button action=\"bypass -h report_" + i + "\" width=32 height=32 back=\"Crest.crest_" + Config.SERVER_ID + "_" + (container.options.get(i) + 1500) + "\" fore=\"Crest.crest_" + Config.SERVER_ID + "_" + (container.options.get(i) + 1500) + "\"></td>");
		}
		StringUtil.append(tb, "</tr></table>");
		StringUtil.append(tb, "</center></body>");
		StringUtil.append(tb, "</html>");
		
		html.setHtml(tb.toString());
		player.sendPacket(html);
	}
	
	public void punishmentnwindow(L2PcInstance player)
	{
		NpcHtmlMessage html = new NpcHtmlMessage(1);
		StringBuilder tb = new StringBuilder();
		StringUtil.append(tb, "<html>");
		StringUtil.append(tb, "<title>Bots prevention</title>");
		StringUtil.append(tb, "<body><center><br><br><img src=\"L2UI_CH3.herotower_deco\" width=\"256\" height=\"32\">");
		StringUtil.append(tb, "<br><br><font color=\"a2a0a2\">if such window appears, it means character haven't<br1>passed through prevention system.");
		StringUtil.append(tb, "<br><br><font color=\"b09979\">in such case character get moved to nearest town.</font>");
		StringUtil.append(tb, "</center></body>");
		StringUtil.append(tb, "</html>");
		html.setHtml(tb.toString());
		player.sendPacket(html);
	}
	
	public void validationtasks(L2PcInstance player)
	{
		PlayerData container = new PlayerData();
		randomizeimages(container, player);
		
		for (int i = 0; i < container.options.size(); i++)
		{
			PledgeCrest packet = new PledgeCrest((container.options.get(i) + 1500), _images.get(container.options.get(i)));
			player.sendPacket(packet);
			
		}
		
		PledgeCrest packet = new PledgeCrest(container.patternid, _images.get(container.options.get(container.mainpattern)));
		player.sendPacket(packet);
		
		_validation.put(player.getObjectId(), container);
		
		Future<?> newTask = ThreadPool.schedule(new ReportCheckTask(player), VALIDATION_TIME);
		ThreadPool.schedule(new countdown(player, VALIDATION_TIME / 1000), 0);
		_beginvalidation.put(player.getObjectId(), newTask);
	}
	
	protected void randomizeimages(PlayerData container,L2PcInstance player)
	{
		int buttonscount = 4;
		int imagescount = _images.size();
		
		for (int i = 0; i < buttonscount; i++)
		{
			int next = _randomize.nextInt(imagescount);
			while (container.options.indexOf(next) > -1)
			{
				next = _randomize.nextInt(imagescount);
			}
			container.options.add(next);
		}
				
		int mainIndex = _randomize.nextInt(buttonscount);
		container.mainpattern = mainIndex;	
		
		Calendar token =  Calendar.getInstance();
		String uniquetoken = Integer.toString(token.get(Calendar.DAY_OF_MONTH))+Integer.toString(token.get(Calendar.HOUR_OF_DAY))+Integer.toString(token.get(Calendar.MINUTE))+Integer.toString(token.get(Calendar.SECOND))+Integer.toString(token.get(Calendar.MILLISECOND)/100);
		container.patternid = Integer.parseInt(uniquetoken);	
	}
	
	protected void banpunishment(L2PcInstance player)
	{
		_validation.remove(player.getObjectId());
		_beginvalidation.get(player.getObjectId()).cancel(true);
		_beginvalidation.remove(player.getObjectId());
		
		switch (Config.PUNISHMENT)
		{
		// 0 = move character to the closest village.
		// 1 = kick characters from the server.
		// 2 = put character to jail.
		// 3 = ban character from the server.
			case 0:
				player.stopMove(null);
				player.teleToLocation(MapRegionTable.TeleportWhereType.TOWN);
				punishmentnwindow(player);
				break;
			case 1:
				if (player.isOnline())
				{
					player.logout(true);
				}
				break;
			case 2:
				jailpunishment(player, Config.PUNISHMENT_TIME * 60);
				break;
			case 3:
				//player.setAccessLevel(-100);
				changeaccesslevel(player, -100);
				break;
		}
		
		player.sendMessage("Unfortunately, colours doesn't match.");
	}
	
	private static void changeaccesslevel(L2PcInstance targetPlayer, int lvl)
	{
		if (targetPlayer.isOnline())
		{
			targetPlayer.setAccessLevel(lvl);
			targetPlayer.logout();
		}
		else
		{
			try (Connection con = L2DatabaseFactory.getInstance().getConnection())
			{
				PreparedStatement statement = con.prepareStatement("UPDATE characters SET accesslevel=? WHERE obj_id=?");
				statement.setInt(1, lvl);
				statement.setInt(2, targetPlayer.getObjectId());
				statement.execute();
				statement.close();
			}
			catch (SQLException se)
			{
				if (Config.DEBUG)
					se.printStackTrace();
			}
		}
	}
	
	private static void jailpunishment(L2PcInstance activeChar, int delay)
	{
		if (activeChar.isOnline())
		{
			activeChar.setPunishLevel(L2PcInstance.PunishLevel.JAIL, Config.PUNISHMENT_TIME);
		}
		else
		{
			try (Connection con = L2DatabaseFactory.getInstance().getConnection())
			{
				PreparedStatement statement = con.prepareStatement("UPDATE characters SET x=?, y=?, z=?, punish_level=?, punish_timer=? WHERE obj_id=?");
				statement.setInt(1, -114356);
				statement.setInt(2, -249645);
			statement.setInt(3, -2984);
				statement.setInt(4, L2PcInstance.PunishLevel.JAIL.value());
				statement.setLong(5, (delay > 0 ? delay * Config.PUNISHMENT_TIME * 100 : 0));
				statement.setInt(6, activeChar.getObjectId());
				
				statement.execute();
				statement.close();
			}
		catch (SQLException se)
			{
				activeChar.sendMessage("SQLException while jailing player");
				if (Config.DEBUG)
					se.printStackTrace();
			}
		}
	}
	
	public void AnalyseBypass(String command, L2PcInstance player)
	{
		if (!_validation.containsKey(player.getObjectId()))
			return;
		
		String params = command.substring(command.indexOf("_") + 1);
		
		if (params.startsWith("continue"))
		{
			validationwindow(player);
			_validation.get(player.getObjectId()).firstWindow = false;
			return;
		}
		
		int choosenoption = -1;
		if (tryParseInt(params))
		{
			choosenoption = Integer.parseInt(params);
		}
		
		if (choosenoption > -1)
		{
			PlayerData playerData = _validation.get(player.getObjectId());
			if (choosenoption != playerData.mainpattern)
			{
				banpunishment(player);
			}
			else
			{
				player.sendMessage("Congratulations, colours match!");
				_validation.remove(player.getObjectId());
				_beginvalidation.get(player.getObjectId()).cancel(true);
				_beginvalidation.remove(player.getObjectId());
			}
		}
	}
	
	protected class countdown implements Runnable
	{
		private final L2PcInstance _player;
		private int _time;
		
		public countdown(L2PcInstance player, int time)
		{
			_time = time;
			_player = player;
		}
		
		@Override
		public void run()
		{
			if (_player.isOnline())
			{
				if (_validation.containsKey(_player.getObjectId()) && _validation.get(_player.getObjectId()).firstWindow)
				{
					if (_time % WINDOW_DELAY == 0)
					{
						prevalidationwindow(_player);
					}
				}
				
				switch (_time)
				{
					case 300:
					case 240:
					case 180:
					case 120:
					case 60:
						_player.sendMessage(_time / 60 + " minute(s) to match colors.");
						break;
					case 30:
					case 10:
					case 5:
					case 4:
					case 3:
					case 2:
					case 1:
						_player.sendMessage(_time + " second(s) to match colors!");
						break;
				}
				if (_time > 1 && _validation.containsKey(_player.getObjectId()))
				{
					ThreadPool.schedule(new countdown(_player, _time - 1), 1000);
				}
			}
		}
	}
	
	protected boolean tryParseInt(String value)
	{
		try
		{
			Integer.parseInt(value);
			return true;
		}
		
		catch (NumberFormatException e)
		{
			return false;
		}
	}
	
	public void CaptchaSuccessfull(L2PcInstance player)
	{
		if (_validation.get(player.getObjectId()) != null)
		{
			_validation.remove(player.getObjectId());
		}
	}
	
	public Boolean IsAlredyInReportMode(L2PcInstance player)
	{
		if (_validation.get(player.getObjectId()) != null)
		{
			return true;
		}
		return false;
	}
	
	private class ReportCheckTask implements Runnable
	{
		private final L2PcInstance _player;
		
		public ReportCheckTask(L2PcInstance player)
		{
			_player = player;
		}
		
		@Override
		public void run()
		{
			if (_validation.get(_player.getObjectId()) != null)
			{
				banpunishment(_player);
			}
		}
	}
	
	private static class SingletonHolder
	{
		protected static final BotsPreventionManager _instance = new BotsPreventionManager();
	}
}

My ThreadPool.java

/*
 * This program is free software: you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later
 * version.
 * 
 * This program 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 General Public License for more
 * details.
 * 
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <http://www.gnu.org/licenses/>.
 */
package net.sf.l2j.commons.concurrent;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;

import net.sf.l2j.Config;

/**
 * This class handles thread pooling system. It relies on two ThreadPoolExecutor arrays, which poolers number is generated using config.
 * <p>
 * Those arrays hold following pools :
 * </p>
 * <ul>
 * <li>Scheduled pool keeps a track about incoming, future events.</li>
 * <li>Instant pool handles short-life events.</li>
 * </ul>
 */
public final class ThreadPool
{
	protected static final Logger LOG = Logger.getLogger(ThreadPool.class.getName());
	
	private static final long MAX_DELAY = TimeUnit.NANOSECONDS.toMillis(Long.MAX_VALUE - System.nanoTime()) / 2;
	
	private static int _threadPoolRandomizer;
	
	protected static ScheduledThreadPoolExecutor[] _scheduledPools;
	protected static ThreadPoolExecutor[] _instantPools;
	
	/**
	 * Init the different pools, based on Config. It is launched only once, on Gameserver instance.
	 */
	public static void init()
	{
		// Feed scheduled pool.
		int poolCount = Config.SCHEDULED_THREAD_POOL_COUNT;
		if (poolCount == -1)
			poolCount = Runtime.getRuntime().availableProcessors();
		
		_scheduledPools = new ScheduledThreadPoolExecutor[poolCount];
		for (int i = 0; i < poolCount; i++)
			_scheduledPools[i] = new ScheduledThreadPoolExecutor(Config.THREADS_PER_SCHEDULED_THREAD_POOL);
		
		// Feed instant pool.
		poolCount = Config.INSTANT_THREAD_POOL_COUNT;
		if (poolCount == -1)
			poolCount = Runtime.getRuntime().availableProcessors();
		
		_instantPools = new ThreadPoolExecutor[poolCount];
		for (int i = 0; i < poolCount; i++)
			_instantPools[i] = new ThreadPoolExecutor(Config.THREADS_PER_INSTANT_THREAD_POOL, Config.THREADS_PER_INSTANT_THREAD_POOL, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(100000));
		
		// Prestart core threads.
		for (ScheduledThreadPoolExecutor threadPool : _scheduledPools)
			threadPool.prestartAllCoreThreads();
		
		for (ThreadPoolExecutor threadPool : _instantPools)
			threadPool.prestartAllCoreThreads();
		
		// Launch purge task.
		scheduleAtFixedRate(new Runnable()
		{
			@Override
			public void run()
			{
				for (ScheduledThreadPoolExecutor threadPool : _scheduledPools)
					threadPool.purge();
				
				for (ThreadPoolExecutor threadPool : _instantPools)
					threadPool.purge();
			}
		}, 600000, 600000);
		
		LOG.info("ThreadPool: Initialized " + getPoolSize(_scheduledPools) + "/" + getMaximumPoolSize(_scheduledPools) + " scheduled, " + getPoolSize(_instantPools) + "/" + getMaximumPoolSize(_instantPools) + " instant thread(s).");
	}
	
	/**
	 * Schedules a one-shot action that becomes enabled after a delay. The pool is chosen based on pools activity.
	 * @param r : the task to execute.
	 * @param delay : the time from now to delay execution.
	 * @return a ScheduledFuture representing pending completion of the task and whose get() method will return null upon completion.
	 */
	public static ScheduledFuture<?> schedule(Runnable r, long delay)
	{
		try
		{
			return getPool(_scheduledPools).schedule(new TaskWrapper(r), validate(delay), TimeUnit.MILLISECONDS);
		}
		catch (Exception e)
		{
			return null;
		}
	}
	
	/**
	 * Schedules a periodic action that becomes enabled after a delay. The pool is chosen based on pools activity.
	 * @param r : the task to execute.
	 * @param delay : the time from now to delay execution.
	 * @param period : the period between successive executions.
	 * @return a ScheduledFuture representing pending completion of the task and whose get() method will throw an exception upon cancellation.
	 */
	public static ScheduledFuture<?> scheduleAtFixedRate(Runnable r, long delay, long period)
	{
		try
		{
			return getPool(_scheduledPools).scheduleAtFixedRate(new TaskWrapper(r), validate(delay), validate(period), TimeUnit.MILLISECONDS);
		}
		catch (Exception e)
		{
			return null;
		}
	}
	
	/**
	 * Executes the given task sometime in the future.
	 * @param r : the task to execute.
	 */
	public static void execute(Runnable r)
	{
		try
		{
			getPool(_instantPools).execute(new TaskWrapper(r));
		}
		catch (Exception e)
		{
		}
	}
	
	/**
	 * Retrieve stats of current running thread pools.
	 */
	public static void getStats()
	{
		for (int i = 0; i < _scheduledPools.length; i++)
		{
			final ScheduledThreadPoolExecutor threadPool = _scheduledPools[i];
			
			LOG.info("=================================================");
			LOG.info("Scheduled pool #" + i + ":");
			LOG.info("\tgetActiveCount: ...... " + threadPool.getActiveCount());
			LOG.info("\tgetCorePoolSize: ..... " + threadPool.getCorePoolSize());
			LOG.info("\tgetPoolSize: ......... " + threadPool.getPoolSize());
			LOG.info("\tgetLargestPoolSize: .. " + threadPool.getLargestPoolSize());
			LOG.info("\tgetMaximumPoolSize: .. " + threadPool.getMaximumPoolSize());
			LOG.info("\tgetCompletedTaskCount: " + threadPool.getCompletedTaskCount());
			LOG.info("\tgetQueuedTaskCount: .. " + threadPool.getQueue().size());
			LOG.info("\tgetTaskCount: ........ " + threadPool.getTaskCount());
		}
		
		for (int i = 0; i < _instantPools.length; i++)
		{
			final ThreadPoolExecutor threadPool = _instantPools[i];
			
			LOG.info("=================================================");
			LOG.info("Instant pool #" + i + ":");
			LOG.info("\tgetActiveCount: ...... " + threadPool.getActiveCount());
			LOG.info("\tgetCorePoolSize: ..... " + threadPool.getCorePoolSize());
			LOG.info("\tgetPoolSize: ......... " + threadPool.getPoolSize());
			LOG.info("\tgetLargestPoolSize: .. " + threadPool.getLargestPoolSize());
			LOG.info("\tgetMaximumPoolSize: .. " + threadPool.getMaximumPoolSize());
			LOG.info("\tgetCompletedTaskCount: " + threadPool.getCompletedTaskCount());
			LOG.info("\tgetQueuedTaskCount: .. " + threadPool.getQueue().size());
			LOG.info("\tgetTaskCount: ........ " + threadPool.getTaskCount());
		}
	}
	
	/**
	 * Shutdown thread pooling system correctly. Send different informations.
	 */
	public static void shutdown()
	{
		try
		{
			System.out.println("ThreadPool: Shutting down.");
			
			for (ScheduledThreadPoolExecutor threadPool : _scheduledPools)
				threadPool.shutdownNow();
			
			for (ThreadPoolExecutor threadPool : _instantPools)
				threadPool.shutdownNow();
		}
		catch (Throwable t)
		{
			t.printStackTrace();
		}
	}
	
	/**
	 * @param <T> : The pool type.
	 * @param threadPools : The pool array to check.
	 * @return the less fed pool.
	 */
	private static <T> T getPool(T[] threadPools)
	{
		return threadPools[_threadPoolRandomizer++ % threadPools.length];
	}
	
	/**
	 * @param delay : The delay to validate.
	 * @return a secured value, from 0 to MAX_DELAY.
	 */
	private static long validate(long delay)
	{
		return Math.max(0, Math.min(MAX_DELAY, delay));
	}
	
	/**
	 * @param threadPools : The pool array to check.
	 * @return the overall actual pools size.
	 */
	private static long getPoolSize(ThreadPoolExecutor[] threadPools)
	{
		long result = 0;
		
		for (ThreadPoolExecutor threadPool : threadPools)
			result += threadPool.getPoolSize();
		
		return result;
	}
	
	/**
	 * @param threadPools : The pool array to check.
	 * @return the overall maximum pools size.
	 */
	private static long getMaximumPoolSize(ThreadPoolExecutor[] threadPools)
	{
		long result = 0;
		
		for (ThreadPoolExecutor threadPool : threadPools)
			result += threadPool.getMaximumPoolSize();
		
		return result;
	}
	
	public static final class TaskWrapper implements Runnable
	{
		private final Runnable _runnable;
		
		public TaskWrapper(Runnable runnable)
		{
			_runnable = runnable;
		}
		
		@Override
		public void run()
		{
			try
			{
				_runnable.run();
			}
			catch (RuntimeException e)
			{
				LOG.warning("Exception in a Runnable execution:" + e);
			}
		}
	}
}

3 answers to this question

Recommended Posts

Guest
This topic is now closed to further replies.
×
×
  • Create New...