commit 4fce91b055d2b0adbf49057a1b7c4fb3e0a1dae4 Author: Elias F. Date: Mon Nov 24 14:06:57 2025 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..583aff7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# this is just a demo .gitignore file, put all the files and folders you want to be ignored by git inside +# if you work with NetBeans: ignore NetBeans project files +/nbproject/private/ +# don't commit all the dependencies fetched via Composer +/vendor/ + +.idea/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..903b145 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,102 @@ +# CHANGE LOG + +For the newest (und unstable) version always check the develop branch, for beta check +master branch, for really stable stuff check the releases (the ones that have a real version number :)) + +## master branch + +- [slaveek/panique] [PR](https://github.com/panique/huge/pull/773) [#770] fix for sending user back to last visited page after login +- [slaveek] [PR](https://github.com/panique/huge/pull/815) lots of code styling fixes +- [panique] [#729] Fix, mail sending now returns true or false success status (https://github.com/panique/huge/issues/729) +- [OmarElGabry] [PR](https://github.com/panique/huge/pull/693) session id regeneration in certain situations +- [OmarElGabry] [PR](https://github.com/panique/huge/pull/693) encrypted cookies +- [OmarElGabry] [PR](https://github.com/panique/huge/pull/693) new encryption class +- [OmarElGabry] [PR](https://github.com/panique/huge/pull/693) anti-CSRF feature (used in login and username change forms) +- [josh-bridge] [PR](https://github.com/panique/huge/pull/689) logged-in user can now change password +- [justincdotme] [PR](https://github.com/panique/huge/pull/684) better code for brute-force blocking when logging in +- [panique] soft autoinstaller improvements +- [panique] updated dependencies to current versions +- [Kent55/panique] XSS protection filter +- [FAlbanni] XSS protection with better session/cookie, now only allowed on used domain +- [panique] there's now a simple Favicon and a fallback to avoid browsers hammering the application requesting favicons +- [panique] application has now a page title +- [panique] avatar upload feature can now handle jpg, png, gif +- [panique/tankerkiller125] avatars folder now does not run any PHP code (security improvement) +- [tysonlist] [#657] send user back to last-visited page after successful login (when not being logged in first) +- [sandropons] anti-brute-force feature for login process +- [panique] removed old Facebook texts (as Login-via-Facebook feature was removed since 3.0) +- [oisian/ldmusic] [#608] Deletion / suspension of users, Admin menu +- [panique] [#654](https://github.com/panique/huge/issues/654) little frontend navi bug fixed +- [Dominic28] [PR](https://github.com/panique/huge/pull/645) added checkboxes to request class +- [Dominic28] [PR](https://github.com/panique/huge/pull/644) code style fixes +- [M0ritzWeide] [PR](https://github.com/panique/huge/pull/635) added browser caching +- [modInfo/panique] [PR](https://github.com/panique/huge/pull/647) added missing view table column + +## 3.1 + +Code Quality at Scrutinizer 9.7/10, at Code Climate 3.9/4 + +**February 2015** + +- [panique] several code quality improvements (and line reductions :) ) all over the project +- [PR](https://github.com/panique/huge/pull/620) [owenr88] view rending now possible with multiple view files +- [panique] lots of code refactorings and simplifications all over the project +- [PR](https://github.com/panique/huge/pull/615) [Dominic28] Avatar can now be deleted by the user +- [panique] First Unit tests :) +- [panique] several code quality improvements all over the project +- [panique] avatarModel code improvements +- [panique] renamed AccountType stuff to UserRole, minor changes + +## 3.0 + +Code Quality at Scrutinizer 9.3/10, at Code Climate 3.9/4 + +**February 2015** + +- [panique] removed duplicate code in AccountTypeModel +- [PR](https://github.com/panique/huge/pull/587) [upperwood] Facebook stuff completely removed from SQL +- [panique] tiny text changes + +**January 2015** + +- [panique] added static Text class (gets the messages etc) +- [panique] added static Environment class (get the environment) +- [panique] added static Config class (gets config easily and according to environment) +- [panique] new styling of the entire project: login/index has new look now +- [panique] massive refactoring of all model classes: lots of methods have been organized into other model classes +- [panique] massive refactoring of all model classes: all methods are static now +- [panique] EXPERIMENTAL: added static database call / DatabaseFactory, rebuild NoteModel with static methods +- [panique] massive refactoring of mail sending, (chose between PHPMailer, SwiftMailer, native / SMTP or no SMTP) + +**December 2014** + +- [panique] lots of refactorings +- [panique] refactored LoginModel'S login() method / LoginController's login() method +- [panique] removed COOKIE_DOMAIN (cookie is now valid on the domain/IP it has been created on) +- [panique] Abstracting super-globals like $_POST['x'] into Request::post('x') +- [panique] entirely removed all the Facebook stuff [will be replaced by new proper Oauth2 solution soon] +- [panique] lots of code refactorings and cleaning, deletions of duplicate code +- [panique] moving nearly all hardcoded values to config +- [panique] new View handling: you'll have to pass vars to the view renderer now +- [panique] completely removed Facebook login process from controller (incomplete) [will be replaced by new solution] +- [panique] less config, URL/IP is auto-detected now +- [panique] added loadConfig() to load a specific config according to environment setting (fallback: development) +- [panique] added getEnvironment() to fetch (potential) environment setting +- [panique] replaced native super-globals access by wrapper access (Session:get instead of $_SESSION) +- [panique] complete frontend rebuilding (incomplete yet) +- [panique] massive cleaning of all controllers +- [panique] added Session::add() to allow stacking of elements (useful for collecting feedback, errors etc) +- [panique] complete rebuild of model handling +- [panique] View can now render(), renderWithoutHeaderFooter() and renderJSON +- [panique] using Composer's PSR-4 autoloader (in a very basic way currently) +- [panique] DB construction needs now port by default +- [panique] removed (semi-optional) hashing cost factor (as it's redundant usually) +- [panique] email max limit increased to 254/255 (official number) +- [panique] simpler and improved core +- [panique] improved architecture, controllers are now named like "IndexController" +- [panique] moved index.php to /public folder, new .htaccess, new installation guideline +- [panique] MVC naming fixes +- [nerdalertdk] betters paths, automatic paths +- [panique] removed legacy PHP stuff: 5.5.x is now the minimum +- [PR](https://github.com/panique/php-login/pull/503) [Malkleth] allow users to request password reset by inputting email as well as user names +- [PR](https://github.com/panique/php-login/pull/516) [pein0119] cookie runtime calculation fix diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c78838 --- /dev/null +++ b/README.md @@ -0,0 +1,632 @@ +![HUGE, formerly "php-login" logo](_pictures/huge.png) + +# HUGE + +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/panique/huge/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/panique/huge/?branch=master) +[![Code Climate](https://codeclimate.com/github/panique/huge/badges/gpa.svg)](https://codeclimate.com/github/panique/huge) +[![Codacy Badge](https://api.codacy.com/project/badge/Grade/01a221d168b04b1c94a85813519dab40)](https://www.codacy.com/app/panique/huge?utm_source=github.com&utm_medium=referral&utm_content=panique/huge&utm_campaign=Badge_Grade) +[![Travis CI](https://travis-ci.org/panique/huge.svg?branch=master)](https://travis-ci.org/panique/huge) +[![Dependency Status](https://www.versioneye.com/user/projects/54ca11fbde7924f81a000010/badge.svg?style=flat)](https://www.versioneye.com/user/projects/54ca11fbde7924f81a000010) +[![Support](https://supporterhq.com/api/b/9guz00i6rep05k1mwxyquz30k)](https://supporterhq.com/give/9guz00i6rep05k1mwxyquz30k) + +Just a simple user authentication solution inside a super-simple framework skeleton that works out-of-the-box +(and comes with an auto-installer), using the future-proof official bcrypt password hashing/salting implementation of +PHP 5.5+, plus some nice features that will speed up the time from idea to first usable prototype application +dramatically. Nothing more. This project has its focus on hardcore simplicity. Everything is as simple as possible, +made for smaller projects, typical agency work and quick drafts. If you want to build massive corporate +applications with all the features modern frameworks have, then have a look at [Laravel](http://laravel.com), +[Symfony](http://symfony.com) or [Yii](http://www.yiiframework.com), but if you just want to quickly create something +that just works, then this script might be interesting for you. + +HUGE's simple-as-possible architecture was inspired by several conference talks, slides and articles about huge +applications that - surprisingly and intentionally - go back to the basics of programming, using procedural programming, +static classes, extremely simple constructs, not-totally-DRY code etc. while keeping the code extremely readable +([StackOverflow](http://www.dev-metal.com/architecture-stackoverflow/), Wikipedia, SoundCloud). + +Some interesting Buzzwords in this context: [KISS](http://en.wikipedia.org/wiki/KISS_principle), +[YAGNI](http://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it), [Feature Creep](https://en.wikipedia.org/wiki/Feature_creep), +[Minimum viable product](https://en.wikipedia.org/wiki/Minimum_viable_product). + +#### HUGE has reached "soft End Of Life" + +To keep this project stable, secure, clean and minimal I've decided to reduce the development of HUGE to a +minimum. *Don't worry, this is actually a good thing:* New features usually mean new bugs, lots of testing, fixes, +incompatibilities, and for some people even hardcore update stress. As HUGE is a security-critical script new features +are not as important as a stable and secure core, this is why people use it. This means: + +- HUGE will not get new features +- but will be maintained, so it will get bugfixes, corrections etc for sure, maybe for years + +And to be honest, maintaining a framework for free in my rare free-time is also not what I want to do permanently. :) + +Finally a little note: The PHP world has evolved dramatically, we have excellent frameworks with awesome features and +big professional teams behind, very well written documentations and large communities, so there's simply no reason +to put much work into another framework. Instead, please commit to the popular frameworks, then your work will have +much more impact and is used by much more people! + +Thanks to everybody around this project, have a wonderful time! +XOXO, +Chris + +#### Releases & development + +* stable [v3.1](https://github.com/panique/huge/releases/tag/v3.1), +* public beta branch: [master](https://github.com/panique/huge) +* public in-development branch (please commit new code here): [develop](https://github.com/panique/huge/tree/develop) + +#### Quick-Index + ++ [Features](#features) ++ [Live-Demo](#live-demo) ++ [Support](#support) ++ [Follow the project](#follow) ++ [License](#license) ++ [Requirements](#requirements) ++ [Auto-Installation](#auto-installation) + - [Auto-Installation in Vagrant](#auto-installation-vagrant) (also useful for 100% reproducible installation of HUGE) + - [Auto-Installation in Ubuntu 14.04 LTS server](#auto-installation-ubuntu) ++ [Installation (Ubuntu 14.04 LTS)](#installation) + - [Quick Installation](#quick-installation) + - [Detailed Installation](#detailed-installation) + - [NGINX setup](#nginx-setup) + - [IIS setup](#iis-setup) ++ [Documentation](#documentation) + - [How to use the user roles](#user_roles) + - [How to use the CSRF feature](#csrf) ++ [Community-provided features & feature discussions](#community) ++ [Future of the project, announcing soft EOL](#future) ++ [Why is there no support forum anymore ?](#why-no-support-forum) ++ [Zero tolerance for idiots, trolls and vandals](#zero-tolerance) ++ [Contribute](#contribute) ++ [Code-Quality scanner links](#code-quality) ++ [Report a bug](#bug-report) + +### The History of HUGE + +Back in 2010/2011 there were no useful login solutions in the PHP world, at least not for non-experts. So I did the worst +mistake every young developer does: Trying to build something by myself without having any clue about security basics. +What made it even worse was: The web was (and is) full of totally broken tutorials about building user authentication +systems, even the biggest companies in the world did this completely wrong (we are talking about SONY, LinkedIn and +Adobe here), and also lots of major framework in all big programming languages (!) used totally outdated and insecure +password saving technologies. + +However, in 2012 security expert [Anthony Ferrara](https://github.com/ircmaxell) published a [little PHP library](https://github.com/ircmaxell/password_compat), +allowing extremely secure, modern and correct hashing of passwords in PHP 5.3 and 5.4, usable by every developer without any stress and without any knowledge +about security internals. The script was so awesome that it was written into the core of PHP 5.5, it's the de-facto standard these days. + +When this came out I tried to use this naked library to build a fully working out-of-the-box login system for several private and commercial projects, +and put the code on GitHub. Lots of people found this useful, contributed and bugfixed the project, made forks, smaller and larger versions. +The result is this project. + +Please note: Now, in 2015, most major frameworks have excellent user authentication logic embedded by default. This was +not the case years ago. So, from today's perspective it might be smarter to chose Laravel, Yii or Symfony for serious +projects. But feel free to try out HUGE, the auto-installer will spin up a fully working installation within minutes and +without any configuration. + +And why the name "HUGE" ? It's a nice combination to +[TINY](https://github.com/panique/tiny), +[MINI](https://github.com/panique/mini) and +[MINI2](https://github.com/panique/mini2), +[MINI3](https://github.com/panique/mini3), +which are some of my other older projects. Super-minimal micro frameworks for extremely fast and simple development of simple websites. + +### Features +* built with the official PHP password hashing functions, fitting the most modern password hashing/salting web standards +* proper security features, like CSRF blocking (via form tokens), encryption of cookie contents etc. +* users can register, login, logout (with username, email, password) +* password-forget / reset +* remember-me (login via cookie) +* account verification via mail +* captcha +* failed-login-throttling +* user profiles +* account upgrade / downgrade +* simple user types (type 1, type 2, admin) +* supports local avatars and remote Gravatars +* supports native mail and SMTP sending (via PHPMailer and other tools) +* uses PDO for database access for sure, has nice DatabaseFactory (in case your project goes big) +* uses URL rewriting ("beautiful URLs") +* proper split of application and public files (requests only go into /public) +* uses Composer to load external dependencies (PHPMailer, Captcha-Generator, etc.) for sure +* fits PSR-0/1/2/4 coding guidelines +* uses [Post-Redirect-Get pattern](https://en.wikipedia.org/wiki/Post/Redirect/Get) for nice application flow +* masses of comments +* is actively maintained and bug-fixed (however, no big new features as project slowly reaches End of Life) + +### Planned features + +* A real documentation (currently there's none, but the code is well commented) + +### Live-Demo + +See a [live demo of older 3.0 version here](http://104.131.8.128) and [the server's phpinfo() here](104.131.8.128/info.php). + +### Support the project + +There is a lot of work behind this project. I might save you hundreds, maybe thousands of hours of work (calculate that +in developer costs). So when you are earning money by using HUGE, be fair and give something back to open-source. +HUGE is totally free to private and commercial use. + +Support the project by renting a server at [DigitalOcean](https://www.digitalocean.com/?refcode=40d978532a20) or just tipping a coffee at BuyMeACoffee.com. Thanks! :) + +Buy Me A Coffee + +Also feel free to contribute to this project. + +### License + +Licensed under [MIT](http://www.opensource.org/licenses/mit-license.php). +Totally free for private or commercial projects. + +### Requirements + +Make sure you know the basics of object-oriented programming and MVC, are able to use the command line and have +used Composer before. This script is not for beginners. + +* **PHP 5.5+** +* **MySQL 5** database (better use versions 5.5+ as very old versions have a [PDO injection bug](http://stackoverflow.com/q/134099/1114320) +* installed PHP extensions: pdo, gd, openssl (the install guideline shows how to do) +* installed tools on your server: git, curl, composer (the install guideline shows how to do) +* for professional mail sending: an SMTP account (I use [SMTP2GO](http://www.smtp2go.com/?s=devmetal)) +* activated mod_rewrite on your server (the install guideline shows how to do) + +### Auto-Installations + +Yo, fully automatic. Why ? Because I always hated it to spend days trying to find out how to install a thing. +This will save you masses of time and nerves. Donate a coffee if you like it. + +#### Auto-Installation (in Vagrant) + +If you are using Vagrant for your development, then simply + +1. Add the official Ubuntu 14.04 LTS box to your Vagrant: `vagrant box add ubuntu/trusty64` +2. Move *Vagrantfile* and *bootstrap.sh* (from *_one-click-installation* folder) to a folder where you want to initialize your project. +3. Do `vagrant up` in that folder. + +5 minutes later you'll have a fully installed HUGE inside Ubuntu 14.04 LTS. The full code will be auto-synced with +the current folder. MySQL root password and the PHPMyAdmin root password are set to *12345678*. By default +192.168.33.111 is the IP of your new box. + +#### Auto-Installation in a naked Ubuntu 14.04 LTS server + +Extremely simple installation in a fresh and naked typical Ubuntu 14.04 LTS server: + +Download the installer script +```bash +wget https://raw.githubusercontent.com/panique/huge/master/_one-click-installation/bootstrap.sh +``` + +Make it executable +```bash +chmod +x bootstrap.sh +``` + +Run it! Give it some minutes to perform all the tasks. And yes, you can thank me later :) +```bash +sudo ./bootstrap.sh +``` +### Installation + +#### Quick guide: + +0. Make sure you have Apache, PHP, MySQL installed. [Tutorial](http://www.dev-metal.com/installsetup-basic-lamp-stack-linux-apache-mysql-php-ubuntu-14-04-lts/). +1. Clone the repo to a folder on your server +2. Activate mod_rewrite, route all traffic to application's /public folder. [Tutorial](http://www.dev-metal.com/enable-mod_rewrite-ubuntu-14-04-lts/). +3. Edit application/config: Set your database credentials +4. Execute SQL statements from application/_installation to setup database tables +5. [Install Composer](http://www.dev-metal.com/install-update-composer-windows-7-ubuntu-debian-centos/), + run `Composer install` on application's root folder to install dependencies +6. Make avatar folder (application/public/avatars) writable +7. For proper email usage: Set SMTP credentials in config file, set EMAIL_USE_SMTP to true + +"Email does not work" ? See the troubleshooting below. TODO + +#### Detailed guide (Ubuntu 14.04 LTS): + +This is just a quick guideline for easy setup of a development environment! + +Make sure you have Apache, PHP 5.5+ and MySQL installed. [Tutorial here](http://www.dev-metal.com/installsetup-basic-lamp-stack-linux-apache-mysql-php-ubuntu-14-04-lts/). +Nginx will work for sure too, but no install guidelines are available yet. + +Edit vhost to make clean URLs possible and route all traffic to /public folder of your project: +```bash +sudo nano /etc/apache2/sites-available/000-default.conf +``` + +and make the file look like +``` + + DocumentRoot "/var/www/html/public" + + AllowOverride All + Require all granted + + +``` + +Enable mod_rewrite and restart apache. +```bash +sudo a2enmod rewrite +service apache2 restart +``` + +Install curl (needed to use git), openssl (needed to clone from GitHub, as github is https only), +PHP GD, the graphic lib (we create captchas and avatars), and git. +```bash +sudo apt-get -y install curl +sudo apt-get -y install php5-curl +sudo apt-get -y install openssl +sudo apt-get -y install php5-gd +sudo apt-get -y install git +``` + +git clone HUGE +```bash +sudo git clone https://github.com/panique/huge "/var/www/html" +``` + +Install Composer +```bash +curl -s https://getcomposer.org/installer | php +mv composer.phar /usr/local/bin/composer +``` + +Go to project folder, load Composer packages (--dev is optional, you know the deal) +```bash +cd /var/www/html +composer install --dev +``` + +Execute the SQL statements. Via phpmyadmin or via the command line for example. 12345678 is the example password. +Note that this is written without a space. +```bash +sudo mysql -h "localhost" -u "root" "-p12345678" < "/var/www/html/application/_installation/01-create-database.sql" +sudo mysql -h "localhost" -u "root" "-p12345678" < "/var/www/html/application/_installation/02-create-table-users.sql" +sudo mysql -h "localhost" -u "root" "-p12345678" < "/var/www/html/application/_installation/03-create-table-notes.sql" +``` + +Make avatar folder writable (make sure it's the correct path!) +```bash +sudo chown -R www-data "/var/www/html/public/avatars" +``` +If this doesn't work for you, then you might try the hard way by setting alternatively +```bash +sudo chmod 0777 -R "/var/www/html/public/avatars" +``` + +Remove Apache's default demo file +```bash +sudo rm "/var/www/html/index.html" +``` + +Edit the application's config in application/config/config.development.php and put in your database credentials. + +Last part (not needed for a first test): Set your SMTP credentials in the same file and set EMAIL_USE_SMTP to true, so +you can send proper emails. It's highly recommended to use SMTP for mail sending! Native sending via PHP's mail() will +not work in nearly every case (spam blocking). I use [SMTP2GO](http://www.smtp2go.com/?s=devmetal). + +Then check your server's IP / domain. Everything should work fine. + +#### NGINX setup: + +This is an untested NGINX setup. Please comment [on the ticket](https://github.com/panique/huge/issues/622) if you see +issues. + +``` +server { + # your listening port + listen 80; + + # your server name + server_name example.com; + + # your path to access log files + access_log /srv/www/example.com/logs/access.log; + error_log /srv/www/example.com/logs/error.log; + + # your root + root /srv/www/example.com/public_html; + + # huge + index index.php; + + # huge + location / { + try_files $uri /index.php?url=$uri&$args; + } + + # your PHP config + location ~ \.php$ { + try_files $uri = 401; + include /etc/nginx/fastcgi_params; + fastcgi_pass unix:/var/run/php-fastcgi/php-fastcgi.socket; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } +} +``` + +#### IIS setup: + +Big thanks to razuro for this fine setup: Put this inside your root folder, but don't put any web.config in your public +folder. + +``` + + + + + + + + + + + + + + + + + +``` + +Find the original [ticket here](https://github.com/panique/huge/issues/788). + +#### Testing with demo users + +By default there are two demo users, a normal user and an admin user. For more info on that please have a look on the +user role part of the small documentation block inside this readme. + +Normal user: Username is `demo2`, password is `12345678`. The user is already activated. +Admin user (can delete and suspend other users): Username is `demo`, password is `12345678`. The user is already activated. + +### What the hell are .travis.yml, .scrutinizer.yml etc. ? + +There are several files in the root folder of the project that might be irritating: + + - *.htaccess* (optionally) routes all traffic to /public/index.php! If you installed this project correctly, then this + file is not necessary, but as lots of people have problems setting up the vhost correctly, .htaccess it still there + to increase security, even on partly-broken-installations. + - *.scrutinizer.yml* (can be deleted): Configs for the external code quality analyzer Scrutinizer, just used here on + GitHub, you don't need this for your project. + - *.travis.yml* (can be deleted): Same like above. Travis is an external service that creates installations of this + repo after each code change to make sure everything runs fine. Also runs the unit tests. You don't need this inside + your project. + - *composer.json* (important): You should know what this does. ;) This file says what external dependencies are used. + - *travis-ci-apache* (can be deleted): Config file for Travis, see above, so Travis knows how to setup the Apache. + +*README* and *CHANGELOG* are self-explaining. + +### Documentation + +A real documentation is in the making. Until then, please have a look at the code and use your IDE's code completion +features to get an idea how things work, it's quite obvious when you look at the controller files, the model files and +how data is shown in the view files. A big sorry that there's no documentation yet, but time is rare and we are all +doing this for free in our free time :) + + - TODO: Full documentation + - TODO: Basic examples on how to do things + +#### How to use the different user roles + +Currently there are two types of users: Normal users and admins. There are exactly the same, but... + +1. Admin users can delete and suspend other users, they have an additional button "admin" in the navigation. Admin users +have a value of `7` inside the database table field `user_account_type`. They cannot upgrade or downgrade their accounts +(as this wouldn't make sense). + +2. Normal users don't have admin features for sure. But they can upgrade and downgrade their accounts (try it out via +/user/changeUserRole), which is basically a super-simple implementation of the basic-user / premium-user concept. +Normal users have a value of `1` or `2` inside the database table field `user_account_type`. By default all new +registered users are normal users with user role 1 for sure. + +See the "Testing with demo users" section of this readme for more info. + +There's also a very interesting [pull request adding user roles and user permissions](https://github.com/panique/huge/pull/691), +which is not integrated into the project as it's too advanced and complex. But, this might be exactly what you need, +feel free to try. + +#### How to use the CSRF feature + +To prevent [CSRF attacks](https://en.wikipedia.org/wiki/Cross-site_request_forgery), HUGE does this in the most common +way, by using a security *token* when the user submits critical forms. This means: When PHP renders a form for the user, +the application puts a "random string" inside the form (as a hidden input field), generated via Csrf::makeToken() +(application/core/Csrf.php), which also saves this token to the session. When the form is submitted, the application +checks if the POST request contains exactly the form token that is inside the session. + +This CSRF prevention feature is currently implemented on the login form process (see *application/view/login/index.php*) +and user name change form process (see *application/view/user/editUsername.php*), most other forms are not security- +critical and should stay as simple as possible. + +So, to do this with a normal form, simply: At your form, before the submit button put: +`` +Then, in the controller action validate the CSRF token submitted with the form by doing: +``` +// check if csrf token is valid +if (!Csrf::isTokenValid()) { + LoginModel::logout(); + Redirect::home(); + exit(); +} +``` + +A big thanks to OmarElGabry for implementing this! + +#### Can a user be logged in from multiple devices ? + +In theory: Yes, but this feature didn't work in my tests. As it's an external feature please have a look into the +[according ticket](https://github.com/panique/huge/pull/693) for more. + +#### Troubleshooting & Glitches + +* In 3.0 and 3.1 a user could log into the application from different devices / browsers / locations. This was intended + behaviour as this is standard in most web applications these days. In 3.2 still feature is "missing" by default, a + user will only be able to log in from one browser at the same time. This is a security improvement, but for sure not + optimal for many developers. The plan is to implement a config switch that will allow / disallow logins from multiple + browsers. +* Using this on a sub-domain ? You might get problems with the cookies in IE11. Fix this by replacing "/" with "./" of + the cookie location COOKIE_PATH inside application/config/config.xxx.php! + Check [ticket #733](https://github.com/panique/huge/issues/733) for more info. Thanks to jahbiuabft for figuring this + out. Update: There's another ticket focusing on the same issue: [ticket #681](https://github.com/panique/huge/issues/681) + +### Community-provided features & feature discussions + +There are some awesome features or feature ideas build by awesome people, but these features are too special-interest +to go into the main version of HUGE, but have a look into these tickets if you are interested: + + - [Caching system](https://github.com/panique/huge/issues/643) + - [ReCaptcha as captcha](https://github.com/panique/huge/issues/665) + - [Internationalization feature](https://github.com/panique/huge/issues/582) + - [Using controller A inside controller B](https://github.com/panique/huge/issues/706) + - [HTML mails](https://github.com/panique/huge/issues/738) + - [Deep user roles / user permission system](https://github.com/panique/huge/pull/691) + +### Future of HUGE: Announcing "soft End Of Life" + +The idea of this project is and was to provide a super-simple barebone application with a full user authentication +system inside that just works fine and stable. Due to the highly security-related nature of this script any changes +mean a lot of work, lots of testing, catching edge cases etc., and in the end I spent 90% of the time testing and fixing +new features or new features break existing stuff, and doing this is really not what anybody wants to do for free in +the rare free-time :) + +To keep the project stable, clean and maintainable, I would kindly announce the "soft-End of Life" for this project, +meaning: + +A. HUGE will not get any new features in the future, but ... +B. bugfixes and corrections will be made, probably for years + +### Coding guideline behind HUGE + +While HUGE was in development, there were 3 main rules that helped me (and probably others) to write minimal, clean + and working code. Might be useful for you too: + +1. Reduce features to the bare minimum. +2. Don't implement features that are not needed by most users. +3. Only build everything for the most common use case (like MySQL, not PostGre, NoSQL etc). + +As noted in the intro of this README, there are also some powerful concepts that might help you when developing cool +stuff: [KISS](http://en.wikipedia.org/wiki/KISS_principle), +[YAGNI](http://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it), [Feature Creep](https://en.wikipedia.org/wiki/Feature_creep), +[Minimum viable product](https://en.wikipedia.org/wiki/Minimum_viable_product). + +#### List of features / ideas provided in tickets / pull requests + +To avoid unnecessary work for all of us I would kindly recommend everybody to use HUGE for simple project that only +need the features that already exist, and if you really need a RESTful architecture, migrations, routing, 2FA etc, +then it's easier, cleaner and faster to simply use Laravel, Symfony or Zend. + +However, here are the community-suggested possible features, taken from lots of tickets. Feel free to implement them +into your forks of the project: + +* OAuth2 implementation (let your users create accounts and login via 3rd party auth, like Facebook, Twitter, GitHub, + etc). As this is a lot of work and would make the project much more complicated it might make sense to do this in a + fork or totally skip it. (see [Ticket #528](https://github.com/panique/huge/issues/528)) +* Router (map all URLs to according controller-methods inside one file), [Ticket 727](https://github.com/panique/huge/issues/727) +* RESTful architecture (see [ticket #488](https://github.com/panique/huge/issues/488) for discussion) +* Horizontal MySQL scaling (see [ticket #423](https://github.com/panique/huge/issues/423) for discussion) +* Modules / middleware +* Logging +* Two-Factor-Authentication (see [ticket #732](https://github.com/panique/huge/issues/732)) +* Controller-less URLs (see [ticket #704](https://github.com/panique/huge/issues/704)) +* Email-re-validation after email change (see [ticket #705](https://github.com/panique/huge/issues/705)) +* Connect to multiple databases (see [ticket #702](https://github.com/panique/huge/issues/702)) +* A deeper user role system (see [ticket #701](https://github.com/panique/huge/issues/701), +[pull-request #691](https://github.com/panique/huge/pull/691)), +[ticket #603](https://github.com/panique/huge/issues/603) +* How to run without using Composer [ticket #826](https://github.com/panique/huge/issues/826) + +### Why is there no support forum (anymore) ? + +There were two (!) support forums for v1 and v2 of this project (HUGE is v3), and both were vandalized by people who +didn't even read the readme and / or the install guidelines. Most asked question was "script does not work plz help" +without giving any useful information (like code or server setup or even the version used). While I'm writing these +lines somebody just asked via Twitter "how to install without Composer". You know what I mean :) - 99% of the questions +were not necessary if the people would had read the guidelines, do a minimal research on their own or would stop making +things so unnecessarily complicated. And even when writing detailed answers most of them still messed it up, resulting +in rants and complaints (for free support for a free software!). It was just frustrating to deal with this every day, +especially when people take it for totally granted that *it's the duty* of open-source developers to give detailed, +free and personal support for every "plz help"-request. + +So I decided to completely stop any free support. For serious questions about real problems inside the script please +use the GitHub issues feature. + +### Zero tolerance for idiots, trolls and vandals! + +Harsh words, but as basically every public internet project gets harassed, vandalized and trolled these days by very +strange people it's necessary: Some simple rules. + +1. Respect that this is just a simple script written by unpaid volunteers in their free-time. + This is NOT business-software you've bought for $10.000. + There's no reason to complain (!) about free open-source software. The attitude against free software + is really frustrating these days, people take everything for granted without realizing the work behind it, and the + fact that they get serious software totally for free, saving thousands of dollars. If you don't like it, then don't + use it. If you want a feature, try to take part in the process, maybe even build it by yourself and add it to the + project! Be nice and respectful. Constructive criticism is for sure always welcome! + +2. Don't bash, don't hate, don't spam, don't vandalize. Please don't ask for personal free support, don't ask if + somebody could do your work for you. Before you ask something, make sure you've read the README, followed every + tutorial, double-checked the code and tried to solve the problem by yourself. + +Trolls and very annoying people will get a permanent ban / block. GitHub has a very powerful anti-abuse team. + +### Contribute + +Please commit only in *develop* branch. The *master* branch will always contain the stable version. + +### Code-Quality scanner links + +[Scrutinizer (master branch)](https://scrutinizer-ci.com/g/panique/huge/?branch=master), +[Scrutinizer (develop branch)](https://scrutinizer-ci.com/g/panique/huge/?branch=develop), +[Code Climate](https://codeclimate.com/github/panique/huge), +[Codacy](https://www.codacy.com/public/panique/phplogin/dashboard?bid=789836), +[SensioLabs Insight](https://insight.sensiolabs.com/projects/d4f4e3c0-1445-4245-8cb2-d75026c11fa7/analyses/2). + +### Found a bug (Responsible Disclosure) ? + +Due to the possible consequences when publishing a bug on a public open-source project I'd kindly ask you to send really +big bugs to my email address, not posting this here. If the bug is not interesting for attackers: Feel free to create +an normal GitHub issue. + +### Current and further development + +See active issues here: +https://github.com/panique/huge/issues?state=open + +### Why you should use a favicon.ico in your project :) + +Interesting issue: When a user hits your website, the user's browser will also request one or more (!) favicons +(different sizes). If these static files don't exist, your application will start to generate a 404 response and a 404 +page for each file. This wastes a lot of server power and is also useless, therefore make sure you always have favicons +or handle this from Apache/nginx level. + +HUGE tries to handle this by sending an empty image in the head of the view/_templates/header.php ! + +More inside this ticket: [Return proper 404 for missing favicon.ico, missing images etc.](https://github.com/panique/huge/issues/530) + +More here on Stackflow: [How to prevent favicon.ico requests?](http://stackoverflow.com/questions/1321878/how-to-prevent-favicon-ico-requests), +[Isn't it silly that a tiny favicon requires yet another HTTP request? How to make favicon go into a sprite?](http://stackoverflow.com/questions/5199902/isnt-it-silly-that-a-tiny-favicon-requires-yet-another-http-request-how-to-mak?lq=1). + +### Useful links + +- [How long will my session last?](http://stackoverflow.com/questions/1516266/how-long-will-my-session-last/1516338#1516338) +- [How to do expire a PHP session after X minutes?](http://stackoverflow.com/questions/520237/how-do-i-expire-a-php-session-after-30-minutes/1270960#1270960) +- [How to use PDO](http://wiki.hashphp.org/PDO_Tutorial_for_MySQL_Developers) +- [A short guideline on how to use the PHP 5.5 password hashing functions and its PHP 5.3 & 5.4 implementations](http://www.dev-metal.com/use-php-5-5-password-hashing-functions/) +- [How to setup latest version of PHP 5.5 on Ubuntu 12.04 LTS](http://www.dev-metal.com/how-to-setup-latest-version-of-php-5-5-on-ubuntu-12-04-lts/) +- [How to setup latest version of PHP 5.5 on Debian Wheezy 7.0/7.1 (and how to fix the GPG key error)](http://www.dev-metal.com/setup-latest-version-php-5-5-debian-wheezy-7-07-1-fix-gpg-key-error/) +- [Notes on password & hashing salting in upcoming PHP versions (PHP 5.5.x & 5.6 etc.)](https://github.com/panique/huge/wiki/Notes-on-password-&-hashing-salting-in-upcoming-PHP-versions-%28PHP-5.5.x-&-5.6-etc.%29) +- [Some basic "benchmarks" of all PHP hash/salt algorithms](https://github.com/panique/huge/wiki/Which-hashing-&-salting-algorithm-should-be-used-%3F) +- [How to prevent PHP sessions being shared between different apache vhosts / different applications](http://www.dev-metal.com/prevent-php-sessions-shared-different-apache-vhosts-different-applications/) + +## Interesting links regarding user authentication and application security + +- [interesting article about password resets (by Troy Hunt, security expert)](http://www.troyhunt.com/2012/05/everything-you-ever-wanted-to-know.html) +- Password-Free Email Logins: [Ticket & discussion](https://github.com/panique/huge/issues/674), [article](http://techcrunch.com/2015/06/30/blogging-site-medium-rolls-out-password-free-email-logins/?ref=webdesignernews.com) +- Logging in via QR code: [Ticket & discussion](https://github.com/panique/huge/issues/290), [english article](https://www.grc.com/sqrl/sqrl.htm), + [german article](http://www.phpgangsta.de/sesam-oeffne-dich-sicher-einloggen-im-internetcafe), + [repo](https://github.com/PHPGangsta/Sesame), [live-demo](http://sesame.phpgangsta.de/). Big thanks to *PHPGangsta* for writing this! + +### My blog + +I'm also blogging at **[Dev Metal](http://www.dev-metal.com)**. diff --git a/_one-click-installation/Vagrantfile b/_one-click-installation/Vagrantfile new file mode 100644 index 0000000..0477872 --- /dev/null +++ b/_one-click-installation/Vagrantfile @@ -0,0 +1,22 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! +VAGRANTFILE_API_VERSION = "2" + +Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| + + # Every Vagrant virtual environment requires a box to build off of. + config.vm.box = "ubuntu/trusty64" + + # Create a private network, which allows host-only access to the machine using a specific IP. + config.vm.network "private_network", ip: "192.168.33.111" + + # Share an additional folder to the guest VM. The first argument is the path on the host to the actual folder. + # The second argument is the path on the guest to mount the folder. + config.vm.synced_folder "./", "/var/www/html" + + # Define the bootstrap file: A (shell) script that runs after first setup of your box (= provisioning) + config.vm.provision :shell, path: "bootstrap.sh" + +end diff --git a/_one-click-installation/bootstrap.sh b/_one-click-installation/bootstrap.sh new file mode 100644 index 0000000..541aee3 --- /dev/null +++ b/_one-click-installation/bootstrap.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +# Use single quotes instead of double quotes to make it work with special-character passwords +PASSWORD='12345678' +PROJECTFOLDER='myproject' + +# Create project folder, written in 3 single mkdir-statements to make sure this runs everywhere without problems +sudo mkdir "/var/www" +sudo mkdir "/var/www/html" +sudo mkdir "/var/www/html/${PROJECTFOLDER}" + +sudo apt-get update +sudo apt-get -y upgrade + +sudo apt-get install -y apache2 +sudo apt-get install -y php5 + +sudo debconf-set-selections <<< "mysql-server mysql-server/root_password password $PASSWORD" +sudo debconf-set-selections <<< "mysql-server mysql-server/root_password_again password $PASSWORD" +sudo apt-get -y install mysql-server +sudo apt-get install php5-mysql + +sudo debconf-set-selections <<< "phpmyadmin phpmyadmin/dbconfig-install boolean true" +sudo debconf-set-selections <<< "phpmyadmin phpmyadmin/app-password-confirm password $PASSWORD" +sudo debconf-set-selections <<< "phpmyadmin phpmyadmin/mysql/admin-pass password $PASSWORD" +sudo debconf-set-selections <<< "phpmyadmin phpmyadmin/mysql/app-pass password $PASSWORD" +sudo debconf-set-selections <<< "phpmyadmin phpmyadmin/reconfigure-webserver multiselect apache2" +sudo apt-get -y install phpmyadmin + +# setup hosts file +VHOST=$(cat < + DocumentRoot "/var/www/html/${PROJECTFOLDER}/public" + + AllowOverride All + Require all granted + + +EOF +) +echo "${VHOST}" > /etc/apache2/sites-available/000-default.conf + +# enable mod_rewrite +sudo a2enmod rewrite + +# restart apache +service apache2 restart + +# install curl (needed to use git afaik) +sudo apt-get -y install curl +sudo apt-get -y install php5-curl + +# install openssl (needed to clone from GitHub, as github is https only) +sudo apt-get -y install openssl + +# install PHP GD, the graphic lib (we create captchas and avatars) +sudo apt-get -y install php5-gd + +# install git +sudo apt-get -y install git + +# git clone HUGE +sudo git clone https://github.com/panique/huge "/var/www/html/${PROJECTFOLDER}" + +# install Composer +curl -s https://getcomposer.org/installer | php +mv composer.phar /usr/local/bin/composer + +# go to project folder, load Composer packages +cd "/var/www/html/${PROJECTFOLDER}" +composer install + +# run SQL statements from install folder +sudo mysql -h "localhost" -u "root" "-p${PASSWORD}" < "/var/www/html/${PROJECTFOLDER}/application/_installation/01-create-database.sql" +sudo mysql -h "localhost" -u "root" "-p${PASSWORD}" < "/var/www/html/${PROJECTFOLDER}/application/_installation/02-create-table-users.sql" +sudo mysql -h "localhost" -u "root" "-p${PASSWORD}" < "/var/www/html/${PROJECTFOLDER}/application/_installation/03-create-table-notes.sql" + +# writing rights to avatar folder +sudo chown -R www-data "/var/www/html/${PROJECTFOLDER}/public/avatars" +# if this didn't work for you, you can also try the hard way: +#sudo chmod 0777 -R "/var/www/html/${PROJECTFOLDER}/public/avatars" + +# remove Apache's default demo file +sudo rm "/var/www/html/index.html" + +# final feedback +echo "Voila!" diff --git a/_pictures/huge.png b/_pictures/huge.png new file mode 100644 index 0000000..ad303b8 Binary files /dev/null and b/_pictures/huge.png differ diff --git a/application/_installation/01-create-database.sql b/application/_installation/01-create-database.sql new file mode 100644 index 0000000..e0ffe92 --- /dev/null +++ b/application/_installation/01-create-database.sql @@ -0,0 +1 @@ +CREATE DATABASE IF NOT EXISTS `huge`; diff --git a/application/_installation/02-create-table-users.sql b/application/_installation/02-create-table-users.sql new file mode 100644 index 0000000..398e3d3 --- /dev/null +++ b/application/_installation/02-create-table-users.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS `huge`.`users` ( + `user_id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'auto incrementing user_id of each user, unique index', + `session_id` varchar(48) DEFAULT NULL COMMENT 'stores session cookie id to prevent session concurrency', + `user_name` varchar(64) COLLATE utf8_unicode_ci NOT NULL COMMENT 'user''s name, unique', + `user_password_hash` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'user''s password in salted and hashed format', + `user_email` varchar(254) COLLATE utf8_unicode_ci NOT NULL COMMENT 'user''s email, unique', + `user_active` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'user''s activation status', + `user_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'user''s deletion status', + `user_account_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'user''s account type (basic, premium, etc)', + `user_has_avatar` tinyint(1) NOT NULL DEFAULT '0' COMMENT '1 if user has a local avatar, 0 if not', + `user_remember_me_token` varchar(64) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'user''s remember-me cookie token', + `user_creation_timestamp` bigint(20) DEFAULT NULL COMMENT 'timestamp of the creation of user''s account', + `user_suspension_timestamp` bigint(20) DEFAULT NULL COMMENT 'Timestamp till the end of a user suspension', + `user_last_login_timestamp` bigint(20) DEFAULT NULL COMMENT 'timestamp of user''s last login', + `user_failed_logins` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'user''s failed login attempts', + `user_last_failed_login` int(10) DEFAULT NULL COMMENT 'unix timestamp of last failed login attempt', + `user_activation_hash` varchar(80) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'user''s email verification hash string', + `user_password_reset_hash` char(80) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'user''s password reset code', + `user_password_reset_timestamp` bigint(20) DEFAULT NULL COMMENT 'timestamp of the password reset request', + `user_provider_type` text COLLATE utf8_unicode_ci, + PRIMARY KEY (`user_id`), + UNIQUE KEY `user_name` (`user_name`), + UNIQUE KEY `user_email` (`user_email`) +) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci COMMENT='user data'; + +INSERT INTO `huge`.`users` (`user_id`, `session_id`, `user_name`, `user_password_hash`, `user_email`, `user_active`, `user_deleted`, `user_account_type`, +`user_has_avatar`, `user_remember_me_token`, `user_creation_timestamp`, `user_suspension_timestamp`, `user_last_login_timestamp`, +`user_failed_logins`, `user_last_failed_login`, `user_activation_hash`, `user_password_reset_hash`, +`user_password_reset_timestamp`, `user_provider_type`) VALUES + (1, NULL, 'demo', '$2y$10$OvprunjvKOOhM1h9bzMPs.vuwGIsOqZbw88rzSyGCTJTcE61g5WXi', 'demo@demo.com', 1, 0, 7, 0, NULL, 1422205178, NULL, 1422209189, 0, NULL, NULL, NULL, NULL, 'DEFAULT'), + (2, NULL, 'demo2', '$2y$10$OvprunjvKOOhM1h9bzMPs.vuwGIsOqZbw88rzSyGCTJTcE61g5WXi', 'demo2@demo.com', 1, 0, 1, 0, NULL, 1422205178, NULL, 1422209189, 0, NULL, NULL, NULL, NULL, 'DEFAULT'); diff --git a/application/_installation/03-create-table-notes.sql b/application/_installation/03-create-table-notes.sql new file mode 100644 index 0000000..38d0368 --- /dev/null +++ b/application/_installation/03-create-table-notes.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS `huge`.`notes` ( + `note_id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `note_text` text NOT NULL, + `user_id` int(11) unsigned NOT NULL, + PRIMARY KEY (`note_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci COMMENT='user notes'; diff --git a/application/config/config.development.php b/application/config/config.development.php new file mode 100644 index 0000000..a6fe492 --- /dev/null +++ b/application/config/config.development.php @@ -0,0 +1,156 @@ + 'http://' . $_SERVER['HTTP_HOST'] . str_replace('public', '', dirname($_SERVER['SCRIPT_NAME'])), + /** + * Configuration for: Folders + * Usually there's no reason to change this. + */ + 'PATH_CONTROLLER' => realpath(dirname(__FILE__) . '/../../') . '/application/controller/', + 'PATH_VIEW' => realpath(dirname(__FILE__) . '/../../') . '/application/view/', + /** + * Configuration for: Avatar paths + * Internal path to save avatars. Make sure this folder is writable. The slash at the end is VERY important! + */ + 'PATH_AVATARS' => realpath(dirname(__FILE__) . '/../../') . '/public/avatars/', + 'PATH_AVATARS_PUBLIC' => 'avatars/', + /** + * Configuration for: Default controller and action + */ + 'DEFAULT_CONTROLLER' => 'index', + 'DEFAULT_ACTION' => 'index', + /** + * Configuration for: Database + * DB_TYPE The used database type. Note that other types than "mysql" might break the db construction currently. + * DB_HOST The mysql hostname, usually localhost or 127.0.0.1 + * DB_NAME The database name + * DB_USER The username + * DB_PASS The password + * DB_PORT The mysql port, 3306 by default (?), find out via phpinfo() and look for mysqli.default_port. + * DB_CHARSET The charset, necessary for security reasons. Check Database.php class for more info. + */ + 'DB_TYPE' => 'mysql', + 'DB_HOST' => 'localhost', + 'DB_NAME' => 'huge', + 'DB_USER' => 'root', + 'DB_PASS' => 'root', + 'DB_PORT' => '3306', + 'DB_CHARSET' => 'utf8', + /** + * Configuration for: Captcha size + * The currently used Captcha generator (https://github.com/Gregwar/Captcha) also runs without giving a size, + * so feel free to use ->build(); inside CaptchaModel. + */ + 'CAPTCHA_WIDTH' => 359, + 'CAPTCHA_HEIGHT' => 100, + /** + * Configuration for: Cookies + * 1209600 seconds = 2 weeks + * COOKIE_PATH is the path the cookie is valid on, usually "/" to make it valid on the whole domain. + * @see http://stackoverflow.com/q/9618217/1114320 + * @see php.net/manual/en/function.setcookie.php + * + * COOKIE_DOMAIN: The domain where the cookie is valid for. Usually this does not work with "localhost", + * ".localhost", "127.0.0.1", or ".127.0.0.1". If so, leave it as empty string, false or null. + * When using real domains make sure you have a dot (!) in front of the domain, like ".mydomain.com". This is + * strange, but explained here: + * @see http://stackoverflow.com/questions/2285010/php-setcookie-domain + * @see http://stackoverflow.com/questions/1134290/cookies-on-localhost-with-explicit-domain + * @see http://php.net/manual/en/function.setcookie.php#73107 + * + * COOKIE_SECURE: If the cookie will be transferred through secured connection(SSL). It's highly recommended to set it to true if you have secured connection. + * COOKIE_HTTP: If set to true, Cookies that can't be accessed by JS - Highly recommended! + * SESSION_RUNTIME: How long should a session cookie be valid by seconds, 604800 = 1 week. + */ + 'COOKIE_RUNTIME' => 1209600, + 'COOKIE_PATH' => '/', + 'COOKIE_DOMAIN' => "", + 'COOKIE_SECURE' => false, + 'COOKIE_HTTP' => true, + 'SESSION_RUNTIME' => 604800, + /** + * Configuration for: Avatars/Gravatar support + * Set to true if you want to use "Gravatar(s)", a service that automatically gets avatar pictures via using email + * addresses of users by requesting images from the gravatar.com API. Set to false to use own locally saved avatars. + * AVATAR_SIZE set the pixel size of avatars/gravatars (will be 44x44 by default). Avatars are always squares. + * AVATAR_DEFAULT_IMAGE is the default image in public/avatars/ + */ + 'USE_GRAVATAR' => false, + 'GRAVATAR_DEFAULT_IMAGESET' => 'mm', + 'GRAVATAR_RATING' => 'pg', + 'AVATAR_SIZE' => 44, + 'AVATAR_JPEG_QUALITY' => 85, + 'AVATAR_DEFAULT_IMAGE' => 'default.jpg', + /** + * Configuration for: Encryption Keys + * ENCRYPTION_KEY, HMAC_SALT: Currently used to encrypt and decrypt publicly visible values, like the user id in + * the cookie. Change these values for increased security, but don't touch if you have no idea what this means. + */ + 'ENCRYPTION_KEY' => '6#x0gÊìf^25cL1f$08&', + 'HMAC_SALT' => '8qk9c^4L6d#15tM8z7n0%', + /** + * Configuration for: Email server credentials + * + * Here you can define how you want to send emails. + * If you have successfully set up a mail server on your linux server and you know + * what you do, then you can skip this section. Otherwise please set EMAIL_USE_SMTP to true + * and fill in your SMTP provider account data. + * + * EMAIL_USED_MAILER: Check Mail class for alternatives + * EMAIL_USE_SMTP: Use SMTP or not + * EMAIL_SMTP_AUTH: leave this true unless your SMTP service does not need authentication + */ + 'EMAIL_USED_MAILER' => 'phpmailer', + 'EMAIL_USE_SMTP' => false, + 'EMAIL_SMTP_HOST' => 'yourhost', + 'EMAIL_SMTP_AUTH' => true, + 'EMAIL_SMTP_USERNAME' => 'yourusername', + 'EMAIL_SMTP_PASSWORD' => 'yourpassword', + 'EMAIL_SMTP_PORT' => 465, + 'EMAIL_SMTP_ENCRYPTION' => 'ssl', + /** + * Configuration for: Email content data + */ + 'EMAIL_PASSWORD_RESET_URL' => 'login/verifypasswordreset', + 'EMAIL_PASSWORD_RESET_FROM_EMAIL' => 'no-reply@example.com', + 'EMAIL_PASSWORD_RESET_FROM_NAME' => 'My Project', + 'EMAIL_PASSWORD_RESET_SUBJECT' => 'Password reset for PROJECT XY', + 'EMAIL_PASSWORD_RESET_CONTENT' => 'Please click on this link to reset your password: ', + 'EMAIL_VERIFICATION_URL' => 'register/verify', + 'EMAIL_VERIFICATION_FROM_EMAIL' => 'no-reply@example.com', + 'EMAIL_VERIFICATION_FROM_NAME' => 'My Project', + 'EMAIL_VERIFICATION_SUBJECT' => 'Account activation for PROJECT XY', + 'EMAIL_VERIFICATION_CONTENT' => 'Please click on this link to activate your account: ', +); diff --git a/application/config/texts.php b/application/config/texts.php new file mode 100644 index 0000000..f304617 --- /dev/null +++ b/application/config/texts.php @@ -0,0 +1,75 @@ + "Unknown error occurred!", + "FEEDBACK_DELETED" => "Your account has been deleted.", + "FEEDBACK_ACCOUNT_SUSPENDED" => "Account Suspended for ", + "FEEDBACK_ACCOUNT_SUSPENSION_DELETION_STATUS" => "This user's suspension / deletion status has been edited.", + "FEEDBACK_ACCOUNT_CANT_DELETE_SUSPEND_OWN" => "You can not delete or suspend your own account.", + "FEEDBACK_ACCOUNT_USER_SUCCESSFULLY_KICKED" => "The selected user has been successfully kicked out of the system (by resetting this user's session)", + "FEEDBACK_PASSWORD_WRONG_3_TIMES" => "You have typed in a wrong password 3 or more times already. Please wait 30 seconds to try again.", + "FEEDBACK_ACCOUNT_NOT_ACTIVATED_YET" => "Your account is not activated yet. Please click on the confirm link in the mail.", + "FEEDBACK_USERNAME_OR_PASSWORD_WRONG" => "The username or password is incorrect. Please try again.", + "FEEDBACK_USER_DOES_NOT_EXIST" => "This user does not exist.", + "FEEDBACK_LOGIN_FAILED" => "Login failed.", + "FEEDBACK_LOGIN_FAILED_3_TIMES" => "Login failed 3 or more times already. Please wait 30 seconds to try again.", + "FEEDBACK_USERNAME_FIELD_EMPTY" => "Username field was empty.", + "FEEDBACK_PASSWORD_FIELD_EMPTY" => "Password field was empty.", + "FEEDBACK_USERNAME_OR_PASSWORD_FIELD_EMPTY" => "Username or password field was empty.", + "FEEDBACK_USERNAME_EMAIL_FIELD_EMPTY" => "Username / email field was empty.", + "FEEDBACK_EMAIL_FIELD_EMPTY" => "Email field was empty.", + "FEEDBACK_EMAIL_REPEAT_WRONG" => "Email and email repeat are not the same", + "FEEDBACK_EMAIL_AND_PASSWORD_FIELDS_EMPTY" => "Email and password fields were empty.", + "FEEDBACK_USERNAME_SAME_AS_OLD_ONE" => "Sorry, that username is the same as your current one. Please choose another one.", + "FEEDBACK_USERNAME_ALREADY_TAKEN" => "Sorry, that username is already taken. Please choose another one.", + "FEEDBACK_USER_EMAIL_ALREADY_TAKEN" => "Sorry, that email is already in use. Please choose another one.", + "FEEDBACK_USERNAME_CHANGE_SUCCESSFUL" => "Your username has been changed successfully.", + "FEEDBACK_USERNAME_AND_PASSWORD_FIELD_EMPTY" => "Username and password fields were empty.", + "FEEDBACK_USERNAME_DOES_NOT_FIT_PATTERN" => "Username does not fit the name pattern: only a-Z and numbers are allowed, 2 to 64 characters.", + "FEEDBACK_EMAIL_DOES_NOT_FIT_PATTERN" => "Sorry, your chosen email does not fit into the email naming pattern.", + "FEEDBACK_EMAIL_SAME_AS_OLD_ONE" => "Sorry, that email address is the same as your current one. Please choose another one.", + "FEEDBACK_EMAIL_CHANGE_SUCCESSFUL" => "Your email address has been changed successfully.", + "FEEDBACK_CAPTCHA_WRONG" => "The entered captcha security characters were wrong.", + "FEEDBACK_PASSWORD_REPEAT_WRONG" => "Password and password repeat are not the same.", + "FEEDBACK_PASSWORD_TOO_SHORT" => "Password has a minimum length of 6 characters.", + "FEEDBACK_USERNAME_TOO_SHORT_OR_TOO_LONG" => "Username cannot be shorter than 2 or longer than 64 characters.", + "FEEDBACK_ACCOUNT_SUCCESSFULLY_CREATED" => "Your account has been created successfully and we have sent you an email. Please click the VERIFICATION LINK within that mail.", + "FEEDBACK_VERIFICATION_MAIL_SENDING_FAILED" => "Sorry, we could not send you an verification mail. Your account has NOT been created.", + "FEEDBACK_ACCOUNT_CREATION_FAILED" => "Sorry, your registration failed. Please go back and try again.", + "FEEDBACK_VERIFICATION_MAIL_SENDING_ERROR" => "Verification mail could not be sent due to: ", + "FEEDBACK_VERIFICATION_MAIL_SENDING_SUCCESSFUL" => "A verification mail has been sent successfully.", + "FEEDBACK_ACCOUNT_ACTIVATION_SUCCESSFUL" => "Activation was successful! You can now log in.", + "FEEDBACK_ACCOUNT_ACTIVATION_FAILED" => "Sorry, no such id/verification code combination here! It might be possible that your mail provider (Yahoo? Hotmail?) automatically visits links in emails for anti-scam scanning, so this activation link might been clicked without your action. Please try to log in on the main page.", + "FEEDBACK_AVATAR_UPLOAD_SUCCESSFUL" => "Avatar upload was successful.", + "FEEDBACK_AVATAR_UPLOAD_WRONG_TYPE" => "Only JPEG and PNG files are supported.", + "FEEDBACK_AVATAR_UPLOAD_TOO_SMALL" => "Avatar source file's width/height is too small. Needs to be 100x100 pixel minimum.", + "FEEDBACK_AVATAR_UPLOAD_TOO_BIG" => "Avatar source file is too big. 5 Megabyte is the maximum.", + "FEEDBACK_AVATAR_FOLDER_DOES_NOT_EXIST_OR_NOT_WRITABLE" => "Avatar folder does not exist or is not writable. Please change this via chmod 775 or 777.", + "FEEDBACK_AVATAR_IMAGE_UPLOAD_FAILED" => "Something went wrong with the image upload.", + "FEEDBACK_AVATAR_IMAGE_DELETE_SUCCESSFUL" => "You successfully deleted your avatar.", + "FEEDBACK_AVATAR_IMAGE_DELETE_NO_FILE" => "You don't have a custom avatar.", + "FEEDBACK_AVATAR_IMAGE_DELETE_FAILED" => "Something went wrong while deleting your avatar.", + "FEEDBACK_PASSWORD_RESET_TOKEN_FAIL" => "Could not write token to database.", + "FEEDBACK_PASSWORD_RESET_TOKEN_MISSING" => "No password reset token.", + "FEEDBACK_PASSWORD_RESET_MAIL_SENDING_ERROR" => "Password reset mail could not be sent due to: ", + "FEEDBACK_PASSWORD_RESET_MAIL_SENDING_SUCCESSFUL" => "A password reset mail has been sent successfully.", + "FEEDBACK_PASSWORD_RESET_LINK_EXPIRED" => "Your reset link has expired. Please use the reset link within one hour.", + "FEEDBACK_PASSWORD_RESET_COMBINATION_DOES_NOT_EXIST" => "Username/Verification code combination does not exist.", + "FEEDBACK_PASSWORD_RESET_LINK_VALID" => "Password reset validation link is valid. Please change the password now.", + "FEEDBACK_PASSWORD_CHANGE_SUCCESSFUL" => "Password successfully changed.", + "FEEDBACK_PASSWORD_CHANGE_FAILED" => "Sorry, your password changing failed.", + "FEEDBACK_PASSWORD_NEW_SAME_AS_CURRENT" => "New password is the same as the current password.", + "FEEDBACK_PASSWORD_CURRENT_INCORRECT" => "Current password entered was incorrect.", + "FEEDBACK_ACCOUNT_TYPE_CHANGE_SUCCESSFUL" => "Account type change successful", + "FEEDBACK_ACCOUNT_TYPE_CHANGE_FAILED" => "Account type change failed", + "FEEDBACK_NOTE_CREATION_FAILED" => "Note creation failed.", + "FEEDBACK_NOTE_EDITING_FAILED" => "Note editing failed.", + "FEEDBACK_NOTE_DELETION_FAILED" => "Note deletion failed.", + "FEEDBACK_COOKIE_INVALID" => "Your remember-me-cookie is invalid.", + "FEEDBACK_COOKIE_LOGIN_SUCCESSFUL" => "You were successfully logged in via the remember-me-cookie.", +); diff --git a/application/controller/AdminController.php b/application/controller/AdminController.php new file mode 100644 index 0000000..6cd7614 --- /dev/null +++ b/application/controller/AdminController.php @@ -0,0 +1,35 @@ +View->render('admin/index', array( + 'users' => UserModel::getPublicProfilesOfAllUsers()) + ); + } + + public function actionAccountSettings() + { + AdminModel::setAccountSuspensionAndDeletionStatus( + Request::post('suspension'), Request::post('softDelete'), Request::post('user_id') + ); + + Redirect::to("admin"); + } +} diff --git a/application/controller/DashboardController.php b/application/controller/DashboardController.php new file mode 100644 index 0000000..ef82923 --- /dev/null +++ b/application/controller/DashboardController.php @@ -0,0 +1,26 @@ +View->render('dashboard/index'); + } +} diff --git a/application/controller/ErrorController.php b/application/controller/ErrorController.php new file mode 100644 index 0000000..97da4e7 --- /dev/null +++ b/application/controller/ErrorController.php @@ -0,0 +1,28 @@ + __construct + */ + public function error404() + { + header('HTTP/1.0 404 Not Found', true, 404); + $this->View->render('error/404'); + } +} diff --git a/application/controller/IndexController.php b/application/controller/IndexController.php new file mode 100644 index 0000000..8dff4eb --- /dev/null +++ b/application/controller/IndexController.php @@ -0,0 +1,21 @@ +View->render('index/index'); + } +} diff --git a/application/controller/LoginController.php b/application/controller/LoginController.php new file mode 100644 index 0000000..9a2a6a0 --- /dev/null +++ b/application/controller/LoginController.php @@ -0,0 +1,148 @@ + Request::get('redirect') ? Request::get('redirect') : null); + $this->View->render('login/index', $data); + } + } + + /** + * The login action, when you do login/login + */ + public function login() + { + // check if csrf token is valid + if (!Csrf::isTokenValid()) { + LoginModel::logout(); + Redirect::home(); + exit(); + } + + // perform the login method, put result (true or false) into $login_successful + $login_successful = LoginModel::login( + Request::post('user_name'), Request::post('user_password'), Request::post('set_remember_me_cookie') + ); + + // check login status: if true, then redirect user to user/index, if false, then to login form again + if ($login_successful) { + if (Request::post('redirect')) { + Redirect::toPreviousViewedPageAfterLogin(ltrim(urldecode(Request::post('redirect')), '/')); + } else { + Redirect::to('user/index'); + } + } else { + if (Request::post('redirect')) { + Redirect::to('login?redirect=' . ltrim(urlencode(Request::post('redirect')), '/')); + } else { + Redirect::to('login/index'); + } + } + } + + /** + * The logout action + * Perform logout, redirect user to main-page + */ + public function logout() + { + LoginModel::logout(); + Redirect::home(); + exit(); + } + + /** + * Login with cookie + */ + public function loginWithCookie() + { + // run the loginWithCookie() method in the login-model, put the result in $login_successful (true or false) + $login_successful = LoginModel::loginWithCookie(Request::cookie('remember_me')); + + // if login successful, redirect to dashboard/index ... + if ($login_successful) { + Redirect::to('dashboard/index'); + } else { + // if not, delete cookie (outdated? attack?) and route user to login form to prevent infinite login loops + LoginModel::deleteCookie(); + Redirect::to('login/index'); + } + } + + /** + * Show the request-password-reset page + */ + public function requestPasswordReset() + { + $this->View->render('login/requestPasswordReset'); + } + + /** + * The request-password-reset action + * POST-request after form submit + */ + public function requestPasswordReset_action() + { + PasswordResetModel::requestPasswordReset(Request::post('user_name_or_email'), Request::post('captcha')); + Redirect::to('login/index'); + } + + /** + * Verify the verification token of that user (to show the user the password editing view or not) + * @param string $user_name username + * @param string $verification_code password reset verification token + */ + public function verifyPasswordReset($user_name, $verification_code) + { + // check if this the provided verification code fits the user's verification code + if (PasswordResetModel::verifyPasswordReset($user_name, $verification_code)) { + // pass URL-provided variable to view to display them + $this->View->render('login/resetPassword', array( + 'user_name' => $user_name, + 'user_password_reset_hash' => $verification_code + )); + } else { + Redirect::to('login/index'); + } + } + + /** + * Set the new password + * Please note that this happens while the user is not logged in. The user identifies via the data provided by the + * password reset link from the email, automatically filled into the
fields. See verifyPasswordReset() + * for more. Then (regardless of result) route user to index page (user will get success/error via feedback message) + * POST request ! + * TODO this is an _action + */ + public function setNewPassword() + { + PasswordResetModel::setNewPassword( + Request::post('user_name'), Request::post('user_password_reset_hash'), + Request::post('user_password_new'), Request::post('user_password_repeat') + ); + Redirect::to('login/index'); + } +} diff --git a/application/controller/NoteController.php b/application/controller/NoteController.php new file mode 100644 index 0000000..f44ee7e --- /dev/null +++ b/application/controller/NoteController.php @@ -0,0 +1,77 @@ +__construct + Auth::checkAuthentication(); + } + + /** + * This method controls what happens when you move to /note/index in your app. + * Gets all notes (of the user). + */ + public function index() + { + $this->View->render('note/index', array( + 'notes' => NoteModel::getAllNotes() + )); + } + + /** + * This method controls what happens when you move to /dashboard/create in your app. + * Creates a new note. This is usually the target of form submit actions. + * POST request. + */ + public function create() + { + NoteModel::createNote(Request::post('note_text')); + Redirect::to('note'); + } + + /** + * This method controls what happens when you move to /note/edit(/XX) in your app. + * Shows the current content of the note and an editing form. + * @param $note_id int id of the note + */ + public function edit($note_id) + { + $this->View->render('note/edit', array( + 'note' => NoteModel::getNote($note_id) + )); + } + + /** + * This method controls what happens when you move to /note/editSave in your app. + * Edits a note (performs the editing after form submit). + * POST request. + */ + public function editSave() + { + NoteModel::updateNote(Request::post('note_id'), Request::post('note_text')); + Redirect::to('note'); + } + + /** + * This method controls what happens when you move to /note/delete(/XX) in your app. + * Deletes a note. In a real application a deletion via GET/URL is not recommended, but for demo purposes it's + * totally okay. + * @param int $note_id id of the note + */ + public function delete($note_id) + { + NoteModel::deleteNote($note_id); + Redirect::to('note'); + } +} diff --git a/application/controller/ProfileController.php b/application/controller/ProfileController.php new file mode 100644 index 0000000..3cff93e --- /dev/null +++ b/application/controller/ProfileController.php @@ -0,0 +1,39 @@ +View->render('profile/index', array( + 'users' => UserModel::getPublicProfilesOfAllUsers()) + ); + } + + /** + * This method controls what happens when you move to /overview/showProfile in your app. + * Shows the (public) details of the selected user. + * @param $user_id int id the the user + */ + public function showProfile($user_id) + { + if (isset($user_id)) { + $this->View->render('profile/showProfile', array( + 'user' => UserModel::getPublicProfileOfUser($user_id)) + ); + } else { + Redirect::home(); + } + } +} diff --git a/application/controller/RegisterController.php b/application/controller/RegisterController.php new file mode 100644 index 0000000..3240897 --- /dev/null +++ b/application/controller/RegisterController.php @@ -0,0 +1,74 @@ +View->render('register/index'); + } + } + + /** + * Register page action + * POST-request after form submit + */ + public function register_action() + { + $registration_successful = RegistrationModel::registerNewUser(); + + if ($registration_successful) { + Redirect::to('login/index'); + } else { + Redirect::to('register/index'); + } + } + + /** + * Verify user after activation mail link opened + * @param int $user_id user's id + * @param string $user_activation_verification_code user's verification token + */ + public function verify($user_id, $user_activation_verification_code) + { + if (isset($user_id) && isset($user_activation_verification_code)) { + RegistrationModel::verifyNewUser($user_id, $user_activation_verification_code); + $this->View->render('register/verify'); + } else { + Redirect::to('login/index'); + } + } + + /** + * Generate a captcha, write the characters into $_SESSION['captcha'] and returns a real image which will be used + * like this: + * IMPORTANT: As this action is called via AFTER the real application has finished executing (!), the + * SESSION["captcha"] has no content when the application is loaded. The SESSION["captcha"] gets filled at the + * moment the end-user requests the + * Maybe refactor this sometime. + */ + public function showCaptcha() + { + CaptchaModel::generateAndShowCaptcha(); + } +} diff --git a/application/controller/UserController.php b/application/controller/UserController.php new file mode 100644 index 0000000..0af7fb2 --- /dev/null +++ b/application/controller/UserController.php @@ -0,0 +1,157 @@ +View->render('user/index', array( + 'user_name' => Session::get('user_name'), + 'user_email' => Session::get('user_email'), + 'user_gravatar_image_url' => Session::get('user_gravatar_image_url'), + 'user_avatar_file' => Session::get('user_avatar_file'), + 'user_account_type' => Session::get('user_account_type') + )); + } + + /** + * Show edit-my-username page + */ + public function editUsername() + { + $this->View->render('user/editUsername'); + } + + /** + * Edit user name (perform the real action after form has been submitted) + */ + public function editUsername_action() + { + // check if csrf token is valid + if (!Csrf::isTokenValid()) { + LoginModel::logout(); + Redirect::home(); + exit(); + } + + UserModel::editUserName(Request::post('user_name')); + Redirect::to('user/editUsername'); + } + + /** + * Show edit-my-user-email page + */ + public function editUserEmail() + { + $this->View->render('user/editUserEmail'); + } + + /** + * Edit user email (perform the real action after form has been submitted) + */ + // make this POST + public function editUserEmail_action() + { + UserModel::editUserEmail(Request::post('user_email')); + Redirect::to('user/editUserEmail'); + } + + /** + * Edit avatar + */ + public function editAvatar() + { + $this->View->render('user/editAvatar', array( + 'avatar_file_path' => AvatarModel::getPublicUserAvatarFilePathByUserId(Session::get('user_id')) + )); + } + + /** + * Perform the upload of the avatar + * POST-request + */ + public function uploadAvatar_action() + { + AvatarModel::createAvatar(); + Redirect::to('user/editAvatar'); + } + + /** + * Delete the current user's avatar + */ + public function deleteAvatar_action() + { + AvatarModel::deleteAvatar(Session::get("user_id")); + Redirect::to('user/editAvatar'); + } + + /** + * Show the change-account-type page + */ + public function changeUserRole() + { + $this->View->render('user/changeUserRole'); + } + + /** + * Perform the account-type changing + * POST-request + */ + public function changeUserRole_action() + { + if (Request::post('user_account_upgrade')) { + // "2" is quick & dirty account type 2, something like "premium user" maybe. you got the idea :) + UserRoleModel::changeUserRole(2); + } + + if (Request::post('user_account_downgrade')) { + // "1" is quick & dirty account type 1, something like "basic user" maybe. + UserRoleModel::changeUserRole(1); + } + + Redirect::to('user/changeUserRole'); + } + + /** + * Password Change Page + */ + public function changePassword() + { + $this->View->render('user/changePassword'); + } + + /** + * Password Change Action + * Submit form, if retured positive redirect to index, otherwise show the changePassword page again + */ + public function changePassword_action() + { + $result = PasswordResetModel::changePassword( + Session::get('user_name'), Request::post('user_password_current'), + Request::post('user_password_new'), Request::post('user_password_repeat') + ); + + if($result) + Redirect::to('user/index'); + else + Redirect::to('user/changePassword'); + } +} diff --git a/application/core/Application.php b/application/core/Application.php new file mode 100644 index 0000000..4cc689c --- /dev/null +++ b/application/core/Application.php @@ -0,0 +1,106 @@ +splitUrl(); + + // creates controller and action names (from URL input) + $this->createControllerAndActionNames(); + + // does such a controller exist ? + if (file_exists(Config::get('PATH_CONTROLLER') . $this->controller_name . '.php')) { + + // load this file and create this controller + // example: if controller would be "car", then this line would translate into: $this->car = new car(); + require Config::get('PATH_CONTROLLER') . $this->controller_name . '.php'; + $this->controller = new $this->controller_name(); + + // check are controller and method existing and callable? + if (is_callable(array($this->controller, $this->action_name))) { + if (!empty($this->parameters)) { + // call the method and pass arguments to it + call_user_func_array(array($this->controller, $this->action_name), $this->parameters); + } else { + // if no parameters are given, just call the method without parameters, like $this->index->index(); + $this->controller->{$this->action_name}(); + } + } else { + // load 404 error page + require Config::get('PATH_CONTROLLER') . 'ErrorController.php'; + $this->controller = new ErrorController; + $this->controller->error404(); + } + } else { + // load 404 error page + require Config::get('PATH_CONTROLLER') . 'ErrorController.php'; + $this->controller = new ErrorController; + $this->controller->error404(); + } + } + + /** + * Get and split the URL + */ + private function splitUrl() + { + if (Request::get('url')) { + + // split URL + $url = trim(Request::get('url'), '/'); + $url = filter_var($url, FILTER_SANITIZE_URL); + $url = explode('/', $url); + + // put URL parts into according properties + $this->controller_name = isset($url[0]) ? $url[0] : null; + $this->action_name = isset($url[1]) ? $url[1] : null; + + // remove controller name and action name from the split URL + unset($url[0], $url[1]); + + // rebase array keys and store the URL parameters + $this->parameters = array_values($url); + } + } + + /** + * Checks if controller and action names are given. If not, default values are put into the properties. + * Also renames controller to usable name. + */ + private function createControllerAndActionNames() + { + // check for controller: no controller given ? then make controller = default controller (from config) + if (!$this->controller_name) { + $this->controller_name = Config::get('DEFAULT_CONTROLLER'); + } + + // check for action: no action given ? then make action = default action (from config) + if (!$this->action_name OR (strlen($this->action_name) == 0)) { + $this->action_name = Config::get('DEFAULT_ACTION'); + } + + // rename controller name to real controller class/file name ("index" to "IndexController") + $this->controller_name = ucwords($this->controller_name) . 'Controller'; + } +} diff --git a/application/core/Auth.php b/application/core/Auth.php new file mode 100644 index 0000000..10430b1 --- /dev/null +++ b/application/core/Auth.php @@ -0,0 +1,82 @@ +View->render(); + $this->View = new View(); + } +} diff --git a/application/core/Csrf.php b/application/core/Csrf.php new file mode 100644 index 0000000..4707046 --- /dev/null +++ b/application/core/Csrf.php @@ -0,0 +1,60 @@ +" /> + * + * This validation needed in the controller action method to validate CSRF token submitted with the form: + * + * if (!Csrf::isTokenValid()) { + * LoginModel::logout(); + * Redirect::home(); + * exit(); + * } + * + * To get simpler code it might be better to put the logout, redirect, exit into an own (static) method. + */ +class Csrf +{ + /** + * get CSRF token and generate a new one if expired + * + * @access public + * @static static method + * @return string + */ + public static function makeToken() + { + // token is valid for 1 day + $max_time = 60 * 60 * 24; + $stored_time = Session::get('csrf_token_time'); + $csrf_token = Session::get('csrf_token'); + + if ($max_time + $stored_time <= time() || empty($csrf_token)) { + Session::set('csrf_token', md5(uniqid(rand(), true))); + Session::set('csrf_token_time', time()); + } + + return Session::get('csrf_token'); + } + + /** + * checks if CSRF token in session is same as in the form submitted + * + * @access public + * @static static method + * @return bool + */ + public static function isTokenValid() + { + $token = Request::post('csrf_token'); + return $token === Session::get('csrf_token') && !empty($token); + } +} diff --git a/application/core/DatabaseFactory.php b/application/core/DatabaseFactory.php new file mode 100644 index 0000000..417972c --- /dev/null +++ b/application/core/DatabaseFactory.php @@ -0,0 +1,64 @@ +getConnection(); + * + * That's my personal favourite when creating a database connection. + * It's a slightly modified version of Jon Raphaelson's excellent answer on StackOverflow: + * http://stackoverflow.com/questions/130878/global-or-singleton-for-database-connection + * + * Full quote from the answer: + * + * "Then, in 6 months when your app is super famous and getting dugg and slashdotted and you decide you need more than + * a single connection, all you have to do is implement some pooling in the getConnection() method. Or if you decide + * that you want a wrapper that implements SQL logging, you can pass a PDO subclass. Or if you decide you want a new + * connection on every invocation, you can do do that. It's flexible, instead of rigid." + * + * Thanks! Big up, mate! + */ +class DatabaseFactory +{ + private static $factory; + private $database; + + public static function getFactory() + { + if (!self::$factory) { + self::$factory = new DatabaseFactory(); + } + return self::$factory; + } + + public function getConnection() { + if (!$this->database) { + + /** + * Check DB connection in try/catch block. Also when PDO is not constructed properly, + * prevent to exposing database host, username and password in plain text as: + * PDO->__construct('mysql:host=127....', 'root', '12345678', Array) + * by throwing custom error message + */ + try { + $options = array(PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ, PDO::ATTR_ERRMODE => PDO::ERRMODE_WARNING); + $this->database = new PDO( + Config::get('DB_TYPE') . ':host=' . Config::get('DB_HOST') . ';dbname=' . + Config::get('DB_NAME') . ';port=' . Config::get('DB_PORT') . ';charset=' . Config::get('DB_CHARSET'), + Config::get('DB_USER'), Config::get('DB_PASS'), $options + ); + } catch (PDOException $e) { + + // Echo custom message. Echo error code gives you some info. + echo 'Database connection can not be estabilished. Please try again later.' . '
'; + echo 'Error code: ' . $e->getCode(); + + // Stop application :( + // No connection, reached limit connections etc. so no point to keep it running + exit; + } + } + return $this->database; + } +} diff --git a/application/core/Encryption.php b/application/core/Encryption.php new file mode 100644 index 0000000..9d59eab --- /dev/null +++ b/application/core/Encryption.php @@ -0,0 +1,146 @@ +var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);" + * This means, every user's browser would render "John" anymore, instead interpreting this JavaScript code, calling + * the delete.php, in this case inside the project, in worse scenarios something like performing a bank transaction + * or sending your cookie data (containing your remember-me-token) to somebody else. + * + * What is XSS ? + * @see http://phpsecurity.readthedocs.org/en/latest/Cross-Site-Scripting-%28XSS%29.html + * + * Deeper information: + * @see https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet + * + * XSSFilter expects a value, checks if the value is a string, and if so, encodes typical script tag chars to + * harmless HTML (you'll see the code, it wil not be interpreted). Then the method checks if the value is an array, + * or an object and if so, makes sure all its string content is encoded (recursive call on its values). + * Note that this method uses reference to the assed variable, not a copy, meaning you can use this methods like this: + * + * CORRECT: Filter::XSSFilter($myVariable); + * WRONG: $myVariable = Filter::XSSFilter($myVariable); + * + * This works like some other popular PHP functions, for example sort(). + * @see http://php.net/manual/en/function.sort.php + * + * @see http://stackoverflow.com/questions/1676897/what-does-it-mean-to-start-a-php-function-with-an-ampersand + * @see http://php.net/manual/en/language.references.pass.php + * + * FYI: htmlspecialchars() does this (from PHP docs): + * + * '&' (ampersand) becomes '&' + * '"' (double quote) becomes '"' when ENT_NOQUOTES is not set. + * "'" (single quote) becomes ''' (or ') only when ENT_QUOTES is set. + * '<' (less than) becomes '<' + * '>' (greater than) becomes '>' + * + * @see http://www.php.net/manual/en/function.htmlspecialchars.php + * + * @param $value The value to be filtered + * @return mixed + */ + public static function XSSFilter(&$value) + { + // if argument is a string, filters that string + if (is_string($value)) { + $value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + + // if argument is an array or an object, + // recursivly filters its content + } else if (is_array($value) || is_object($value)) { + + /** + * Make sure the element is passed by reference, + * In PHP 7, foreach does not use the internal array pointer. + * In order to be able to directly modify array elements within the loop + * precede $value with &. In that case the value will be assigned by reference. + * @see http://php.net/manual/en/control-structures.foreach.php + */ + foreach ($value as &$valueInValue) { + self::XSSFilter($valueInValue); + } + } + + // other types are untouched + return $value; + } +} diff --git a/application/core/Mail.php b/application/core/Mail.php new file mode 100644 index 0000000..7218cd8 --- /dev/null +++ b/application/core/Mail.php @@ -0,0 +1,154 @@ +CharSet = 'UTF-8'; + + // if you want to send mail via PHPMailer using SMTP credentials + if (Config::get('EMAIL_USE_SMTP')) { + + // set PHPMailer to use SMTP + $mail->IsSMTP(); + + // 0 = off, 1 = commands, 2 = commands and data, perfect to see SMTP errors + $mail->SMTPDebug = 0; + + // enable SMTP authentication + $mail->SMTPAuth = Config::get('EMAIL_SMTP_AUTH'); + + // encryption + if (Config::get('EMAIL_SMTP_ENCRYPTION')) { + $mail->SMTPSecure = Config::get('EMAIL_SMTP_ENCRYPTION'); + } + + // set SMTP provider's credentials + $mail->Host = Config::get('EMAIL_SMTP_HOST'); + $mail->Username = Config::get('EMAIL_SMTP_USERNAME'); + $mail->Password = Config::get('EMAIL_SMTP_PASSWORD'); + $mail->Port = Config::get('EMAIL_SMTP_PORT'); + + } else { + + $mail->IsMail(); + } + + // fill mail with data + $mail->From = $from_email; + $mail->FromName = $from_name; + $mail->AddAddress($user_email); + $mail->Subject = $subject; + $mail->Body = $body; + + // try to send mail, put result status (true/false into $wasSendingSuccessful) + // I'm unsure if mail->send really returns true or false every time, tis method in PHPMailer is quite complex + $wasSendingSuccessful = $mail->Send(); + + if ($wasSendingSuccessful) { + return true; + + } else { + + // if not successful, copy errors into Mail's error property + $this->error = $mail->ErrorInfo; + return false; + } + } + + /** + * The main mail sending method, this simply calls a certain mail sending method depending on which mail provider + * you've selected in the application's config. + * + * @param $user_email string email + * @param $from_email string sender's email + * @param $from_name string sender's name + * @param $subject string subject + * @param $body string full mail body text + * @return bool the success status of the according mail sending method + */ + public function sendMail($user_email, $from_email, $from_name, $subject, $body) + { + if (Config::get('EMAIL_USED_MAILER') == "phpmailer") { + + // returns true if successful, false if not + return $this->sendMailWithPHPMailer( + $user_email, $from_email, $from_name, $subject, $body + ); + } + + if (Config::get('EMAIL_USED_MAILER') == "swiftmailer") { + return $this->sendMailWithSwiftMailer(); + } + + if (Config::get('EMAIL_USED_MAILER') == "native") { + return $this->sendMailWithNativeMailFunction(); + } + } + + /** + * The different mail sending methods write errors to the error property $this->error, + * this method simply returns this error / error array. + * + * @return mixed + */ + public function getError() + { + return $this->error; + } +} diff --git a/application/core/Redirect.php b/application/core/Redirect.php new file mode 100644 index 0000000..c0db5bd --- /dev/null +++ b/application/core/Redirect.php @@ -0,0 +1,48 @@ +getConnection(); + $sql = "UPDATE users SET session_id = :session_id WHERE user_id = :user_id"; + + $query = $database->prepare($sql); + $query->execute(array(':session_id' => $sessionId, ":user_id" => $userId)); + } + + /** + * checks for session concurrency + * + * This is done as the following: + * UserA logs in with his session id('123') and it will be stored in the database. + * Then, UserB logs in also using the same email and password of UserA from another PC, + * and also store the session id('456') in the database + * + * Now, Whenever UserA performs any action, + * You then check the session_id() against the last one stored in the database('456'), + * If they don't match then log both of them out. + * + * @access public + * @static static method + * @return bool + * @see Session::updateSessionId() + * @see http://stackoverflow.com/questions/6126285/php-stop-concurrent-user-logins + */ + public static function isConcurrentSessionExists() + { + $session_id = session_id(); + $userId = Session::get('user_id'); + + if (isset($userId) && isset($session_id)) { + + $database = DatabaseFactory::getFactory()->getConnection(); + $sql = "SELECT session_id FROM users WHERE user_id = :user_id LIMIT 1"; + + $query = $database->prepare($sql); + $query->execute(array(":user_id" => $userId)); + + $result = $query->fetch(); + $userSessionId = !empty($result)? $result->session_id: null; + + return $session_id !== $userSessionId; + } + + return false; + } + + /** + * Checks if the user is logged in or not + * + * @return bool user's login status + */ + public static function userIsLoggedIn() + { + return (self::get('user_logged_in') ? true : false); + } +} diff --git a/application/core/Text.php b/application/core/Text.php new file mode 100644 index 0000000..24ec988 --- /dev/null +++ b/application/core/Text.php @@ -0,0 +1,32 @@ + $value) { + ${$var} = $value; + } + } + + // load config file (this is only done once per application lifecycle) + if (!self::$texts) { + self::$texts = require('../application/config/texts.php'); + } + + // check if array key exists + if (!array_key_exists($key, self::$texts)) { + return null; + } + + return self::$texts[$key]; + } +} diff --git a/application/core/View.php b/application/core/View.php new file mode 100644 index 0000000..435fe03 --- /dev/null +++ b/application/core/View.php @@ -0,0 +1,177 @@ +view->render('help/index'); to show (in this example) the view index.php in the folder help. + * Usually the Class and the method are the same like the view, but sometimes you need to show different views. + * @param string $filename Path of the to-be-rendered view, usually folder/file(.php) + * @param array $data Data to be used in the view + */ + public function render($filename, $data = null) + { + if ($data) { + foreach ($data as $key => $value) { + $this->{$key} = $value; + } + } + + require Config::get('PATH_VIEW') . '_templates/header.php'; + require Config::get('PATH_VIEW') . $filename . '.php'; + require Config::get('PATH_VIEW') . '_templates/footer.php'; + } + + /** + * Similar to render, but accepts an array of separate views to render between the header and footer. Use like + * the following: $this->view->renderMulti(array('help/index', 'help/banner')); + * @param array $filenames Array of the paths of the to-be-rendered view, usually folder/file(.php) for each + * @param array $data Data to be used in the view + * @return bool + */ + public function renderMulti($filenames, $data = null) + { + if (!is_array($filenames)) { + self::render($filenames, $data); + return false; + } + + if ($data) { + foreach ($data as $key => $value) { + $this->{$key} = $value; + } + } + + require Config::get('PATH_VIEW') . '_templates/header.php'; + + foreach($filenames as $filename) { + require Config::get('PATH_VIEW') . $filename . '.php'; + } + + require Config::get('PATH_VIEW') . '_templates/footer.php'; + } + + /** + * Same like render(), but does not include header and footer + * @param string $filename Path of the to-be-rendered view, usually folder/file(.php) + * @param mixed $data Data to be used in the view + */ + public function renderWithoutHeaderAndFooter($filename, $data = null) + { + if ($data) { + foreach ($data as $key => $value) { + $this->{$key} = $value; + } + } + + require Config::get('PATH_VIEW') . $filename . '.php'; + } + + /** + * Renders pure JSON to the browser, useful for API construction + * @param $data + */ + public function renderJSON($data) + { + header("Content-Type: application/json"); + echo json_encode($data); + } + + /** + * renders the feedback messages into the view + */ + public function renderFeedbackMessages() + { + // echo out the feedback messages (errors and success messages etc.), + // they are in $_SESSION["feedback_positive"] and $_SESSION["feedback_negative"] + require Config::get('PATH_VIEW') . '_templates/feedback.php'; + + // delete these messages (as they are not needed anymore and we want to avoid to show them twice + Session::set('feedback_positive', null); + Session::set('feedback_negative', null); + } + + /** + * Checks if the passed string is the currently active controller. + * Useful for handling the navigation's active/non-active link. + * + * @param string $filename + * @param string $navigation_controller + * + * @return bool Shows if the controller is used or not + */ + public static function checkForActiveController($filename, $navigation_controller) + { + $split_filename = explode("/", $filename); + $active_controller = $split_filename[0]; + + if ($active_controller == $navigation_controller) { + return true; + } + + return false; + } + + /** + * Checks if the passed string is the currently active controller-action (=method). + * Useful for handling the navigation's active/non-active link. + * + * @param string $filename + * @param string $navigation_action + * + * @return bool Shows if the action/method is used or not + */ + public static function checkForActiveAction($filename, $navigation_action) + { + $split_filename = explode("/", $filename); + $active_action = $split_filename[1]; + + if ($active_action == $navigation_action) { + return true; + } + + return false; + } + + /** + * Checks if the passed string is the currently active controller and controller-action. + * Useful for handling the navigation's active/non-active link. + * + * @param string $filename + * @param string $navigation_controller_and_action + * + * @return bool + */ + public static function checkForActiveControllerAndAction($filename, $navigation_controller_and_action) + { + $split_filename = explode("/", $filename); + $active_controller = $split_filename[0]; + $active_action = $split_filename[1]; + + $split_filename = explode("/", $navigation_controller_and_action); + $navigation_controller = $split_filename[0]; + $navigation_action = $split_filename[1]; + + if ($active_controller == $navigation_controller AND $active_action == $navigation_action) { + return true; + } + + return false; + } + + /** + * Converts characters to HTML entities + * This is important to avoid XSS attacks, and attempts to inject malicious code in your page. + * + * @param string $str The string. + * @return string + */ + public function encodeHTML($str) + { + return htmlentities($str, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/application/model/AdminModel.php b/application/model/AdminModel.php new file mode 100644 index 0000000..33548fe --- /dev/null +++ b/application/model/AdminModel.php @@ -0,0 +1,95 @@ + 0) { + $suspensionTime = time() + ($suspensionInDays * 60 * 60 * 24); + } else { + $suspensionTime = null; + } + + // FYI "on" is what a checkbox delivers by default when submitted. Didn't know that for a long time :) + if ($softDelete == "on") { + $delete = 1; + } else { + $delete = 0; + } + + // write the above info to the database + self::writeDeleteAndSuspensionInfoToDatabase($userId, $suspensionTime, $delete); + + // if suspension or deletion should happen, then also kick user out of the application instantly by resetting + // the user's session :) + if ($suspensionTime != null OR $delete = 1) { + self::resetUserSession($userId); + } + } + + /** + * Simply write the deletion and suspension info for the user into the database, also puts feedback into session + * + * @param $userId + * @param $suspensionTime + * @param $delete + * @return bool + */ + private static function writeDeleteAndSuspensionInfoToDatabase($userId, $suspensionTime, $delete) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("UPDATE users SET user_suspension_timestamp = :user_suspension_timestamp, user_deleted = :user_deleted WHERE user_id = :user_id LIMIT 1"); + $query->execute(array( + ':user_suspension_timestamp' => $suspensionTime, + ':user_deleted' => $delete, + ':user_id' => $userId + )); + + if ($query->rowCount() == 1) { + Session::add('feedback_positive', Text::get('FEEDBACK_ACCOUNT_SUSPENSION_DELETION_STATUS')); + return true; + } + } + + /** + * Kicks the selected user out of the system instantly by resetting the user's session. + * This means, the user will be "logged out". + * + * @param $userId + * @return bool + */ + private static function resetUserSession($userId) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("UPDATE users SET session_id = :session_id WHERE user_id = :user_id LIMIT 1"); + $query->execute(array( + ':session_id' => null, + ':user_id' => $userId + )); + + if ($query->rowCount() == 1) { + Session::add('feedback_positive', Text::get('FEEDBACK_ACCOUNT_USER_SUCCESSFULLY_KICKED')); + return true; + } + } +} diff --git a/application/model/AvatarModel.php b/application/model/AvatarModel.php new file mode 100644 index 0000000..fa2ae21 --- /dev/null +++ b/application/model/AvatarModel.php @@ -0,0 +1,258 @@ +getConnection(); + + $query = $database->prepare("SELECT user_has_avatar FROM users WHERE user_id = :user_id LIMIT 1"); + $query->execute(array(':user_id' => $user_id)); + + if ($query->fetch()->user_has_avatar) { + return Config::get('URL') . Config::get('PATH_AVATARS_PUBLIC') . $user_id . '.jpg'; + } + + return Config::get('URL') . Config::get('PATH_AVATARS_PUBLIC') . Config::get('AVATAR_DEFAULT_IMAGE'); + } + + /** + * Create an avatar picture (and checks all necessary things too) + * TODO decouple + * TODO total rebuild + */ + public static function createAvatar() + { + // check avatar folder writing rights, check if upload fits all rules + if (self::isAvatarFolderWritable() AND self::validateImageFile()) { + + // create a jpg file in the avatar folder, write marker to database + $target_file_path = Config::get('PATH_AVATARS') . Session::get('user_id'); + self::resizeAvatarImage($_FILES['avatar_file']['tmp_name'], $target_file_path, Config::get('AVATAR_SIZE'), Config::get('AVATAR_SIZE')); + self::writeAvatarToDatabase(Session::get('user_id')); + Session::set('user_avatar_file', self::getPublicUserAvatarFilePathByUserId(Session::get('user_id'))); + Session::add('feedback_positive', Text::get('FEEDBACK_AVATAR_UPLOAD_SUCCESSFUL')); + } + } + + /** + * Checks if the avatar folder exists and is writable + * + * @return bool success status + */ + public static function isAvatarFolderWritable() + { + if (is_dir(Config::get('PATH_AVATARS')) AND is_writable(Config::get('PATH_AVATARS'))) { + return true; + } + + Session::add('feedback_negative', Text::get('FEEDBACK_AVATAR_FOLDER_DOES_NOT_EXIST_OR_NOT_WRITABLE')); + return false; + } + + /** + * Validates the image + * Only accepts gif, jpg, png types + * @see http://php.net/manual/en/function.image-type-to-mime-type.php + * + * @return bool + */ + public static function validateImageFile() + { + if (!isset($_FILES['avatar_file'])) { + Session::add('feedback_negative', Text::get('FEEDBACK_AVATAR_IMAGE_UPLOAD_FAILED')); + return false; + } + + // if input file too big (>5MB) + if ($_FILES['avatar_file']['size'] > 5000000) { + Session::add('feedback_negative', Text::get('FEEDBACK_AVATAR_UPLOAD_TOO_BIG')); + return false; + } + + // get the image width, height and mime type + $image_proportions = getimagesize($_FILES['avatar_file']['tmp_name']); + + // if input file too small, [0] is the width, [1] is the height + if ($image_proportions[0] < Config::get('AVATAR_SIZE') OR $image_proportions[1] < Config::get('AVATAR_SIZE')) { + Session::add('feedback_negative', Text::get('FEEDBACK_AVATAR_UPLOAD_TOO_SMALL')); + return false; + } + + // if file type is not jpg, gif or png + if (!in_array($image_proportions['mime'], array('image/jpeg', 'image/gif', 'image/png'))) { + Session::add('feedback_negative', Text::get('FEEDBACK_AVATAR_UPLOAD_WRONG_TYPE')); + return false; + } + + return true; + } + + /** + * Writes marker to database, saying user has an avatar now + * + * @param $user_id + */ + public static function writeAvatarToDatabase($user_id) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("UPDATE users SET user_has_avatar = TRUE WHERE user_id = :user_id LIMIT 1"); + $query->execute(array(':user_id' => $user_id)); + } + + /** + * Resize avatar image (while keeping aspect ratio and cropping it off in a clean way). + * Only works with gif, jpg and png file types. If you want to change this also have a look into + * method validateImageFile() inside this model. + * + * TROUBLESHOOTING: You don't see the new image ? Press F5 or CTRL-F5 to refresh browser cache. + * + * @param string $source_image The location to the original raw image + * @param string $destination The location to save the new image + * @param int $final_width The desired width of the new image + * @param int $final_height The desired height of the new image + * + * @return bool success state + */ + public static function resizeAvatarImage($source_image, $destination, $final_width = 44, $final_height = 44) + { + $imageData = getimagesize($source_image); + $width = $imageData[0]; + $height = $imageData[1]; + $mimeType = $imageData['mime']; + + if (!$width || !$height) { + return false; + } + + switch ($mimeType) { + case 'image/jpeg': $myImage = imagecreatefromjpeg($source_image); break; + case 'image/png': $myImage = imagecreatefrompng($source_image); break; + case 'image/gif': $myImage = imagecreatefromgif($source_image); break; + default: return false; + } + + // calculating the part of the image to use for thumbnail + if ($width > $height) { + $verticalCoordinateOfSource = 0; + $horizontalCoordinateOfSource = ($width - $height) / 2; + $smallestSide = $height; + } else { + $horizontalCoordinateOfSource = 0; + $verticalCoordinateOfSource = ($height - $width) / 2; + $smallestSide = $width; + } + + // copying the part into thumbnail, maybe edit this for square avatars + $thumb = imagecreatetruecolor($final_width, $final_height); + imagecopyresampled($thumb, $myImage, 0, 0, $horizontalCoordinateOfSource, $verticalCoordinateOfSource, $final_width, $final_height, $smallestSide, $smallestSide); + + // add '.jpg' to file path, save it as a .jpg file with our $destination_filename parameter + imagejpeg($thumb, $destination . '.jpg', Config::get('AVATAR_JPEG_QUALITY')); + imagedestroy($thumb); + + if (file_exists($destination)) { + return true; + } + return false; + } + + /** + * Delete a user's avatar + * + * @param int $userId + * @return bool success + */ + public static function deleteAvatar($userId) + { + if (!ctype_digit($userId)) { + Session::add("feedback_negative", Text::get("FEEDBACK_AVATAR_IMAGE_DELETE_FAILED")); + return false; + } + + // try to delete image, but still go on regardless of file deletion result + self::deleteAvatarImageFile($userId); + + $database = DatabaseFactory::getFactory()->getConnection(); + + $sth = $database->prepare("UPDATE users SET user_has_avatar = 0 WHERE user_id = :user_id LIMIT 1"); + $sth->bindValue(":user_id", (int)$userId, PDO::PARAM_INT); + $sth->execute(); + + if ($sth->rowCount() == 1) { + Session::set('user_avatar_file', self::getPublicUserAvatarFilePathByUserId($userId)); + Session::add("feedback_positive", Text::get("FEEDBACK_AVATAR_IMAGE_DELETE_SUCCESSFUL")); + return true; + } else { + Session::add("feedback_negative", Text::get("FEEDBACK_AVATAR_IMAGE_DELETE_FAILED")); + return false; + } + } + + /** + * Removes the avatar image file from the filesystem + * + * @param integer $userId + * @return bool + */ + public static function deleteAvatarImageFile($userId) + { + // Check if file exists + if (!file_exists(Config::get('PATH_AVATARS') . $userId . ".jpg")) { + Session::add("feedback_negative", Text::get("FEEDBACK_AVATAR_IMAGE_DELETE_NO_FILE")); + return false; + } + + // Delete avatar file + if (!unlink(Config::get('PATH_AVATARS') . $userId . ".jpg")) { + Session::add("feedback_negative", Text::get("FEEDBACK_AVATAR_IMAGE_DELETE_FAILED")); + return false; + } + + return true; + } +} diff --git a/application/model/CaptchaModel.php b/application/model/CaptchaModel.php new file mode 100644 index 0000000..a7a0350 --- /dev/null +++ b/application/model/CaptchaModel.php @@ -0,0 +1,46 @@ +build( + Config::get('CAPTCHA_WIDTH'), + Config::get('CAPTCHA_HEIGHT') + ); + + // write the captcha character into session + Session::set('captcha', $captcha->getPhrase()); + + // render an image showing the characters (=the captcha) + header('Content-type: image/jpeg'); + $captcha->output(); + } + + /** + * Checks if the entered captcha is the same like the one from the rendered image which has been saved in session + * @param $captcha string The captcha characters + * @return bool success of captcha check + */ + public static function checkCaptcha($captcha) + { + if (Session::get('captcha') && ($captcha == Session::get('captcha'))) { + return true; + } + + return false; + } +} diff --git a/application/model/LoginModel.php b/application/model/LoginModel.php new file mode 100644 index 0000000..0e57ba0 --- /dev/null +++ b/application/model/LoginModel.php @@ -0,0 +1,382 @@ +user_deleted == 1) { + Session::add('feedback_negative', Text::get('FEEDBACK_DELETED')); + return false; + } + + // stop the user from logging in if user has a suspension, display how long they have left in the feedback. + if ($result->user_suspension_timestamp != null && $result->user_suspension_timestamp - time() > 0) { + $suspensionTimer = Text::get('FEEDBACK_ACCOUNT_SUSPENDED') . round(abs($result->user_suspension_timestamp - time())/60/60, 2) . " hours left"; + Session::add('feedback_negative', $suspensionTimer); + return false; + } + + // reset the failed login counter for that user (if necessary) + if ($result->user_last_failed_login > 0) { + self::resetFailedLoginCounterOfUser($result->user_name); + } + + // save timestamp of this login in the database line of that user + self::saveTimestampOfLoginOfUser($result->user_name); + + // if user has checked the "remember me" checkbox, then write token into database and into cookie + if ($set_remember_me_cookie) { + self::setRememberMeInDatabaseAndCookie($result->user_id); + } + + // successfully logged in, so we write all necessary data into the session and set "user_logged_in" to true + self::setSuccessfulLoginIntoSession( + $result->user_id, $result->user_name, $result->user_email, $result->user_account_type + ); + + // return true to make clear the login was successful + // maybe do this in dependence of setSuccessfulLoginIntoSession ? + return true; + } + + /** + * Validates the inputs of the users, checks if password is correct etc. + * If successful, user is returned + * + * @param $user_name + * @param $user_password + * + * @return bool|mixed + */ + private static function validateAndGetUser($user_name, $user_password) + { + // brute force attack mitigation: use session failed login count and last failed login for not found users. + // block login attempt if somebody has already failed 3 times and the last login attempt is less than 30sec ago + // (limits user searches in database) + if (Session::get('failed-login-count') >= 3 AND (Session::get('last-failed-login') > (time() - 30))) { + Session::add('feedback_negative', Text::get('FEEDBACK_LOGIN_FAILED_3_TIMES')); + return false; + } + + // get all data of that user (to later check if password and password_hash fit) + $result = UserModel::getUserDataByUsername($user_name); + + // check if that user exists. We don't give back a cause in the feedback to avoid giving an attacker details. + // brute force attack mitigation: reset failed login counter because of found user + if (!$result) { + + // increment the user not found count, helps mitigate user enumeration + self::incrementUserNotFoundCounter(); + + // user does not exist, but we won't to give a potential attacker this details, so we just use a basic feedback message + Session::add('feedback_negative', Text::get('FEEDBACK_USERNAME_OR_PASSWORD_WRONG')); + return false; + } + + // block login attempt if somebody has already failed 3 times and the last login attempt is less than 30sec ago + if (($result->user_failed_logins >= 3) AND ($result->user_last_failed_login > (time() - 30))) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_WRONG_3_TIMES')); + return false; + } + + // if hash of provided password does NOT match the hash in the database: +1 failed-login counter + if (!password_verify($user_password, $result->user_password_hash)) { + self::incrementFailedLoginCounterOfUser($result->user_name); + Session::add('feedback_negative', Text::get('FEEDBACK_USERNAME_OR_PASSWORD_WRONG')); + return false; + } + + // if user is not active (= has not verified account by verification mail) + if ($result->user_active != 1) { + Session::add('feedback_negative', Text::get('FEEDBACK_ACCOUNT_NOT_ACTIVATED_YET')); + return false; + } + + // reset the user not found counter + self::resetUserNotFoundCounter(); + + return $result; + } + + /** + * Reset the failed-login-count to 0. + * Reset the last-failed-login to an empty string. + */ + private static function resetUserNotFoundCounter() + { + Session::set('failed-login-count', 0); + Session::set('last-failed-login', ''); + } + + /** + * Increment the failed-login-count by 1. + * Add timestamp to last-failed-login. + */ + private static function incrementUserNotFoundCounter() + { + // Username enumeration prevention: set session failed login count and last failed login for users not found + Session::set('failed-login-count', Session::get('failed-login-count') + 1); + Session::set('last-failed-login', time()); + } + + /** + * performs the login via cookie (for DEFAULT user account, FACEBOOK-accounts are handled differently) + * TODO add throttling here ? + * + * @param $cookie string The cookie "remember_me" + * + * @return bool success state + */ + public static function loginWithCookie($cookie) + { + // do we have a cookie ? + if (!$cookie) { + Session::add('feedback_negative', Text::get('FEEDBACK_COOKIE_INVALID')); + return false; + } + + // before list(), check it can be split into 3 strings. + if (count (explode(':', $cookie)) !== 3) { + Session::add('feedback_negative', Text::get('FEEDBACK_COOKIE_INVALID')); + return false; + } + + // check cookie's contents, check if cookie contents belong together or token is empty + list ($user_id, $token, $hash) = explode(':', $cookie); + + // decrypt user id + $user_id = Encryption::decrypt($user_id); + + if ($hash !== hash('sha256', $user_id . ':' . $token) OR empty($token) OR empty($user_id)) { + Session::add('feedback_negative', Text::get('FEEDBACK_COOKIE_INVALID')); + return false; + } + + // get data of user that has this id and this token + $result = UserModel::getUserDataByUserIdAndToken($user_id, $token); + + // if user with that id and exactly that cookie token exists in database + if ($result) { + + // successfully logged in, so we write all necessary data into the session and set "user_logged_in" to true + self::setSuccessfulLoginIntoSession($result->user_id, $result->user_name, $result->user_email, $result->user_account_type); + + // save timestamp of this login in the database line of that user + self::saveTimestampOfLoginOfUser($result->user_name); + + // NOTE: we don't set another remember_me-cookie here as the current cookie should always + // be invalid after a certain amount of time, so the user has to login with username/password + // again from time to time. This is good and safe ! ;) + + Session::add('feedback_positive', Text::get('FEEDBACK_COOKIE_LOGIN_SUCCESSFUL')); + return true; + } else { + Session::add('feedback_negative', Text::get('FEEDBACK_COOKIE_INVALID')); + return false; + } + } + + /** + * Log out process: delete cookie, delete session + */ + public static function logout() + { + $user_id = Session::get('user_id'); + + self::deleteCookie($user_id); + + Session::destroy(); + Session::updateSessionId($user_id); + } + + /** + * The real login process: The user's data is written into the session. + * Cheesy name, maybe rename. Also maybe refactoring this, using an array. + * + * @param $user_id + * @param $user_name + * @param $user_email + * @param $user_account_type + */ + public static function setSuccessfulLoginIntoSession($user_id, $user_name, $user_email, $user_account_type) + { + Session::init(); + + // remove old and regenerate session ID. + // It's important to regenerate session on sensitive actions, + // and to avoid fixated session. + // e.g. when a user logs in + session_regenerate_id(true); + $_SESSION = array(); + + Session::set('user_id', $user_id); + Session::set('user_name', $user_name); + Session::set('user_email', $user_email); + Session::set('user_account_type', $user_account_type); + Session::set('user_provider_type', 'DEFAULT'); + + // get and set avatars + Session::set('user_avatar_file', AvatarModel::getPublicUserAvatarFilePathByUserId($user_id)); + Session::set('user_gravatar_image_url', AvatarModel::getGravatarLinkByEmail($user_email)); + + // finally, set user as logged-in + Session::set('user_logged_in', true); + + // update session id in database + Session::updateSessionId($user_id, session_id()); + + // set session cookie setting manually, + // Why? because you need to explicitly set session expiry, path, domain, secure, and HTTP. + // @see https://www.owasp.org/index.php/PHP_Security_Cheat_Sheet#Cookies + setcookie(session_name(), session_id(), time() + Config::get('SESSION_RUNTIME'), Config::get('COOKIE_PATH'), + Config::get('COOKIE_DOMAIN'), Config::get('COOKIE_SECURE'), Config::get('COOKIE_HTTP')); + + } + + /** + * Increments the failed-login counter of a user + * + * @param $user_name + */ + public static function incrementFailedLoginCounterOfUser($user_name) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE users + SET user_failed_logins = user_failed_logins+1, user_last_failed_login = :user_last_failed_login + WHERE user_name = :user_name OR user_email = :user_name + LIMIT 1"; + $sth = $database->prepare($sql); + $sth->execute(array(':user_name' => $user_name, ':user_last_failed_login' => time() )); + } + + /** + * Resets the failed-login counter of a user back to 0 + * + * @param $user_name + */ + public static function resetFailedLoginCounterOfUser($user_name) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE users + SET user_failed_logins = 0, user_last_failed_login = NULL + WHERE user_name = :user_name AND user_failed_logins != 0 + LIMIT 1"; + $sth = $database->prepare($sql); + $sth->execute(array(':user_name' => $user_name)); + } + + /** + * Write timestamp of this login into database (we only write a "real" login via login form into the database, + * not the session-login on every page request + * + * @param $user_name + */ + public static function saveTimestampOfLoginOfUser($user_name) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE users SET user_last_login_timestamp = :user_last_login_timestamp + WHERE user_name = :user_name LIMIT 1"; + $sth = $database->prepare($sql); + $sth->execute(array(':user_name' => $user_name, ':user_last_login_timestamp' => time())); + } + + /** + * Write remember-me token into database and into cookie + * Maybe splitting this into database and cookie part ? + * + * @param $user_id + */ + public static function setRememberMeInDatabaseAndCookie($user_id) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + // generate 64 char random string + $random_token_string = hash('sha256', mt_rand()); + + // write that token into database + $sql = "UPDATE users SET user_remember_me_token = :user_remember_me_token WHERE user_id = :user_id LIMIT 1"; + $sth = $database->prepare($sql); + $sth->execute(array(':user_remember_me_token' => $random_token_string, ':user_id' => $user_id)); + + // generate cookie string that consists of user id, random string and combined hash of both + // never expose the original user id, instead, encrypt it. + $cookie_string_first_part = Encryption::encrypt($user_id) . ':' . $random_token_string; + $cookie_string_hash = hash('sha256', $user_id . ':' . $random_token_string); + $cookie_string = $cookie_string_first_part . ':' . $cookie_string_hash; + + // set cookie, and make it available only for the domain created on (to avoid XSS attacks, where the + // attacker could steal your remember-me cookie string and would login itself). + // If you are using HTTPS, then you should set the "secure" flag (the second one from right) to true, too. + // @see http://www.php.net/manual/en/function.setcookie.php + setcookie('remember_me', $cookie_string, time() + Config::get('COOKIE_RUNTIME'), Config::get('COOKIE_PATH'), + Config::get('COOKIE_DOMAIN'), Config::get('COOKIE_SECURE'), Config::get('COOKIE_HTTP')); + } + + /** + * Deletes the cookie + * It's necessary to split deleteCookie() and logout() as cookies are deleted without logging out too! + * Sets the remember-me-cookie to ten years ago (3600sec * 24 hours * 365 days * 10). + * that's obviously the best practice to kill a cookie @see http://stackoverflow.com/a/686166/1114320 + * + * @param string $user_id + */ + public static function deleteCookie($user_id = null) + { + // is $user_id was set, then clear remember_me token in database + if (isset($user_id)) { + + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE users SET user_remember_me_token = :user_remember_me_token WHERE user_id = :user_id LIMIT 1"; + $sth = $database->prepare($sql); + $sth->execute(array(':user_remember_me_token' => null, ':user_id' => $user_id)); + } + + // delete remember_me cookie in browser + setcookie('remember_me', false, time() - (3600 * 24 * 3650), Config::get('COOKIE_PATH'), + Config::get('COOKIE_DOMAIN'), Config::get('COOKIE_SECURE'), Config::get('COOKIE_HTTP')); + } + + /** + * Returns the current state of the user's login + * + * @return bool user's login status + */ + public static function isUserLoggedIn() + { + return Session::userIsLoggedIn(); + } +} diff --git a/application/model/NoteModel.php b/application/model/NoteModel.php new file mode 100644 index 0000000..468694d --- /dev/null +++ b/application/model/NoteModel.php @@ -0,0 +1,120 @@ +getConnection(); + + $sql = "SELECT user_id, note_id, note_text FROM notes WHERE user_id = :user_id"; + $query = $database->prepare($sql); + $query->execute(array(':user_id' => Session::get('user_id'))); + + // fetchAll() is the PDO method that gets all result rows + return $query->fetchAll(); + } + + /** + * Get a single note + * @param int $note_id id of the specific note + * @return object a single object (the result) + */ + public static function getNote($note_id) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "SELECT user_id, note_id, note_text FROM notes WHERE user_id = :user_id AND note_id = :note_id LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array(':user_id' => Session::get('user_id'), ':note_id' => $note_id)); + + // fetch() is the PDO method that gets a single result + return $query->fetch(); + } + + /** + * Set a note (create a new one) + * @param string $note_text note text that will be created + * @return bool feedback (was the note created properly ?) + */ + public static function createNote($note_text) + { + if (!$note_text || strlen($note_text) == 0) { + Session::add('feedback_negative', Text::get('FEEDBACK_NOTE_CREATION_FAILED')); + return false; + } + + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "INSERT INTO notes (note_text, user_id) VALUES (:note_text, :user_id)"; + $query = $database->prepare($sql); + $query->execute(array(':note_text' => $note_text, ':user_id' => Session::get('user_id'))); + + if ($query->rowCount() == 1) { + return true; + } + + // default return + Session::add('feedback_negative', Text::get('FEEDBACK_NOTE_CREATION_FAILED')); + return false; + } + + /** + * Update an existing note + * @param int $note_id id of the specific note + * @param string $note_text new text of the specific note + * @return bool feedback (was the update successful ?) + */ + public static function updateNote($note_id, $note_text) + { + if (!$note_id || !$note_text) { + return false; + } + + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE notes SET note_text = :note_text WHERE note_id = :note_id AND user_id = :user_id LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array(':note_id' => $note_id, ':note_text' => $note_text, ':user_id' => Session::get('user_id'))); + + if ($query->rowCount() == 1) { + return true; + } + + Session::add('feedback_negative', Text::get('FEEDBACK_NOTE_EDITING_FAILED')); + return false; + } + + /** + * Delete a specific note + * @param int $note_id id of the note + * @return bool feedback (was the note deleted properly ?) + */ + public static function deleteNote($note_id) + { + if (!$note_id) { + return false; + } + + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "DELETE FROM notes WHERE note_id = :note_id AND user_id = :user_id LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array(':note_id' => $note_id, ':user_id' => Session::get('user_id'))); + + if ($query->rowCount() == 1) { + return true; + } + + // default return + Session::add('feedback_negative', Text::get('FEEDBACK_NOTE_DELETION_FAILED')); + return false; + } +} diff --git a/application/model/PasswordResetModel.php b/application/model/PasswordResetModel.php new file mode 100644 index 0000000..25a0f2a --- /dev/null +++ b/application/model/PasswordResetModel.php @@ -0,0 +1,365 @@ +user_name, $user_password_reset_hash, $temporary_timestamp); + if (!$token_set) { + return false; + } + + // ... and send a mail to the user, containing a link with username and token hash string + $mail_sent = self::sendPasswordResetMail($result->user_name, $user_password_reset_hash, $result->user_email); + if ($mail_sent) { + return true; + } + + // default return + return false; + } + + /** + * Set password reset token in database (for DEFAULT user accounts) + * + * @param string $user_name username + * @param string $user_password_reset_hash password reset hash + * @param int $temporary_timestamp timestamp + * + * @return bool success status + */ + public static function setPasswordResetDatabaseToken($user_name, $user_password_reset_hash, $temporary_timestamp) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE users + SET user_password_reset_hash = :user_password_reset_hash, user_password_reset_timestamp = :user_password_reset_timestamp + WHERE user_name = :user_name AND user_provider_type = :provider_type LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array( + ':user_password_reset_hash' => $user_password_reset_hash, ':user_name' => $user_name, + ':user_password_reset_timestamp' => $temporary_timestamp, ':provider_type' => 'DEFAULT' + )); + + // check if exactly one row was successfully changed + if ($query->rowCount() == 1) { + return true; + } + + // fallback + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_RESET_TOKEN_FAIL')); + return false; + } + + /** + * Send the password reset mail + * + * @param string $user_name username + * @param string $user_password_reset_hash password reset hash + * @param string $user_email user email + * + * @return bool success status + */ + public static function sendPasswordResetMail($user_name, $user_password_reset_hash, $user_email) + { + // create email body + $body = Config::get('EMAIL_PASSWORD_RESET_CONTENT') . ' ' . Config::get('URL') . + Config::get('EMAIL_PASSWORD_RESET_URL') . '/' . urlencode($user_name) . '/' . urlencode($user_password_reset_hash); + + // create instance of Mail class, try sending and check + $mail = new Mail; + $mail_sent = $mail->sendMail($user_email, Config::get('EMAIL_PASSWORD_RESET_FROM_EMAIL'), + Config::get('EMAIL_PASSWORD_RESET_FROM_NAME'), Config::get('EMAIL_PASSWORD_RESET_SUBJECT'), $body + ); + + if ($mail_sent) { + Session::add('feedback_positive', Text::get('FEEDBACK_PASSWORD_RESET_MAIL_SENDING_SUCCESSFUL')); + return true; + } + + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_RESET_MAIL_SENDING_ERROR') . $mail->getError() ); + return false; + } + + /** + * Verifies the password reset request via the verification hash token (that's only valid for one hour) + * @param string $user_name Username + * @param string $verification_code Hash token + * @return bool Success status + */ + public static function verifyPasswordReset($user_name, $verification_code) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + // check if user-provided username + verification code combination exists + $sql = "SELECT user_id, user_password_reset_timestamp + FROM users + WHERE user_name = :user_name + AND user_password_reset_hash = :user_password_reset_hash + AND user_provider_type = :user_provider_type + LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array( + ':user_password_reset_hash' => $verification_code, ':user_name' => $user_name, + ':user_provider_type' => 'DEFAULT' + )); + + // if this user with exactly this verification hash code does NOT exist + if ($query->rowCount() != 1) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_RESET_COMBINATION_DOES_NOT_EXIST')); + return false; + } + + // get result row (as an object) + $result_user_row = $query->fetch(); + + // 3600 seconds are 1 hour + $timestamp_one_hour_ago = time() - 3600; + + // if password reset request was sent within the last hour (this timeout is for security reasons) + if ($result_user_row->user_password_reset_timestamp > $timestamp_one_hour_ago) { + + // verification was successful + Session::add('feedback_positive', Text::get('FEEDBACK_PASSWORD_RESET_LINK_VALID')); + return true; + } else { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_RESET_LINK_EXPIRED')); + return false; + } + } + + /** + * Writes the new password to the database + * + * @param string $user_name username + * @param string $user_password_hash + * @param string $user_password_reset_hash + * + * @return bool + */ + public static function saveNewUserPassword($user_name, $user_password_hash, $user_password_reset_hash) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE users SET user_password_hash = :user_password_hash, user_password_reset_hash = NULL, + user_password_reset_timestamp = NULL + WHERE user_name = :user_name AND user_password_reset_hash = :user_password_reset_hash + AND user_provider_type = :user_provider_type LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array( + ':user_password_hash' => $user_password_hash, ':user_name' => $user_name, + ':user_password_reset_hash' => $user_password_reset_hash, ':user_provider_type' => 'DEFAULT' + )); + + // if one result exists, return true, else false. Could be written even shorter btw. + return ($query->rowCount() == 1 ? true : false); + } + + /** + * Set the new password (for DEFAULT user, FACEBOOK-users don't have a password) + * Please note: At this point the user has already pre-verified via verifyPasswordReset() (within one hour), + * so we don't need to check again for the 60min-limit here. In this method we authenticate + * via username & password-reset-hash from (hidden) form fields. + * + * @param string $user_name + * @param string $user_password_reset_hash + * @param string $user_password_new + * @param string $user_password_repeat + * + * @return bool success state of the password reset + */ + public static function setNewPassword($user_name, $user_password_reset_hash, $user_password_new, $user_password_repeat) + { + // validate the password + if (!self::validateResetPassword($user_name, $user_password_reset_hash, $user_password_new, $user_password_repeat)) { + return false; + } + + // crypt the password (with the PHP 5.5+'s password_hash() function, result is a 60 character hash string) + $user_password_hash = password_hash($user_password_new, PASSWORD_DEFAULT); + + // write the password to database (as hashed and salted string), reset user_password_reset_hash + if (self::saveNewUserPassword($user_name, $user_password_hash, $user_password_reset_hash)) { + Session::add('feedback_positive', Text::get('FEEDBACK_PASSWORD_CHANGE_SUCCESSFUL')); + return true; + } else { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_CHANGE_FAILED')); + return false; + } + } + + /** + * Validate the password submission + * + * @param $user_name + * @param $user_password_reset_hash + * @param $user_password_new + * @param $user_password_repeat + * + * @return bool + */ + public static function validateResetPassword($user_name, $user_password_reset_hash, $user_password_new, $user_password_repeat) + { + if (empty($user_name)) { + Session::add('feedback_negative', Text::get('FEEDBACK_USERNAME_FIELD_EMPTY')); + return false; + } else if (empty($user_password_reset_hash)) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_RESET_TOKEN_MISSING')); + return false; + } else if (empty($user_password_new) || empty($user_password_repeat)) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_FIELD_EMPTY')); + return false; + } else if ($user_password_new !== $user_password_repeat) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_REPEAT_WRONG')); + return false; + } else if (strlen($user_password_new) < 6) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_TOO_SHORT')); + return false; + } + + return true; + } + + + /** + * Writes the new password to the database + * + * @param string $user_name + * @param string $user_password_hash + * + * @return bool + */ + public static function saveChangedPassword($user_name, $user_password_hash) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE users SET user_password_hash = :user_password_hash + WHERE user_name = :user_name + AND user_provider_type = :user_provider_type LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array( + ':user_password_hash' => $user_password_hash, ':user_name' => $user_name, + ':user_provider_type' => 'DEFAULT' + )); + + // if one result exists, return true, else false. Could be written even shorter btw. + return ($query->rowCount() == 1 ? true : false); + } + + + /** + * Validates fields, hashes new password, saves new password + * + * @param string $user_name + * @param string $user_password_current + * @param string $user_password_new + * @param string $user_password_repeat + * + * @return bool + */ + public static function changePassword($user_name, $user_password_current, $user_password_new, $user_password_repeat) + { + // validate the passwords + if (!self::validatePasswordChange($user_name, $user_password_current, $user_password_new, $user_password_repeat)) { + return false; + } + + // crypt the password (with the PHP 5.5+'s password_hash() function, result is a 60 character hash string) + $user_password_hash = password_hash($user_password_new, PASSWORD_DEFAULT); + + // write the password to database (as hashed and salted string) + if (self::saveChangedPassword($user_name, $user_password_hash)) { + Session::add('feedback_positive', Text::get('FEEDBACK_PASSWORD_CHANGE_SUCCESSFUL')); + return true; + } else { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_CHANGE_FAILED')); + return false; + } + } + + + /** + * Validates current and new passwords + * + * @param string $user_name + * @param string $user_password_current + * @param string $user_password_new + * @param string $user_password_repeat + * + * @return bool + */ + public static function validatePasswordChange($user_name, $user_password_current, $user_password_new, $user_password_repeat) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "SELECT user_password_hash, user_failed_logins FROM users WHERE user_name = :user_name LIMIT 1;"; + $query = $database->prepare($sql); + $query->execute(array( + ':user_name' => $user_name + )); + + $user = $query->fetch(); + + if ($query->rowCount() == 1) { + $user_password_hash = $user->user_password_hash; + } else { + Session::add('feedback_negative', Text::get('FEEDBACK_USER_DOES_NOT_EXIST')); + return false; + } + + if (!password_verify($user_password_current, $user_password_hash)) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_CURRENT_INCORRECT')); + return false; + } else if (empty($user_password_new) || empty($user_password_repeat)) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_FIELD_EMPTY')); + return false; + } else if ($user_password_new !== $user_password_repeat) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_REPEAT_WRONG')); + return false; + } else if (strlen($user_password_new) < 6) { + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_TOO_SHORT')); + return false; + } else if ($user_password_current == $user_password_new){ + Session::add('feedback_negative', Text::get('FEEDBACK_PASSWORD_NEW_SAME_AS_CURRENT')); + return false; + } + + return true; + } +} diff --git a/application/model/RegistrationModel.php b/application/model/RegistrationModel.php new file mode 100644 index 0000000..044e00f --- /dev/null +++ b/application/model/RegistrationModel.php @@ -0,0 +1,293 @@ +getConnection(); + + // write new users data into database + $sql = "INSERT INTO users (user_name, user_password_hash, user_email, user_creation_timestamp, user_activation_hash, user_provider_type) + VALUES (:user_name, :user_password_hash, :user_email, :user_creation_timestamp, :user_activation_hash, :user_provider_type)"; + $query = $database->prepare($sql); + $query->execute(array(':user_name' => $user_name, + ':user_password_hash' => $user_password_hash, + ':user_email' => $user_email, + ':user_creation_timestamp' => $user_creation_timestamp, + ':user_activation_hash' => $user_activation_hash, + ':user_provider_type' => 'DEFAULT')); + $count = $query->rowCount(); + if ($count == 1) { + return true; + } + + return false; + } + + /** + * Deletes the user from users table. Currently used to rollback a registration when verification mail sending + * was not successful. + * + * @param $user_id + */ + public static function rollbackRegistrationByUserId($user_id) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("DELETE FROM users WHERE user_id = :user_id"); + $query->execute(array(':user_id' => $user_id)); + } + + /** + * Sends the verification email (to confirm the account). + * The construction of the mail $body looks weird at first, but it's really just a simple string. + * + * @param int $user_id user's id + * @param string $user_email user's email + * @param string $user_activation_hash user's mail verification hash string + * + * @return boolean gives back true if mail has been sent, gives back false if no mail could been sent + */ + public static function sendVerificationEmail($user_id, $user_email, $user_activation_hash) + { + $body = Config::get('EMAIL_VERIFICATION_CONTENT') . Config::get('URL') . Config::get('EMAIL_VERIFICATION_URL') + . '/' . urlencode($user_id) . '/' . urlencode($user_activation_hash); + + $mail = new Mail; + $mail_sent = $mail->sendMail($user_email, Config::get('EMAIL_VERIFICATION_FROM_EMAIL'), + Config::get('EMAIL_VERIFICATION_FROM_NAME'), Config::get('EMAIL_VERIFICATION_SUBJECT'), $body + ); + + if ($mail_sent) { + Session::add('feedback_positive', Text::get('FEEDBACK_VERIFICATION_MAIL_SENDING_SUCCESSFUL')); + return true; + } else { + Session::add('feedback_negative', Text::get('FEEDBACK_VERIFICATION_MAIL_SENDING_ERROR') . $mail->getError() ); + return false; + } + } + + /** + * checks the email/verification code combination and set the user's activation status to true in the database + * + * @param int $user_id user id + * @param string $user_activation_verification_code verification token + * + * @return bool success status + */ + public static function verifyNewUser($user_id, $user_activation_verification_code) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "UPDATE users SET user_active = 1, user_activation_hash = NULL + WHERE user_id = :user_id AND user_activation_hash = :user_activation_hash LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array(':user_id' => $user_id, ':user_activation_hash' => $user_activation_verification_code)); + + if ($query->rowCount() == 1) { + Session::add('feedback_positive', Text::get('FEEDBACK_ACCOUNT_ACTIVATION_SUCCESSFUL')); + return true; + } + + Session::add('feedback_negative', Text::get('FEEDBACK_ACCOUNT_ACTIVATION_FAILED')); + return false; + } +} diff --git a/application/model/UserModel.php b/application/model/UserModel.php new file mode 100644 index 0000000..1321eb3 --- /dev/null +++ b/application/model/UserModel.php @@ -0,0 +1,343 @@ +getConnection(); + + $sql = "SELECT user_id, user_name, user_email, user_active, user_has_avatar, user_deleted FROM users"; + $query = $database->prepare($sql); + $query->execute(); + + $all_users_profiles = array(); + + foreach ($query->fetchAll() as $user) { + + // all elements of array passed to Filter::XSSFilter for XSS sanitation, have a look into + // application/core/Filter.php for more info on how to use. Removes (possibly bad) JavaScript etc from + // the user's values + array_walk_recursive($user, 'Filter::XSSFilter'); + + $all_users_profiles[$user->user_id] = new stdClass(); + $all_users_profiles[$user->user_id]->user_id = $user->user_id; + $all_users_profiles[$user->user_id]->user_name = $user->user_name; + $all_users_profiles[$user->user_id]->user_email = $user->user_email; + $all_users_profiles[$user->user_id]->user_active = $user->user_active; + $all_users_profiles[$user->user_id]->user_deleted = $user->user_deleted; + $all_users_profiles[$user->user_id]->user_avatar_link = (Config::get('USE_GRAVATAR') ? AvatarModel::getGravatarLinkByEmail($user->user_email) : AvatarModel::getPublicAvatarFilePathOfUser($user->user_has_avatar, $user->user_id)); + } + + return $all_users_profiles; + } + + /** + * Gets a user's profile data, according to the given $user_id + * @param int $user_id The user's id + * @return mixed The selected user's profile + */ + public static function getPublicProfileOfUser($user_id) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "SELECT user_id, user_name, user_email, user_active, user_has_avatar, user_deleted + FROM users WHERE user_id = :user_id LIMIT 1"; + $query = $database->prepare($sql); + $query->execute(array(':user_id' => $user_id)); + + $user = $query->fetch(); + + if ($query->rowCount() == 1) { + if (Config::get('USE_GRAVATAR')) { + $user->user_avatar_link = AvatarModel::getGravatarLinkByEmail($user->user_email); + } else { + $user->user_avatar_link = AvatarModel::getPublicAvatarFilePathOfUser($user->user_has_avatar, $user->user_id); + } + } else { + Session::add('feedback_negative', Text::get('FEEDBACK_USER_DOES_NOT_EXIST')); + } + + // all elements of array passed to Filter::XSSFilter for XSS sanitation, have a look into + // application/core/Filter.php for more info on how to use. Removes (possibly bad) JavaScript etc from + // the user's values + array_walk_recursive($user, 'Filter::XSSFilter'); + + return $user; + } + + /** + * @param $user_name_or_email + * + * @return mixed + */ + public static function getUserDataByUserNameOrEmail($user_name_or_email) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("SELECT user_id, user_name, user_email FROM users + WHERE (user_name = :user_name_or_email OR user_email = :user_name_or_email) + AND user_provider_type = :provider_type LIMIT 1"); + $query->execute(array(':user_name_or_email' => $user_name_or_email, ':provider_type' => 'DEFAULT')); + + return $query->fetch(); + } + + /** + * Checks if a username is already taken + * + * @param $user_name string username + * + * @return bool + */ + public static function doesUsernameAlreadyExist($user_name) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("SELECT user_id FROM users WHERE user_name = :user_name LIMIT 1"); + $query->execute(array(':user_name' => $user_name)); + if ($query->rowCount() == 0) { + return false; + } + return true; + } + + /** + * Checks if a email is already used + * + * @param $user_email string email + * + * @return bool + */ + public static function doesEmailAlreadyExist($user_email) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("SELECT user_id FROM users WHERE user_email = :user_email LIMIT 1"); + $query->execute(array(':user_email' => $user_email)); + if ($query->rowCount() == 0) { + return false; + } + return true; + } + + /** + * Writes new username to database + * + * @param $user_id int user id + * @param $new_user_name string new username + * + * @return bool + */ + public static function saveNewUserName($user_id, $new_user_name) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("UPDATE users SET user_name = :user_name WHERE user_id = :user_id LIMIT 1"); + $query->execute(array(':user_name' => $new_user_name, ':user_id' => $user_id)); + if ($query->rowCount() == 1) { + return true; + } + return false; + } + + /** + * Writes new email address to database + * + * @param $user_id int user id + * @param $new_user_email string new email address + * + * @return bool + */ + public static function saveNewEmailAddress($user_id, $new_user_email) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $query = $database->prepare("UPDATE users SET user_email = :user_email WHERE user_id = :user_id LIMIT 1"); + $query->execute(array(':user_email' => $new_user_email, ':user_id' => $user_id)); + $count = $query->rowCount(); + if ($count == 1) { + return true; + } + return false; + } + + /** + * Edit the user's name, provided in the editing form + * + * @param $new_user_name string The new username + * + * @return bool success status + */ + public static function editUserName($new_user_name) + { + // new username same as old one ? + if ($new_user_name == Session::get('user_name')) { + Session::add('feedback_negative', Text::get('FEEDBACK_USERNAME_SAME_AS_OLD_ONE')); + return false; + } + + // username cannot be empty and must be azAZ09 and 2-64 characters + if (!preg_match("/^[a-zA-Z0-9]{2,64}$/", $new_user_name)) { + Session::add('feedback_negative', Text::get('FEEDBACK_USERNAME_DOES_NOT_FIT_PATTERN')); + return false; + } + + // clean the input, strip usernames longer than 64 chars (maybe fix this ?) + $new_user_name = substr(strip_tags($new_user_name), 0, 64); + + // check if new username already exists + if (self::doesUsernameAlreadyExist($new_user_name)) { + Session::add('feedback_negative', Text::get('FEEDBACK_USERNAME_ALREADY_TAKEN')); + return false; + } + + $status_of_action = self::saveNewUserName(Session::get('user_id'), $new_user_name); + if ($status_of_action) { + Session::set('user_name', $new_user_name); + Session::add('feedback_positive', Text::get('FEEDBACK_USERNAME_CHANGE_SUCCESSFUL')); + return true; + } else { + Session::add('feedback_negative', Text::get('FEEDBACK_UNKNOWN_ERROR')); + return false; + } + } + + /** + * Edit the user's email + * + * @param $new_user_email + * + * @return bool success status + */ + public static function editUserEmail($new_user_email) + { + // email provided ? + if (empty($new_user_email)) { + Session::add('feedback_negative', Text::get('FEEDBACK_EMAIL_FIELD_EMPTY')); + return false; + } + + // check if new email is same like the old one + if ($new_user_email == Session::get('user_email')) { + Session::add('feedback_negative', Text::get('FEEDBACK_EMAIL_SAME_AS_OLD_ONE')); + return false; + } + + // user's email must be in valid email format, also checks the length + // @see http://stackoverflow.com/questions/21631366/php-filter-validate-email-max-length + // @see http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address + if (!filter_var($new_user_email, FILTER_VALIDATE_EMAIL)) { + Session::add('feedback_negative', Text::get('FEEDBACK_EMAIL_DOES_NOT_FIT_PATTERN')); + return false; + } + + // strip tags, just to be sure + $new_user_email = substr(strip_tags($new_user_email), 0, 254); + + // check if user's email already exists + if (self::doesEmailAlreadyExist($new_user_email)) { + Session::add('feedback_negative', Text::get('FEEDBACK_USER_EMAIL_ALREADY_TAKEN')); + return false; + } + + // write to database, if successful ... + // ... then write new email to session, Gravatar too (as this relies to the user's email address) + if (self::saveNewEmailAddress(Session::get('user_id'), $new_user_email)) { + Session::set('user_email', $new_user_email); + Session::set('user_gravatar_image_url', AvatarModel::getGravatarLinkByEmail($new_user_email)); + Session::add('feedback_positive', Text::get('FEEDBACK_EMAIL_CHANGE_SUCCESSFUL')); + return true; + } + + Session::add('feedback_negative', Text::get('FEEDBACK_UNKNOWN_ERROR')); + return false; + } + + /** + * Gets the user's id + * + * @param $user_name + * + * @return mixed + */ + public static function getUserIdByUsername($user_name) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "SELECT user_id FROM users WHERE user_name = :user_name AND user_provider_type = :provider_type LIMIT 1"; + $query = $database->prepare($sql); + + // DEFAULT is the marker for "normal" accounts (that have a password etc.) + // There are other types of accounts that don't have passwords etc. (FACEBOOK) + $query->execute(array(':user_name' => $user_name, ':provider_type' => 'DEFAULT')); + + // return one row (we only have one result or nothing) + return $query->fetch()->user_id; + } + + /** + * Gets the user's data + * + * @param $user_name string User's name + * + * @return mixed Returns false if user does not exist, returns object with user's data when user exists + */ + public static function getUserDataByUsername($user_name) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + $sql = "SELECT user_id, user_name, user_email, user_password_hash, user_active,user_deleted, user_suspension_timestamp, user_account_type, + user_failed_logins, user_last_failed_login + FROM users + WHERE (user_name = :user_name OR user_email = :user_name) + AND user_provider_type = :provider_type + LIMIT 1"; + $query = $database->prepare($sql); + + // DEFAULT is the marker for "normal" accounts (that have a password etc.) + // There are other types of accounts that don't have passwords etc. (FACEBOOK) + $query->execute(array(':user_name' => $user_name, ':provider_type' => 'DEFAULT')); + + // return one row (we only have one result or nothing) + return $query->fetch(); + } + + /** + * Gets the user's data by user's id and a token (used by login-via-cookie process) + * + * @param $user_id + * @param $token + * + * @return mixed Returns false if user does not exist, returns object with user's data when user exists + */ + public static function getUserDataByUserIdAndToken($user_id, $token) + { + $database = DatabaseFactory::getFactory()->getConnection(); + + // get real token from database (and all other data) + $query = $database->prepare("SELECT user_id, user_name, user_email, user_password_hash, user_active, + user_account_type, user_has_avatar, user_failed_logins, user_last_failed_login + FROM users + WHERE user_id = :user_id + AND user_remember_me_token = :user_remember_me_token + AND user_remember_me_token IS NOT NULL + AND user_provider_type = :provider_type LIMIT 1"); + $query->execute(array(':user_id' => $user_id, ':user_remember_me_token' => $token, ':provider_type' => 'DEFAULT')); + + // return one row (we only have one result or nothing) + return $query->fetch(); + } +} diff --git a/application/model/UserRoleModel.php b/application/model/UserRoleModel.php new file mode 100644 index 0000000..1ab347e --- /dev/null +++ b/application/model/UserRoleModel.php @@ -0,0 +1,65 @@ +getConnection(); + + $query = $database->prepare("UPDATE users SET user_account_type = :new_type WHERE user_id = :user_id LIMIT 1"); + $query->execute(array( + ':new_type' => $type, + ':user_id' => Session::get('user_id') + )); + + if ($query->rowCount() == 1) { + // set account type in session + Session::set('user_account_type', $type); + return true; + } + + return false; + } +} diff --git a/application/view/_templates/feedback.php b/application/view/_templates/feedback.php new file mode 100644 index 0000000..0ab3d62 --- /dev/null +++ b/application/view/_templates/feedback.php @@ -0,0 +1,19 @@ +'.$feedback.''; + } +} + +// echo out negative messages +if (isset($feedback_negative)) { + foreach ($feedback_negative as $feedback) { + echo ''; + } +} diff --git a/application/view/_templates/footer.php b/application/view/_templates/footer.php new file mode 100644 index 0000000..6829782 --- /dev/null +++ b/application/view/_templates/footer.php @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/application/view/_templates/header.php b/application/view/_templates/header.php new file mode 100644 index 0000000..67b9c38 --- /dev/null +++ b/application/view/_templates/header.php @@ -0,0 +1,79 @@ + + + + HUGE + + + + + + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/application/view/admin/index.php b/application/view/admin/index.php new file mode 100644 index 0000000..cf3f6b0 --- /dev/null +++ b/application/view/admin/index.php @@ -0,0 +1,57 @@ +
+

Admin/index

+ +
+ + + renderFeedbackMessages(); ?> + +

What happens here ?

+ +
+ This controller/action/view shows a list of all users in the system. with the ability to soft delete a user + or suspend a user. +
+
+ + + + + + + + + + + + + + + users as $user) { ?> + + + + + + + + admin/actionAccountSettings" method="post"> + + + + + + +
IdAvatarUsernameUser's emailActivated ?Link to user's profilesuspension Time in daysSoft deleteSubmit
user_id; ?> + user_avatar_link)) { ?> + + + user_name; ?>user_email; ?>user_active == 0 ? 'No' : 'Yes'); ?> + Profile + user_deleted) { ?> checked /> + + +
+
+
+
diff --git a/application/view/dashboard/index.php b/application/view/dashboard/index.php new file mode 100644 index 0000000..7a74d9b --- /dev/null +++ b/application/view/dashboard/index.php @@ -0,0 +1,15 @@ +
+

DashboardController/index

+
+ + + renderFeedbackMessages(); ?> + +

What happens here ?

+

+ This is an area that's only visible for logged in users. Try to log out, an go to /dashboard/ again. You'll + be redirected to /index/ as you are not logged in. You can protect a whole section in your app within the + according controller by placing Auth::handleLogin(); into the constructor. +

+

+
diff --git a/application/view/error/404.php b/application/view/error/404.php new file mode 100644 index 0000000..decc746 --- /dev/null +++ b/application/view/error/404.php @@ -0,0 +1,6 @@ +
+

404 - Page not found

+
+

This page does not exist.

+
+
diff --git a/application/view/index/index.php b/application/view/index/index.php new file mode 100644 index 0000000..d7a819b --- /dev/null +++ b/application/view/index/index.php @@ -0,0 +1,18 @@ +
+

IndexController/index

+
+ + + renderFeedbackMessages(); ?> + +

What happens here ?

+

+ This is the homepage. As no real URL-route (like /register/index) is provided, the app uses the default + controller and the default action, defined in application/config/config.php, by default it's + IndexController and index()-method. So, the app will load application/controller/IndexController.php and + run index() from that file. Easy. That index()-method (= the action) has just one line of code inside + ($this->view->render('index/index');) that loads application/view/index/index.php, which is basically + this text you are reading right now. +

+
+
diff --git a/application/view/login/index.php b/application/view/login/index.php new file mode 100644 index 0000000..3f61fe6 --- /dev/null +++ b/application/view/login/index.php @@ -0,0 +1,50 @@ +
+ + + renderFeedbackMessages(); ?> + + +
diff --git a/application/view/login/requestPasswordReset.php b/application/view/login/requestPasswordReset.php new file mode 100644 index 0000000..416bd91 --- /dev/null +++ b/application/view/login/requestPasswordReset.php @@ -0,0 +1,35 @@ +
+

Request a password reset

+
+ + + renderFeedbackMessages(); ?> + + +
+ + + +
+ + + + Reload Captcha + + +
+ +
+
+
+

+ Please note: This captcha will be generated when the img tag requests the captcha-generation + (= a real image) from YOURURL/register/showcaptcha. As this is a client-side triggered request, a + $_SESSION["captcha"] dump will not show the captcha characters. The captcha generation + happens AFTER the request that generates THIS page has been finished. +

+
diff --git a/application/view/login/resetPassword.php b/application/view/login/resetPassword.php new file mode 100644 index 0000000..8d9089b --- /dev/null +++ b/application/view/login/resetPassword.php @@ -0,0 +1,27 @@ +
+

LoginController/resetPassword

+ + + renderFeedbackMessages(); ?> + +
+

Set new password

+ +

FYI: ... Idenfitication process works via password-reset-token (hidden input field)

+ + +
+ + + + + + + +
+ + Back to Login Page +
+
diff --git a/application/view/note/edit.php b/application/view/note/edit.php new file mode 100644 index 0000000..6e29473 --- /dev/null +++ b/application/view/note/edit.php @@ -0,0 +1,22 @@ +
+

NoteController/edit/:note_id

+ +
+

Edit a note

+ + + renderFeedbackMessages(); ?> + + note) { ?> +
+ + + + + +
+ +

This note does not exist.

+ +
+
diff --git a/application/view/note/index.php b/application/view/note/index.php new file mode 100644 index 0000000..ce908a6 --- /dev/null +++ b/application/view/note/index.php @@ -0,0 +1,44 @@ +
+

NoteController/index

+
+ + + renderFeedbackMessages(); ?> + +

What happens here ?

+

+ This is just a simple CRUD implementation. Creating, reading, updating and deleting things. +

+

+

+ + +
+

+ + notes) { ?> + + + + + + + + + + + notes as $key => $value) { ?> + + + + + + + + +
IdNoteEDITDELETE
note_id; ?>note_text); ?>EditDelete
+ +
No notes yet. Create some !
+ +
+
diff --git a/application/view/profile/index.php b/application/view/profile/index.php new file mode 100644 index 0000000..14e1477 --- /dev/null +++ b/application/view/profile/index.php @@ -0,0 +1,44 @@ +
+

