diff --git a/include/class-Domain.php b/include/class-Domain.php
index d4ba3fd..a5bdcc2 100644
--- a/include/class-Domain.php
+++ b/include/class-Domain.php
@@ -1,253 +1,284 @@
 <?php
 # Copyright (C) 2018, 2019, 2020 Valerio Bozzolan
 # Boz Libre Hosting Panel
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero 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 Affero 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/>.
 
 // load Plan trait
 class_exists( 'Plan' );
 
 /**
  * Methods for a Domain class
  */
 trait DomainTrait {
 
 	use PlanTrait;
 
 	/**
 	 * Count of the Domain's Mailboxes
 	 *
 	 * This is a kind of cache
 	 *
 	 * @var int
 	 */
 	private $domainMailboxCount = null;
 
 	/**
 	 * Count of the Domain's FTP accounts
 	 *
 	 * This is a kind of cache
 	 *
 	 * @var int
 	 */
 	private $domainFTPAccountCount = null;
 
 	/**
 	 * Get domain ID
 	 *
 	 * @return int
 	 */
 	public function getDomainID() {
 		return $this->get( 'domain_ID' );
 	}
 
 	/*
 	 * Get domain name
 	 *
 	 * @return string
 	 */
 	public function getDomainName() {
 		return $this->get( 'domain_name' );
 	}
 
+	/**
+	 * Get the sanitized relative directory name of this domain name
+	 *
+	 * Actually this should be valid for both the MTA and for the webserver.
+	 *
+	 * @return string
+	 */
+	public function getDomainDirname() {
+
+		$dir = $this->getDomainName();
+
+		// it was validated during creation time, but validate also now
+		// to prevent malicious actions over hacked databases
+		sanitize_subdirectory( $dir );
+
+		return $dir;
+	}
+
 	/**
 	 * Get the domain edit URl
 	 *
 	 * @param boolean $absolute True for an absolute URL
 	 * @return string
 	 */
 	public function getDomainPermalink( $absolute = false ) {
 		return Domain::permalink( $this->get( 'domain_name' ), $absolute );
 	}
 
 	/**
 	 * Check if you can create a new Mailbox for this Domain
 	 *
 	 * The Domain must have Plan informations.
 	 *
 	 * @return boolean
 	 */
 	public function canCreateMailboxInDomain() {
 		return $this->getPlanMailboxes() > $this->getDomainMailboxCount()
 		       ||
 		       has_permission( 'edit-email-all' );
 	}
 
 	/**
 	 * Check if you can create a new FTP account for this Domain
 	 *
 	 * The Domain must have Plan informations.
 	 *
 	 * The Domain must have Plan informations.
 	 */
 	public function canCreateFTPAccountForDomain() {
 		return $this->getPlanFTPUsers() > $this->getDomainFTPAccountCount()
 		       ||
 		       has_permission( 'edit-ftp-all' );
 	}
 
 	/**
 	 * Factory mailbox from this domain
 	 *
 	 * @return MailboxFullAPI
 	 */
 	public function factoryMailbox() {
 		return ( new MailboxFullAPI() )->whereDomain( $this );
 	}
 
 	/**
 	 * Factory e-mail forward from this domain
 	 *
 	 * @return MailforwardFullAPI
 	 */
 	public function factoryMailforwardfrom() {
 		return ( new MailforwardfromQuery() )->whereDomain( $this );
 	}
 
 	/**
 	 * Set a count of Domain's Mailboxes
 	 *
 	 * This method should not be used directly.
 	 *
 	 * @param $count int
 	 * @return self
 	 */
 	public function setDomainMailboxCount( $count ) {
 		$this->domainMailboxCount = $count;
 	}
 
 	/**
 	 * Get the number of Mailboxes of this Domain
 	 *
 	 * This method has a layer of cache.
 	 *
 	 * @return int
 	 */
 	public function getDomainMailboxCount() {
 
 		// check if we already know the count
 		if( !isset( $this->domainMailboxCount ) ) {
 
 			// count the number of mailboxes associated to this Domain
 			$count = $this->factoryMailbox()
 				->select( 'COUNT(*) count' )
 				->queryValue( 'count' );
 
 			// save in cache
 			$this->domainMailboxCount = (int) $count;
 		}
 
 		return $this->domainMailboxCount;
 	}
 
 	/**
 	 * Get the number of FTP accounts of this Domain
 	 *
 	 * This method has a layer of cache.
 	 *
 	 * @return int
 	 */
 	public function getDomainFTPAccountCount() {
 
 		// check if we already know the count
 		if( !isset( $this->domainFTPAccountCount ) ) {
 
 			// count the number of mailboxes associated to this Domain
 			$count = $this->factoryFTP()
 				->select( 'COUNT(*) count' )
 				->queryValue( 'count' );
 
 			// save in cache
 			$this->domainFTPAccountCount = (int) $count;
 		}
 
 		return $this->domainFTPAccountCount;
 	}
 
 	/**
-	 * Get the MTA directory containing Domain's mailboxes
+	 * Get the expected MTA directory containing Domain's mailboxes
+	 *
+	 * This pathname should be considered true for the MTA host.
 	 *
 	 * TODO: actually all the mailbox are on the same host.
 	 * Then, we should support multiple hosts.
 	 *
 	 * @return string
 	 */
 	public function getDomainMailboxesPath() {
+		// mailboxes are stored under a $BASE/domain/username filesystem structure
+		return MAILBOX_BASE_PATH . __ . $this->getDomainDirname();
+	}
 
-		// require a valid filename or throw
-		$domain_name  = $this->getDomainName();
-		require_safe_filename( $domain_name  );
-
+	/**
+	 * Get the expected and sanitized base domain directory containing its directories
+	 *
+	 * This pathname should be considered true both for the webserver serving
+	 * that domain and for the related FTP server.
+	 *
+	 * TODO: actually all the domains are on the same host.
+	 * Then, we should support multiple hosts.
+	 *
+	 * @return string
+	 */
+	public function getDomainBasePath() {
 		// mailboxes are stored under a $BASE/domain/username filesystem structure
-		return MAILBOX_BASE_PATH . __ . $domain_name;
+		return VIRTUALHOSTS_DIR . __ . $this->getDomainDirname();
 	}
 
 	/**
 	 * Factory FTP users from this domain
 	 *
 	 * @return FTPAPI
 	 */
 	public function factoryFTP() {
 		return ( new FTPAPI() )->whereDomain( $this );
 	}
 
 	/**
 	 * Normalize a Domain object after being retrieved from database
 	 */
 	protected function normalizeDomain() {
 		$this->integers( 'domain_ID' );
 		$this->booleans( 'domain_active' );
 		$this->dates( 'domain_born', 'domain_expiration' );
 
 		$this->normalizePlan();
 	}
 
 }
 
 /**
  * Describe the 'domain' table
  */
 class Domain extends Queried {
 
 	use DomainTrait;
 
 	/**
 	 * Table name
 	 */
 	const T = 'domain';
 
 	const UID = 'domain_name';
 
 	/**
 	 * Constructor
 	 */
 	public function __construct() {
 		$this->normalizeDomain();
 	}
 
 	/**
 	 * Get the domain permalink
 	 *
 	 * @param string  $domain_name Domain name
 	 * @param boolean $absolute    True for an absolute URL
 	 */
 	public static function permalink( $domain_name = null, $absolute = false ) {
 		$url = 'domain.php';
 		if( $domain_name ) {
 			$url .= _ . $domain_name;
 		}
 		return site_page( $url, $absolute );
 	}
 
 }
