├── .gitignore ├── BUGS ├── logo.gif ├── templates ├── user.twig ├── master.twig ├── welcome.twig └── user_list.twig ├── provisioning ├── apache-site.conf ├── Dockerfile_pureftp └── pureftp-mysql.conf ├── LICENSE.txt ├── include ├── Form │ ├── Form.php │ └── User.php ├── Flash.php ├── Template.php ├── Database.php └── UserAdmin.php ├── schema.sql ├── INSTALL ├── docs └── pureftp-mysql.conf.example ├── .github └── workflows │ └── php.yml ├── CHANGELOG ├── composer.json ├── docker-compose.yml ├── config.php ├── psalm.xml ├── README.md └── public └── index.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | config.local.php 3 | -------------------------------------------------------------------------------- /BUGS: -------------------------------------------------------------------------------- 1 | See https://github.com/DavidGoodwin/pureftp-user-admin/issues -------------------------------------------------------------------------------- /logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidGoodwin/pureftp-user-admin/HEAD/logo.gif -------------------------------------------------------------------------------- /templates/user.twig: -------------------------------------------------------------------------------- 1 |

{{ edit_type }} User

2 | 3 | {{ form | raw }} 4 | 5 | -------------------------------------------------------------------------------- /provisioning/apache-site.conf: -------------------------------------------------------------------------------- 1 | 2 | ServerName test 3 | DocumentRoot /srv/pureftp-admin/public 4 | 5 | Require all granted 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GPL v3.0 2 | 3 | See : http://pureuseradmin.sourceforge.net 4 | See : https://github.com/DavidGoodwin/pureftp-user-admin/blob/aa19ef51dec4ef1e001e228c7e02286fb1e54c0c/pureuserclass.php 5 | See : http://www.gnu.org/licenses/gpl.html 6 | -------------------------------------------------------------------------------- /include/Form/Form.php: -------------------------------------------------------------------------------- 1 | messages = ['info' => [], 'error' => [] ]; 19 | } 20 | 21 | 22 | public function info(string $message) : void { 23 | $this->messages['info'][] = $message; 24 | } 25 | 26 | public function error(string $message) : void { 27 | $this->messages['error'][] = $message; 28 | } 29 | 30 | public function getMessages() : array { 31 | return $this->messages; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: setup PHP? 14 | uses: shivammathur/setup-php@v2 15 | with: 16 | php-version: 7.4 17 | extensions: composer:v2 18 | coverage: none 19 | env: 20 | update: true 21 | 22 | - name: Validate composer.json and composer.lock 23 | run: composer validate 24 | 25 | - name: Install dependencies 26 | run: composer install --prefer-dist --no-progress --no-suggest 27 | 28 | - name: Run test suite 29 | run: composer psalm 30 | 31 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | PureUserAdmin: ChangeLog 2 | 3 | [0.4.0] 2021/05/21 4 | 5 | * Support PHP 7.x 6 | * Move to use PDO / prepared statements etc 7 | * add psalm static analysis 8 | * support argon2i/sha1/crypt/md5 password hashing 9 | 10 | [0.2.1] 11 | * added search and paging in userlist again 12 | 13 | [0.2.0] 14 | * php5 support 15 | * dropped php4 support 16 | * all is in a class now 17 | * phpdoc comments 18 | * made the database fields part of the settings array 19 | 20 | [0.1.0] 21 | * added welcome screen 22 | * added email notification 23 | 24 | [0.0.3] 25 | * changed vars to match my release system 26 | 27 | [0.0.2] 28 | * added check for homedir rights 29 | * changed look 30 | 31 | [0.0.1] 32 | * Initial Release 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "davidgoodwin/pureftp-user-admin", 3 | "description": "Web UI for managing PureFTP users within a SQL database", 4 | "type": "project", 5 | "require-dev": { 6 | "php-parallel-lint/php-parallel-lint": "^1.0", 7 | "phpunit/phpunit": "^7.0 | ^8", 8 | "vimeo/psalm": "*" 9 | }, 10 | "license": "GPL v2.0", 11 | "authors": [ 12 | { 13 | "name": "David Goodwin", 14 | "email": "david@palepurple.co.uk" 15 | } 16 | ], 17 | "require": { 18 | "php" : "^7.2 | ^8.0", 19 | "twig/twig": "^3.0", 20 | "shardj/zf1-future" : "^1.14.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "PureFTPAdmin\\" : "include\\" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "PureFTPAdmin\\Test\\": "tests\\" 30 | } 31 | }, 32 | "scripts": { 33 | "lint": "@php vendor/bin/parallel-lint --exclude vendor public include", 34 | "psalm": "@php vendor/bin/psalm --show-info=false", 35 | "phpunit": "@php vendor/bin/phpunit", 36 | "test": [ 37 | "@lint", 38 | "@phpunit" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | web: 4 | image: davidgoodwin/debian-buster-php74:latest 5 | volumes: 6 | - ./provisioning/apache-site.conf:/etc/apache2/sites-enabled/000-default.conf 7 | - ./:/srv/pureftp-admin 8 | ports: 9 | - "08:80" 10 | networks: 11 | - testnet 12 | depends_on: 13 | - mysql 14 | environment: 15 | - DATABASE_DSN=mysql:host=mysql;dbname=pureftp 16 | - DATABASE_USER=username 17 | - DATABASE_PASS=password 18 | 19 | ftp: 20 | build: 21 | context: ./ 22 | dockerfile: provisioning/Dockerfile_pureftp 23 | ports: 24 | - "21:21" 25 | networks: 26 | - testnet 27 | privileged: true 28 | 29 | mysql: 30 | image: mariadb:latest 31 | ports: 32 | - "3306:3306" 33 | networks: 34 | - testnet 35 | environment: 36 | MYSQL_INITDB_SKIP_TZINFO: non-empty 37 | MYSQL_ROOT_PASSWORD: test 38 | MYSQL_USER: username 39 | MYSQL_PASSWORD: password 40 | MYSQL_DATABASE: pureftp 41 | volumes: 42 | - ./schema.sql:/docker-entrypoint-initdb.d/pureftp-schema.sql 43 | networks: 44 | testnet: 45 | -------------------------------------------------------------------------------- /templates/master.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PureUserAdmin - {{ page_title }} 6 | 11 | 12 | 13 | 14 |
15 | 23 | 24 | {% for message in messages.info %} 25 |