ProfileController/index

+
+ + + renderFeedbackMessages(); ?> + +

What happens here ?

+
+ This controller/action/view shows a list of all users in the system. You could use the underlying code to + build things that use profile information of one or multiple/all users. +
+
+ + + + + + + + + + + + users as $user) { ?> + + + + + + + + + +
IdAvatarUsernameUser's emailActivated ?Link to user's profile
user_id; ?> + user_avatar_link)) { ?> + + + user_name; ?>user_email; ?>user_active == 0 ? 'No' : 'Yes'); ?> + Profile +
+
+
+
diff --git a/application/view/profile/showProfile.php b/application/view/profile/showProfile.php new file mode 100644 index 0000000..dc2bdba --- /dev/null +++ b/application/view/profile/showProfile.php @@ -0,0 +1,41 @@ +
+

ProfileController/showProfile/:id

+
+ + + renderFeedbackMessages(); ?> + +

What happens here ?

+
This controller/action/view shows all public information about a certain user.
+ + user) { ?> +
+ + + + + + + + + + + + + + + + + + + +
IdAvatarUsernameUser's emailActivated ?
user->user_id; ?> + user->user_avatar_link)) { ?> + + + user->user_name; ?>user->user_email; ?>user->user_active == 0 ? 'No' : 'Yes'); ?>
+
+ + +
+
diff --git a/application/view/register/index.php b/application/view/register/index.php new file mode 100644 index 0000000..40fd18c --- /dev/null +++ b/application/view/register/index.php @@ -0,0 +1,38 @@ +
+ + + renderFeedbackMessages(); ?> + + + +
+
+

