commit 5ed6195cc119ae8fe19c3ded1e6561eb7c835f3d Author: uohlhv Date: Thu Sep 16 20:27:51 2021 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f7fafa --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/vendor +/views/cache/*.php +Config.php +sample.sql +tags diff --git a/.local.vimrc b/.local.vimrc new file mode 100644 index 0000000..decca52 --- /dev/null +++ b/.local.vimrc @@ -0,0 +1 @@ +setlocal wildignore-=Config.php diff --git a/Config.php.example b/Config.php.example new file mode 100644 index 0000000..00da1c7 --- /dev/null +++ b/Config.php.example @@ -0,0 +1,20 @@ + [ + 'name' => 'blog-app', + 'username' => 'root', + 'password' => '', + 'connection' => 'mysql:host=127.0.0.1', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION + ] + ], + 'mailer' => [ + 'host' => '', + 'username' => '', + 'password' => '', + 'from' => '' + ], + 'port' => '' // e.g. ':8080' +]; diff --git a/README.md b/README.md new file mode 100644 index 0000000..51fe838 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Environment + +- PHP (>= 7, with openssl support) +- MySQL (>= 5.7) +- Git (>= 2.17) + +# Configuration + +Create `Config.php` (copy from `Config.php.example`) file and enter Database & Mailer credentials. + +# Testing + +``` +$ php composer.phar install # install dependencies +$ php seed.php # seed random dummy data +$ php -S localhost:8080 public/index.php # start the server +``` + +- Each dummy user's password is `1234`. diff --git a/Routes.php b/Routes.php new file mode 100644 index 0000000..dd3d5a6 --- /dev/null +++ b/Routes.php @@ -0,0 +1,67 @@ +filter('auth', function () { + if (! isset($_SESSION['is_auth'])) { + header('Location: /user/login'); + return false; + } +}); + +$route->filter('csrf', function () { + if ($_POST['_token'] != $_SESSION['_token']) { + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } +}); + +$route->get('/404', function () { + return view('404'); +}); + +$route->get('/{page:i}?', ['Controllers\\Post', 'index']); + +$route->group(['prefix' => 'user'], function ($route) { + $route->controller('/password', 'Controllers\\Password'); + $route->get('/activate', ['Controllers\\User', 'activate']); + $route->get('/email/verify', ['Controllers\\User', 'verifyEmail'], ['before' => 'auth']); + $route->get('/login', ['Controllers\\User', 'showLogin']); + $route->get('/register', ['Controllers\\User', 'showCreate']); + $route->get('/settings', ['Controllers\\User', 'showSettings'], ['before' => 'auth']); + $route->get('/{name:[\w\d._]+}', ['Controllers\\User', 'show']); + $route->get('/{name:[\w\d._]+}/{page:i}?', ['Controllers\\User', 'show']); + + $route->group(['before' => 'csrf'], function ($route) { + $route->post('/delete', ['Controllers\\User', 'delete'], ['before' => 'auth']); + $route->post('/login', ['Controllers\\User', 'login']); + $route->post('/logout', ['Controllers\\User', 'logout'], ['before' => 'auth']); + $route->post('/password/create', ['Controllers\\Password', 'create']); + $route->post('/password/reset', ['Controllers\\Password', 'reset']); + $route->post('/register', ['Controllers\\User', 'create']); + $route->post('/settings', ['Controllers\\User', 'update'], ['before' => 'auth']); + }); +}); + +$route->group(['prefix' => 'post'], function ($route) { + $route->get('/create', ['Controllers\\Post', 'showCreate'], ['before' => 'auth']); + $route->get('/{id:i}', ['Controllers\\Post', 'show']); + $route->get('/{id:i}/edit', ['Controllers\\Post', 'showUpdate'], ['before' => 'auth']); + + $route->group(['before' => ['auth', 'csrf']], function ($route) { + $route->post('/create', ['Controllers\\Post', 'create']); + $route->post('/{id:i}/delete', ['Controllers\\Post', 'delete']); + $route->post('/{id:i}/edit', ['Controllers\\Post', 'update']); + }); +}); + +$route->group(['prefix' => 'comment', 'before' => ['auth', 'csrf']], function ($route) { + $route->post('/create', ['Controllers\Comment', 'create']); + $route->post('/{id:i}/delete', ['Controllers\Comment', 'delete']); + $route->post('/{id:i}/edit', ['Controllers\Comment', 'update']); +}); + +$route->get('/tag/{id:i}', ['Controllers\\Tag', 'show']); +$route->get('/tag/{id:i}/{page:i}?', ['Controllers\\Tag', 'show']); + +$route->get('/search/{keyword:[^/]+}', ['Controllers\\Search', 'show']); +$route->get('/search/{keyword:[^/]+}/{page:i}?', ['Controllers\\Search', 'show']); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..11e11f6 --- /dev/null +++ b/composer.json @@ -0,0 +1,19 @@ +{ + "autoload": { + "classmap": [ + "core" + ], + "files": [ + "core/Helpers.php" + ] + }, + "require": { + "erusev/parsedown": "^1.7", + "ezyang/htmlpurifier": "^4.10", + "fzaninotto/faker": "^1.7", + "illuminate/support": "^5.6", + "jenssegers/blade": "^1.1", + "phpmailer/phpmailer": "^6.1", + "phroute/phroute": "^2.1" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..8704398 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1191 @@ +{ + "_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": "9688ea4c926b21a3975da19996d4019b", + "packages": [ + { + "name": "doctrine/inflector", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "ec3a55242203ffa6a4b27c58176da97ff0a7aec1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/ec3a55242203ffa6a4b27c58176da97ff0a7aec1", + "reference": "ec3a55242203ffa6a4b27c58176da97ff0a7aec1", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^6.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\Inflector\\": "lib/Doctrine/Common/Inflector" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "Common String Manipulations with regard to casing and singular/plural rules.", + "homepage": "http://www.doctrine-project.org", + "keywords": [ + "inflection", + "pluralize", + "singularize", + "string" + ], + "time": "2019-10-30T19:59:35+00:00" + }, + { + "name": "erusev/parsedown", + "version": "1.7.4", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown.git", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "Parsedown": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Emanuil Rusev", + "email": "hello@erusev.com", + "homepage": "http://erusev.com" + } + ], + "description": "Parser for Markdown.", + "homepage": "http://parsedown.org", + "keywords": [ + "markdown", + "parser" + ], + "time": "2019-12-30T22:54:17+00:00" + }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.12.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "a617e55bc62a87eec73bd456d146d134ad716f03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/a617e55bc62a87eec73bd456d146d134ad716f03", + "reference": "a617e55bc62a87eec73bd456d146d134ad716f03", + "shasum": "" + }, + "require": { + "php": ">=5.2" + }, + "require-dev": { + "simpletest/simpletest": "dev-master#72de02a7b80c6bb8864ef9bf66d41d2f58f826bd" + }, + "type": "library", + "autoload": { + "psr-0": { + "HTMLPurifier": "library/" + }, + "files": [ + "library/HTMLPurifier.composer.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "time": "2019-10-28T03:44:26+00:00" + }, + { + "name": "fzaninotto/faker", + "version": "v1.9.1", + "source": { + "type": "git", + "url": "https://github.com/fzaninotto/Faker.git", + "reference": "fc10d778e4b84d5bd315dad194661e091d307c6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/fc10d778e4b84d5bd315dad194661e091d307c6f", + "reference": "fc10d778e4b84d5bd315dad194661e091d307c6f", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0" + }, + "require-dev": { + "ext-intl": "*", + "phpunit/phpunit": "^4.8.35 || ^5.7", + "squizlabs/php_codesniffer": "^2.9.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "time": "2019-12-12T13:22:17+00:00" + }, + { + "name": "illuminate/container", + "version": "v5.8.36", + "source": { + "type": "git", + "url": "https://github.com/illuminate/container.git", + "reference": "b42e5ef939144b77f78130918da0ce2d9ee16574" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/container/zipball/b42e5ef939144b77f78130918da0ce2d9ee16574", + "reference": "b42e5ef939144b77f78130918da0ce2d9ee16574", + "shasum": "" + }, + "require": { + "illuminate/contracts": "5.8.*", + "illuminate/support": "5.8.*", + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.8-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Container\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Container package.", + "homepage": "https://laravel.com", + "time": "2019-08-20T02:00:23+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v5.8.36", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "00fc6afee788fa07c311b0650ad276585f8aef96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/00fc6afee788fa07c311b0650ad276585f8aef96", + "reference": "00fc6afee788fa07c311b0650ad276585f8aef96", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/container": "^1.0", + "psr/simple-cache": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.8-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "time": "2019-07-30T13:57:21+00:00" + }, + { + "name": "illuminate/events", + "version": "v5.8.36", + "source": { + "type": "git", + "url": "https://github.com/illuminate/events.git", + "reference": "a85d7c273bc4e3357000c5fc4812374598515de3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/events/zipball/a85d7c273bc4e3357000c5fc4812374598515de3", + "reference": "a85d7c273bc4e3357000c5fc4812374598515de3", + "shasum": "" + }, + "require": { + "illuminate/container": "5.8.*", + "illuminate/contracts": "5.8.*", + "illuminate/support": "5.8.*", + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.8-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Events\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Events package.", + "homepage": "https://laravel.com", + "time": "2019-02-18T18:37:54+00:00" + }, + { + "name": "illuminate/filesystem", + "version": "v5.8.36", + "source": { + "type": "git", + "url": "https://github.com/illuminate/filesystem.git", + "reference": "494ba903402d64ec49c8d869ab61791db34b2288" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/filesystem/zipball/494ba903402d64ec49c8d869ab61791db34b2288", + "reference": "494ba903402d64ec49c8d869ab61791db34b2288", + "shasum": "" + }, + "require": { + "illuminate/contracts": "5.8.*", + "illuminate/support": "5.8.*", + "php": "^7.1.3", + "symfony/finder": "^4.2" + }, + "suggest": { + "league/flysystem": "Required to use the Flysystem local and FTP drivers (^1.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^1.0).", + "league/flysystem-cached-adapter": "Required to use the Flysystem cache (^1.0).", + "league/flysystem-rackspace": "Required to use the Flysystem Rackspace driver (^1.0).", + "league/flysystem-sftp": "Required to use the Flysystem SFTP driver (^1.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.8-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Filesystem\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Filesystem package.", + "homepage": "https://laravel.com", + "time": "2019-08-14T13:38:15+00:00" + }, + { + "name": "illuminate/support", + "version": "v5.8.36", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "df4af6a32908f1d89d74348624b57e3233eea247" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/df4af6a32908f1d89d74348624b57e3233eea247", + "reference": "df4af6a32908f1d89d74348624b57e3233eea247", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^1.1", + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/contracts": "5.8.*", + "nesbot/carbon": "^1.26.3 || ^2.0", + "php": "^7.1.3" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "suggest": { + "illuminate/filesystem": "Required to use the composer class (5.8.*).", + "moontoast/math": "Required to use ordered UUIDs (^1.1).", + "ramsey/uuid": "Required to use Str::uuid() (^3.7).", + "symfony/process": "Required to use the composer class (^4.2).", + "symfony/var-dumper": "Required to use the dd function (^4.2).", + "vlucas/phpdotenv": "Required to use the env helper (^3.3)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.8-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + }, + "files": [ + "helpers.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "time": "2019-12-12T14:16:47+00:00" + }, + { + "name": "illuminate/view", + "version": "v5.8.36", + "source": { + "type": "git", + "url": "https://github.com/illuminate/view.git", + "reference": "c859919bc3be97a3f114377d5d812f047b8ea90d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/view/zipball/c859919bc3be97a3f114377d5d812f047b8ea90d", + "reference": "c859919bc3be97a3f114377d5d812f047b8ea90d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/container": "5.8.*", + "illuminate/contracts": "5.8.*", + "illuminate/events": "5.8.*", + "illuminate/filesystem": "5.8.*", + "illuminate/support": "5.8.*", + "php": "^7.1.3", + "symfony/debug": "^4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.8-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\View\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate View package.", + "homepage": "https://laravel.com", + "time": "2019-06-20T13:13:59+00:00" + }, + { + "name": "jenssegers/blade", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/jenssegers/blade.git", + "reference": "d221b717b4302ad71476ebd05abf0c2b98cbd7d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jenssegers/blade/zipball/d221b717b4302ad71476ebd05abf0c2b98cbd7d8", + "reference": "d221b717b4302ad71476ebd05abf0c2b98cbd7d8", + "shasum": "" + }, + "require": { + "illuminate/view": "^5.5|^6.0|^7.0", + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0|^7.0", + "satooshi/php-coveralls": "^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jenssegers\\Blade\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jens Segers", + "homepage": "https://jenssegers.com" + } + ], + "description": "The standalone version of Laravel's Blade templating engine for use outside of Laravel.", + "keywords": [ + "blade", + "laravel", + "render", + "template", + "view" + ], + "time": "2020-03-17T15:00:55+00:00" + }, + { + "name": "nesbot/carbon", + "version": "2.32.2", + "source": { + "type": "git", + "url": "https://github.com/briannesbitt/Carbon.git", + "reference": "f10e22cf546704fab1db4ad4b9dedbc5c797a0dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/f10e22cf546704fab1db4ad4b9dedbc5c797a0dc", + "reference": "f10e22cf546704fab1db4ad4b9dedbc5c797a0dc", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.1.8 || ^8.0", + "symfony/translation": "^3.4 || ^4.0 || ^5.0" + }, + "require-dev": { + "doctrine/orm": "^2.7", + "friendsofphp/php-cs-fixer": "^2.14 || ^3.0", + "kylekatarnls/multi-tester": "^1.1", + "phpmd/phpmd": "^2.8", + "phpstan/phpstan": "^0.11", + "phpunit/phpunit": "^7.5 || ^8.0", + "squizlabs/php_codesniffer": "^3.4" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + }, + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "http://nesbot.com" + }, + { + "name": "kylekatarnls", + "homepage": "http://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "http://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "time": "2020-03-31T13:43:19+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.1.5", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "a8bf068f64a580302026e484ee29511f661b2ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/a8bf068f64a580302026e484ee29511f661b2ad3", + "reference": "a8bf068f64a580302026e484ee29511f661b2ad3", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "doctrine/annotations": "^1.2", + "friendsofphp/php-cs-fixer": "^2.2", + "phpunit/phpunit": "^4.8 || ^5.7" + }, + "suggest": { + "ext-mbstring": "Needed to send email in multibyte encoding charset", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "stevenmaguire/oauth2-microsoft": "Needed for Microsoft XOAUTH2 authentication", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)" + }, + "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", + "time": "2020-03-14T14:23:48+00:00" + }, + { + "name": "phroute/phroute", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/mrjgreen/phroute.git", + "reference": "dbe2b986f9ee1dd33dc956fcc35d1fa22e8e196c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mrjgreen/phroute/zipball/dbe2b986f9ee1dd33dc956fcc35d1fa22e8e196c", + "reference": "dbe2b986f9ee1dd33dc956fcc35d1fa22e8e196c", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "*", + "satooshi/php-coveralls": "dev-master" + }, + "type": "library", + "autoload": { + "psr-4": { + "Phroute\\Phroute\\": "src/Phroute" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Joe Green", + "email": "joe.green.0991@gmail.com" + } + ], + "description": "Fast, fully featured restful request router for PHP", + "keywords": [ + "router", + "routing" + ], + "time": "2015-07-22T20:46:43+00:00" + }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "time": "2017-02-14T16:28:37+00:00" + }, + { + "name": "psr/log", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "time": "2020-03-23T09:12:05+00:00" + }, + { + "name": "psr/simple-cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "time": "2017-10-23T01:57:42+00:00" + }, + { + "name": "symfony/debug", + "version": "v4.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug.git", + "reference": "346636d2cae417992ecfd761979b2ab98b339a45" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug/zipball/346636d2cae417992ecfd761979b2ab98b339a45", + "reference": "346636d2cae417992ecfd761979b2ab98b339a45", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/log": "~1.0" + }, + "conflict": { + "symfony/http-kernel": "<3.4" + }, + "require-dev": { + "symfony/http-kernel": "^3.4|^4.0|^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Debug\\": "" + }, + "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 Debug Component", + "homepage": "https://symfony.com", + "time": "2020-03-27T16:54:36+00:00" + }, + { + "name": "symfony/finder", + "version": "v4.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "5729f943f9854c5781984ed4907bbb817735776b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/5729f943f9854c5781984ed4907bbb817735776b", + "reference": "5729f943f9854c5781984ed4907bbb817735776b", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "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": "Symfony Finder Component", + "homepage": "https://symfony.com", + "time": "2020-03-27T16:54:36+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.15.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/81ffd3a9c6d707be22e3012b827de1c9775fc5ac", + "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.15-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2020-03-09T19:04:49+00:00" + }, + { + "name": "symfony/translation", + "version": "v5.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "99b831770e10807dca0979518e2c89edffef5978" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/99b831770e10807dca0979518e2c89edffef5978", + "reference": "99b831770e10807dca0979518e2c89edffef5978", + "shasum": "" + }, + "require": { + "php": "^7.2.5", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2" + }, + "conflict": { + "symfony/config": "<4.4", + "symfony/dependency-injection": "<5.0", + "symfony/http-kernel": "<5.0", + "symfony/twig-bundle": "<5.0", + "symfony/yaml": "<4.4" + }, + "provide": { + "symfony/translation-implementation": "2.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "^4.4|^5.0", + "symfony/console": "^4.4|^5.0", + "symfony/dependency-injection": "^5.0", + "symfony/finder": "^4.4|^5.0", + "symfony/http-kernel": "^5.0", + "symfony/intl": "^4.4|^5.0", + "symfony/service-contracts": "^1.1.2|^2", + "symfony/yaml": "^4.4|^5.0" + }, + "suggest": { + "psr/log-implementation": "To use logging capability in translator", + "symfony/config": "", + "symfony/yaml": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "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 Translation Component", + "homepage": "https://symfony.com", + "time": "2020-03-27T16:56:45+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "8cc682ac458d75557203b2f2f14b0b92e1c744ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/8cc682ac458d75557203b2f2f14b0b92e1c744ed", + "reference": "8cc682ac458d75557203b2f2f14b0b92e1c744ed", + "shasum": "" + }, + "require": { + "php": "^7.2.5" + }, + "suggest": { + "symfony/translation-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-11-18T17:27:11+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/composer.phar b/composer.phar new file mode 100644 index 0000000..4055d87 Binary files /dev/null and b/composer.phar differ diff --git a/core/Bootstrap.php b/core/Bootstrap.php new file mode 100644 index 0000000..32f9b86 --- /dev/null +++ b/core/Bootstrap.php @@ -0,0 +1,35 @@ +getData()); + +try { + $response = $dispatcher->dispatch( + $_SERVER['REQUEST_METHOD'], + parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) + ); + echo $response; +} catch (HttpRouteNotFoundException $e) { + header('Location: /404'); + die(); +} catch (HttpMethodNotAllowedException $e) { + header('HTTP/1.0 403 Forbidden'); + die(); +} diff --git a/core/Helpers.php b/core/Helpers.php new file mode 100644 index 0000000..962efa3 --- /dev/null +++ b/core/Helpers.php @@ -0,0 +1,118 @@ +get('database')) + ); + } +} + +if (! function_exists('view')) { + function view($name, $data = []) + { + if (isset($_SESSION['errors'])) { + $data['errors'] = $_SESSION['errors']; + unset($_SESSION['errors']); + } + if (isset($_SESSION['comment_errors'])) { + $data['comment_errors'] = $_SESSION['comment_errors']; + unset($_SESSION['comment_errors']); + } + if (isset($_SESSION['messages'])) { + $data['messages'] = $_SESSION['messages']; + unset($_SESSION['messages']); + } + if (isset($_SESSION['inputs'])) { + $inputs = $_SESSION['inputs']; + foreach ($inputs as $key => $value) { + $data[$key] = $value; + } + unset($_SESSION['inputs']); + } + $blade = new Jenssegers\Blade\Blade('views', 'views/cache'); + $blade->compiler()->directive('markdown', function ($expression) { + return ""; + }); + return $blade->make($name)->with($data); + } +} + +if (! function_exists('markdown')) { + function markdown($content) + { + $parsedown = new \Parsedown; + $html = $parsedown->text($content); + $config = \HTMLPurifier_Config::createDefault(); + $purifier = new \HTMLPurifier($config); + return $purifier->purify($html); + } +} + +if (! function_exists('valid_username')) { + function valid_username($username) + { + $sql = 'SELECT * FROM users WHERE name=?'; + $query = db()->prepare($sql); + $query->execute([$username]); + $user = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + return strlen($username) <= 32 && + preg_match('/^[\w\d._]+$/', $username) && + ! count($user); + } +} + +if (! function_exists('valid_email')) { + function valid_email($email) + { + $sql = 'SELECT * FROM users WHERE email=?'; + $query = db()->prepare($sql); + $query->execute([$email]); + $user = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + return filter_var($email, FILTER_VALIDATE_EMAIL) && + ! count($user); + } +} + +if (! function_exists('valid_password')) { + function valid_password($password1, $password2) + { + return preg_match('/^[!-~]+$/', $password1) && + $password1 == $password2; + } +} + +if (! function_exists('send_mail')) { + function send_mail($options) + { + $config = new Core\Config; + $mail = new PHPMailer\PHPMailer\PHPMailer(true); + + $mail->SMTPDebug = 0; + $mail->isSMTP(); + $mail->Host = $config->get('mailer')['host']; + $mail->SMTPAuth = true; + $mail->Username = $config->get('mailer')['username']; + $mail->Password = $config->get('mailer')['password']; + $mail->SMTPSecure = 'tls'; + $mail->Port = 465; + + $mail->setFrom($config->get('mailer')['from']); + $mail->addAddress($options['email']); + + $mail->isHTML(true); + $mail->Subject = $options['title']; + $mail->Body = $options['body']; + $mail->send(); + } +} + +if (! function_exists('port')) { + function port() + { + $config = new Core\Config; + return $config->get('port'); + } +} diff --git a/core/Record.php b/core/Record.php new file mode 100644 index 0000000..dbc03e8 --- /dev/null +++ b/core/Record.php @@ -0,0 +1,7 @@ +commentValid()) { + header('Location: /post/' . $_POST['post_id']); + return false; + } + $sql = 'INSERT INTO comments VALUES (NULL, ?, ?, DEFAULT, DEFAULT, ?)'; + $query = db()->prepare($sql); + $query->execute([ + trim($_POST['content']), + $_SESSION['username'], + $_POST['post_id'] + ]); + header("Location: /post/{$_POST['post_id']}"); + return false; + } + + public function update($id) + { + if (! $this->isCommentAuthor($id)) { + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } + if (! $this->commentValid()) { + header('Location: /post/' . $_POST['post_id']); + return false; + } + $sql = 'UPDATE comments SET content=? WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([trim($_POST['content']), $id]); + header("Location: /post/{$_POST['post_id']}"); + return false; + } + + public function delete($id) + { + if (! $this->isAuthor($id)) { + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } + $sql = 'DELETE FROM comments WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([$id]); + header("Location: /post/{$_POST['post_id']}"); + return false; + } + + private function isCommentAuthor($id) + { + $sql = 'SELECT author FROM comments WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([$id]); + $comment = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + return (count($comment) != 0 && + $comment[0]->author == $_SESSION['username']); + } + + private function isAuthor($id) + { + $sql = 'SELECT author FROM posts WHERE id IN ( ' . + 'SELECT comment_to FROM comments WHERE id=? )'; + $query = db()->prepare($sql); + $query->execute([$id]); + $post = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + return (count($post) != 0 && + $post[0]->author == $_SESSION['username']); + } + + private function commentValid() + { + if (empty(trim($_POST['content']))) { + $_SESSION['comment_errors'] = ['Comment cannot be empty.']; + return false; + } + return true; + } +} diff --git a/core/controllers/Password.php b/core/controllers/Password.php new file mode 100644 index 0000000..d3c22c2 --- /dev/null +++ b/core/controllers/Password.php @@ -0,0 +1,105 @@ +hasToken() && $this->tokenValid()) { + return view('password.create'); + } + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } + + public function reset() + { + if ($id = $this->getUserId()) { + $db = db(); + $forget_token = \Illuminate\Support\Str::random(40); + $sql = 'INSERT INTO forget_tokens VALUES (:id, :token)'; + $query = $db->prepare($sql); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->bindValue(':token', $forget_token); + $query->execute(); + send_mail([ + 'email' => $_POST['email'], + 'title' => '[Blog App] A Password Reset Requested!', + 'body' => 'Click here to confirm that ' . + 'you really want to reset your password.' + ]); + $sql = 'SET global event_scheduler = 1;' . + 'DROP EVENT IF EXISTS clear_forget_token_:id;' . + 'CREATE EVENT clear_forget_token_:id ' . + 'ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 HOUR ' . + 'DO DELETE FROM forget_tokens WHERE id=:id'; + $query = $db->prepare($sql); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->execute(); + $message = 'Check your email inbox. We have sent you a confirmation mail.'; + return view('redirect', ['message' => $message]); + } + $_SESSION['errors'] = ['This E-mail is not registered or is already requested.']; + header('Location: /user/password/reset'); + return false; + } + + public function create() + { + if (! valid_password($_POST['password'], $_POST['confirm-password'])) { + $_SESSION['errors'] = ['Invalid password.']; + header("Location: /user/password/create?id={$_POST['id']}&forget_token={$_POST['forget_token']}"); + return false; + } + $sql = 'UPDATE users SET password=? WHERE id=?'; + $db = db(); + $query = $db->prepare($sql); + $query->execute([ + password_hash($_POST['password'], PASSWORD_DEFAULT), + $_POST['id'] + ]); + $sql = 'DELETE FROM forget_tokens WHERE id=?'; + $db = db(); + $query = $db->prepare($sql); + $query->execute([$_POST['id']]); + header('Location: /user/login'); + return false; + } + + private function hasToken() + { + return (isset($_GET['id']) && + isset($_GET['forget_token'])); + } + + private function tokenValid() + { + $sql = 'SELECT * FROM (users AS u JOIN ' . + 'forget_tokens AS t ON u.id=t.id) WHERE u.id=? AND token=?'; + $query = db()->prepare($sql); + $query->execute([$_GET['id'], $_GET['forget_token']]); + $user = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + return (count($user) != 0); + } + + private function getUserId() + { + $sql = 'SELECT u.id FROM users AS u WHERE email=? AND NOT EXISTS (' . + 'SELECT * FROM forget_tokens AS t WHERE u.id=t.id)'; + $query = db()->prepare($sql); + $query->execute([$_POST['email']]); + $user = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + if (count($user) != 0) { + return $user[0]->id; + } + return null; + } +} diff --git a/core/controllers/Post.php b/core/controllers/Post.php new file mode 100644 index 0000000..a66fbd9 --- /dev/null +++ b/core/controllers/Post.php @@ -0,0 +1,280 @@ += 1) ? ($page - 1) : 0; + $offset *= 10; + $sql = 'SELECT * FROM posts ORDER BY create_at DESC LIMIT ?,10'; + $query = $db->prepare($sql); + $query->bindValue(1, $offset, \PDO::PARAM_INT); + $query->execute(); + $posts = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $offset += 10; + $query->bindValue(1, $offset, \PDO::PARAM_INT); + $query->execute(); + $next = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $sql = 'SELECT id, keyword, COUNT(id) AS num_of_posts FROM ('. + 'tags JOIN post_tag ON id=tag_id) GROUP BY id ORDER BY keyword'; + $query = $db->prepare($sql); + $query->execute(); + $tags = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $is_last = count($next) == 0 ? true : false; + return view('post.index', [ + 'posts' => $posts, + 'tags' => $tags, + 'page' => $page, + 'is_last' => $is_last, + 'pager_uri' => '' + ]); + } + + public function showCreate() + { + return view('post.create', [ + 'title' => '', + 'content' => '', + 'tags' => '' + ]); + } + + public function show($id) + { + $post = $this->getPost($id); + if ($post != null) { + return view('post.show', [ + 'post' => $post, + 'tags' => $this->getTags($id), + 'comments' => $this->getComments($id) + ]); + } + throw new \Phroute\Phroute\Exception\HttpRouteNotFoundException; + } + + public function showUpdate($id) + { + if (! $this->isAuthor($id)) { + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } + if ($post = $this->getPostWithTags($id)) { + return view('post.edit', ['post' => $post]); + } + throw new \Phroute\Phroute\Exception\HttpRouteNotFoundException; + } + + public function create() + { + if (! $this->titleValid()) { + header('Location: /post/create'); + return false; + } + $id = $this->setPost(); + $tags = array_values(array_filter( + array_map('trim', explode(',', $_POST['tags'])) + )); + $this->setTags($tags); + $this->syncPostTag($id, $tags); + header("Location: /post/$id"); + return false; + } + + public function update($id) + { + if (! $this->isAuthor($id)) { + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } + if (! $this->titleValid()) { + header("Location: /post/$id/edit"); + return false; + } + $this->updatePost($id); + $tags = array_values(array_filter( + array_map('trim', explode(',', $_POST['tags'])) + )); + $this->setTags($tags); + $this->syncPostTag($id, $tags); + header("Location: /post/$id"); + return false; + } + + public function delete($id) + { + if (! $this->isAuthor($id)) { + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } + $sql = 'DELETE FROM posts WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([$id]); + $path = parse_url($_SERVER['HTTP_REFERER'])['path']; + if (preg_match('/^\/post\//', $path)) { + header('Location: /'); + return false; + } + header("Location: $path"); + return false; + } + + private function isAuthor($id) + { + $sql = 'SELECT author FROM posts WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([$id]); + $post = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + return (count($post) != 0 && + $post[0]->author == $_SESSION['username']); + } + + private function titleValid() + { + if (empty(trim($_POST['title']))) { + $_SESSION['errors'] = ['Post title cannot be empty.']; + $_SESSION['inputs'] = [ + 'title' => $_POST['title'], + 'content' => $_POST['content'], + 'tags' => $_POST['tags'] + ]; + return false; + } + return true; + } + + private function getPost($id) + { + $sql = 'SELECT p.*, COUNT(c.id) AS num_of_comments FROM posts AS p ' . + 'LEFT JOIN comments AS c ON p.id=c.comment_to WHERE p.id=:id'; + $query = db()->prepare($sql); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->execute(); + $post = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + if (count($post) != 0) { + return $post[0]; + } + return null; + } + + private function getTags($id) + { + $sql = 'SELECT * FROM tags WHERE id IN ( ' . + "SELECT tag_id FROM post_tag WHERE post_id=? ) ORDER BY keyword"; + $query = db()->prepare($sql); + $query->execute([$id]); + return $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + } + + private function getComments($id) + { + $sql = 'SELECT c.*, u.email FROM comments AS c, users AS u ' . + 'WHERE c.comment_to=? AND c.author=u.name ORDER BY c.create_at'; + $query = db()->prepare($sql); + $query->execute([$id]); + return $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + } + + private function getPostWithTags($id) + { + $sql = "SELECT p.*, GROUP_CONCAT(DISTINCT t.keyword SEPARATOR ', ') AS tags " . + 'FROM posts AS p, tags AS t WHERE p.id=:id AND t.id IN ( ' . + 'SELECT tag_id FROM post_tag WHERE post_id=:id ) ORDER BY t.keyword'; + $query = db()->prepare($sql); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->execute(); + $post = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + if (count($post) != 0) { + return $post[0]; + } + return null; + } + + private function setPost() + { + $db = db(); + $sql = 'INSERT INTO posts VALUES (NULL, ?, ?, ?, DEFAULT, DEFAULT)'; + $query = $db->prepare($sql); + $query->execute([ + trim($_POST['title']), + trim($_POST['content']), + $_SESSION['username'] + ]); + return $db->lastInsertId(); + } + + private function updatePost($id) + { + $sql = 'UPDATE posts SET title=?, content=? WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([ + trim($_POST['title']), + trim($_POST['content']), + $id + ]); + } + + private function setTags($tags) + { + if (count($tags) != 0) { + $rows = []; + foreach ($tags as $tag) { + array_push($rows, "(NULL, ?)"); + } + $sql = 'INSERT INTO tags VALUES ' . implode(', ', $rows) . + ' ON DUPLICATE KEY UPDATE keyword=keyword'; + $query = db()->prepare($sql); + $query->execute($tags); + } + } + + private function syncPostTag($post_id, $tags) + { + $db = db(); + if (count($tags) != 0) { + $tuple_holders = []; + $element_holders = []; + $rows = []; + $tag_ids = $this->tagsToIds($tags); + foreach ($tag_ids as $tag_id) { + array_push($tuple_holders, '(?, ?)'); + array_push($element_holders, '?'); + array_push($rows, $post_id, $tag_id); + } + $sql = 'INSERT INTO post_tag VALUES ' . implode(', ', $tuple_holders) . + ' ON DUPLICATE KEY UPDATE post_id=post_id'; + $query = $db->prepare($sql); + $query->execute($rows); + $sql = 'DELETE FROM post_tag WHERE post_id=? AND tag_id NOT IN ' . + '(' . implode(', ', $element_holders) . ')'; + $query = $db->prepare($sql); + $query->bindValue(1, $post_id, \PDO::PARAM_INT); + foreach ($tag_ids as $index => $tag_id) { + $query->bindValue($index + 2, $tag_id, \PDO::PARAM_INT); + } + $query->execute(); + } else { + $sql = 'DELETE FROM post_tag WHERE post_id=?'; + $query = $db->prepare($sql); + $query->execute([$post_id]); + } + } + + private function tagsToIds($tags) + { + $element_holders = []; + foreach ($tags as $tag) { + array_push($element_holders, '?'); + } + $sql = 'SELECT DISTINCT id FROM tags WHERE keyword IN ' . + '(' . implode(', ', $element_holders) . ') ORDER BY id'; + $query = db()->prepare($sql); + $query->execute($tags); + return $query->fetchAll(\PDO::FETCH_COLUMN, 0); + } +} diff --git a/core/controllers/Search.php b/core/controllers/Search.php new file mode 100644 index 0000000..4c5ecca --- /dev/null +++ b/core/controllers/Search.php @@ -0,0 +1,39 @@ += 1) ? ($page - 1) : 0; + $offset *= 10; + $sql = 'SELECT * FROM posts WHERE title LIKE ? ' . + 'ORDER BY create_at DESC LIMIT ?,10'; + $query = $db->prepare($sql); + $query->bindValue(1, '%' . preg_quote($keyword) . '%'); + $query->bindValue(2, $offset, \PDO::PARAM_INT); + $query->execute(); + $posts = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $offset += 10; + $query->bindValue(2, $offset, \PDO::PARAM_INT); + $query->execute(); + $next = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $sql = 'SELECT id, keyword, COUNT(id) AS num_of_posts FROM (' . + 'tags JOIN post_tag ON id=tag_id) GROUP BY id ORDER BY keyword'; + $query = $db->prepare($sql); + $query->execute(); + $tags = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $is_last = count($next) == 0 ? true : false; + return view('post.index', [ + 'keyword' => $keyword, + 'posts' => $posts, + 'tags' => $tags, + 'page' => $page, + 'is_last' => $is_last, + 'pager_uri' => preg_replace('/\/\d+$/', '', $_SERVER['REQUEST_URI']) + ]); + } +} diff --git a/core/controllers/Tag.php b/core/controllers/Tag.php new file mode 100644 index 0000000..a5d515c --- /dev/null +++ b/core/controllers/Tag.php @@ -0,0 +1,41 @@ += 1) ? ($page - 1) : 0; + $offset *= 10; + $sql = 'SELECT * FROM posts WHERE id IN (' . + 'SELECT post_id FROM post_tag WHERE tag_id=? ) ORDER BY create_at DESC LIMIT ?,10'; + $query = $db->prepare($sql); + $query->bindValue(1, $id, \PDO::PARAM_INT); + $query->bindValue(2, $offset, \PDO::PARAM_INT); + $query->execute(); + $posts = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $offset += 10; + $query->bindValue(2, $offset, \PDO::PARAM_INT); + $query->execute(); + $next = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $sql = 'SELECT id, keyword, COUNT(id) AS num_of_posts FROM (' . + 'tags JOIN post_tag ON id=tag_id) GROUP BY id ORDER BY keyword'; + $query = $db->prepare($sql); + $query->execute(); + $tags = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $is_last = count($next) == 0 ? true : false; + if (count($posts) != 0) { + return view('tag.index', [ + 'posts' => $posts, + 'tags' => $tags, + 'page' => $page, + 'is_last' => $is_last, + 'pager_uri' => preg_replace('/(\/\d+)(\/\d+)$/', '$1', $_SERVER['REQUEST_URI']) + ]); + } + header('Location: /'); + return false; + } +} diff --git a/core/controllers/User.php b/core/controllers/User.php new file mode 100644 index 0000000..f2115a6 --- /dev/null +++ b/core/controllers/User.php @@ -0,0 +1,330 @@ +hasToken()) { + header('Location: /'); + return false; + } + if ($id = $this->getUserId()) { + $sql = 'DELETE FROM active_tokens WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([$id]); + $messages = ['Your account has been activated.']; + return view('user.login', ['messages' => $messages]); + } + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } + + public function showCreate() + { + return view('user.register'); + } + + public function showLogin() + { + return view('user.login'); + } + + public function showSettings() + { + return view('user.settings'); + } + + public function show($name, $page = '1') + { + $db = db(); + $offset = ($page >= 1) ? ($page - 1) : 0; + $offset *= 10; + $sql = 'SELECT * FROM posts WHERE author IN ( ' . + 'SELECT name FROM users WHERE name=? ) ' . + 'ORDER BY create_at DESC LIMIT ?,10'; + $query = $db->prepare($sql); + $query->bindValue(1, $name); + $query->bindValue(2, $offset, \PDO::PARAM_INT); + $query->execute(); + $posts = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $offset += 10; + $query->bindValue(2, $offset, \PDO::PARAM_INT); + $query->execute(); + $next = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $sql = 'SELECT id, keyword, COUNT(id) AS num_of_posts FROM (' . + 'tags JOIN post_tag ON id=tag_id) GROUP BY id ORDER BY keyword'; + $query = $db->prepare($sql); + $query->execute(); + $tags = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + $is_last = count($next) == 0 ? true : false; + return view('post.index', [ + 'posts' => $posts, + 'tags' => $tags, + 'page' => $page, + 'is_last' => $is_last, + 'pager_uri' => preg_replace('/\/\d+$/', '', $_SERVER['REQUEST_URI']) + ]); + } + + public function create() + { + if (! $this->validator()) { + header('Location: /user/register'); + return false; + } + $active_token = \Illuminate\Support\Str::random(40); + $id = $this->setUser($active_token); + send_mail([ + 'email' => $_POST['email'], + 'title' => '[Blog App] Active Your Account!', + 'body' => 'Click here to activate.' + ]); + $sql = 'SET global event_scheduler = 1;' . + 'DROP EVENT IF EXISTS clear_unactive_user_:id;' . + 'CREATE EVENT clear_unactive_user_:id ' . + 'ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 HOUR ' . + 'DO DELETE FROM users WHERE id=:id AND EXISTS (' . + 'SELECT * FROM active_tokens WHERE id=:id)'; + $query = db()->prepare($sql); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->execute(); + $message = 'Check your email inbox. We\'ve sent you an activation mail.'; + return view('redirect', ['message' => $message]); + } + + public function update() + { + $validation = []; + $usernameGiven = ($_POST['username'] != $_SESSION['username']); + $passwordGiven = (! empty(trim($_POST['password']))); + $emailGiven = ($_POST['email'] != $_SESSION['email']); + if ($usernameGiven) { + $validation['username'] = valid_username($_POST['username']); + } + if ($passwordGiven) { + $validation['password'] = valid_password( + $_POST['password'], + $_POST['confirm-password'] + ); + } + if ($emailGiven) { + $validation['email'] = valid_email($_POST['email']); + } + if (count($validation) != 0 && ! $this->validator($validation)) { + header('Location: /user/settings'); + return false; + } + return $this->updateUser($usernameGiven, $passwordGiven, $emailGiven); + } + + public function delete() + { + $sql = 'DELETE FROM users WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([$_SESSION['id']]); + return $this->logout(); + } + + public function verifyEmail() + { + $db = db(); + $sql = 'SELECT token FROM verify_tokens WHERE id=?'; + $query = $db->prepare($sql); + $query->execute([$_GET['id']]); + $verify_token = $query->fetchAll(\PDO::FETCH_COLUMN, 0); + if (count($verify_token) != 0) { + $verify_token = $verify_token[0]; + if ($verify_token == $_GET['verify_token']) { + $sql = 'UPDATE users SET email=? WHERE id=?'; + $query = $db->prepare($sql); + $query->execute([$_GET['email'], $_GET['id']]); + $sql = 'DELETE FROM verify_tokens WHERE id=?'; + $query = $db->prepare($sql); + $query->execute([$_GET['id']]); + $_SESSION['messages'] = ['E-mail updated.']; + $_SESSION['email'] = $_GET['email']; + header('Location: /user/settings'); + return false; + } + } + header('HTTP/1.0 403 Forbidden'); + die(); + return false; + } + + public function login() + { + $sql = 'SELECT u.id, u.name, u.email, u.password ' . + 'FROM users AS u WHERE name=? AND NOT EXISTS (' . + 'SELECT * FROM active_tokens AS t WHERE u.id=t.id)'; + $query = db()->prepare($sql); + $query->execute([$_POST['username']]); + $user = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + if (count($user) != 0 && + password_verify($_POST['password'], trim($user[0]->password)) + ) { + $_SESSION['is_auth'] = true; + $_SESSION['id'] = $user[0]->id; + $_SESSION['username'] = $user[0]->name; + $_SESSION['email'] = $user[0]->email; + header('Location: /'); + return false; + } + $_SESSION['errors'] = ['Incorrect username or password.']; + $_SESSION['inputs'] = [ + 'username' => $_POST['username'] + ]; + header('Location: /user/login'); + return false; + } + + public function logout() + { + session_unset(); + session_destroy(); + header('Location: /'); + return false; + } + + private function hasToken() + { + if (isset($_GET['id']) && + isset($_GET['active_token']) + ) { + return true; + } + return false; + } + + private function getUserId() + { + $sql = 'SELECT u.id FROM (users AS u ' . + 'JOIN active_tokens AS t ON u.id=t.id) ' . + 'WHERE u.id=? AND t.token=?'; + $query = db()->prepare($sql); + $query->execute([$_GET['id'], $_GET['active_token']]); + $user = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + if (count($user) != 0) { + return $user[0]->id; + } + return null; + } + + private function validator($validation = []) + { + if (count($validation) == 0) { + $validation = [ + 'username' => valid_username($_POST['username']), + 'email' => valid_email($_POST['email']), + 'password' => valid_password( + $_POST['password'], + $_POST['confirm-password'] + ) + ]; + } + $errors = []; + foreach ($validation as $input => $result) { + if (! $result) { + array_push($errors, "Invalid $input."); + } + } + if (count($errors) != 0) { + $_SESSION['errors'] = $errors; + if (isset($_POST['username'])) { + $_SESSION['inputs'] = [ + 'username' => $_POST['username'], + 'email' => $_POST['email'] + ]; + } + return false; + } + return true; + } + + private function setUser($active_token) + { + $sql = 'INSERT INTO users VALUES (NULL, ?, ?, ?)'; + $db = db(); + $query = $db->prepare($sql); + $query->execute([ + $_POST['username'], + $_POST['email'], + password_hash($_POST['password'], PASSWORD_DEFAULT) + ]); + $id = $db->lastInsertId(); + $sql = 'INSERT INTO active_tokens VALUES (:id, :token)'; + $query = $db->prepare($sql); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->bindValue(':token', $active_token); + $query->execute(); + return $id; + } + + private function updateUser($usernameGiven, $passwordGiven, $emailGiven) + { + $db = db(); + if ($usernameGiven) { + $sql = 'UPDATE users SET name=? WHERE id=?'; + $query = $db->prepare($sql); + $query->execute([$_POST['username'], $_POST['id']]); + $_SESSION['messages'][] = 'Username updated.'; + $_SESSION['username'] = $_POST['username']; + } + if ($passwordGiven) { + $sql = 'UPDATE users SET password=? WHERE id=?'; + $query = $db->prepare($sql); + $query->execute([ + password_hash($_POST['password'], PASSWORD_DEFAULT), + $_POST['id'] + ]); + $_SESSION['messages'][] = 'Password updated.'; + } + if ($emailGiven) { + if (! $this->requested($_POST['id'])) { + $verify_token = \Illuminate\Support\Str::random(40); + $sql = 'INSERT INTO verify_tokens VALUES (:id, :token)'; + $query = $db->prepare($sql); + $query->bindValue(':id', $_POST['id'], \PDO::PARAM_INT); + $query->bindValue(':token', $verify_token); + $query->execute(); + send_mail([ + 'email' => $_POST['email'], + 'title' => '[Blog App] Verify Your E-mail!', + 'body' => 'Click here to verify.' + ]); + $sql = 'SET global event_scheduler = 1;' . + 'DROP EVENT IF EXISTS clear_unverify_email_:id;' . + 'CREATE EVENT clear_unverify_email_:id ' . + 'ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 HOUR ' . + 'DO DELETE FROM verify_tokens WHERE id=:id'; + $query = $db->prepare($sql); + $query->bindValue(':id', $_POST['id'], \PDO::PARAM_INT); + $query->execute(); + } + $_SESSION['messages'][] = 'Check your email inbox. ' . + 'We\'ve sent you an verification mail ' . + 'for email updating request.'; + } + header('Location: /user/settings'); + return false; + } + + private function requested($id) + { + $sql = 'SELECT * FROM verify_tokens WHERE id=?'; + $query = db()->prepare($sql); + $query->execute([$id]); + $verify_token = $query->fetchAll(\PDO::FETCH_CLASS, 'Core\\Record'); + if (count($verify_token) != 0) { + return true; + } + return false; + } +} diff --git a/core/database/Connection.php b/core/database/Connection.php new file mode 100644 index 0000000..b5aa67d --- /dev/null +++ b/core/database/Connection.php @@ -0,0 +1,20 @@ +getMessage()); + } + } +} diff --git a/core/database/QueryBuilder.php b/core/database/QueryBuilder.php new file mode 100644 index 0000000..1b17e5a --- /dev/null +++ b/core/database/QueryBuilder.php @@ -0,0 +1,25 @@ +pdo = $pdo; + } + + public function prepare($query) + { + return $this->pdo->prepare($query); + } + + public function lastInsertId() + { + return $this->pdo->lastInsertId(); + } +} diff --git a/public/css/main.css b/public/css/main.css new file mode 100644 index 0000000..b1a27a3 --- /dev/null +++ b/public/css/main.css @@ -0,0 +1,178 @@ +a { + color: #2a7ae2; +} + +a:visited { + color: #1756a9; +} + +a:hover { + color: #111; +} + +.header { + margin-bottom: 2em; + border-top: 5px solid #424242; + border-bottom: 1px solid #e0e0e0; + background-color: #efefef; +} + +.header h4 { + display: inline-block; + margin: 1em 0; +} + +.header form, +.header .login, +.header .post-create, +.header .register, +.header .search { + display: inline-block; + margin: 1.3em 0.25em 0; +} + +.header .search { + width: 250px; +} + +.header a.login:visited { + color: #212529; +} + +.header a.post-create:visited, +.header a.register:visited { + color: #fff; +} + +.pager li > a { + display: inline-block; + padding: 5px 14px; + border: 1px solid #ddd; + border-radius: 15px; + background-color: #fff; +} + +.pager li > span { + display: inline-block; + padding: 5px 14px; + border: 1px solid #ddd; + border-radius: 15px; + background-color: #fff; +} + +.pager .next > a, +.pager .next > span { + float: right; +} + +.pager .previous > a, +.pager .previous > span { + float: left; +} + +.header a:hover, +.sidebar a.user-info:hover { + text-decoration: none; +} + +.pager li > a:hover, +.pager li > a:focus { + text-decoration: none; + background-color: #eee; +} + +ul.errors, +ul.messages { + padding-left: 3em; +} + +.posts .title { + margin-bottom: 25px; +} + +.posts .title .match { + background-color: #ff0; +} + +button.preview-post-btn { + margin-right: 10px; +} + +.pager { + margin: 20px 0; + padding-left: 0; + list-style: none; + text-align: center; +} + +.pager li { + display: inline; +} + +.clearfix::before, +.clearfix::after, +.pager::before, +.pager::after { + display: table; + content: " "; +} + +.clearfix::after, +.pager::after { + clear: both; +} + +article { + margin-top: 1em; +} + +.comments { + padding-top: 25px; + border-top: 2px solid #f2f2f2; +} + +.comments h3 { + margin-bottom: 25px; +} + +.comments p { + margin: 25px; +} + +.comments .comment-form { + margin-bottom: 50px; +} + +.comments .avatar { + float: left; + margin-right: 20px; +} + +.sidebar h4 { + position: relative; + top: 2px; + display: inline-block; +} + +.sidebar img { + border-radius: 25px; +} + +.sidebar .tags { + margin-top: 2em; +} + +.user-setting { + display: inline-block; + margin: 11px 5px; +} + +.gear { + color: #aaa; +} + +.footer { + margin-top: 120px; + padding: 30px 0; + border-top: 1px solid #e8e8e8; +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..20f05e6 --- /dev/null +++ b/public/index.php @@ -0,0 +1,23 @@ + 'text/css', + '.js' => 'application/javascript', + '.jpg' => 'image/jpg', + '.png' => 'image/png' + ]; + $path = __DIR__ . $_SERVER['REQUEST_URI']; + if (is_file($path)) { + header("Content-Type: {$mimeTypes[$match[0]]}"); + require $path; + exit; + } +} + +require 'vendor/autoload.php'; +require 'core/Bootstrap.php'; diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..85d9f71 --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,64 @@ +/* global marked */ +(function () { + var forms = document.querySelectorAll('form') + forms.forEach(function (node) { + node.addEventListener( + 'submit', + function () { + node.querySelector('button[type="submit"]').disabled = true + }, + false + ) + }) + var deleter = document.querySelectorAll( + 'a[data-toggle][data-target="#confirm-modal"], ' + + 'a[data-toggle][data-target="#comment-confirm-modal"]' + ) + deleter.forEach(function (node) { + node.addEventListener( + 'click', + function () { + document + .getElementById('delete-form') + .setAttribute('action', node.dataset.action) + }, + false + ) + }) + var search = document.querySelector('#search') + search.addEventListener('keydown', function (e) { + if (e.keyCode === 13) { + window.location = '/search/' + encodeURIComponent(search.value) + return false + } + }) + var editor = document.querySelectorAll( + 'a[data-toggle][data-target="#comment-edit-modal"]' + ) + editor.forEach(function (node) { + node.addEventListener( + 'click', + function (e) { + e.preventDefault() + var form = document.getElementById('comment-edit-form') + form.setAttribute('action', node.dataset.action) + var textarea = form.querySelector('textarea') + textarea.textContent = textarea.value = + node.parentNode.nextElementSibling.textContent + }, + false + ) + }) + var previewBtn = document.querySelector('#preview-post-btn') + if (previewBtn != null) { + previewBtn.addEventListener( + 'click', + function () { + document.getElementById('preview-content').innerHTML = marked( + document.getElementById('source-content').value + ) + }, + false + ) + } +})() diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..66a549b --- /dev/null +++ b/schema.sql @@ -0,0 +1,69 @@ +DROP SCHEMA IF EXISTS `blog-app`; +CREATE SCHEMA `blog-app`; +USE `blog-app`; + +CREATE TABLE users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(32) NOT NULL UNIQUE, + email VARCHAR(254) NOT NULL UNIQUE, + password BINARY(60) NOT NULL UNIQUE +); + +CREATE TABLE active_tokens ( + id INT UNSIGNED PRIMARY KEY, + token CHAR(40) NOT NULL, + FOREIGN KEY (id) REFERENCES users(id) ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE forget_tokens ( + id INT UNSIGNED PRIMARY KEY, + token CHAR(40) NOT NULL, + FOREIGN KEY (id) REFERENCES users(id) ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE verify_tokens ( + id INT UNSIGNED PRIMARY KEY, + token CHAR(40) NOT NULL, + FOREIGN KEY (id) REFERENCES users(id) ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE posts ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + author VARCHAR(32) NOT NULL, + create_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (author) REFERENCES users(name) ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE comments ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + content TEXT NOT NULL, + author VARCHAR(32) NOT NULL, + create_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + comment_to INT UNSIGNED NOT NULL, + FOREIGN KEY (author) REFERENCES users(name) ON DELETE CASCADE + ON UPDATE CASCADE, + FOREIGN KEY (comment_to) REFERENCES posts(id) ON DELETE CASCADE +); + +CREATE TABLE tags ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + keyword VARCHAR(32) NOT NULL UNIQUE +); + +CREATE TABLE post_tag ( + post_id INT UNSIGNED NOT NULL, + tag_id INT UNSIGNED NOT NULL, + PRIMARY KEY (post_id, tag_id), + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); diff --git a/seed.php b/seed.php new file mode 100644 index 0000000..c884f66 --- /dev/null +++ b/seed.php @@ -0,0 +1,168 @@ +get('database'); +$db = new Core\Database\QueryBuilder( + new PDO( + "{$config['connection']};charset=utf8", + $config['username'], + $config['password'], + $config['options'] + ) +); +$schema = file_get_contents('schema.sql'); +$sql = $db->prepare($schema); +$sql->execute(); + +$faker = Faker\Factory::create(); + +$rows = []; +$holders = []; +$usernames = []; + +for ($i = 0; $i < NUM_OF_USERS; $i += 1) { + array_push($usernames, $faker->unique()->userName); +} + +for ($i = 0; $i < NUM_OF_USERS; $i += 1) { + array_push( + $rows, + $usernames[$i], + $faker->unique()->freeEmail, + password_hash(USER_PASSWORD, PASSWORD_DEFAULT) + ); + array_push($holders, '(NULL,?,?,?)'); +} + +$sql = $db->prepare('INSERT INTO users VALUES ' . implode(', ', $holders)); +$sql->execute($rows); + +$rows = []; +$holders = []; +$datetimes = []; +$from = NUM_OF_POSTS + 3; + +for ($i = 0; $i < NUM_OF_POSTS; $i += 1) { + array_push( + $datetimes, + $faker->unique() + ->dateTimeBetween("-{$from} days", '-3 days') + ->format('Y-m-d H:i:s') + ); +} + +sort($datetimes); + +$mh = curl_multi_init(); +for ($i = 0; $i < NUM_OF_POSTS; $i += 1) { + $fetchURL = 'https://jaspervdj.be/lorem-markdownum/markdown.txt'; + $multiCurl[$i] = curl_init(); + curl_setopt($multiCurl[$i], CURLOPT_URL, $fetchURL); + curl_setopt($multiCurl[$i], CURLOPT_HEADER, 0); + curl_setopt($multiCurl[$i], CURLOPT_RETURNTRANSFER, 1); + curl_multi_add_handle($mh, $multiCurl[$i]); +} + +$index = null; +do { + curl_multi_exec($mh, $index); +} while ($index > 0); + +for ($i = 0; $i < NUM_OF_POSTS; $i += 1) { + $r = $faker->numberBetween(0, 9); + array_push( + $rows, + implode(' ', array_slice(explode(' ', $faker->unique()->realText()), 0, 6)), + curl_multi_getcontent($multiCurl[$i]), + $usernames[$r], + $datetimes[$i], + $datetimes[$i] + ); + array_push($holders, '(NULL,?,?,?,?,?)'); + curl_multi_remove_handle($mh, $multiCurl[$i]); +} +curl_multi_close($mh); + +$sql = $db->prepare('INSERT INTO posts VALUES ' . implode(', ', $holders)); +$sql->execute($rows); + +$rows = []; +$holders = []; +$cdatetimes = []; +$cposts = []; + +for ($i = 0; $i < NUM_OF_COMMENTS; $i += 1) { + $p = $faker->numberBetween(0, NUM_OF_POSTS - 1); + $d = $faker->numberBetween(0, 1); + $h = $faker->numberBetween(0, 23); + $m = $faker->numberBetween(0, 59); + $s = $faker->numberBetween(0, 59); + array_push( + $cdatetimes, + date_create_from_format('Y-m-d H:i:s', $datetimes[$p]) + ->add(new DateInterval("P{$d}DT{$h}H{$m}M{$s}S")) + ->format('Y-m-d H:i:s') + ); + array_push($cposts, $p + 1); +} + +array_multisort($cdatetimes, $cposts); + +for ($i = 0; $i < NUM_OF_COMMENTS; $i += 1) { + $r = $faker->numberBetween(0, NUM_OF_USERS - 1); + array_push( + $rows, + $faker->unique()->realText(100, 1), + $usernames[$r], + $cdatetimes[$i], + $cdatetimes[$i], + $cposts[$i] + ); + array_push($holders, '(NULL,?,?,?,?,?)'); +} + +$sql = $db->prepare('INSERT INTO comments VALUES ' . implode(', ', $holders)); +$sql->execute($rows); + +$rows = []; +$holders = []; +$tags = array_values(array_unique($faker->words(NUM_OF_TAGS_CANDIDATE))); +$num_of_tags = count($tags); + +for ($i = 0; $i < $num_of_tags; $i += 1) { + array_push($rows, null, $tags[$i]); + array_push($holders, '(?,?)'); +} + +$sql = $db->prepare('INSERT INTO tags VALUES ' . implode(', ', $holders)); +$sql->execute($rows); + +$rows = []; +$holders = []; +$links = []; + +for ($i = 0; $i < 4 * NUM_OF_POSTS; $i += 1) { + $p = $faker->numberBetween(1, NUM_OF_POSTS); + $t = $faker->numberBetween(1, $num_of_tags); + array_push($links, [$p, $t]); +} + +$rows = array_values(array_unique($links, SORT_REGULAR)); + +sort($rows); + +for ($i = 0; $i < count($rows); $i += 1) { + array_push($holders, '(?,?)'); +} + +$sql = $db->prepare('INSERT INTO post_tag VALUES ' . implode(', ', $holders)); +$sql->execute(call_user_func_array('array_merge', $rows)); + +echo "Done!\n"; diff --git a/views/404.blade.php b/views/404.blade.php new file mode 100644 index 0000000..d344c7e --- /dev/null +++ b/views/404.blade.php @@ -0,0 +1,13 @@ +@extends('layout.master') + +@section('title') + 404 Not Found - +@endsection + +@section('header') + @parent +@endsection + +@section('content') +

404 Not Found

+@endsection diff --git a/views/cache/.gitkeep b/views/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/views/layout/footer.blade.php b/views/layout/footer.blade.php new file mode 100644 index 0000000..de58041 --- /dev/null +++ b/views/layout/footer.blade.php @@ -0,0 +1,5 @@ + diff --git a/views/layout/header.blade.php b/views/layout/header.blade.php new file mode 100644 index 0000000..ba05de8 --- /dev/null +++ b/views/layout/header.blade.php @@ -0,0 +1,30 @@ +
+
+ @section('header') +

Blog App

+ @show + @if(isset($_SESSION['is_auth'])) +
+ + +
+
+ New Post +
+ @else + @if(! preg_match('/\/register$/', $_SERVER['REQUEST_URI'])) +
+ Register +
+ @endif + @if(! preg_match('/\/login$/', $_SERVER['REQUEST_URI'])) +
+ +
+ @endif + @endif +
+ +
+
+
diff --git a/views/layout/master.blade.php b/views/layout/master.blade.php new file mode 100644 index 0000000..880e626 --- /dev/null +++ b/views/layout/master.blade.php @@ -0,0 +1,46 @@ + + + + + + @yield('title')Blog App + + + + + + @include('layout.header') +
+
+
+ @if(isset($errors)) +
    + @foreach($errors as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + @if(isset($messages)) +
    + @foreach($messages as $message) +
  • {{ $message }}
  • + @endforeach +
+ @endif + @yield('content') + @yield('comments') +
+ +
+
+ + @include('layout.footer') + + + + + + + diff --git a/views/layout/sidebar.blade.php b/views/layout/sidebar.blade.php new file mode 100644 index 0000000..d3e5a7f --- /dev/null +++ b/views/layout/sidebar.blade.php @@ -0,0 +1,19 @@ +@if(isset($_SESSION['is_auth'])) + + + + +

+ +

+@endif + +@if(isset($tags)) + +@endif diff --git a/views/password/create.blade.php b/views/password/create.blade.php new file mode 100644 index 0000000..ab6333e --- /dev/null +++ b/views/password/create.blade.php @@ -0,0 +1,23 @@ +@extends('layout.master') + +@section('title') + Password Reset - +@endsection + +@section('content') +
+ + + +
+ + +
+
+ + +
+ +
+@endsection + diff --git a/views/password/reset.blade.php b/views/password/reset.blade.php new file mode 100644 index 0000000..7351815 --- /dev/null +++ b/views/password/reset.blade.php @@ -0,0 +1,16 @@ +@extends('layout.master') + +@section('title') + Password Reset - +@endsection + +@section('content') +
+ +
+ + +
+ +
+@endsection diff --git a/views/post/create.blade.php b/views/post/create.blade.php new file mode 100644 index 0000000..974cbad --- /dev/null +++ b/views/post/create.blade.php @@ -0,0 +1,3 @@ +@extends('post.edit') + +@section('submit') Publish @endsection diff --git a/views/post/edit.blade.php b/views/post/edit.blade.php new file mode 100644 index 0000000..463fce3 --- /dev/null +++ b/views/post/edit.blade.php @@ -0,0 +1,38 @@ +@extends('layout.master') + +@section('content') +
+ +
+ +
+
+ +
+
+ +
+ +
+
+ + +
+ + + + +@endsection diff --git a/views/post/index.blade.php b/views/post/index.blade.php new file mode 100644 index 0000000..76f0f83 --- /dev/null +++ b/views/post/index.blade.php @@ -0,0 +1,59 @@ +@extends('layout.master') + +@section('content') +
+ @if(count($posts) == 0) +

No content

+ @endif + + @foreach($posts as $post) +
+ {{ explode(' ', $post->create_at)[0] }} + @if(isset($_SESSION['is_auth']) && $post->author == $_SESSION['username']) + Edit + Delete + @endif +
+

+ {!! isset($keyword) ? preg_replace('/' . preg_quote(e($keyword)) . '/i', '$0', e($post->title)) : e($post->title) !!} +

+ @endforeach + + + + +
+@endsection + +@section('sidebar') + @include('layout.sidebar') +@endsection diff --git a/views/post/show.blade.php b/views/post/show.blade.php new file mode 100644 index 0000000..957dbab --- /dev/null +++ b/views/post/show.blade.php @@ -0,0 +1,139 @@ +@extends('layout.master') + +@section('title') + {{ $post->title }} - +@endsection + +@section('content') +

{{ $post->title }}

+
+ Published at: {{ $post->create_at }}, + Author: {{ $post->author }} + @if(isset($_SESSION['is_auth']) && $post->author == $_SESSION['username']) + Edit + Delete + + @endif +
+
+ @markdown($post->content) +
+ @if(count($tags)) +
+ @foreach($tags as $key => $tag) + {{ $tag->keyword }} + @if($key < count($tags) - 1),@endif + @endforeach +
+ @endif +@endsection + +@section('comments') +
+ @if(isset($comment_errors)) + + @endif + @if(isset($_SESSION['is_auth'])) +
+ + +
+ +
+ +
+ @endif + @if(count($comments)) +

{{ $post->num_of_comments }} {{ $post->num_of_comments > 1 ? 'comments' : 'comment' }}:

+ @foreach($comments as $comment) + +
+
{{ $comment->author }}
+ {{ $comment->create_at }} + @if(isset($_SESSION['is_auth'])) + @if($comment->author == $_SESSION['username']) + Edit + @endif + @if($post->author == $_SESSION['username']) + Delete + @endif + @endif +
+

{!! nl2br(e($comment->content)) !!}

+ @endforeach + + + @endif +
+@endsection diff --git a/views/redirect.blade.php b/views/redirect.blade.php new file mode 100644 index 0000000..73ab2cb --- /dev/null +++ b/views/redirect.blade.php @@ -0,0 +1,16 @@ +@extends('layout.master') + +@section('content') +

{{ $message }}

+

You will be redirected to homepage in 10 seconds.

+ + +@endsection diff --git a/views/tag/index.blade.php b/views/tag/index.blade.php new file mode 100644 index 0000000..9915f07 --- /dev/null +++ b/views/tag/index.blade.php @@ -0,0 +1 @@ +@extends('post.index') diff --git a/views/user/login.blade.php b/views/user/login.blade.php new file mode 100644 index 0000000..0ab09aa --- /dev/null +++ b/views/user/login.blade.php @@ -0,0 +1,21 @@ +@extends('layout.master') + +@section('title') + Login - +@endsection + +@section('content') +
+ +
+ + +
+
+ + +
+ + Forgot password? +
+@endsection diff --git a/views/user/register.blade.php b/views/user/register.blade.php new file mode 100644 index 0000000..246d84c --- /dev/null +++ b/views/user/register.blade.php @@ -0,0 +1,28 @@ +@extends('layout.master') + +@section('title') + Register - +@endsection + +@section('content') +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+@endsection diff --git a/views/user/settings.blade.php b/views/user/settings.blade.php new file mode 100644 index 0000000..dbac55c --- /dev/null +++ b/views/user/settings.blade.php @@ -0,0 +1,62 @@ +@extends('layout.master') + +@section('title') + Settings - +@endsection + +@section('content') + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Name:
E-mail:
Password:
Confirmation:
+
+ + Delete account + + +@endsection