diff --git a/include/class-FTP.php b/include/class-FTP.php
index 7fb31f5..972e913 100644
--- a/include/class-FTP.php
+++ b/include/class-FTP.php
@@ -1,148 +1,184 @@
 <?php
-# Copyright (C) 2019 Valerio Bozzolan
+# Copyright (C) 2019, 2020 Valerio Bozzolan
 # Boz Libre Hosting Panel
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero 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 Affero 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/>.
 
 // load dependent traits
 class_exists( 'Domain' );
 
 /**
  * Methods for an FTP class
  */
 trait FTPTrait {
 
 	use DomainTrait;
 
 	/**
 	 * Get the FTP login username
 	 *
 	 * @return string E-mail
 	 */
 	public function getFTPLogin() {
 		return $this->get( 'ftp_login' );
 	}
 
 	/**
 	 * Check if this FTP account is active
 	 *
 	 * @return bool
 	 */
 	public function isFTPActive() {
 		return $this->get( 'ftp_active' );
 	}
 
+	/**
+	 * Get the sanitized raw directory
+	 *
+	 * This value is sanitized before saving but it can be a malicious string
+	 * if the database is overtaken so it's sanitized again.
+	 *
+	 * @return string
+	 */
+	public function getFTPRawDirectory() {
+
+		$path = $this->get( 'ftp_directory' );
+
+		// even if it was sanitized during creation, double-sanitize
+		// to protect against hacked databases
+		validate_subdirectory( $path );
+
+		return $path;
+	}
+
+	/**
+	 * Get the sanitized absolute FTP directory pathname
+	 *
+	 * @return string
+	 */
+	public function getFTPAbsoluteDirectory() {
+
+		// get the sanitized domain base path
+		$absolute_base = $this->getDomainBasePath();
+
+		// get the sanitized FTP raw directory
+		$relative_path = $this->getFTPRawDirectory();
+
+		// at this point the absolute realpath of the subdirectory is verified
+		// to do not contain '/../' or other crap
+		return $absolute_base . __ . $relative_path;
+	}
+
 	/**
 	 * Get the mailbox permalink
 	 *
 	 * @return string
 	 */
 	public function getFTPPermalink( $absolute = false ) {
 		return FTP::permalink(
 			$this->getDomainName(),
 			$this->getFTPLogin()
 		);
 	}
 
 	protected function normalizeFTP() {
 		$this->normalizeDomain();
 		$this->booleans( 'ftp_active' );
 		$this->integers( 'ftp_ulbandwidth',
 		                 'ftp_dlbandwidth',
 		                 'ftp_quotasize',
 		                 'ftp_quotafiles'
 		);
 	}
 
 }
 
 /**
  * An FTP user
  */
 class FTP extends Queried {
 
 	use FTPTrait;
 
 	/**
 	 * Table name
 	 */
 	const T = 'ftp';
 
 	/**
 	 * Constructor
 	 *
 	 * Normalize the object obtained from the database
 	 */
 	public function __construct() {
 		$this->normalizeFTP();
 	}
 
 	/**
 	 * Get the FTP permalink from domain name and FTP login
 	 *
 	 * @param string $domain Domain name
 	 * @param string $login FTP user login
 	 * @return string
 	 */
 	public static function permalink( $domain, $login = null ) {
 		$url = sprintf( '%s/%s', ROOT . '/ftp.php', $domain );
 		if( $login ) {
 			$url .= "/$login";
 		}
 		return $url;
 	}
 
 	/**
 	 * Encrypt an FTP user password
 	 *
 	 * @param $password string Clear text password
 	 * @return          string One-way encrypted password
 	 */
 	public static function encryptPassword( $password ) {
 		global $HOSTING_CONFIG;
 
 		// the FTP password encryption mechanism can be customized
 		if( isset( $HOSTING_CONFIG->FTP_ENCRYPT_PWD ) ) {
 			return call_user_func( $HOSTING_CONFIG->FTP_ENCRYPT_PWD, $password );
 		}
 
 		// or then just a default behaviour
 
 		/**
 		 * The default behaviour is to adopt the crypt() encryption mechanism
 		 * with SHA512 and some random salt. It's strong enough nowadays.
 		 *
 		 * Read your FTP server documentation, whatever you are using.
 		 * We don't know how your infrastructure works, so we don't know
 	 	 * how you want your password encrypted in the database and what kind
 		 * of password encryption mechanisms your FTP server supports.
 		 *
 		 * In short if you are using PureFTPd this default configuration may work
 		 * because you may have PureFTPd configured as follow:
 		 *  ...
 		 *  MYSQLCrypt crypt
 		 *  ...
 		 *
 		 * You can read more here:
 		 *   https://download.pureftpd.org/pub/pure-ftpd/doc/README.MySQL
 		 *
 		 * Anyway you can use whatever FTP server that talks with a MySQL database
 		 * and so you should adopt the most stronger encryption mechanism available.
 		 */
 
 		$salt = bin2hex( openssl_random_pseudo_bytes( 3 ) );
 		return '{SHA512-CRYPT}' . crypt( $password, "$6$$salt" );
 	}
-
 }