{{ message }}

26 | {% endfor %} 27 | {% for message in messages.error %} 28 |

{{ message }}

29 | {% endfor %} 30 | 31 |
32 | 33 | 34 |
35 |
36 | {% include body_template %} 37 |
38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /templates/welcome.twig: -------------------------------------------------------------------------------- 1 |

Welcome

2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 |
Settings 
Debugging:{{ settings_debug }}
Program version:{{ settings_version }}
FTP server address:{{ settings_ftp_hostname }}
Admin email address:{{ settings_admin_email }}
Database DSN:{{ settings_database_dsn }}
Password crypt method:{{ settings_pwcrypt }}
Check homedir access:{{ settings_check_access }}
Email user:{{ settings_notify_user }}
Default uid: {{ settings_default_uid }}
Default gid: {{ settings_default_gid }}
58 | -------------------------------------------------------------------------------- /include/Template.php: -------------------------------------------------------------------------------- 1 | variables['page_title'] = $page_title; 25 | 26 | } 27 | 28 | /** 29 | * @param string $variable 30 | * @param mixed $value 31 | * @return void 32 | */ 33 | public function assign(string $variable, $value): void 34 | { 35 | if ($value instanceof \PureFTPAdmin\Form\Form) { 36 | $value = $value->render(); 37 | } 38 | 39 | $this->variables[$variable] = $value; 40 | } 41 | 42 | /** 43 | * @param string $body_template - inner template. 44 | * @return string html hopefully. 45 | * @throws \Twig_Error_Loader 46 | * @throws \Twig_Error_Runtime 47 | * @throws \Twig_Error_Syntax 48 | */ 49 | public function display(string $body_template): string 50 | { 51 | 52 | $loader = new \Twig\Loader\FilesystemLoader(dirname(__FILE__) . '/../templates'); 53 | 54 | $twig = new \Twig\Environment($loader); 55 | 56 | 57 | $variables = $this->variables; 58 | 59 | $variables['body_template'] = $body_template; 60 | 61 | return $twig->render('master.twig', $variables); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /templates/user_list.twig: -------------------------------------------------------------------------------- 1 |

User List

2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for user in users %} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 42 | 43 | {% endfor %} 44 | 45 |
UsernameHomeEmailUIDGIDPermissions?Actions
{{ user.username |e }}{{ user.dir |e }}{{ user.email |e }}{{ user.uid |e }}{{ user.gid | e}}{{ user.rights | e }} 31 |
32 | 33 | 34 | 35 |
36 |
37 | 38 | 39 | 40 |
41 |
46 | -------------------------------------------------------------------------------- /config.php: -------------------------------------------------------------------------------- 1 | '0.4.0', 9 | 'homepage' => "https://github.com/DavidGoodwin/pureftp-user-admin", 10 | 'check_access' => true , // boolean - check if user has read/write access in homedir 11 | 'notify_user' => false, // if enabled, email new user with password. Database needs field "email" 12 | 'admin_email' => 'admin+pureftp@example.com', 13 | 'ftp_hostname' => php_uname('n'), 14 | 15 | // database settigs 16 | // We require a PDO DSN. 17 | 'database_dsn' => getenv('DATABASE_DSN') ?: "mysql:host=localhost;dbname=pureftp", 18 | 'database_user' => getenv('DATABASE_USER') ?: 'db_username', 19 | 'database_pass' => getenv('DATABASE_PASS') ?: 'db_password', 20 | 'sql_table' => 'logins', 21 | 'field_uid' => 'uid', 22 | 'field_gid' => 'gid', 23 | 'field_pass' => 'password', 24 | 'field_user' => 'username', 25 | 'field_dir' => 'dir', 26 | 'field_email' => 'email', 27 | 28 | // How we bash/encrypt user's passwords. 29 | // https://download.pureftpd.org/pub/pure-ftpd/doc/README.MySQL 30 | // (best) argon2i > (good) crypt > sha1 > md5 > cleartext (not good) 31 | 'pwcrypt' => "argon2i", 32 | 'default_uid' => "65534", // nobody 33 | 'default_gid' => "65534", // nogrop 34 | 'page_size' => 40, 35 | ]; 36 | 37 | 38 | $optional = dirname(__FILE__) . '/config.local.php'; 39 | 40 | if(file_exists($optional)) { 41 | // put your local config changes in this file to overwrite $config['things'] 42 | require_once($optional); 43 | } 44 | 45 | return $config; 46 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /include/Database.php: -------------------------------------------------------------------------------- 1 | pdo = $pdo; 16 | } 17 | 18 | /** 19 | * Run an update/insert/delete on the db; returns row count. 20 | * @param string $sql 21 | * @param array $args for prepared statement placeholders 22 | * @return int 23 | */ 24 | public function update(string $sql, array $args = []): int 25 | { 26 | $stmt = $this->pdo->prepare($sql); 27 | $stmt->execute($args); 28 | return $stmt->rowCount(); 29 | } 30 | 31 | /** 32 | * Run a select query on the DB. 33 | * @param string $sql 34 | * @param array $args 35 | * @return array - assoc array of results 36 | */ 37 | public function select(string $sql, array $args): array 38 | { 39 | $stmt = $this->pdo->prepare($sql); 40 | $stmt->execute($args); 41 | return $stmt->fetchAll(\PDO::FETCH_ASSOC); 42 | } 43 | 44 | /** 45 | * Run a select query on the DB. 46 | * @param string $sql 47 | * @param array $args 48 | * @return array|false - assoc array - single row from the database. 49 | */ 50 | public function selectOne(string $sql, array $args) 51 | { 52 | $stmt = $this->pdo->prepare($sql); 53 | $stmt->execute($args); 54 | return $stmt->fetch(\PDO::FETCH_ASSOC); 55 | } 56 | 57 | 58 | /** 59 | * Get the last autoincrement value. 60 | * @return string 61 | */ 62 | public function getLastInsertId(): string 63 | { 64 | return $this->pdo->lastInsertId(); 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PureUserAdmin v 0.4.0 2 | 3 | ![PHP Composer](https://github.com/DavidGoodwin/pureftp-user-admin/workflows/PHP%20Composer/badge.svg) 4 | 5 | 6 | # TODO 7 | 8 | * Basic tests 9 | * docker-services.yml for example with pureftp + end-to-end test. 10 | 11 | # Changes since 0.3 ... 12 | 13 | * Refactor codebase 14 | * Try and remove XSS, and SQL Injection issues 15 | * Try and support multiple databases (via PDO) 16 | * Support better password hash variants 17 | 18 | 19 | # Installation 20 | 21 | (Assuming Debian or derivative) 22 | 23 | 24 | * apt-get install pure-ftpd-mysql 25 | * git clone git@github.com:DavidGoodwin/pureftp-user-admin.git /var/www/somewhere/ 26 | * In /var/www/somewhere ... 27 | * wget https://getcomposer.org/composer.phar && php composer.phar install 28 | * echo "CREATE DATABASE pureftp" | mysql --defaults-extra-file=/etc/mysql/debian.cnf 29 | * echo "CREATE USER pureftp IDENTIFIED BY PASSWORD 'somepass'" | mysql --defaults-extra-file=/etc/mysql/debian.cnf 30 | * mysql --defaults-extra-file=/etc/mysql/debian.cnf pureftp < schema.sql 31 | * Edit /var/www/somewhere/config.php with your database details 32 | * Expose /var/www/somewhere/public via Apache (e.g. Alias /pureftp-admin /var/www/somewhere/public ) 33 | * Configure pure-ftp 34 | * See docs/pureftp-mysql.conf.example for what you could put in /etc/pure-ftpd/db/mysql.conf 35 | * cp docs/pureftp-mysql.conf.example /etc/pure-ftpd/db/mysql.conf 36 | * echo "yes" > /etc/pure-ftpd/conf/DisplayDotFiles 37 | * echo "no" > /etc/pure-ftpd/conf/PAMAuthentication 38 | * And if you're having problems: 39 | * echo "yes" > /etc/pure-ftpd/conf/VerboseLog 40 | * /etc/init.d/pure-ftpd-mysql restart 41 | * test? 42 | 43 | # Copyright 44 | 45 | Historical note etc - 46 | 47 | Copyright (c) 2004, Michiel van Baak 48 | Licensed under the General Public License (GPL), see COPYING file 49 | provided with this program. 50 | 51 | 52 | -------------------------------------------------------------------------------- /include/Form/User.php: -------------------------------------------------------------------------------- 1 | form = new \Zend_Form(); 19 | 20 | $this->form->setMethod('POST'); 21 | $this->form->setAction(''); 22 | 23 | $username = new \Zend_Form_Element_Text('username'); 24 | $username->setRequired(True); 25 | $username->setLabel('Username'); 26 | $username->setDescription('FTP Username'); 27 | $username->addValidator(new \Zend_Validate_StringLength([1,30])); 28 | 29 | 30 | $dir = new \Zend_Form_Element_Text('dir'); 31 | $dir->setRequired(True); 32 | $dir->setLabel('Home Directory'); 33 | $dir->setDescription('Filesystem path'); 34 | $dir->addValidator(new \Zend_Validate_StringLength([1,100])); 35 | 36 | 37 | $email = new \Zend_Form_Element_Text('email'); 38 | $email->setRequired(false); 39 | $email->setLabel('Email address'); 40 | 41 | $email->addValidator(new \Zend_Validate_StringLength([1, 100])); 42 | $email->addValidator(new \Zend_Validate_EmailAddress()); 43 | 44 | 45 | $password = new \Zend_Form_Element_Text('password'); 46 | $password->setRequired(false); 47 | if($is_new) { 48 | $password->setRequired(true); 49 | } 50 | $password->addValidator(new \Zend_Validate_StringLength([0,100])); 51 | $password->setLabel('Password'); 52 | 53 | 54 | $uid_select = new \Zend_Form_Element_Select('uid'); 55 | $uid_select->setRequired(true); 56 | $uid_select->setLabel('User ID'); 57 | 58 | $gid_select = new \Zend_Form_Element_Select('gid'); 59 | $gid_select->setRequired(true); 60 | $gid_select->setLabel("Group ID"); 61 | 62 | $this->form->addElement($username); 63 | $this->form->addElement($password); 64 | 65 | $this->form->addElement($dir); 66 | $this->form->addElement($email); 67 | 68 | $this->form->addElement($uid_select); 69 | $this->form->addElement($gid_select); 70 | 71 | $submit = new \Zend_Form_Element_Submit('Save'); 72 | 73 | $this->form->addElement($submit); 74 | 75 | if(!empty($data)) { 76 | $this->form->isValid($data); 77 | } 78 | 79 | $this->form->setElementFilters(array('StringTrim', 'StripTags')); 80 | } 81 | 82 | /** 83 | * @return string (html, presumably) 84 | */ 85 | public function render() { 86 | 87 | // see http://blog.kosev.net/2010/06/tutorial-create-zend-framework-form/ 88 | $this->form->setDecorators(array( 89 | 'FormElements', 90 | array('HtmlTag', array('tag' => 'table')), 91 | 'Form' 92 | )); 93 | $this->form->setElementDecorators(array( 94 | 'ViewHelper', 95 | 'Errors', 96 | array(array('data' => 'HtmlTag'), array('tag' => 'td')), 97 | array('Label', array('tag' => 'td')), 98 | array(array('row' => 'HtmlTag'), array('tag' => 'tr')) 99 | )); 100 | 101 | 102 | return $this->form->render(new \Zend_View()); 103 | } 104 | 105 | /** 106 | * @return array 107 | */ 108 | public function getValues() { 109 | return $this->form->getValues(); 110 | } 111 | 112 | /** 113 | * @param array $data 114 | * @return bool 115 | */ 116 | public function isValid(array $data) { 117 | return $this->form->isValid($data); 118 | } 119 | 120 | /** 121 | * @param array $list 122 | * @return User 123 | */ 124 | public function setGidList(array $list) { 125 | /* @var \Zend_Form_Element_Select $select */ 126 | $select = $this->form->getElement('uid'); 127 | $select->setMultiOptions($list); 128 | return $this; 129 | } 130 | 131 | /** 132 | * @param array $list 133 | * @return User 134 | */ 135 | public function setUidList(array $list) { 136 | /* @var \Zend_Form_Element_Select $select */ 137 | $select = $this->form->getElement('gid'); 138 | $select->setMultiOptions($list); 139 | return $this; 140 | 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); 10 | 11 | 12 | $database = new \PureFTPAdmin\Database($pdo); 13 | $model = new \PureFTPAdmin\UserAdmin($database, $settings); 14 | $flash = new \PureFTPAdmin\Flash(); 15 | 16 | $action = isset($_REQUEST['action']) && is_string($_REQUEST['action']) ? $_REQUEST['action'] : 'welcome'; 17 | 18 | $allowable_actions = ['welcome', 'delete_user', 'edit_user', 'new_user']; 19 | 20 | if (!in_array($action, $allowable_actions)) { 21 | $action = 'welcome'; 22 | } 23 | 24 | if (in_array($action, ['edit_user', 'new_user'])) { 25 | 26 | $what = 'New User'; 27 | $user = []; 28 | $is_new = true; 29 | 30 | $username = $_REQUEST['username'] ?? null; 31 | if ($username !== null && is_string($username)) { 32 | $what = 'Edit User'; 33 | $user = $model->getUserByUsername($username); 34 | if (!empty($user)) { 35 | $is_new = false; 36 | } 37 | } 38 | 39 | $template = new \PureFTPAdmin\Template($what); 40 | 41 | $form = new PureFTPAdmin\Form\User([], $is_new); 42 | 43 | $form->setGidList($model->getGidList()); 44 | $form->setUidList($model->getUidList()); 45 | $form->isValid($user); 46 | 47 | if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] == 'POST') { 48 | if ($form->isValid($_POST)) { 49 | //error_log("Valid form"); 50 | $values = $form->getValues(); 51 | if ($model->saveUser($values)) { 52 | //error_log("saved user" . json_encode($values)); 53 | $flash->info("User saved"); 54 | if ($what == 'New User' && $settings['notify_user']) { 55 | $flash->info("User emailed"); 56 | $with_password = $values; 57 | $with_password['password'] = $_POST['password']; 58 | $model->sendPostCreationEmail($with_password); 59 | } 60 | $form = new PureFTPAdmin\Form\User(); 61 | } 62 | } 63 | } 64 | 65 | $template->assign('form', $form); 66 | $template->assign('messages', $flash->getMessages()); 67 | 68 | echo $template->display('user.twig'); 69 | exit(0); 70 | } 71 | 72 | if ($action == 'delete_user' && isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['username']) && is_string($_POST['username'])) { 73 | 74 | if ($model->deleteUser($_POST['username'])) { 75 | $flash->info('Deleted user'); 76 | } 77 | } 78 | 79 | if ($action == 'welcome') { 80 | $template = new \PureFTPAdmin\Template('Welcome'); 81 | 82 | foreach ($settings as $key => $value) { 83 | $template->assign("settings_" . $key, $value); 84 | } 85 | 86 | $template->assign('settings_check_access', 'No'); 87 | $template->assign('settings_email_user', 'No'); 88 | if ($settings['check_access']) { 89 | $template->assign('settings_check_access', 'Yes'); 90 | } 91 | if ($settings['notify_user']) { 92 | $template->assign('settings_notify_user', 'Yes'); 93 | } 94 | $template->assign('messages', $flash->getMessages()); 95 | 96 | echo $template->display('welcome.twig'); 97 | exit(0); 98 | } 99 | 100 | 101 | // fall through to a list-users. 102 | 103 | $start = 0; 104 | if (isset($_GET['start']) && is_numeric($_GET['start'])) { 105 | $start = (int)$_GET['start']; 106 | } 107 | 108 | $search = ''; 109 | if (isset($_GET['q']) && is_string($_GET['q'])) { 110 | $search = $_GET['q']; 111 | } 112 | 113 | $list = $model->getAllUsers($search, $start, 500); 114 | 115 | if ($settings['check_access']) { 116 | foreach ($list as $k => $row) { 117 | $list[$k]['rights'] = $model->check_access($row['dir'], $row['uid'], $row['gid']); 118 | } 119 | } 120 | 121 | $template = new \PureFTPAdmin\Template('User List'); 122 | 123 | if (empty($list)) { 124 | header("Location: ?action=welcome"); 125 | exit(0); 126 | } 127 | 128 | $template->assign('users', $list); 129 | $template->assign('start', $start); 130 | $template->assign('page_size', 50); 131 | $template->assign('totalResults', $model->get_nr_users($search)); 132 | 133 | $template->assign('messages', $flash->getMessages()); 134 | 135 | echo $template->display('user_list.twig'); 136 | 137 | exit(0); 138 | 139 | 140 | /* 141 | 142 | if ($a->settings["check_access"]) { 143 | $user_rights = $a->check_access($user["dir"], $user["uid"], $user["gid"]); 144 | if ($user_rights["error"]) { 145 | $right = $user_rights["error"]; 146 | } else { 147 | if ($user_rights["write"]) { 148 | $right = "user can read and write files in homedir"; 149 | } elseif ($user_rights["read"]) { 150 | $right = "user can only read files in homedir"; 151 | } else { 152 | $right = "user has no access to homedir"; 153 | } 154 | } 155 | } 156 | */ 157 | -------------------------------------------------------------------------------- /include/UserAdmin.php: -------------------------------------------------------------------------------- 1 | $instance = new pureuseradmin(); 31 | * @access protected 32 | */ 33 | public function __construct(Database $database, array $settings) 34 | { 35 | $this->database = $database; 36 | $this->settings = $settings; 37 | 38 | } 39 | 40 | /** 41 | * Hash a plaintext password. 42 | * @param string $passwd The password to insert into the database. 43 | * @return string The string to use in the sql statement. 44 | */ 45 | private function mkpass(string $passwd) : string 46 | { 47 | $mode = $this->settings["pwcrypt"]; 48 | 49 | // https://download.pureftpd.org/pub/pure-ftpd/doc/README.MySQL 50 | // md5, sha1 shouldn't really be used - use crypt if you have a choice. 51 | if ($mode == "crypt") { 52 | $salt = uniqid(); /* not all that good */ 53 | $ret = crypt($passwd, $salt); 54 | } elseif ($mode == "cleartext") { 55 | $ret = $passwd; 56 | } elseif ($mode == "md5") { 57 | $ret = md5($passwd); 58 | } elseif ($mode == "sha1") { 59 | $ret = sha1($passwd); 60 | } elseif ($mode == "argon2i") { 61 | 62 | /** 63 | * @psalm-suppress InvalidScalarArgument 64 | */ 65 | $ret = password_hash($passwd, PASSWORD_ARGON2I); 66 | } else { 67 | //error 68 | throw new \Exception("Please provide a valid password encryption (pwcrypt) method in the configuration section (crypt, sha1, md5, cleartext)"); 69 | } 70 | return $ret; 71 | } 72 | 73 | 74 | /** 75 | * Load all the uids and usernames on the system. 76 | * self::load_uids(); 77 | * @return array uids as key and usernames as value. 78 | */ 79 | public function getUidList() : array 80 | { 81 | $uids = [ 82 | $this->settings['default_uid'] => 'default' 83 | ]; 84 | return $uids; 85 | } 86 | 87 | /** 88 | * Load all the gids and groupnames on the system. 89 | * self::load_gids(); 90 | * @return array gids as key and groupnames as value. 91 | */ 92 | public function getGidList() : array 93 | { 94 | $gids = [ 95 | $this->settings['default_gid'] => 'default' 96 | ]; 97 | 98 | return $gids; 99 | } 100 | 101 | /** 102 | * Save a user in the database. 103 | * @param array{username: string, password: string, uid: string, gid: string, email: string, username: string, dir: string} $userinfo 104 | * @return boolean true when success, false on error. 105 | */ 106 | public function saveUser(array $userinfo) : bool 107 | { 108 | if (!count($userinfo)) { 109 | return false; 110 | //error, $userinfo is an array with fields from edit form 111 | } 112 | 113 | if(!isset($userinfo['username'])) { 114 | throw new \InvalidArgumentException("username required"); 115 | } 116 | $uid_field = $this->settings['field_uid']; 117 | $gid_field = $this->settings['field_gid']; 118 | $dir_field = $this->settings['field_dir']; 119 | $email_field = $this->settings['field_email']; 120 | $username_field = $this->settings['field_user']; 121 | $password_field = $this->settings['field_pass']; 122 | 123 | $args = []; 124 | $existing = null; 125 | 126 | if (!empty($userinfo['username'])) { 127 | $existing = $this->getUserByUsername($userinfo['username']); 128 | } 129 | 130 | $password_stuff =''; 131 | if (!empty($userinfo["password"])) { 132 | $password_stuff = ", {$password_field} = :password "; 133 | $args['password'] = $this->mkpass($userinfo['password']); 134 | } 135 | 136 | if(!empty($existing)) { 137 | $sql = <<settings['sql_table']} SET 139 | {$uid_field} = :uid, 140 | {$gid_field} = :gid, 141 | {$dir_field} = :dir, 142 | {$email_field} = :email 143 | $password_stuff WHERE {$username_field} = :username 144 | SQL; 145 | } else { 146 | if(!isset($userinfo['password'])) { 147 | throw new \InvalidArgumentException("password required"); 148 | } 149 | // no existing record; insert 150 | $sql = <<settings['sql_table']} ({$uid_field}, {$gid_field}, {$dir_field}, {$email_field}, {$username_field}, {$password_field} ) 152 | VALUES (:uid, :gid, :dir, :email, :username, :password) 153 | SQL; 154 | $args['password'] = $this->mkpass($userinfo['password']); 155 | } 156 | 157 | $args['uid'] = $userinfo['uid']; 158 | $args['gid'] = $userinfo['gid']; 159 | $args['dir'] = $userinfo['dir']; 160 | $args['email'] = $userinfo['email']; 161 | $args['username'] = $userinfo['username']; 162 | 163 | return $this->database->update($sql, $args) == 1; 164 | 165 | } 166 | 167 | 168 | /** 169 | * @return bool 170 | * @param array $userinfo 171 | */ 172 | public function sendPostCreationEmail(array $userinfo) : bool 173 | { 174 | if ($this->settings["notify_user"] && strlen($userinfo["email"])) { 175 | // send email 176 | $subject = $this->settings["ftp_hostname"] . " FTP information"; 177 | $body = "Hi " . $userinfo["username"] . ",\n\n"; 178 | $body .= "Here is some information you will need to login with FTP:\n"; 179 | $body .= "hostname: " . $this->settings["ftp_hostname"] . "\n"; 180 | $body .= "username: " . $userinfo["username"] . "\n"; 181 | $body .= "password: " . $userinfo["password"] . "\n\n"; 182 | $body .= "Please download and use an FTP client application (such as Filezilla) rather than using a browser to upload and download files\n Thanks\n"; 183 | mail($userinfo["email"], $subject, $body, "From: " . $this->settings["admin_email"] . "\r\n", "-f" . $this->settings["admin_email"]); 184 | } 185 | return true; 186 | } 187 | 188 | /** 189 | * Delete a user from the database. 190 | * $result = $instance->delete_user($userinfo); 191 | * @param string $username 192 | * @return boolean true when success, false on error. 193 | */ 194 | public function deleteUser($username) : bool 195 | { 196 | $sql = "DELETE FROM {$this->settings['sql_table']} WHERE {$this->settings['field_user']} = :username"; 197 | return (bool) $this->database->update($sql, ['username' => $username]); 198 | } 199 | 200 | /** 201 | * Get a user from the database. 202 | * $user = $instance->getUserByUsername($username); 203 | * @param string $username 204 | * @return array A user with all info that is in the database; empty if user does not exist. 205 | */ 206 | public function getUserByUsername(string $username) : array 207 | { 208 | $sql = "SELECT * FROM {$this->settings['sql_table']} WHERE {$this->settings['field_user']} = :username"; 209 | 210 | $row = $this->database->selectOne($sql, ['username' => $username]); 211 | 212 | if(empty($row) || !is_array($row)) { 213 | return []; 214 | } 215 | return $this->remapFromDb($row); 216 | } 217 | 218 | /** 219 | * @return array 220 | */ 221 | private function remapFromDb(array $row) : array { 222 | 223 | if(empty($row)) { 224 | return []; 225 | } 226 | $field_username = $this->settings['field_user']; 227 | $field_uid = $this->settings['field_uid']; 228 | $field_gid = $this->settings['field_gid']; 229 | $field_email = $this->settings['field_email']; 230 | $field_dir = $this->settings['field_dir']; 231 | 232 | return [ 233 | 'username' => $row[$field_username], 234 | 'uid' => $row[$field_uid], 235 | 'gid' => $row[$field_gid], 236 | 'dir' => $row[$field_dir], 237 | 'email' => $row[$field_email], 238 | ]; 239 | 240 | } 241 | 242 | /** 243 | * Get all users from the database, in alphabetic order. 244 | * $userlist = $instance->getAllUsers(); 245 | * @param string $search Searchstring to limit results. 246 | * @param int $start Record in database to start output. 247 | * @param int $pagesize Number of users to show on a page. 248 | * @return array All users with all info that is in the database. 249 | */ 250 | public function getAllUsers(string $search = "", int $start = 0, int $pagesize = 0) : array 251 | { 252 | if (!$pagesize) { 253 | $pagesize = $this->settings["page_size"]; 254 | } 255 | 256 | if ($search) { 257 | $q = " WHERE {$this->settings["field_user"]} LIKE :search OR {$this->settings["field_dir"]} LIKE :search"; 258 | } else { 259 | $q = ""; 260 | } 261 | $sql = "SELECT * FROM {$this->settings["sql_table"]} $q ORDER BY {$this->settings["field_user"]} LIMIT $start, $pagesize"; 262 | 263 | $search = "$search%"; 264 | 265 | $rows = $this->database->select($sql, ['search' => $search]); 266 | $users = []; 267 | foreach($rows as $row) { 268 | $users[] = $this->remapFromDb($row); 269 | } 270 | return $users; 271 | } 272 | 273 | 274 | /** 275 | * Get number of users in the database. 276 | * $nr_users = $instance->get_nr_users(); 277 | * @param string $search Searchstring to limit results. 278 | * @return integer Number of users in the database. 279 | */ 280 | public function get_nr_users(string $search = "") : int 281 | { 282 | if ($search) { 283 | $q = " WHERE {$this->settings["field_user"]} LIKE :search OR {$this->settings["field_dir"]} LIKE :search"; 284 | } else { 285 | $q = ""; 286 | } 287 | $sql = "SELECT COUNT(*) as count FROM {$this->settings["sql_table"]} $q"; 288 | 289 | $search = "%$search%"; 290 | 291 | $count = $this->database->selectOne($sql, ['search' => $search]); 292 | if(is_array($count) && isset($count['count'])) { 293 | return $count['count']; 294 | } 295 | return 0; 296 | } 297 | 298 | 299 | /** 300 | * Check what type of access the user has. 301 | * @param string $homedir The home directory of the user processed. 302 | * @param int $uid The main userid of the user. 303 | * @param int $gid The main groupid of the user. 304 | * @return string read/write/none 305 | */ 306 | public function check_access(string $homedir, int $uid, int $gid) : string 307 | { 308 | $rights = ['error' => false, 'write' => false, 'read' => false]; 309 | 310 | if (file_exists($homedir)) { 311 | $fuid = fileowner($homedir); 312 | $fgid = filegroup($homedir); 313 | $fperms = fileperms($homedir); 314 | $fperm = substr(sprintf("%o", $fperms), 2); 315 | $rights["owner"] = substr($fperm, 0, 1); 316 | $rights["group"] = substr($fperm, 1, 1); 317 | $rights["world"] = substr($fperm, 2, 1); 318 | $rights["read"] = 0; 319 | $rights["write"] = 0; 320 | if ($rights["world"] > 6) { 321 | $rights["write"] = 1; 322 | } 323 | if ($rights["world"] > 4) { 324 | $rights["read"] = 1; 325 | } 326 | if ($uid == $fuid) { 327 | if ($rights["owner"] > 6) { 328 | $rights["write"] = 1; 329 | } 330 | if ($rights["owner"] > 4) { 331 | $rights["read"] = 1; 332 | } 333 | } 334 | if ($gid == $fgid) { 335 | if ($rights["group"] > 6) { 336 | $rights["write"] = 1; 337 | } 338 | if ($rights["group"] > 4) { 339 | $rights["read"] = 1; 340 | } 341 | } 342 | } else { 343 | $rights["error"] = "Error: Dir Path not found"; 344 | } 345 | 346 | if($rights['write']) { 347 | return 'write'; 348 | } 349 | if($rights['read']) { 350 | return 'read'; 351 | } 352 | if($rights['error']) { 353 | return $rights['error']; 354 | } 355 | return 'none'; 356 | } 357 | } 358 | --------------------------------------------------------------------------------