From 4fce91b055d2b0adbf49057a1b7c4fb3e0a1dae4 Mon Sep 17 00:00:00 2001 From: "Elias F." Date: Mon, 24 Nov 2025 14:06:57 +0100 Subject: [PATCH] initial commit --- .gitignore | 7 + CHANGELOG.md | 102 ++ README.md | 632 ++++++++ _one-click-installation/Vagrantfile | 22 + _one-click-installation/bootstrap.sh | 87 ++ _pictures/huge.png | Bin 0 -> 82974 bytes .../_installation/01-create-database.sql | 1 + .../_installation/02-create-table-users.sql | 31 + .../_installation/03-create-table-notes.sql | 6 + application/config/config.development.php | 156 ++ application/config/texts.php | 75 + application/controller/AdminController.php | 35 + .../controller/DashboardController.php | 26 + application/controller/ErrorController.php | 28 + application/controller/IndexController.php | 21 + application/controller/LoginController.php | 148 ++ application/controller/NoteController.php | 77 + application/controller/ProfileController.php | 39 + application/controller/RegisterController.php | 74 + application/controller/UserController.php | 157 ++ application/core/Application.php | 106 ++ application/core/Auth.php | 82 + application/core/Config.php | 23 + application/core/Controller.php | 34 + application/core/Csrf.php | 60 + application/core/DatabaseFactory.php | 64 + application/core/Encryption.php | 146 ++ application/core/Environment.php | 18 + application/core/Filter.php | 77 + application/core/Mail.php | 154 ++ application/core/Redirect.php | 48 + application/core/Request.php | 64 + application/core/Session.php | 135 ++ application/core/Text.php | 32 + application/core/View.php | 177 +++ application/model/AdminModel.php | 95 ++ application/model/AvatarModel.php | 258 ++++ application/model/CaptchaModel.php | 46 + application/model/LoginModel.php | 382 +++++ application/model/NoteModel.php | 120 ++ application/model/PasswordResetModel.php | 365 +++++ application/model/RegistrationModel.php | 293 ++++ application/model/UserModel.php | 343 ++++ application/model/UserRoleModel.php | 65 + application/view/_templates/feedback.php | 19 + application/view/_templates/footer.php | 7 + application/view/_templates/header.php | 79 + application/view/admin/index.php | 57 + application/view/dashboard/index.php | 15 + application/view/error/404.php | 6 + application/view/index/index.php | 18 + application/view/login/index.php | 50 + .../view/login/requestPasswordReset.php | 35 + application/view/login/resetPassword.php | 27 + application/view/note/edit.php | 22 + application/view/note/index.php | 44 + application/view/profile/index.php | 44 + application/view/profile/showProfile.php | 41 + application/view/register/index.php | 38 + application/view/register/verify.php | 12 + application/view/user/changePassword.php | 25 + application/view/user/changeUserRole.php | 31 + application/view/user/editAvatar.php | 28 + application/view/user/editUserEmail.php | 17 + application/view/user/editUsername.php | 20 + application/view/user/index.php | 21 + composer.json | 17 + composer.lock | 1374 +++++++++++++++++ public/.htaccess | 23 + public/avatars/.htaccess | 9 + public/avatars/default.jpg | Bin 0 -> 2636 bytes public/css/style.css | 268 ++++ public/favicon.ico | Bin 0 -> 5430 bytes public/index.php | 17 + tests/core/ConfigTest.php | 44 + tests/core/EnvironmentTest.php | 23 + tests/core/FilterTest.php | 262 ++++ tests/core/RequestTest.php | 55 + tests/core/TextTest.php | 28 + tests/phpunit.xml | 24 + travis-ci-apache | 7 + 81 files changed, 7718 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 _one-click-installation/Vagrantfile create mode 100644 _one-click-installation/bootstrap.sh create mode 100644 _pictures/huge.png create mode 100644 application/_installation/01-create-database.sql create mode 100644 application/_installation/02-create-table-users.sql create mode 100644 application/_installation/03-create-table-notes.sql create mode 100644 application/config/config.development.php create mode 100644 application/config/texts.php create mode 100644 application/controller/AdminController.php create mode 100644 application/controller/DashboardController.php create mode 100644 application/controller/ErrorController.php create mode 100644 application/controller/IndexController.php create mode 100644 application/controller/LoginController.php create mode 100644 application/controller/NoteController.php create mode 100644 application/controller/ProfileController.php create mode 100644 application/controller/RegisterController.php create mode 100644 application/controller/UserController.php create mode 100644 application/core/Application.php create mode 100644 application/core/Auth.php create mode 100644 application/core/Config.php create mode 100644 application/core/Controller.php create mode 100644 application/core/Csrf.php create mode 100644 application/core/DatabaseFactory.php create mode 100644 application/core/Encryption.php create mode 100644 application/core/Environment.php create mode 100644 application/core/Filter.php create mode 100644 application/core/Mail.php create mode 100644 application/core/Redirect.php create mode 100644 application/core/Request.php create mode 100644 application/core/Session.php create mode 100644 application/core/Text.php create mode 100644 application/core/View.php create mode 100644 application/model/AdminModel.php create mode 100644 application/model/AvatarModel.php create mode 100644 application/model/CaptchaModel.php create mode 100644 application/model/LoginModel.php create mode 100644 application/model/NoteModel.php create mode 100644 application/model/PasswordResetModel.php create mode 100644 application/model/RegistrationModel.php create mode 100644 application/model/UserModel.php create mode 100644 application/model/UserRoleModel.php create mode 100644 application/view/_templates/feedback.php create mode 100644 application/view/_templates/footer.php create mode 100644 application/view/_templates/header.php create mode 100644 application/view/admin/index.php create mode 100644 application/view/dashboard/index.php create mode 100644 application/view/error/404.php create mode 100644 application/view/index/index.php create mode 100644 application/view/login/index.php create mode 100644 application/view/login/requestPasswordReset.php create mode 100644 application/view/login/resetPassword.php create mode 100644 application/view/note/edit.php create mode 100644 application/view/note/index.php create mode 100644 application/view/profile/index.php create mode 100644 application/view/profile/showProfile.php create mode 100644 application/view/register/index.php create mode 100644 application/view/register/verify.php create mode 100644 application/view/user/changePassword.php create mode 100644 application/view/user/changeUserRole.php create mode 100644 application/view/user/editAvatar.php create mode 100644 application/view/user/editUserEmail.php create mode 100644 application/view/user/editUsername.php create mode 100644 application/view/user/index.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 public/.htaccess create mode 100755 public/avatars/.htaccess create mode 100755 public/avatars/default.jpg create mode 100644 public/css/style.css create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 tests/core/ConfigTest.php create mode 100644 tests/core/EnvironmentTest.php create mode 100644 tests/core/FilterTest.php create mode 100644 tests/core/RequestTest.php create mode 100644 tests/core/TextTest.php create mode 100644 tests/phpunit.xml create mode 100644 travis-ci-apache 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 0000000000000000000000000000000000000000..ad303b8fe94a705d80c997b0e81660500cbcb775 GIT binary patch literal 82974 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..45f18c7fd1ca0bdd31432c398f880b5f06dbb202 GIT binary patch literal 2636 zcmbVM2~-nT7oIFEil9Ld1(z`dMTKlgFwuxWKoDb7TtF>qNG1>=nUDksM2ohHsF-39 zMNpfHEm}YjDoX4(}Cjh_pkry`R|!I^WJ^;`|f@3eRqtl z#=F2|UxiQr009AW0dwG^8G8U6D~1;t51;@n;^qT@@h)awgj_CRkVs+~k;{j}AR-SI zkrZ4Bi9#fkfJL4P36~cM$-yuvTqtJZpJ}V`ppehR2fMS$Y>5vPA@om>LMszitl}j^ z^5}fL=VEY?f}s#eM39^dDnwCY8AHLukB~Ev@vxhO2S-BWkxabTa8@vc%>jL2DFnI` z-CTKO3I(Lmh?M#6G#bSPq>?FA5*c}Du9W!)rUg$pn22ZV_7gSMg)oF@3H)kSl`cLhovBr zAemW^R2U2K{iLu69N98M`1xAAKg&0Y<$t~wpU+}RNHL`0i~Z!H-@6d?3=coZ7MXky zJ|sreE=6Q*dfamsqhUVSC>)JKA*;yyVd38nj5+{k4sZYt z289FAI1~nlGU@>s01z$|>SOlssGMWakc94Isfz?$Gp zXIh(4yre}F-OMlJ)hnsCHr`>5J_OvStRwNpHeehEg=ELz08ijGQ5}FO>!0y$Y2=w_ z%-JiBkN!~CCLGgW|vrhmrX0$$;>X?GqwTYn+_!XbVinqxDW;OwFYP=quM z5KTza0nRj+Nx$84IPONf*|t8-6a6Uvy4(18=HVN~2VZ5YnyQUJRMPh1ERXo!@=#bpPOQ^NyaC1if)gs3`Ikf#h~p@X>W^@Z=+Be5X=QkZbq*TDwHo zAa89l`e(fJ*knxvV2|=T#7dt&=SzZ$#aDZ99zML2o1Kl$OUdybk9M9pd*izWU7q>% z&(0m`N}G{dsM?Zrr>z9HqpcERgdg(2(0 z1=?C&=8Nj@3_ez2ZCLD4$5JCe&-Us_cBT*gJ+m3>k(KzixPN8NE9FINhXrxdD$`r8 zOkb7c(o}hA3KXWmsZ{G?T1=%c9OJY}4K0)I)oxn; zO(=f}4=!|aMR`q0cy;55;Jp5pD8C?4lvq`?=EZt_$rUI4`ZZ%NuwJF)`qkhocgE04 z3@mkHgW))+-^n|^gQF=Hwmq%sj-9(o6{i&he}iJIMf{_s%byRqw8dtOQv{}@bR5dsMdevUXBzyseHH5O z{~$1At2Ux4g*>-lqQNkYh^S($mGGMQ@ZHxrzdRjy$8tL74Gv~0sVD7!x5!Y@CJja( zr5|ex-MTQNDy7+tglTB7wN%%3OUhkTc9f{7qCb>RjtEvXALE+zo=Bp5dk&=rocI6! z%)J<{t~ow?(6O%|gJZ2!E7}baPNjOwtmzvS;idTJYp{_CeRo*=c8J*u)3w{UMMlUq1z6ZLt){X>ML{_Oi%^l}f=fYyR}6_U=B z)#q}S_=iw(f5c`X2Qb_ljvkH6IrZ{A5BfOs%a}E#FQTi6r*}L3-XA 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 0000000000000000000000000000000000000000..be44088b2a27f70a083c3b239352f8b3c3bb08c5 GIT binary patch literal 5430 zcmeI0_fJ(>5Xawc_7Ct+;HS<085;zo#28jZ6s2kadla$J6nGFRLE{Q5VnjtTDr!s= zdqavPkbq)PDF#uRyt{>)$!7c6X0!SEed||S`eIA>i>0$}tr6e!R^w7+lH}{xuQEJ5EWN$G!abKK`Sj_N zj-mbh`Lk@@x>Z6#LS)*sX|j3qW*He7k&hog>KLB867+`;AC?OjF39ZJvt{@0-LhxT z9*K^QmQ$xrX&)c%ggJKZ+$ouvnGzEdBeAiuvS`sFZPU`yWZSlF^6c3&cjnUE+^l0W zGBRZT{Q0tc`Ep53O&!(En>SA`UApAb9^12L&q{xPzr1+yLY_W-Dt&!@GBh+a+M;W0 zY?O-^FOKu^=+Prxr`NAvOI1~s6ciLlb#=8IJa|yfoH--e+1cuI`}Xb9($X@{{^7%i zvSGspdH?>sbar-1O-+s9^YZ1(a_7z+Ieq%HynOjmii?Xi24@HC_4)JXrK6)mZr!@2 z_QY!L>+9>KySrOXo;;~};cQQzz3=Jikp~YRNNa1WtX#QL7A{;Uw{PE;uC6YP>%)f+ zu1r<;<;xe%9XjM&d3m`M6%`43g#2)qhmN*pY>AbFx%NEGJ$2y0hjUIIIdViwN=jtk zzI{@TGe%AG?b%96j z#LV}9``=grQzz6Ubq$Wbefw5}=b3pv(YGNdOubp+1)_TPFVg{(Ald1_nw%K!5}X2di%8%$bVQtO59Jgn50bZ8M(M zF^2T?blJOiuUx-=UFYHaxOVNDV(87AH>IJWLGttSbuG%u$`ltlr-*wZbDKEW3-Er^ zrcLtX$rCwn;DFA7@8slU)pLFZ&K1^~mh(G4K3<}tq9i;#Tox=?pt0=VzhCv7%dXBuU*lmP&z(CbwY9bSuDs`x zB}*hBAwlBe;v^|4Nm5c$lnXHT>eZ_yG&EG$17bLF;)KsR=NuFH!rF5#!x<726ZMRR zd#qToLOBHIU`|eso`=LmZW7~`En9RgSi7S~kILZSptF0gecqk9d7q%5Ae{s5#XalI z8o*()va<9nCMV!!#Dk7;*sfZ&O7qa`gboAX0!>X#T61s=E_etv)z;RgeRTKl-&gKn z#_$YoLf#nN^XJdy@#Dur?)WOKAN=m>)vIa(7MQaV9>m(f#f**`qmLdeeDmgw#*Xp5 zd-r6`nl-X?>C#c0^cKgB8#gq6@7}$uLwTRl(o((8v^Xev1%JUW{@1Nrr#VO7kq2M} z918&x*#mfoCyuLwzyDmhaz%MExPvf1^-c`bH~Wh(@BmH=M`aDD6UGq-dxJ2B+T68k zm-c=6!4El4i#WeOYe0R#V-FoVB)Pe{ijD2E#DrP99Tle^=o7aBaSQ zOuqW+ll^Dict7?Ljvo;bq4iGA@owOY-ELQWnl)>dt{J!sPd|SAxZ)7GF%jYHU>$e| zzQ4c#@``)r2Zz96z6IdZ>^tKtD=U>_^X*~wZ6fm<8*<&8-(UgXGT;duglp~EwL-1J zO%TWN#>YfkSH8$S)*cMzVvoQjM{#B6_}rI!Pd@l&gBMUE?#@_G_TJ|AW=r0Isho@8 zwL9_V$CaHo8)_cx$;!6NALSL+`WGn{xblV0n?FZ8YSiSKqu$)J_jY@Z#eX8@cMFee zjvdn)h>eRGmjlLzmKc1gL;QOZuassertEquals('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 + +