+ Please note: This captcha will be generated when the img tag requests the captcha-generation + (= a real image) from YOURURL/register/showcaptcha. As this is a client-side triggered request, a + $_SESSION["captcha"] dump will not show the captcha characters. The captcha generation + happens AFTER the request that generates THIS page has been finished. +

+
diff --git a/application/view/register/verify.php b/application/view/register/verify.php new file mode 100644 index 0000000..6a880c4 --- /dev/null +++ b/application/view/register/verify.php @@ -0,0 +1,12 @@ +
+ +

Verification

+
+ + + renderFeedbackMessages(); ?> + + Go back to home page +
+ +
diff --git a/application/view/user/changePassword.php b/application/view/user/changePassword.php new file mode 100644 index 0000000..be1a037 --- /dev/null +++ b/application/view/user/changePassword.php @@ -0,0 +1,25 @@ +
+

UserController/changePassword

+ + + renderFeedbackMessages(); ?> + +
+

Set new password

+ + +
+ +

+ +

+ +

+ +
+ +
+
diff --git a/application/view/user/changeUserRole.php b/application/view/user/changeUserRole.php new file mode 100644 index 0000000..c80713a --- /dev/null +++ b/application/view/user/changeUserRole.php @@ -0,0 +1,31 @@ +
+

UserController/changeUserRole