diff --git a/include/functions.php b/include/functions.php
index 126dd82..69c8859 100644
--- a/include/functions.php
+++ b/include/functions.php
@@ -1,231 +1,251 @@
 <?php
 # Copyright (C) 2018, 2019, 2020 Valerio Bozzolan
 # Reyboz another self-hosting panel project
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero 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 Affero 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/>.
 
 /**
  * Require a certain page from the template directory
  *
  * @param $name string page name (to be sanitized)
  * @param $args mixed arguments to be passed to the page scope
  */
 function template( $template_name, $template_args = [] ) {
 	extract( $template_args, EXTR_SKIP );
 	return require TEMPLATE_PATH . __ . "$template_name.php";
 }
 
 /**
  * Get the returned text from a template
  *
  * @param $name string page name (to be sanitized)
  * @param $args mixed arguments to be passed to the page scope
  * @see template()
  * @return string
  */
 function template_content( $name, $args = [] ) {
 	ob_start();
 	template( $name, $args );
 	$text = ob_get_contents();
 	ob_end_clean();
 	return $text;
 }
 
 /**
  * Print an e-mail (safe for bots)
  *
  * @param $email string
  */
 function email_blur( $email ) {
 	$dot = strip_tags( __( " dot <!-- . -->" ) );
 	$at  = strip_tags( __( " at <!-- @ -->"  ) );
 	$email = esc_html( $email );
 	echo str_replace( [ '.', '@' ], [ $dot, $at ], $email );
 }
 
 /**
  * Send an e-mail to someone
  *
  * @param $subject string E-mail subject
  * @param $message string E-mail message
  * @param $to string E-mail recipient (from current logged-in user as default)
  */
 function send_email( $subject, $message, $to = false ) {
 	if( ! $to ) {
 		if( ! is_logged() ) {
 			die( "can't retrieve e-mail address from anon user" );
 		}
 		$to = get_user( 'user_email' );
 	}
 	return SMTPMail::instance()
 		->to( $to )
 		->message( $subject, $message )
 		->disconnect();
 }
 
 /**
  * Require a certain permission
  *
  * @param $permission string An internal permission like 'edit-all-user'
  * @param $redirect boolean Enable or disable the redirect
  */
 function require_permission( $permission, $redirect = true ) {
 	if( ! has_permission( $permission ) ) {
 		require_more_privileges( $redirect );
 	}
 }
 
 /**
  * Require more privileges then actual ones
  */
 function require_more_privileges( $redirect = true ) {
 	if( is_logged() ) {
 		Header::spawn( [
 			'title' => __( "Permission denied" ),
 		] );
 		Footer::spawn();
 		exit;
 	} else {
 		$login = menu_entry( 'login' );
 		$url = $login->getAbsoluteURL();
 		if( $redirect && isset( $_SERVER[ 'REQUEST_URI' ] ) ) {
 			$url = http_build_get_query( $url, [
 				'redirect' => $_SERVER[ 'REQUEST_URI' ],
 			] );
 		}
 		http_redirect( $url, 307 );
 	}
 }
 /**
  * Get URL parts from the PATH_INFO
  *
  * It spawn a "bad request" page if something goes wrong.
  *
  * @param $max int If $min is specified, this is the maximum number of parameters. When unspecified, this is the exact number of parameters.
  * @param $min int Mininum number of parameters.
  * @return array
  * @see https://httpd.apache.org/docs/2.4/mod/core.html#acceptpathinfo
  */
 function url_parts( $max, $min = false ) {
 	if( $min === false ) {
 		$min = $max;
 	}
 
 	// split the PATH_INFO parts
 	$parts = explode( _, $_SERVER['PATH_INFO'] ?? '' );
 	array_shift( $parts );
 
 	// eventually spawn the "bad request"
 	$n = count( $parts );
 	if( $n > $max || $n < $min ) {
 		BadRequest::spawn( __( "unexpected URL" ) );
 	}
 
 	// eventually fill expected fields
 	for( $i = $n; $i < $max; $i++ ) {
 		$parts[] = null;
 	}
 	return $parts;
 }
 
 /**
  * Link to an existing page from the menu
  *
  * @param $uid string E.g. 'index'
  * @param $args mixed Arguments
  */
 function the_menu_link( $uid, $args = [] ) {
 	$page = menu_entry( $uid );
 	the_link( $page->getURL(), $page->name, $args );
 }
 
 /**
  * Link to a whatever page
  *
  * P.S. link() is a reserved function
  *
  * @param $url string
  * @param $title string
  * @param $args mixed Arguments
  */
 function the_link( $url, $title, $args = [] ) {
 	template( 'link', [
 		'title' => $title,
 		'url'   => $url,
 		'args'  => $args,
 	] );
 }
 
 /**
  * Generate a password
  *
  * @param $bytes int
  */
 function generate_password( $bytes = 8 ) {
 	return rtrim( base64_encode( bin2hex( openssl_random_pseudo_bytes( $bytes ) ) ), '=' );
 }
 
 /**
  * Validate a mailbox username
  *
  * @param $mailbox string
  * @return bool
  */
 function validate_mailbox_username( $mailbox ) {
 	return 1 === preg_match( '/^[a-z][a-z0-9-_.]+$/', $mailbox );
 }
 
 /**
  * A certain value must be an e-mail
  *
  * @param $email string
  * @return string filtered e-mail
  */
 function require_email( $email ) {
 	$email = luser_input( $email, 128 );
 	if( filter_var( $email, FILTER_VALIDATE_EMAIL ) === false ) {
 		BadRequest::spawn( __( "fail e-mail validation" ) );
 	}
 	return $email;
 }
 
 /**
  * Require a safe filename or throw an exception
  *
- * It returns that filename untouched.
- *
  * This method is designed to prevend known filesystem exploitations
  * that may occurr if a database is compromised and with malicous
  * data inside domain names, etc.
  *
  * Just don't try to register a domain name with a slash in the name.
  *
  * @param  string $filename
- * @return string
  */
 function require_safe_filename( $filename ) {
-
 	$bads = [ '/', '\\', '..' ];
 	foreach( $bads as $bad ) {
-		if( strpos( $filename, '/' ) !== false ) {
+		if( strpos( $filename, $bad ) !== false ) {
 			throw new Exception( sprintf(
 				"found '%s' in the string '%s': it cannot be considered a safe file/directory name",
 				$bad,
 				$filename
 			) );
 		}
 	}
+}
 
-	return $filename;
+/**
+ * Check if a directory is under a subdirectory or throw
+ *
+ * @param string $sub_directory  Specific sub-directory or just '/'
+ */
+function validate_subdirectory( $sub_directory ) {
+
+	// validate each part of this pathname
+	// note that '' and '/' are allowed and also '///' because
+	// Unix already accepts it and resolve '/base////suffix'
+	// It does NOT accept stuff like '/../../'
+	$parts = explode( '/', $sub_directory );
+	foreach( $parts as $part ) {
+		try {
+			require_safe_filename( $part );
+		} catch( Exception $e ) {
+			// give a meaningful exception
+			throw new Exception( sprintf(
+				"unable to sanitize '%s': %s",
+				$sub_directory,
+				$e->getMessage()
+			) );
+		}
+	}
 }
diff --git a/load-post.php b/load-post.php
index 289e558..c7d61ef 100644
--- a/load-post.php
+++ b/load-post.php
@@ -1,103 +1,109 @@
 <?php
 # Copyright (C) 2018 Valerio Bozzolan
 # Boz Libre Hosting Panel
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero 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 Affero 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/>.
 
 // include path
 define_default( 'INCLUDE_PATH',  ABSPATH . __ . 'include' );
 
 // template path
 define_default( 'TEMPLATE_PATH', ABSPATH . __ . 'template' );
 
 // autoload classes from the /include directory
 spl_autoload_register( function( $name ) {
 	// TODO: autoload classes and create DomainTrait and use in Mailbox
 	$path = INCLUDE_PATH . __ . "class-$name.php";
 	if( is_file( $path ) ) {
 		require $path;
 	}
 } );
 
 // override default user class
 define( 'SESSIONUSER_CLASS', 'User' );
 
 // load common functions
 require INCLUDE_PATH . __ . 'functions.php';
 
 // jquery URL
 // provided by the libjs-jquery package as default
 define_default( 'JQUERY_URL', '/javascript/jquery/jquery.min.js' );
 
 // Bootstrap CSS/JavaScript files without trailing slash
 // provided by the libjs-bootstrap package as default
 define_default( 'BOOTSTRAP_DIR_URL', '/javascript/bootstrap' );
 
 // path to the Net SMTP class
 // provided by the php-net-smtp package as default
 define_default( 'NET_SMTP', '/usr/share/php/Net/SMTP.php' );
 
+// base directory for your virtualhosts
+// e.g. you may have /var/www/example.com/index.html
+// do NOT end with a slash
+// TODO: support multiple hosts
+define_default( 'VIRTUALHOSTS_DIR', '/var/www' );
+
 // register JavaScript/CSS files
 register_js(  'jquery',     JQUERY_URL );
 register_js(  'bootstrap',  BOOTSTRAP_DIR_URL .  '/js/bootstrap.min.js'  );
 register_css( 'bootstrap',  BOOTSTRAP_DIR_URL . '/css/bootstrap.min.css' );
 register_css( 'custom-css', ROOT . '/content/style.css' );
 
 // GNU Gettext i18n
 define( 'GETTEXT_DOMAIN', 'reyboz-hosting-panel' );
 define( 'GETTEXT_DIRECTORY', 'l10n' );
 define( 'GETTEXT_DEFAULT_ENCODE', CHARSET ); // UTF-8
 
 // common strings
 define_default( 'SITE_NAME', "Boz Libre Hosting Panel" );
 define_default( 'CONTACT_EMAIL', 'support@' . DOMAIN );
 define_default( 'REPO_URL', 'https://gitpull.it/project/profile/15/' );
 
 // limit session duration to 5 minutes (60s * 100m)
 define_default( 'SESSION_DURATION', 6000 );
 
 /**
  * Mailbox base path
  *
  * Used by CLI scripts to calculate the current quotas.
  *
  * The mailboxes should have paths like:
  *     MAILBOX_BASE_PATH/domain_name/user_name/
  */
 define_default( 'MAILBOX_BASE_PATH', '/home/vmail' );
 
 // register web pages
 add_menu_entries( [
 	new MenuEntry( 'index',          '/',                   __( "Dashboard"      ), null, 'backend'       ),
 	new MenuEntry( 'login',          'login.php',           __( "Login"          )                        ),
 	new MenuEntry( 'profile',        'profile.php',         __( "Profile"        )                        ),
 	new MenuEntry( 'logout',         'logout.php',          __( "Logout"         ), null, 'read'          ),
 	new MenuEntry( 'user-list',      'user-list.php',       __( "Users"          ), null, 'edit-user-all' ),
 	new MenuEntry( 'password-reset', 'password-reset.php',  __( "Password reset" )                        ),
 ] );
 
 // permissions of a normal user
 register_permissions( 'user', [
 	'read',
 	'backend',
 ] );
 
 // permissions of an admin
 inherit_permissions( 'admin', 'user', [
 	'edit-user-all',
 	'edit-email-all',
 	'edit-domain-all',
 	'edit-plan-all',
 	'edit-ftp-all',
 ] );
diff --git a/template/ftp.php b/template/ftp.php
index 117e112..ba4556f 100644
--- a/template/ftp.php
+++ b/template/ftp.php
@@ -1,77 +1,108 @@
 <?php
-# Copyright (C) 2019 Valerio Bozzolan
+# Copyright (C) 2019, 2020 Valerio Bozzolan
 # Boz Libre Hosting Panel
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero 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 Affero 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/>.
 
 /*
  * This is the template for a single FTP account
  *
  * Called from:
  * 	ftp.php
  *
  * Available variables:
  * 	$domain Domain object
  * 	$ftp FTP object
  */
 
 // unuseful when load directly
 defined( 'BOZ_PHP' ) or die;
 ?>
 
 	<?php template( 'ftp-description' ) ?>
 
+	<!-- access instructions -->
 	<?php if( $ftp ): ?>
 
 		<?php template( 'ftp-access', [
 			'domain' => $domain,
 			'ftp'    => $ftp,
 		] ) ?>
+	<?php endif ?>
+	<!-- /access instructions -->
 
-		<h3><?= __( "Actions" ) ?></h3>
+	<!-- create/edit form -->
+	<form method="post">
+		<?php form_action( 'ftp-save' ) ?>
 
-		<!-- delete form -->
-		<form method="post">
-			<?php form_action( 'ftp-delete' ) ?>
-			<?= HTML::input( 'hidden', 'ftp_login', $ftp->getFTPLogin() ) ?>
-			<p><button type="submit" class="btn btn-danger"><?= __( "Delete" ) ?></button></p>
-		</form>
-		<!-- /delete form -->
+		<p>
+			<label for="ftp-login"><?= __( "FTP login:") ?></label><br />
+			<input type="text" id="ftp-login" name="ftp_login"<?php
+				if( $ftp ) {
+					echo value( $ftp->getFTPLogin() );
+					echo " readonly";
+				}
+			?> />
+		</p>
+
+		<p>
+			<label for="ftp-directory"><?= __( "Sub-Directory:") ?></label><br />
+			<input type="text" id="ftp-directory" name="ftp_directory" placeholder="/"<?php
+				if( $ftp ) {
+					echo value( $ftp->getFTPRawDirectory() );
+				}
+			?> />
+		</p>
+		<p class="tip"><?= __( "Tip:" ) ?>
+			<em><?= __( "You may want to change the default Sub-Directory to restrict this FTP user to a specific pathname." ) ?>
+			    <?= __( "Anyway, you may break the login of this FTP user if you don't know what you are doing." ) ?>
+			</em>
+		</p>
+		<p>
+			<button type="submit" class="btn btn-default"><?=
+				$ftp ? __( "Save"   )
+				     : __( "Create" )
+			?></button>
+		</p>
+	</form>
+	<!-- /create/edit form -->
+
+	<!-- other actions -->
+	<?php if( $ftp ): ?>
+		<h3><?= __( "Password" ) ?></h3>
 
 		<!-- change password form -->
 		<form method="post">
 			<?php form_action( 'ftp-password-reset' ) ?>
 			<?php if( $ftp_password ): ?>
 				<label for="password"><?= __( "Please copy your new password:" ) ?><br />
 				<input type="text" id="password" readonly<?= value( $ftp_password ) ?> />
 			<?php else: ?>
 				<p><button type="submit" class="btn btn-default"><?= __( "Generate new password" ) ?></button></p>
 			<?php endif ?>
 		</form>
 		<!-- /change password form -->
 
-	<?php else: ?>
+		<h3><?= __( "Actions" ) ?></h3>
 
-		<!-- create form -->
+		<!-- delete form -->
 		<form method="post">
-			<?php form_action( 'ftp-save' ) ?>
-			<p>
-				<label for="ftp-login"><?= __( "FTP login:") ?></label><br />
-				<input type="text" id="ftp-login" name="ftp_login" />
-				<button type="submit" class="btn btn-default"><?= __( "Create" ) ?></button>
-			</p>
+			<?php form_action( 'ftp-delete' ) ?>
+			<?= HTML::input( 'hidden', 'ftp_login', $ftp->getFTPLogin() ) ?>
+			<p><button type="submit" class="btn btn-danger"><?= __( "Delete" ) ?></button></p>
 		</form>
-		<!-- /create form -->
+		<!-- /delete form -->
 
 	<?php endif ?>
+	<!-- /other actions -->
diff --git a/www/ftp.php b/www/ftp.php
index d3b36f9..75316e3 100644
--- a/www/ftp.php
+++ b/www/ftp.php
@@ -1,179 +1,208 @@
 <?php
-# Copyright (C) 2019 Valerio Bozzolan
+# Copyright (C) 2019, 2020 Valerio Bozzolan
 # Boz Libre Hosting Panel
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero 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 Affero 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/>.
 
 /*
  * This is the single FTP account creation/edit page
  */
 
 // load framework
 require '../load.php';
 
 // wanted informations
 $domain       = null;
 $ftp          = null;
 $ftp_password = null;
 
 // URL paramenters (maximum both domain and FTP login, minimum just domain)
 list( $domain_name, $ftp_login ) = url_parts( 2, 1 );
 
 // eventually retrieve mailforward from database
 if( $ftp_login ) {
 	$ftp = ( new FTPAPI() )
 		->select( [
 			'domain.domain_ID',
 			'domain_name',
 			'ftp_login',
+			'ftp_directory',
 		] )
 		->joinFTPDomain()
 		->whereDomainName( $domain_name )
 		->whereFTPLogin( $ftp_login )
 		->whereDomainIsEditable()
 		->queryRow();
 
 	// 404
 	$ftp or PageNotFound::spawn();
 
 	// recycle the mailforward object that has domain informations
 	$domain = $ftp;
 }
 
 // eventually retrieve domain from database
 if( ! $domain ) {
 	$domain = ( new DomainAPI() )
 		->select( [
 			'domain.domain_ID',
 			'domain.domain_name',
 		] )
 		->whereDomainName( $domain_name )
 		->whereDomainIsEditable()
 		->queryRow();
 
 	// 404
 	$domain or PageNotFound::spawn();
 }
 
 if( ! $ftp ) {
 	// to create an FTP user, must edit all FTP users
 	require_permission( 'edit-ftp-all' );
 }
 
 // save destination action
 if( is_action( 'ftp-save' ) ) {
 
-	// save source only during creation
-	if( ! $ftp ) {
+	$data = [];
 
-		// sanitize data
+	// during creation require FTP login
+	if( !$ftp ) {
 		if( !isset( $_POST['ftp_login'] ) || !is_string( $_POST['ftp_login'] ) ) {
 			BadRequest::spawn( __( "missing parameter" ) );
 		}
 
-		// generate the username (must start with domain name)
-		$username = generate_slug( $domain->getDomainName() ) . '_' . $_POST[ 'ftp_login' ];
+		// generate the username - MUST start with domain name
+		$username = generate_slug( $domain->getDomainName() ) . '_' . $_POST['ftp_login'];
 		$username = luser_input( $username, 128 );
 
 		// validate username
 		if( !validate_mailbox_username( $username ) ) {
-			BadRequest::spawn( __( "invalid mailbox name" ) );
+			BadRequest::spawn( __( "invalid username" ) );
 		}
 
 		// check existence
 		$ftp_exists = ( new FTPAPI() )
 			->select( 1 )
 			->whereDomain( $domain )
 			->whereFTPLogin( $username )
 			->queryRow();
 
 		// die if exists
 		if( $ftp_exists ) {
 			BadRequest::spawn( __( "FTP account already existing" ) );
 		}
 
-		// generate a random password and die (probably the User will not see it because of the redirect)
-		$ftp_password      = generate_password();
-		$ftp_password_safe = FTP::encryptPassword( $ftp_password );
+		// save these fields
+		$data['domain_ID'] = $domain->getDomainID();
+		$data['ftp_login'] = $username;
+	} else {
+		$username = $ftp->getFTPLogin();
+	}
 
-		// insert as new row
-		insert_row( 'ftp', [
-			new DBCol( 'domain_ID',    $domain->getDomainID(), 'd' ),
-			new DBCol( 'ftp_login',    $username,              's' ),
-			new DBCol( 'ftp_password', $ftp_password_safe,         's' ),
-		] );
+	// no FTP directory no party
+	$ftp_directory = $_POST['ftp_directory'] ?? null;
+
+	// validate the FTP directory and save or die
+	try {
+		validate_subdirectory( $ftp_directory );
+	} catch( Exception $e ) {
+		error_log( $e->getMessage() );
+		BadRequest::spawn( sprintf(
+			__( "invalid Sub-Directory: %s" ),
+			$e->getMessage()
+		) );
+	}
 
-		// POST/redirect/GET
-		http_redirect( FTP::permalink(
-			$domain->getDomainName(),
-			$username,
-			true
-		), 303 );
+	// at this point the directory is safe
+	$data['ftp_directory'] = $ftp_directory ?? null;
+
+	// during creation generate a random password
+	if( !$ftp ) {
+		$ftp_password = generate_password();
+		$data['ftp_password'] = FTP::encryptPassword( $ftp_password );
 	}
+
+	// insert or update
+	if( $ftp ) {
+		( new FTPAPI() )
+			->whereFTP( $ftp )
+			->update( $data );
+	} else {
+		( new FTPAPI() )
+			->insertRow( $data );
+	}
+
+	// POST/redirect/GET
+	http_redirect( FTP::permalink(
+		$domain->getDomainName(),
+		$username,
+		true
+	), 303 );
 }
 
 // change password action
 if( $ftp && is_action( 'ftp-password-reset' ) ) {
 
 	// generate a password and die
 	$ftp_password      = generate_password();
 	$ftp_password_safe = FTP::encryptPassword( $ftp_password );
 
 	// update its password
 	( new FTPAPI() )
 		->whereFTP( $ftp )
 		->update( [
 			'ftp_password' => $ftp_password_safe,
 		] );
 }
 
 // delete action
 if( $ftp ) {
 
 	// action fired when deleting a whole mailforward
 	if( is_action( 'ftp-delete' ) ) {
 
 		// delete the account
 		( new FTPAPI() )
 			->whereFTP( $ftp )
 			->delete();
 
 		// POST/redirect/GET
 		http_redirect( $domain->getDomainPermalink( true ), 303 );
 
 	}
 
 }
 
 // spawn header
 Header::spawn( [
 	'uid' => false,
 	'title-prefix' => __( "FTP user" ),
 	'title' => $ftp
 		? $ftp->getFTPLogin()
 		: __( "create" ),
 	'breadcrumb' => [
 		new MenuEntry( null, $domain->getDomainPermalink(), $domain->getDomainName() ),
 	],
 ] );
 
 // spawn the page content
 template( 'ftp', [
 	'domain'       => $domain,
 	'ftp'          => $ftp,
 	'ftp_password' => $ftp_password,
 ] );
 
 // spawn the footer
 Footer::spawn();