+ + + renderFeedbackMessages(); ?> + +
+

Change account type

+

+ This page is a basic implementation of the upgrade-process. + User can click on that button to upgrade their accounts from + "basic account" to "premium account". This script simple offers + a click-able button that will upgrade/downgrade the account instantly. + In a real world application you would implement something like a + pay-process. +

+

+ Please note: This whole process has been renamed from AccountType (v3.0) to UserRole (v3.1). +

+ +

Currently your account type is:

+ +
+ + + + + +
+
+
diff --git a/application/view/user/editAvatar.php b/application/view/user/editAvatar.php new file mode 100644 index 0000000..66b17c8 --- /dev/null +++ b/application/view/user/editAvatar.php @@ -0,0 +1,28 @@ +
+

Edit your avatar

+ + + renderFeedbackMessages(); ?> + +
+

Upload an Avatar

+ + + +
+ + + + + +
+
+ +
+

Delete your avatar

+

Click this link to delete your (local) avatar: Delete your avatar +

+
diff --git a/application/view/user/editUserEmail.php b/application/view/user/editUserEmail.php new file mode 100644 index 0000000..4313362 --- /dev/null +++ b/application/view/user/editUserEmail.php @@ -0,0 +1,17 @@ +
+

UserController/editUserEmail

+ + + renderFeedbackMessages(); ?> + +
+

Change your email address

+ +
+ + +
+
+
diff --git a/application/view/user/editUsername.php b/application/view/user/editUsername.php new file mode 100644 index 0000000..e84d1c3 --- /dev/null +++ b/application/view/user/editUsername.php @@ -0,0 +1,20 @@ +
+

UserController/editUsername

+ + + renderFeedbackMessages(); ?> + +
+

Change your username

+ +
+ + + + + +
+
+
diff --git a/application/view/user/index.php b/application/view/user/index.php new file mode 100644 index 0000000..4a3d970 --- /dev/null +++ b/application/view/user/index.php @@ -0,0 +1,21 @@ +
+

UserController/showProfile

+ +
+

Your profile

+ + + renderFeedbackMessages(); ?> + +
Your username: user_name; ?>
+
Your email: user_email; ?>
+
Your avatar image: + + Your gravatar pic (on gravatar.com): + + Your avatar pic (saved locally): + +
+
Your account type is: user_account_type; ?>
+
+
diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2bbf77d --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "panique/huge", + "type": "project", + "description": "A full-feature user authentication / login system embedded into a simple but powerful MVC framework structure", + "keywords": ["login", "auth", "user", "authentication", "mvc", "membership"], + "homepage": "https://github.com/panique/huge", + "license": "MIT", + "require-dev": { + "php": ">=5.5.0", + "phpmailer/phpmailer": "~6.0", + "gregwar/captcha": "~1.1", + "phpunit/phpunit": "4.8.*|5.7.*" + }, + "autoload": { + "psr-4": { "": ["application/core/", "application/model/"] } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..af67e06 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1374 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "20821c10675bc4dd005e512556018d4f", + "packages": [], + "packages-dev": [ + { + "name": "doctrine/instantiator", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:15:36+00:00" + }, + { + "name": "gregwar/captcha", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/Gregwar/Captcha.git", + "reference": "4edbcd09fde4353b94ce550f43460eba73baf2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Gregwar/Captcha/zipball/4edbcd09fde4353b94ce550f43460eba73baf2cc", + "reference": "4edbcd09fde4353b94ce550f43460eba73baf2cc", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-mbstring": "*", + "php": ">=5.3.0", + "symfony/finder": "*" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6.4 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gregwar\\": "src/Gregwar" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Passault", + "email": "g.passault@gmail.com", + "homepage": "http://www.gregwar.com/" + }, + { + "name": "Jeremy Livingston", + "email": "jeremy.j.livingston@gmail.com" + } + ], + "description": "Captcha generator", + "homepage": "https://github.com/Gregwar/Captcha", + "keywords": [ + "bot", + "captcha", + "spam" + ], + "support": { + "issues": "https://github.com/Gregwar/Captcha/issues", + "source": "https://github.com/Gregwar/Captcha/tree/v1.3.0" + }, + "time": "2025-06-23T12:25:54+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "2.0.5", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "e6a969a640b00d8daa3c66518b0405fb41ae0c4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e6a969a640b00d8daa3c66518b0405fb41ae0c4b", + "reference": "e6a969a640b00d8daa3c66518b0405fb41ae0c4b", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "suggest": { + "dflydev/markdown": "~1.0", + "erusev/parsedown": "~1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-0": { + "phpDocumentor": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "mike.vanriel@naenius.com" + } + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/release/2.x" + }, + "time": "2016-01-25T08:17:30+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.12.0", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2025-10-15T16:49:08+00:00" + }, + { + "name": "phpspec/prophecy", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "4745ded9307786b730d7a60df5cb5a6c43cf95f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4745ded9307786b730d7a60df5cb5a6c43cf95f7", + "reference": "4745ded9307786b730d7a60df5cb5a6c43cf95f7", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "phpdocumentor/reflection-docblock": "~2.0", + "sebastian/comparator": "~1.1" + }, + "require-dev": { + "phpspec/phpspec": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "psr-0": { + "Prophecy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "support": { + "issues": "https://github.com/phpspec/prophecy/issues", + "source": "https://github.com/phpspec/prophecy/tree/master" + }, + "time": "2015-08-13T10:07:40+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "2.2.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/eabf68b476ac7d0f73793aada060f1c1a9bf8979", + "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "phpunit/php-file-iterator": "~1.3", + "phpunit/php-text-template": "~1.2", + "phpunit/php-token-stream": "~1.3", + "sebastian/environment": "^1.3.2", + "sebastian/version": "~1.0" + }, + "require-dev": { + "ext-xdebug": ">=2.1.4", + "phpunit/phpunit": "~4" + }, + "suggest": { + "ext-dom": "*", + "ext-xdebug": ">=2.2.1", + "ext-xmlwriter": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "irc": "irc://irc.freenode.net/phpunit", + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/2.2" + }, + "time": "2015-10-06T15:47:00+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "1.4.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "irc": "irc://irc.freenode.net/phpunit", + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/1.4.5" + }, + "time": "2017-11-27T13:52:08+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/1.2.1" + }, + "time": "2015-06-21T13:50:34+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "38e9124049cf1a164f1e4537caf19c99bf1eb260" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/38e9124049cf1a164f1e4537caf19c99bf1eb260", + "reference": "38e9124049cf1a164f1e4537caf19c99bf1eb260", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4|~5" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "irc": "irc://irc.freenode.net/phpunit", + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/master" + }, + "time": "2016-05-12T18:03:57+00:00" + }, + { + "name": "phpunit/php-token-stream", + "version": "1.4.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-token-stream.git", + "reference": "1ce90ba27c42e4e44e6d8458241466380b51fa16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/1ce90ba27c42e4e44e6d8458241466380b51fa16", + "reference": "1ce90ba27c42e4e44e6d8458241466380b51fa16", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Wrapper around PHP's tokenizer extension.", + "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "keywords": [ + "tokenizer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-token-stream/issues", + "source": "https://github.com/sebastianbergmann/php-token-stream/tree/1.4" + }, + "abandoned": true, + "time": "2017-12-04T08:55:13+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "4.8.36", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "46023de9a91eec7dfb06cc56cb4e260017298517" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/46023de9a91eec7dfb06cc56cb4e260017298517", + "reference": "46023de9a91eec7dfb06cc56cb4e260017298517", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "php": ">=5.3.3", + "phpspec/prophecy": "^1.3.1", + "phpunit/php-code-coverage": "~2.1", + "phpunit/php-file-iterator": "~1.4", + "phpunit/php-text-template": "~1.2", + "phpunit/php-timer": "^1.0.6", + "phpunit/phpunit-mock-objects": "~2.3", + "sebastian/comparator": "~1.2.2", + "sebastian/diff": "~1.2", + "sebastian/environment": "~1.3", + "sebastian/exporter": "~1.2", + "sebastian/global-state": "~1.0", + "sebastian/version": "~1.0", + "symfony/yaml": "~2.1|~3.0" + }, + "suggest": { + "phpunit/php-invoker": "~1.1" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.8.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "source": "https://github.com/sebastianbergmann/phpunit/tree/4.8.36" + }, + "time": "2017-06-21T08:07:12+00:00" + }, + { + "name": "phpunit/phpunit-mock-objects", + "version": "2.3.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", + "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/ac8e7a3db35738d56ee9a76e78a4e03d97628983", + "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": ">=5.3.3", + "phpunit/php-text-template": "~1.2", + "sebastian/exporter": "~1.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "suggest": { + "ext-soap": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Mock Object library for PHPUnit", + "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", + "keywords": [ + "mock", + "xunit" + ], + "support": { + "irc": "irc://irc.freenode.net/phpunit", + "issues": "https://github.com/sebastianbergmann/phpunit-mock-objects/issues", + "source": "https://github.com/sebastianbergmann/phpunit-mock-objects/tree/2.3" + }, + "abandoned": true, + "time": "2015-10-02T06:51:40+00:00" + }, + { + "name": "sebastian/comparator", + "version": "1.2.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2b7424b55f5047b47ac6e5ccb20b2aea4011d9be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2b7424b55f5047b47ac6e5ccb20b2aea4011d9be", + "reference": "2b7424b55f5047b47ac6e5ccb20b2aea4011d9be", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "sebastian/diff": "~1.2", + "sebastian/exporter": "~1.2 || ~2.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "http://www.github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/1.2" + }, + "time": "2017-01-29T09:50:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/13edfd8706462032c2f52b4b862974dd46b71c9e", + "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/master" + }, + "time": "2015-12-08T07:14:41+00:00" + }, + { + "name": "sebastian/environment", + "version": "1.3.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "4e8f0da10ac5802913afc151413bc8c53b6c2716" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/4e8f0da10ac5802913afc151413bc8c53b6c2716", + "reference": "4e8f0da10ac5802913afc151413bc8c53b6c2716", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/1.3.7" + }, + "time": "2016-05-17T03:18:57+00:00" + }, + { + "name": "sebastian/exporter", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "42c4c2eec485ee3e159ec9884f95b431287edde4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/42c4c2eec485ee3e159ec9884f95b431287edde4", + "reference": "42c4c2eec485ee3e159ec9884f95b431287edde4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "sebastian/recursion-context": "~1.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/master" + }, + "time": "2016-06-17T09:04:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4", + "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.2" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/1.1.1" + }, + "time": "2015-10-12T03:26:01+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "b19cc3298482a335a95f3016d2f8a6950f0fbcd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/b19cc3298482a335a95f3016d2f8a6950f0fbcd7", + "reference": "b19cc3298482a335a95f3016d2f8a6950f0fbcd7", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/master" + }, + "time": "2016-10-03T07:41:43+00:00" + }, + { + "name": "sebastian/version", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", + "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", + "shasum": "" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/1.0.6" + }, + "time": "2015-06-21T13:59:46+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-15T18:45:57+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/yaml", + "version": "v3.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "88289caa3c166321883f67fe5130188ebbb47094" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/88289caa3c166321883f67fe5130188ebbb47094", + "reference": "88289caa3c166321883f67fe5130188ebbb47094", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/console": "<3.4" + }, + "require-dev": { + "symfony/console": "~3.4|~4.0" + }, + "suggest": { + "symfony/console": "For validating YAML files using the lint command" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Yaml Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v3.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T10:57:07+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": { + "php": ">=5.5.0" + }, + "plugin-api-version": "2.9.0" +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..fa61be3 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,23 @@ +# Necessary to prevent problems when using a controller named "index" and having a root index.php +# more here: http://httpd.apache.org/docs/2.2/content-negotiation.html +Options -MultiViews + +# Activates URL rewriting (like myproject.com/controller/action/1/2/3) +RewriteEngine On + +# Prevent people from looking directly into folders +Options -Indexes + +# If the following conditions are true, then rewrite the URL: +# If the requested filename is not a directory, +RewriteCond %{REQUEST_FILENAME} !-d +# and if the requested filename is not a regular file that exists, +RewriteCond %{REQUEST_FILENAME} !-f +# and if the requested filename is not a symbolic link, +RewriteCond %{REQUEST_FILENAME} !-l +# then rewrite the URL in the following way: +# Take the whole request filename and provide it as the value of a +# "url" query parameter to index.php. Append any query string from +# the original URL as further query parameters (QSA), and stop +# processing this .htaccess file (L). +RewriteRule ^(.+)$ index.php?url=$1 [QSA,L] diff --git a/public/avatars/.htaccess b/public/avatars/.htaccess new file mode 100755 index 0000000..7238e59 --- /dev/null +++ b/public/avatars/.htaccess @@ -0,0 +1,9 @@ +# this disallows direct access to the folder listing +# and disallows access to any executables files (that users may upload) +# the script allows only .jpg/.png uploads, but we never know... +Options -Indexes +Options -ExecCGI +AddHandler cgi-script .php .php3 .php4 .phtml .pl .py .jsp .asp .htm .shtml .sh .cgi +# completely disable the PHP engine inside THIS folder, so what ever an attacker will do, PHP code will never run here +# more info on this: http://www.electrictoolbox.com/disable-php-apache-htaccess/ +php_flag engine off diff --git a/public/avatars/default.jpg b/public/avatars/default.jpg new file mode 100755 index 0000000..45f18c7 Binary files /dev/null and b/public/avatars/default.jpg differ diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..d88c288 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,268 @@ +body { + font-family: Arial, sans-serif; + font-weight: 400; + font-size: 14px; +} +.wrapper { + width: 960px; + margin: 0 auto; +} +.logo { + width: 722px; + height: 450px; + /* To keep this project compact, we show a base64-encoded image, not a real file. */ + /* Excellent base64-encoders and more information here: http://base64image.org and http://www.base64-image.de */ + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAtIAAAHCCAIAAABNNrn7AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3FpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDIxIDc5LjE1NDkxMSwgMjAxMy8xMC8yOS0xMTo0NzoxNiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo1NzcyOWI1ZS0xYjcwLTQxNGEtYTgyMC1lNGFhY2Q3YzI1MmMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6N0Y2NDcxNzQ5RTc2MTFFNEJCRUFCMjM0OTBBM0E4MUEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6N0Y2NDcxNzM5RTc2MTFFNEJCRUFCMjM0OTBBM0E4MUEiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjg2MDk5Y2ZhLWI5MWItMDM0NS1iNWRhLTcyMWRhODk0M2M5ZSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo1NzcyOWI1ZS0xYjcwLTQxNGEtYTgyMC1lNGFhY2Q3YzI1MmMiLz4gPC9y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'); + margin: auto; +} +.support-button { + position: absolute; + cursor: pointer; + top: 0; + right: 0; + width: 233px; + height: 233px; + background-image: url('data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAOkAAADpCAYAAADBNxDjAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyFpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDIxIDc5LjE1NDkxMSwgMjAxMy8xMC8yOS0xMTo0NzoxNiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDozMjhFNzZGREE2NDUxMUU0QkRBQjgyQUVBQkQzQUQyMiIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDozMjhFNzZGRUE2NDUxMUU0QkRBQjgyQUVBQkQzQUQyMiI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjMyOEU3NkZCQTY0NTExRTRCREFCODJBRUFCRDNBRDIyIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjMyOEU3NkZDQTY0NTExRTRCREFCODJBRUFCRDNBRDIyIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+7O6Z0AAAHIdJREFUeNrsnWmoVdX/xnfpG3+mkc3ZqFlUNFhGWYaUEl2kgUawgbKoaHgRNECGDVA0QS+ySImK5jCpaBKxwrJBGmiWJm2wMi0tbQDpxb/Pgmf/1113n3Puufcc797nPg8czr17WGvtfdazvmt41ve72ZQpU17Osuy4zDCMUmLz/z5d/30W+FUYRnlJmpmohlF+kpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohlEBkpqohtEGDG1DmhC1Es7NjjnmmGyvvfbK5s2bl/322289jt933329ur4vuPjii7Mvv/wye/XVV3ucO+uss8L3o48+OmgqYr33Uetc/J723HPPbOrUqdmiRYuyr7/+usf9f/75Z7f3qevHjBmT/fXXX9kvv/zS7Xcl7S222KJHWdJ0VC8OPvjgbPjw4dk333zT8t9taJveeSWIOmLEiOzUU08Nf8eEPO6448JL//nnn7PnnnsuP3722Wdn//vf/3qQty8g36effrqwUk6cOHHQkbTe+6h1Ln5Pu+66a7gOMqck5fgXX3yRv08IeO655/bIZ9q0aeE4RCXtvffeu8c1cTrgxBNPzC6//PKc3EcddVQgdyvqSLtJWgmiQsALLrggO+yww/KXuvXWWweCgoMOOign6SGHHJLtsssu2YIF/9+b59oDDzww/P3RRx/lPxStNJWGSqW/v//++7zy0PKC7bffPvwd30s+NAS6Lr6vVn4p4utUseMyxdYkPhbnpzLWur7ouVLrAtLzaRrr1q3Ly1r0PloNSAURf/jhh2z27NnZ+++/n5d3p5126pYvhLz00kvrpnfssceGey655JLw/dRTT2X7779/JSxpZYj63nvvhdaPCkNlmjRpUjjOC58wYUJ+3dFHHx2+X3vttfB95ZVXBosr/PPPP9n9998fSE03itabrrEsNXjooYdCK3zttdfmrS6fW265JSfDjBkzQmMAuA4LQrlo/c8444xs2LBheX5UiNTaptddccUV4aMyxaRLj5HfBx98kO22226B6OCiiy7Kbr/99lCZdT2NWPzsNFx33nln3shcffXV+f3peaUBISALz17vfbQaJ510Unh3/H4xIfuaX0xiPTPd5zJPHFVuMumFF17IK4+sJz/i888/Hyo6lQ4ceuih4UdVy7tq1apQmbgPUvz999+hAsTAMjAe4sO9XBfnBQH5O64g/Oi04Hw4h4VX60+DQhqkx98ck8USjj/++GzZsmV5uSBykaWrBQhIQ8T9PB9WHdLF2G+//UIZSP+NN94IhKVxoKG74YYbwrvQeZ6R8/yflvPuu+8OVrPe+4jfJc8af9TjaAY0gLy73ljqbbbZJv/99OEZa/VeaIgoE++8Spa09BYV0vGDqcuL9eRHfPnllwMJZEH5EahEAhZMlQV8/vnnwQrEoDulykDFj61qMzjyyCNDOjfeeGNu5fmbhoQ840r966+/Zvvss0+wFOTZ7LgW0qnrT7qyeHFjcPPNN+fEpxxUSsZwjMVo2OLzpMXkDO8xHqfNmTOnKeslK1s0RmwWTBLVmryKJ4b4zdPfrGjMCy677LJAahopNeRVI2mpifruu+/mrT2V7MMPPwxEoOuHBRWYOdSPd++994ZWkzGXWt0UrRpXMWsI+VKQ93bbbdft2KxZs0KF4Xn4UIk51tcK/NNPP/W4Jq2kadnS88uXL8/H+X3tXtJ48InBRF4KGpUiqAtKLymeg6g3wdSbManA78Dv0WqCbqrubum7vhpnqtVcsmRJXrkg5OTJk8NEgyrfaaedFo4z1uNH5APR2wUqGI1APM7jbyZgVqxY0eN6rBvdRiw/M5RdXV09xk2gaIkBqxeDcTX4448/8mMaAig9yhaPw+LzgIkU3l9/QOMBseMP3WqBbnM845tOYPFbag6CLq+Wb1oF1YN2YOgAcKJ0FlVdXioc1lMWEMsJcbGuS5cuza+nS6SxJUSh9YbIzUAtOss8LAXF3VIqPF1WxqI77rhj6E7edttt2U033ZQtXLgwnwABzz77bDfCYOFpdOiWCRs2bAgfcM0114SJHMgXT/7EY1K6yrwHTXxhUXhH6lUwRn388cfzcpAv3VeIQreW83TFscLkQUPB+LOv76M34DfjuciPia4333wzvDuWVUibNVANQXbffffQhR87dmzec9CMbNzYaEyaosgK33PPPT0mkqpM0lISVWPGjz/+uFu3DQtAy6uursajO+ywQ255qSBM1qRdunpg7EJlZo0t7caJkJyDIOTNJA4zrRwDlIs00q4lz0HFVNmouFpGGjduXL4GTLqq1DE4tscee+THKRsVOwYEZOmKxotnh4Dqvs6cOTP0MLQOyXlmteP15mbfR2/BxA0NKM+v34LnfOCBB/KGl28aIYYEzD9oFpzj9DxiAhaNSWuRlKFP0ZCnFdhsypQpA8kNh10sEWgM0oqaTqxQaTUbW2bQ5f7222/btt66KbH5AOdvra/R1iFMJ2BIOlEwAHjsv8/h/332dNUaWNBlYyxbNBkFRo4cGcZwTL4Ymw4D3d1119cwSt7dddfXMCpEUhPVMCpAUhPVMCpAUhPVMCpAUhPVMCpAUhPVMCpAUhPVMEkrUk4T1TBJTVTDMElNVMMYBCQ1UQ2T1EQ1DJPURDWMQUJSE3UQAB9Fqc+kwYahHfAMlYk9I+AEK3aYhV8d/Bj1xdPcQMSNgTT4MpLD6/68h3plxxMEfo/WrFnTMV4W+oIybPpuBSq1cfyII44IfoSefPLJ4BuJzdSnn3567tcX3zqQeNSoUcExl4ixcePGbN999w2OzzjOdfgi4rohQ4Z026zN9TgzW79+fXDEFUPpc8/atWtr5okjaP7ng9NwpY+DtPHjxwfnZqTPfZwjT5UrTSvNd7PNNqtZdl2LN3/I+cwzz2S///57eHbewejRo0O5a+VT79l1rt67Il3lxXd6PdZd1/I3v6XOx++MZ0zzH6yWtJIWFXeUse9ZOf6iwuCUCx+uOLbC6TYuOqmweE7gPjwULl68ODgdoxIB/L4qPRxt4WWeaxVmQg7LFKwIB12kQ2iMlStXFuaJLyMsGSBv/Nyed9554RjXnXLKKcHVJ2EiOAeh+B/n2Pji5RpcnWJx03xffPHFwrILePTjfvKlp4H3QhyxyUP8lltuWVjm66+/PqRL/jx37KwaL4LbbrtteC84KqNcRdeTtvKinDg2i2PkcI7YLw8++GBeXp6f9653JkfZrbD+nUTSShGVCsAPjvtKPNTLEztkVEwZWnQ87WlM9sorr4SuIZYMb31UMvkMjp2HqfIQCInKQ8WhgpEeoR9w60nFlQ9e3IAqrozcgpIHgHhUfo5zDWV55JFHAkFj95UcE9GowJAMF6A4LqM8RfkKlF1hJYG8yEOMTz75JD/PO1PUM1xopu+J7jH5KpyHQl2IpFhEnoHoBKRBmkXX4zExzosQF7gu5X3jRZ93ju/lzz77LO/yUx6OqQEmjIbHpBUnqiwRVoVWnFYY4KOWj1x3Alp2IG/ydDPlirIIqjhUltjNJKTlPlVatfJUciqujlEefNYC+aXVtSpLChFUDQgEU6yWWvk2C9LUvUXvCaTuV+MwFHfddVfosUA+DS1qXR/nRbwgrCKNDSSFyLhcJf/YJSoNSvzt2d2Kz/pSCbBE/PhUFIX/o1unwEWyPo1CMuCJXtZJIRrplpJ+HAKCdBgjacKGa/lQMWUFFJqwkUd+ETC1ivQK6CKStxx318o3LjvXYDH59GYSrOg9UWYFxlLAqdja02vBty9+gumSNrpeoHHhPdL44ROYeyAiZeA+3dvKmKSDgaSVIKoqAJWNcQ6VFcfYeFOnW8ZHAaNqAStAt5LuHt030qMi4cCa++NQDIBGga4Y91Fhd9555+A8mkrLMaxNo5lmhXjgepFb4F7GcHxD2Hr5pmVvBkXviTLz3KTHMd6pGgMaH2KJyrE3Q4d616fgeho/Oe6GkKtXrw7386FXlDZYrUKZvAW2C/ZCaFQamw+CZ7TgwTBJTVTDMElNVMMkNVENwyQ1UY2OA7PB7ZrVFYYO0ndbKQkhQgdULkXLIihd3n777W5ri6xHsvaHgAD1DOt7VCbUTPE6IOmuWrUqVxoRs5PFesAa4Ny5c3M5Iddq4Z5zWvZAtxqDsgDit5KvRBqxGD8V1qP0YdmG8hJ3lfXVNJ4p6aR5tSuydjPgnc6fP7/hWrZJ2kFElZhAWlH+R4er6OIxUansqF5QFanCS3TOeidpoKPVGiTXxqDSky73QCIkgIpsfeaZZ4Y0UOdMmjQprGfqHIv3XEvDAWGl22UNkTw5L0LxTb7ko8V+BAwC2lmkeUj8UFKxeUBRydOysi4ZBxim3IhAYoLwrEQcR3BAw4RCSv/zLgUpp/Q+a6WFFjm9Jj4Wo9F5k7QDiIqkbvr06UHdIvE7KhlIiNiAyh3/+IgDWIjHakJYiEplbNbCdHV1BQURGl2Bv6VHZYcJ52kssHCyrpLNsZCPfFAVPA0yDJGxlIRVjEkAabDckD+NWF6EOA+RAlF8Kn7nfRQJ52loJH5HFUVPQLpdFFrDhw8P6Uu/zL08N5YdnW7RxoG421sk+PeYtMPGqEuWLAmVhu4TMUBRyEhojoA9rhQSnHOeChfvT42tFJUqrtgQTx8aAlm2WD4oIH1jKyPEZNcNSh1UQqTbzDhs+fLloXsMWWJrpvxjgpJuLfURYn3y50P5aSR4T4jfgcTvsXCeBov3IzJK/C6rro0EkydPDhpdwL1g4cKFgbxqmNg4gDqLPGLNrrq9NKrkx3UTJkxo2Wb1oeZoeSwqFYLWXq11EXEEKgnnsUKQjC4llVOEVDdSY0KBBkCgmyrEQnwB2Z3E4jQQfGTtsbDNaFVpTMaOHZvNnDkzW7p0aThGt1DElFWmu8jWt6IxHvLJNM8i8TtlqyWcj8XvkA3pIr0V3qV6KZILQn4aJsgH0o0DMeptjLAl7SCLSms+Z86c0BqnAncqkqwX1oj9kKrsjPeohJr0iQma7jih8usjXe+8efNCxYstJOTH0lHRIabOYVW+++67Pj2fun90fUUGyg35ZWEZkzayqDGKxO+9Fc7z3LxHpLFoc2Nrzo4jGk16EOPGjSvcOJB26ZvdGGFLWkGLSpcQK8K4KiYC3S5acSwYXSkmdaiQsVWhkvO/xnlKTxaI8WAtUKGxRnTZdA/k4RjdPCwCXe/4HBW8L2Bcx4SUMGvWrDCrrGdhtwzlxRpC5rii093lI0A+zkMwJrgYEsSWkPG6GiJZ7fS5eY90TbVVT9ac30Abv3kPgG/GsUWNFIJ/tq/xjiA5s+qtmjwaDAL7vsCi/EEAyKQJo1ZN8rQD7u6WfDLJaA/oSmP1mAN47LHHSl1WW1JbVKPksCW1RTVMUhPVMExSE7Xu2EvLG1rS2BSi8FaD8vZVHNBfL/jNunYxSU3UpoDoQTI9lncASy1yfFYWoCBKwTqvyEV5KXezYL0X0QZrl71tmJBYahNA/N4GCl4nbZ6opZlMogKz/idZXaw4okIiaGAGs2iNsCgt1kOlXeVerfPJEq9bty4cR0wPaSRcj/PmW1paId00oPxIRxI8KaZqbSyILWqad2ztUm0vWl3yQZm01VZbhU9aBo4JPCPKqPR96v/02dJ8Y9kjadk59iAnKgvrWuNDFYQkDWUSqiDW/wC7R5DC1ROwp57dUc8gmhdJZYkRRLDAz3Xyji8P8Vgb7gEQUs62tW2Oyh0fl0d+PlJXcT9b3Sjr4Ycf3m1jAWXif+mX5ZkfIsSaZnmShxypF3wRXu9C28xwyh17nS/yrC9LT2OCS1B01kX52oO9idoNyNOk3iFWCxWCb0jK+h/kS/dgFqHIs3s9iAR0JVELqUFAfA5hsVAocIhzgxJIu1x0XOoeZI1SGtEtj6V7EJlNA9qrqTIpL7S6ajyKPMmTLmWJveBrq10RYq/zRVEBeM88A8+ORBDn4ZvKg73HpBUeo1Jp0K1iRZG2ofuNd1/0VpZGBTzggAMCUetVZEB+sg5YVoTsQixQB3Sf2cQty6XjGg/XkyrWyjsmFWATAATX7ph0z2xv0cjrvJ4Ba0zvpV6+9mBvonYD1kh7ULE4fGPdGnmgj5F6dleXlTEdn9hBN8fVCKARphsaT7joGw0u40a6fbqexoTjtYTnyi8tW70Jn2Y9yUMujUfrETqOCtCKfE3SQUxUWnEqk3bE8I11U5etEYo8u0MiuolYVjY/s4E5tmA0AuTLmA8XKwLWmOOMQbHOWFy2eTE24ziNicTqKRDVkx+7RwRtLKAMkLUIzXiSpww0GnwzHo3jxKTXNfKsbw/21UTHSwipsGk0tbhipx4ZDFtSW1RjUMCW1BbVsCW1RTUMk9RENUxSw0Q1TFITtdRgqSbWpfYH/dnRUgW0+vlMUhO1V9CifSvQ1x0t7QKESl2flun5TFITtS7i/ajpcS30F7nf5H8t7kOC+P8ikqTnlV6av/bC8i1rpfzT9NN01RvgE5cX4QI7btI0lG5sFWuVS/m1o4fgJZhNi8osz1DZkAqiMGIXCd+omXDlGe/+AFgh1DnsSqkX7gFJIYojNL0SRRSdR+OLOEKKoHj3DEJ2lYfjeLDXdjfURCpDUboKMwGUBoJ9xPrci8pKmwQgYFFYibhc8Y4Y3HlynTTFfLcqoNQQwggYmwy4pTucRr3sBaWyf/XVV0Gmh2QPb+4//vhjsDjsT+U4u22QFFI5169fH/ZhvvXWW9k555yTffrpp9nIkSODw2+0vC+99FIgJ3pfSASBRo0aFYJBQWadP/nkk0O6pHHHHXcEWSFC/KuuuiqP3/L6669nt956azZkyJCQP76In3jiiWz8+PFhb2itfNnW9u+//4ZnQzZ5/vnnh50skG706NGBVMS9AWvXrg3XrFixItu4cWN2wgknZA8//HAo13XXXRcaDLaysYMIh97sNiI/yo4zbcpB3q2At6oNTNe39BaVnR6xeF7aYATqWJA4Fgpi82bDPQBE7PFGAMgYO79Od9Vo9wze5fWN9dOuHGLOAPaH1sqXoE9A99QKBcFmgFphJeIdMdoKqPcDeG+92SLoManHqP0C4nERMd4JU2v3R1/CPUBKuqAaB2rDuZDuqult2IbehpmIISKqLI3CSsSArLwf3ZsGc7IltUVtC2bPnh3GWVgjCMIGbgAhGYOy8wNgaeTKpNlwD5AOqxeHryDshKBdNeQfh6ZohN6GmYjLwRiZvBQUijLVCysRg64vQZP1HDRS9EQ8ceTJpI6Gd9W4u+uur1EZ2JLaohq2pIYtqmGSmqiGSWqYqK0FyxWsRXYiJDtslc8jk7SEWLRoUccTFSkfYoF6SMM9VKXxYbmINV/54TVJTdQBrZASvKeC+lRonordCe+gtc8i4TvXITPkE4vh03RqlUHnmhHIF1lAHSvaZFCUfldXVzZs2LAgykDn3Ap4drecBM3/njp1amlnfSWkl2AADS+CdzStiNoXL14cFEiIH9D8ShSPdpZvQlOwFopD7lT4jpgCgT9AyVRLjI+sLy0D6YPeCuTR25I2jsUVDoM0CHVRFCJDjQX5c470cXtKuA/SQRZJHhLr9xdWHFXAopaZqEChH6jEaGOprAopATHQ8wK0t8RMgZip93osK/dhnaTcicM90CDQRYY0AGLz94IFC7qVQeEnuAfhPfcgPUQTrN07aciIDRs2BPKjlkI5hdXkPGmQh0JvQFyRlAZEpKXM+AZmjI3aCJK2ageMSWqi9htx6Af+FvliCyJyYO2wOkVoJHyvJ5ovCj/RjECetOPwGdpup4anVldfMW24j/uJDwPhWw2PST1GbSuwqJALS4d1iQnVGyjcQ7Oi+WYE8qQdh8+A4OiR0QyzJU9d8jhODg2EJoYU2rGZ0B62pLaopQEWlS4ggnesXDMkxWoyrmRvKF3UZkTzzQjkKaOuZ9IHAq5cuTL8r3Ex1phrZIUZbxJug240ZOYc6bQjKrgnjko+cVSEqhHVcHfXXV/DJDVMVMMkNUxUwyQ1UQ2T1DBRDZPUGGxEbceSRiOwXtqqkBomqdFxRE13vqDv3dSQYKJZIDNshwd7k9RELQ1q7XyRRU2tWxzqosj61gtRER9Ld77MnTu321p1rTAZOg7Ih00E6IRbTVSLGcpJtH6nUUXBg0JbAO18qRXWoSjUhbS3jUJU8KkVAgPVkOR/COyLrmEzAPlrxw7AnSlpch1e8dPy9QeWBXawRa2ihDDe+SKku1YQssdEgHzoaON7tE1Mu3EkhsfTPNfRpYZ88a4aZH7aTSNLXLTzBk/5a9asyRsU7ZqZOHFiNn/+/F478TZJjUoStQjprpVaoS5SwsffClGhXTqNQlyAWjtvyD/OT+XzxJExaMao2vlSC7VCXaQTUPouClHRKMQFqLXzhvzZpK5744muESNG1AzFaEtqdIRFTXe+FKFeqAuhUYiKRiEuZIWLdt6QPw1JHFYCQi5cuDDsYZ0+fXr4XyErPHHkiaOOnkzq6/vra4gKyMXYlE3orSCYu7tGx3d9NyXoomIdmVBqV0NpS2pLaovagbAltUU1TFLDRDVMUsNErYNUCtifMetAwEswJmpXp4xRWa9E9RNjxYoVYf2TSN79dVSN2L83aiIUUKBVvndtSY2OsajvvPNOkOWtXr066Gr5WzJDWdT+hqKIr9G5ovAUNA62pIYtagLkeXwQKRDqQlYPAuGUW1pfPNHXC0WBxcTygjgUhYDoHk0vggr0vLgNJQ3uwXrSSCBh9JjUsEVtEojn+UAoxA4QmlAUiPGxuLEWGJUShCOMBaL+IoKicBozZkwgt64H8sZvkhomahOoFYoCyd+MGTPygFGxVQZYxOHDh+fHCeoUh61g7ynnIanGotyTxroxSQ0TtQ9oJhSFQFAnCK8tbewxJQIcx+kaMy5dtmxZ0BObpIaJ2k8gAZw2bVr4jru09UAXFyE+GwCwxERQ436iqiG0166ZCy+8sGXltCzQaARLCAcYtqRGI1iZZJIaJqphkhomqklqmKiGSWqYqCapYZioJqlhohomqWGimqSGYaKapIaJapikholqkhomqmGSGiaqSWoYJqpJapiohklqmKgmqWGYqCapYaKapIZhopqkhmGimqSGiWqSGoaJapIaJqphkhomqklqGCaqSWqYqCapYZioJqlhmKgmqWGimqSGYaKapIaJapIaholqkhqGiWqSGiaqSWoYJqpJahgmqklqmKgmqWGYqCapYQwiopqkholqkhqGiWqSGiZqBxPVJDVMVJPUMExUk9QwOpioJqlhopqkhmGimqSG0cFENUkNE9UkNQwT1SQ1jA4mqklqmKgmqWGYqCapYXQwUU1Sw0Q1SQ3DRDVJDaODiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJiWqSGkbJifp/AgwAY70blSkdxAMAAAAASUVORK5CYII='); +} +.container { + max-width: 960px; + border: 1px solid #ccc; + padding: 20px; + margin: 30px 0 30px 0; +} +.container a { + color: #454545; +} +.container table { + font-size: 11px; + margin-top: 20px; +} +.container table thead td { + background-color: #f5f5f5; + padding: 4px 10px; +} +.container table tbody td { + padding: 4px 10px; +} +.container .box { + border-top: 1px solid #ddd; + padding-top: 10px; + margin-top: 30px; +} +.container input { + background-color: #f5f5f5; + border: 0; + padding: 5px 10px; +} +.container input[type="submit"] { + background-color: #ccc; + cursor: pointer; +} +.container input[type="submit"]:hover { + background-color: #222; + color: #fff; +} +.container button { + background-color: #ccc; + border: 0; + padding: 5px 10px; + cursor: pointer; +} +.container button:hover { + background-color: #222; + color: #fff; +} + + + +/* navigation dropdown menu */ +.navigation, .navigation-submenu { + display: inline-block; + list-style: none; + /* btw this is necessary to remove most browsers's "hidden" default
    intent */ + margin: 0; + padding: 0; +} +/* TODO */ +.navigation { +} +.navigation.right { + float: right; +} +.navigation li { + float: left; + margin-right: 5px; +} +.navigation .navigation-submenu { + display: none; +} +.navigation li a { + display: block; + text-decoration: none; + padding: 10px 15px; + border: 2px solid #454545; + background: #454545; + color: #fff; + float:none; + font-size: 10px; + text-transform: uppercase; + font-weight: bold; +} +.navigation li:hover .navigation-submenu { + display: block; + position: absolute; + float: left; +} +.navigation li:hover li, +.navigation li:hover a { + float: none; +} +.navigation li a:hover, +.navigation li:hover li a:hover { + background: #fff; + color: #454545; +} + +.navigation > li.active a { + background: #fff; + color: #454545; +} +.navigation > li.active li a { + background: #454545; + color: #fff; +} + +/* overview */ +.overview-table img { + width: 40px; + height: 40px; +} + +/* feedback boxes */ +.feedback { + padding: 30px; + margin-bottom: 10px; +} +.feedback.success { + color: #558f2d; + background-color: #ddf2c0; +} +.feedback.error { + color: #ff7272; + background-color: #ffe5e5; +} +.feedback.info { + color: #00529B; + background-color: #BDE5F8; +} + +.header_right_box { + float: right; +} + +/* login screen */ +.login-page-box { + display: table; + width: 100%; +} +.login-page-box .table-wrapper { + display: table-row; +} + +.login-box { + display: table-cell; + margin: 0; + color: #777; + background-color: #f4f3f1; + padding: 20px 50px 45px 50px; + width: 49%; + box-sizing: border-box; + font-weight: 400; + text-transform: uppercase; +} +.login-box h2 { + color: #252525; +} +.login-box input[type="text"], +.login-box input[type="password"] { + font-family: Arial, sans-serif; + color: #252525; + background-color: #ffffff; + padding: 15px 20px; + margin-bottom: 10px; + display: block; + width: 100%; + box-sizing: border-box; /* modern way to say width:100% without padding */ + /*text-transform: uppercase;*/ +} +.login-box input[type="submit"] { + color: #777; + background-color: transparent; + border: 2px solid #777; + padding: 15px 20px; + margin-bottom: 10px; + display: block; + width: 100%; + box-sizing: border-box; /* modern way to say width:100% without padding */ + text-transform: uppercase; +} +.login-box input[type="submit"]:hover { + color: #fff; + border-color: #252525; + background-color: #252525; +} +.login-box .remember-me-label { + display: block; + margin-bottom: 10px; +} +.login-box .link-forgot-my-password { + display: block; + text-align: right; +} +.login-box .link-forgot-my-password a { + color: #777; + text-decoration: none; +} +.login-box .link-forgot-my-password a:hover { + text-decoration: underline; +} +.login-box ::-webkit-input-placeholder { color: #777; opacity: 0.5; } +.login-box ::-moz-placeholder { color: #777; opacity: 0.5; } +.login-box :-ms-input-placeholder { color: #777; opacity: 0.5; } +.login-box input:-moz-placeholder { color: #777; opacity: 0.5; } + +.register-box { + display: table-cell; + color: #fff; + background-color: #252525; + padding: 20px 50px 45px 50px; + width: 49%; + box-sizing: border-box; + font-weight: 400; + margin: 0; + text-transform: uppercase; +} +.register-box h2 { + color: #fff; +} +.register-box a { + width: 100%; + display: block; + box-sizing: border-box; /* modern way to say width:100% without padding */ + background-color: transparent; + border: 2px solid #fff; + padding: 15px 20px; + margin-bottom: 10px; + text-decoration: none; + text-align: center; + color: #fff; +} +.register-box a:hover { + background-color: #ffffff; + color: #252525; +} + +/* error page */ +.red-text { + color: red; +} \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..be44088 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..fc3bc9b --- /dev/null +++ b/public/index.php @@ -0,0 +1,17 @@ +assertEquals('index', Config::get('DEFAULT_ACTION')); + } + + public function testGetFailingEnvironment() + { + // manually set environment to "foobar" (and non-existing environment) + putenv('APPLICATION_ENV=foobar'); + + // call for environment should return false because config.foobar.php does not exist + $this->assertEquals(false, Config::get('DEFAULT_ACTION')); + } +} diff --git a/tests/core/EnvironmentTest.php b/tests/core/EnvironmentTest.php new file mode 100644 index 0000000..a6d32cf --- /dev/null +++ b/tests/core/EnvironmentTest.php @@ -0,0 +1,23 @@ +assertEquals('development', Environment::get()); + } + + public function testGetDevelopment() + { + putenv('APPLICATION_ENV=development'); + // call for environment should return "development" + $this->assertEquals('development', Environment::get()); + } + + public function testGetProduction() + { + putenv('APPLICATION_ENV=production'); + $this->assertEquals('production', Environment::get()); + } +} diff --git a/tests/core/FilterTest.php b/tests/core/FilterTest.php new file mode 100644 index 0000000..16a7e5f --- /dev/null +++ b/tests/core/FilterTest.php @@ -0,0 +1,262 @@ +var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);"; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + Filter::XSSFilter($codeBefore); + $this->assertEquals($codeAfter, $codeBefore); + } + + /** + * When string argument contains bad code the encoded (and therefore un-dangerous) string should be returned + */ + public function testXSSFilterWithBadCodeInString_return() + { + $codeBefore = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + $this->assertEquals($codeAfter, Filter::XSSFilter($codeBefore)); + } + + + public function testXSSFilterWithArrayOfBadCode_byref() + { + $codeBefore1 = "Hello "; + $codeBefore2 = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + $badArray = [$codeBefore1, $codeBefore2]; + Filter::XSSFilter($badArray); + + $this->assertEquals($codeAfter, $badArray[0]); + $this->assertEquals($codeAfter, $badArray[1]); + } + + public function testXSSFilterWithArrayOfBadCode_return() + { + $codeBefore1 = "Hello "; + $codeBefore2 = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + $badArray = [$codeBefore1, $codeBefore2]; + + $this->assertEquals($codeAfter, Filter::XSSFilter($badArray)[1]); + } + + public function testXSSFilterWithAssociativeArrayOfBadCode() + { + $codeBefore1 = "Hello "; + $codeBefore2 = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + $badArray = ['foo' => $codeBefore1, 'bar' => $codeBefore2]; + Filter::XSSFilter($badArray); + + $this->assertEquals($codeAfter, $badArray['foo']); + $this->assertEquals($codeAfter, $badArray['bar']); + } + + public function testXSSFilterWithSimpleObject_byref() + { + $codeBefore = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + $integerBefore = 123; + $integerAfter = 123; + + $object = new stdClass(); + $object->int = $integerBefore; + $object->str = 'foo'; + $object->badstr = $codeBefore; + + Filter::XSSFilter($object); + + $this->assertEquals('foo', $object->str); + $this->assertEquals($integerAfter, $object->int); + $this->assertEquals($codeAfter, $object->badstr); + } + + public function testXSSFilterWithSimpleObject_return() + { + $codeBefore = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + $integerBefore = 123; + $integerAfter = 123; + + $object = new stdClass(); + $object->str = 'foo'; + $object->badstr = $codeBefore; + + $this->assertEquals($codeAfter, Filter::XSSFilter($object)->badstr); + } + + public function testXSSFilterWithObjectContainingArray_byref() + { + $codeBefore1 = "Hello "; + $codeBefore2 = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + $badArray = ['foo' => 'bar', 'bad1' => $codeBefore1, 'bad2' => $codeBefore2]; + $object = new stdClass(); + $object->badArray = $badArray; + + Filter::XSSFilter($object); + + $this->assertEquals('bar', $object->badArray['foo']); + $this->assertEquals($codeAfter, $object->badArray['bad1']); + $this->assertEquals($codeAfter, $object->badArray['bad2']); + } + + public function testXSSFilterWithObjectContainingArray_return() + { + $codeBefore = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + $badArray = ['foo' => 'bar', 'bad' => $codeBefore]; + $object = new stdClass(); + $object->badArray = $badArray; + + $this->assertEquals($codeAfter, Filter::XSSFilter($object)->badArray['bad']); + } + + public function testXSSFilterWithObjectContainingObject_byref() + { + $codeBefore1 = "Hello "; + $codeBefore2 = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + + $object = new stdClass(); + $object->badStr = $codeBefore1; + + $childObject = new stdClass(); + $childObject->badStr = $codeBefore2; + + $object->badObject = $childObject; + + Filter::XSSFilter($object); + + $this->assertEquals($codeAfter, $object->badStr); + $this->assertEquals($codeAfter, $object->badObject->badStr); + } + + public function testXSSFilterWithObjectContainingObject_return() + { + $codeBefore = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + $object = new stdClass(); + $childObject = new stdClass(); + $childObject->badStr = $codeBefore; + $object->badObject = $childObject; + + $this->assertEquals($codeAfter, Filter::XSSFilter($object)->badObject->badStr); + } + + + /** + * For every type other than strings or arrays, the method should return the untouched passed argument + */ + public function testXSSFilterWithNonStringOrArrayArguments() + { + $integerBefore = 123; + $integerAfter = 123; + $arrayBefore = [1, 2, 3]; + $arrayAfter = [1, 2, 3]; + $floatsBefore = 17.001; + $floatsAfter = 17.001; + $null = null; + + Filter::XSSFilter($integerBefore); + Filter::XSSFilter($arrayBefore); + Filter::XSSFilter($floatsBefore); + Filter::XSSFilter($null); + + $this->assertEquals($integerAfter, $integerBefore); + $this->assertEquals($arrayBefore, $arrayAfter); + $this->assertEquals($floatsBefore, $floatsAfter); + $this->assertNull($null); + } + + /** + * For every type other than strings or arrays, the method should return the untouched passed argument + */ + public function testXSSFilterWithNonStringOrArrayArguments_return() + { + $integerBefore = 123; + $integerAfter = 123; + $arrayBefore = [1, 2, 3]; + $arrayAfter = [1, 2, 3]; + $floatsBefore = 17.001; + $floatsAfter = 17.001; + $null = null; + + $this->assertEquals($integerAfter, Filter::XSSFilter($integerBefore)); + $this->assertEquals($arrayBefore, Filter::XSSFilter($arrayBefore)); + $this->assertEquals($floatsBefore, Filter::XSSFilter($floatsBefore)); + $this->assertNull(Filter::XSSFilter($null)); + } + + /** + * For every type other than strings or arrays, the method should return the untouched passed argument + */ + public function testXSSFilterWithNonStringOrArrayArguments_byref() + { + $integerBefore = 123; + $integerAfter = 123; + $arrayBefore = [1, 2, 3]; + $arrayAfter = [1, 2, 3]; + $floatsBefore = 17.001; + $floatsAfter = 17.001; + $null = null; + + Filter::XSSFilter($integerBefore); + Filter::XSSFilter($arrayBefore); + Filter::XSSFilter($floatsBefore); + Filter::XSSFilter($null); + + $this->assertEquals($integerAfter, $integerBefore); + $this->assertEquals($arrayBefore, $arrayAfter); + $this->assertEquals($floatsBefore, $floatsAfter); + $this->assertNull($null); + } + + public function testXSSFilterWithComplexArrayOfBadCode() + { + $codeBefore1 = "Hello "; + $codeBefore2 = "Hello "; + $codeBefore3 = "Hello "; + $codeBefore4 = "Hello "; + $codeAfter = 'Hello <script>var http = new XMLHttpRequest(); http.open('POST', 'example.com/my_account/delete.php', true);</script>'; + + $badObject = new stdClass(); + $badObject->badstr = $codeBefore4; + + $badArray = [ + 'foo', + $codeBefore1, + 'bar', + [ + 'foo' => $codeBefore2, + 'bar' => $codeBefore3 + ], + $badObject + ]; + + Filter::XSSFilter($badArray); + + $this->assertEquals('foo', $badArray[0]); + $this->assertEquals($codeAfter, $badArray[1]); + $this->assertEquals('bar', $badArray[2]); + $this->assertEquals($codeAfter, $badArray[3]['foo']); + $this->assertEquals($codeAfter, $badArray[3]['bar']); + $this->assertEquals($codeAfter, $badArray[4]->badstr); + } + +} diff --git a/tests/core/RequestTest.php b/tests/core/RequestTest.php new file mode 100644 index 0000000..d172613 --- /dev/null +++ b/tests/core/RequestTest.php @@ -0,0 +1,55 @@ +assertEquals(22, Request::post('test')); + $this->assertEquals(null, Request::post('not_existing_key')); + + // test trim & strip_tags: Method is used with second argument "true", triggering a cleaning of the input + $_POST["attacker_string"] = ' '; + $this->assertEquals('alert("yo!");', Request::post('attacker_string', true)); + } + + /** + * Testing the postCheckbox() method of the Request class + */ + public function testPostCheckbox() + { + // Weird side-fact: a checked checkbox that has no manually set value will mostly contain 'on' as the default + // value in most modern browsers btw, so it makes sense to test this + $_POST['checkboxName'] = 'on'; + $this->assertEquals(1, Request::postCheckbox('checkboxName')); + + $_POST['checkboxName'] = 1; + $this->assertEquals(1, Request::postCheckbox('checkboxName')); + + $_POST['checkboxName'] = null; + $this->assertEquals(null, Request::postCheckbox('checkboxName')); + } + + /** + * Testing the get() method of the Request class + */ + public function testGet() + { + $_GET["test"] = 33; + $this->assertEquals(33, Request::get('test')); + $this->assertEquals(null, Request::get('not_existing_key')); + } + + /** + * Testing the cookie() method of the Request class + */ + public function testCookie() + { + $_COOKIE["test"] = 44; + $this->assertEquals(44, Request::cookie('test')); + $this->assertEquals(null, Request::cookie('not_existing_key')); + } +} diff --git a/tests/core/TextTest.php b/tests/core/TextTest.php new file mode 100644 index 0000000..3ef14f8 --- /dev/null +++ b/tests/core/TextTest.php @@ -0,0 +1,28 @@ +assertEquals("The username or password is incorrect. Please try again.", Text::get('FEEDBACK_USERNAME_OR_PASSWORD_WRONG')); + } + + /** + * When argument is null, should return null + */ + public function testGetWithNullKey() + { + $this->assertEquals(null, Text::get(null)); + } + + /** + * When key does not exist in text data file, should return null + */ + public function testGetWithNonExistingKey() + { + $this->assertEquals(null, Text::get('XXX')); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml new file mode 100644 index 0000000..684b9d0 --- /dev/null +++ b/tests/phpunit.xml @@ -0,0 +1,24 @@ + + + + + ./core/ + + + + + ../application/core + ../application/model + ../application/controller + + + diff --git a/travis-ci-apache b/travis-ci-apache new file mode 100644 index 0000000..10050bf --- /dev/null +++ b/travis-ci-apache @@ -0,0 +1,7 @@ + + DocumentRoot "%TRAVIS_BUILD_DIR%/public" + + AllowOverride All + Require all granted + +