Initial commit

This commit is contained in:
uohlhv 2021-09-16 20:27:51 +08:00
commit 5ed6195cc1
41 changed files with 3429 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.DS_Store
/vendor
/views/cache/*.php
Config.php
sample.sql
tags

1
.local.vimrc Normal file
View File

@ -0,0 +1 @@
setlocal wildignore-=Config.php

20
Config.php.example Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
'database' => [
'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'
];

19
README.md Normal file
View File

@ -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`.

67
Routes.php Normal file
View File

@ -0,0 +1,67 @@
<?php
$route->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']);

19
composer.json Normal file
View File

@ -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"
}
}

1191
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

BIN
composer.phar Normal file

Binary file not shown.

35
core/Bootstrap.php Normal file
View File

@ -0,0 +1,35 @@
<?php
session_start();
header("X-Frame-Options: DENY");
if (! isset($_SESSION['_token'])) {
$_SESSION['_token'] = Illuminate\Support\Str::random(40);
}
use Phroute\Phroute\Dispatcher;
use Phroute\Phroute\Exception\HttpMethodNotAllowedException;
use Phroute\Phroute\Exception\HttpRouteNotFoundException;
use Phroute\Phroute\RouteCollector;
use Phroute\Phroute\RouteParser;
$route = new RouteCollector(new RouteParser);
require 'Routes.php';
$dispatcher = new Dispatcher($route->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();
}

118
core/Helpers.php Normal file
View File

@ -0,0 +1,118 @@
<?php
if (! function_exists('db')) {
function db()
{
$config = new Core\Config;
return new Core\Database\QueryBuilder(
Core\Database\Connection::make($config->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 "<?php echo markdown({$expression}) ?>";
});
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');
}
}

7
core/Record.php Normal file
View File

@ -0,0 +1,7 @@
<?php
namespace Core;
class Record
{
}

View File

@ -0,0 +1,85 @@
<?php
namespace Controllers;
class Comment
{
public function create()
{
if (! $this->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;
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace Controllers;
class Password
{
public function getReset()
{
return view('password.reset');
}
public function getCreate()
{
if ($this->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 <a href="http://localhost' . port() . '/user/password/create?id=' . $id .
'&forget_token=' . $forget_token . '">here</a> 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;
}
}

280
core/controllers/Post.php Normal file
View File

@ -0,0 +1,280 @@
<?php
namespace Controllers;
class Post
{
public function index($page = '1')
{
$db = db();
$offset = ($page >= 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);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Controllers;
class Search
{
public function show($keyword, $page = '1')
{
$db = db();
$keyword = urldecode($keyword);
$offset = ($page >= 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'])
]);
}
}

41
core/controllers/Tag.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace Controllers;
class Tag
{
public function show($id, $page = '1')
{
$db = db();
$offset = ($page >= 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;
}
}

330
core/controllers/User.php Normal file
View File

@ -0,0 +1,330 @@
<?php
namespace Controllers;
class User
{
public function activate()
{
if (! $this->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 <a href="http://localhost' . port() . '/user/activate?id=' . $id .
'&active_token=' . $active_token . '">here</a> 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 <a href="http://localhost' . port() . '/user/email/verify' .
'?id=' . $_SESSION['id'] .
'&email=' . $_POST['email'] .
'&verify_token=' . $verify_token . '">here</a> 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;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Core\Database;
class Connection
{
public static function make($config)
{
try {
return new \PDO(
"{$config['connection']};dbname={$config['name']};charset=utf8",
$config['username'],
$config['password'],
$config['options']
);
} catch (\PDOException $e) {
die($e->getMessage());
}
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Core\Database;
use \PDO;
class QueryBuilder
{
protected $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function prepare($query)
{
return $this->pdo->prepare($query);
}
public function lastInsertId()
{
return $this->pdo->lastInsertId();
}
}

178
public/css/main.css Normal file
View File

@ -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;
}

23
public/index.php Normal file
View File

@ -0,0 +1,23 @@
<?php
if (preg_match(
'/\.css|\.js|\.jpg|\.png$/',
$_SERVER['REQUEST_URI'],
$match
)) {
$mimeTypes = [
'.css' => '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';

64
public/js/main.js Normal file
View File

@ -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
)
}
})()

69
schema.sql Normal file
View File

@ -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
);

168
seed.php Normal file
View File

@ -0,0 +1,168 @@
<?php
require 'vendor/autoload.php';
const NUM_OF_COMMENTS = 200;
const NUM_OF_POSTS = 100;
const NUM_OF_TAGS_CANDIDATE = 30;
const NUM_OF_USERS = 10;
const USER_PASSWORD = '1234';
$config = (new Core\Config)->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";

13
views/404.blade.php Normal file
View File

@ -0,0 +1,13 @@
@extends('layout.master')
@section('title')
404 Not Found -
@endsection
@section('header')
@parent
@endsection
@section('content')
<h1>404 Not Found</h1>
@endsection

0
views/cache/.gitkeep vendored Normal file
View File

View File

@ -0,0 +1,5 @@
<div class="footer">
<div class="container">
<h4 class="text-secondary">Blog App</h4>
</div>
</div>

View File

@ -0,0 +1,30 @@
<div class="header">
<div class="container">
@section('header')
<h4><a href="/" class="title text-dark">Blog App</a></h4>
@show
@if(isset($_SESSION['is_auth']))
<form method="POST" action="/user/logout" class="float-right">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<button type="submit" class="btn btn-dark">Logout</button>
</form>
<div class="float-right">
<a href="/post/create" class="post-create btn btn-success">New Post</a>
</div>
@else
@if(! preg_match('/\/register$/', $_SERVER['REQUEST_URI']))
<div class="float-right">
<a href="/user/register" class="register btn btn-info">Register</a>
</div>
@endif
@if(! preg_match('/\/login$/', $_SERVER['REQUEST_URI']))
<div class="float-right">
<a href="/user/login" class="login btn btn-light">Login</a>
</div>
@endif
@endif
<div class="float-right">
<input type="text" placeholder="Search title ..." id="search" class="search form-control" value="{{ isset($keyword) ? urldecode($keyword) : '' }}">
</div>
</div>
</div>

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>@yield('title')Blog App</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous">
<link rel="stylesheet" href="/css/main.css">
<script defer src="https://use.fontawesome.com/releases/v5.0.13/js/all.js" integrity="sha384-xymdQtn1n3lH2wcu0qhcdaOpQwyoarkgLVxC/wZ5q7h9gHtxICrpcaSUfygqZGOe" crossorigin="anonymous"></script>
</head>
<body>
@include('layout.header')
<div class="container">
<div class="row">
<div class="main col">
@if(isset($errors))
<ul class="errors alert alert-danger">
@foreach($errors as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
@if(isset($messages))
<ul class="messages alert alert-info">
@foreach($messages as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
@endif
@yield('content')
@yield('comments')
</div>
<div class="sidebar col-lg-3">
@yield('sidebar')
</div>
</div>
</div>
@include('layout.footer')
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.0/umd/popper.min.js" integrity="sha384-cs/chFZiN24E4KMATLdqdvsezGxaGsi4hLGOzlXwp5UZB1LY//20VyM2taTB4QvJ" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js" integrity="sha384-uefMccjFJAIv6A+rW+L4AHf99KvxDjWSu1z9VI8SKNVmz4sk7buKt/6v9KI65qnm" crossorigin="anonymous"></script>
<script src="/js/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,19 @@
@if(isset($_SESSION['is_auth']))
<img src="https://www.gravatar.com/avatar/{{ md5(strtolower(trim($_SESSION['email']))) }}?s=50&d=mm&r=pg" class="avatar">
<a href="/user/settings" class="user-setting" title="Settings">
<i class="fas fa-cog gear"></i>
</a>
<h4>
<a href="/user/{{ $_SESSION['username'] }}" class="user-info">
{{ $_SESSION['username'] }}
</a>
</h4>
@endif
@if(isset($tags))
<ul class="tags">
@foreach($tags as $tag)
<li><a href="/tag/{{ $tag->id }}">{{ $tag->keyword }}</a> ({{ $tag->num_of_posts }})</li>
@endforeach
</ul>
@endif

View File

@ -0,0 +1,23 @@
@extends('layout.master')
@section('title')
Password Reset -
@endsection
@section('content')
<form method="POST" action="">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<input type="hidden" name="id" value="{{ $_GET['id'] }}">
<input type="hidden" name="forget_token" value="{{ $_GET['forget_token'] }}">
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control" placeholder="Enter password">
</div>
<div class="form-group">
<label for="confirm-password">Confirmation</label>
<input type="password" name="confirm-password" class="form-control" placeholder="Enter password again">
</div>
<button type="submit" class="btn btn-primary">Reset</button>
</form>
@endsection

View File

@ -0,0 +1,16 @@
@extends('layout.master')
@section('title')
Password Reset -
@endsection
@section('content')
<form method="POST" action="">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" class="form-control" placeholder="Enter email">
</div>
<button type="submit" class="btn btn-primary">Send</button>
</form>
@endsection

View File

@ -0,0 +1,3 @@
@extends('post.edit')
@section('submit') Publish @endsection

38
views/post/edit.blade.php Normal file
View File

@ -0,0 +1,38 @@
@extends('layout.master')
@section('content')
<form method="POST" action="">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<div class="form-group">
<input type="text" class="form-control" name="title" placeholder="Enter title" maxlength="255" value="{{ $title ?? $post->title }}">
</div>
<div class="form-group">
<textarea id="source-content" class="form-control" name="content" rows="10">{{ $content ?? $post->content }}</textarea>
</div>
<div class="form-group row">
<label class="col-sm-1 col-form-label text-right">Tags: </label>
<div class="col-sm-11">
<input type="text" class="form-control" name="tags" placeholder="Separated by commas (,)" maxlength="255" value="{{ $tags ?? $post->tags }}">
</div>
</div>
<button type="submit" class="btn btn-primary float-right">@section('submit') Edit @show</button>
<button type="button" id="preview-post-btn" class="preview-post-btn btn btn-secondary float-right" data-toggle="modal" data-target="#preview-post">Preview</button>
</form>
<div class="modal fade" id="preview-post" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Preview</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" id="preview-content">
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
@endsection

View File

@ -0,0 +1,59 @@
@extends('layout.master')
@section('content')
<div class="posts">
@if(count($posts) == 0)
<h3 class="text-secondary">No content</h3>
@endif
@foreach($posts as $post)
<div>
<span class="text-secondary">{{ explode(' ', $post->create_at)[0] }}</span>
@if(isset($_SESSION['is_auth']) && $post->author == $_SESSION['username'])
<a href="/post/{{ $post->id }}/edit" class="text-primary">Edit</a>
<a href="#" class="text-danger" data-toggle="modal" data-target="#confirm-modal" data-action="/post/{{ $post->id }}/delete">Delete</a>
@endif
</div>
<h2 class="title">
<a href="/post/{{ $post->id }}">{!! isset($keyword) ? preg_replace('/' . preg_quote(e($keyword)) . '/i', '<span class="match">$0</span>', e($post->title)) : e($post->title) !!}</a>
</h2>
@endforeach
<ul class="pager">
@if($page > 1)
<li class="previous"><a href="{{ $pager_uri }}/{{ $page - 1 }}">&larr; Prev</a></li>
@endif
@if(! $is_last)
<li class="next"><a href="{{ $pager_uri }}/{{ $page + 1 }}">Next &rarr;</a></li>
@endif
</ul>
<div class="modal fade" id="confirm-modal" tabindex="-1" role="dialog" aria-labelledby="confirm-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirm-label">Confirmation</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
Are you sure you want to delete this post?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">No</button>
<form method="POST" action="" id="delete-form">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<button type="submit" class="btn btn-danger">Yes</button>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('sidebar')
@include('layout.sidebar')
@endsection

139
views/post/show.blade.php Normal file
View File

@ -0,0 +1,139 @@
@extends('layout.master')
@section('title')
{{ $post->title }} -
@endsection
@section('content')
<h2>{{ $post->title }}</h2>
<div>
<span class="text-secondary">Published at: {{ $post->create_at }}</span>,
<span class="text-secondary">Author: <a href="/user/{{ $post->author }}" class="text-info">{{ $post->author }}</a></span>
@if(isset($_SESSION['is_auth']) && $post->author == $_SESSION['username'])
<a href="/post/{{ $post->id }}/edit" class="text-primary">Edit</a>
<a href="#" class="text-danger" data-toggle="modal" data-target="#confirm-modal">Delete</a>
<div class="modal fade" id="confirm-modal" tabindex="-1" role="dialog" aria-labelledby="confirm-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirm-label">Confirmation</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
Are you sure you want to delete this post?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">No</button>
<form method="POST" action="/post/{{ $post->id }}/delete">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<button type="submit" class="btn btn-danger">Yes</button>
</form>
</div>
</div>
</div>
</div>
@endif
</div>
<article id="content">
@markdown($post->content)
</article>
@if(count($tags))
<div class="tags text-right">
@foreach($tags as $key => $tag)
<a href="/tag/{{ $tag->id }}" class="text-info">{{ $tag->keyword }}</a>
@if($key < count($tags) - 1),@endif
@endforeach
</div>
@endif
@endsection
@section('comments')
<div class="comments">
@if(isset($comment_errors))
<ul class="errors alert alert-danger">
@foreach($comment_errors as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
@if(isset($_SESSION['is_auth']))
<form method="POST" action="/comment/create" class="comment-form">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<input type="hidden" name="post_id" value="{{ $post->id }}">
<div class="form-group">
<textarea name="content" rows="1" class="form-control"></textarea>
</div>
<button type="submit" class="btn btn-primary float-right">Comment</button>
</form>
@endif
@if(count($comments))
<h3>{{ $post->num_of_comments }} {{ $post->num_of_comments > 1 ? 'comments' : 'comment' }}:</h3>
@foreach($comments as $comment)
<img src="https://www.gravatar.com/avatar/{{ md5(strtolower(trim($comment->email))) }}?s=50&d=mm&r=pg" class="avatar">
<div>
<h5>{{ $comment->author }}</h5>
<span class="text-secondary">{{ $comment->create_at }}</span>
@if(isset($_SESSION['is_auth']))
@if($comment->author == $_SESSION['username'])
<a href="#" class="text-primary" data-toggle="modal" data-target="#comment-edit-modal" data-action="/comment/{{ $comment->id }}/edit">Edit</a>
@endif
@if($post->author == $_SESSION['username'])
<a href="#" class="text-danger" data-toggle="modal" data-target="#comment-confirm-modal" data-action="/comment/{{ $comment->id }}/delete">Delete</a>
@endif
@endif
</div>
<p>{!! nl2br(e($comment->content)) !!}</p>
@endforeach
<div class="modal fade" id="comment-confirm-modal" tabindex="-1" role="dialog" aria-labelledby="confirm-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="comment-confirm-label">Confirmation</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
Are you sure you want to delete this comment?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">No</button>
<form method="POST" action="" id="delete-form">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<input type="hidden" name="post_id" value="{{ $post->id }}">
<button type="submit" class="btn btn-danger">Yes</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal fade" id="comment-edit-modal" tabindex="-1" role="dialog" aria-labelledby="edit-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="comment-edit-label">Edit Comment</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form method="POST" action="" id="comment-edit-form">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<input type="hidden" name="post_id" value="{{ $post->id }}">
<div class="modal-body">
<div class="form-group">
<textarea name="content" rows="3" class="form-control"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Edit</button>
</div>
</form>
</div>
</div>
</div>
@endif
</div>
@endsection

16
views/redirect.blade.php Normal file
View File

@ -0,0 +1,16 @@
@extends('layout.master')
@section('content')
<p>{{ $message }}</p>
<p>You will be redirected to homepage in <span id="sec">10</span> seconds.</p>
<script>
setInterval(function () {
var sec = document.getElementById('sec');
sec.textContent -= 1;
if (sec.textContent == 0) {
window.location = '/';
}
}, 1000);
</script>
@endsection

View File

@ -0,0 +1 @@
@extends('post.index')

View File

@ -0,0 +1,21 @@
@extends('layout.master')
@section('title')
Login -
@endsection
@section('content')
<form method="POST" action="/user/login">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" name="username" class="form-control" placeholder="Enter username" value="{{$username ?? '' }}">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control" placeholder="Enter password">
</div>
<button type="submit" class="btn btn-primary">Login</button>
<a href="/user/password/reset" class="text-info float-right">Forgot password?</a>
</form>
@endsection

View File

@ -0,0 +1,28 @@
@extends('layout.master')
@section('title')
Register -
@endsection
@section('content')
<form method="POST" action="">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" name="username" class="form-control" placeholder="Enter username" value="{{ $username ?? '' }}">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" class="form-control" placeholder="Enter email" value="{{ $email ?? '' }}">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control" placeholder="Enter password">
</div>
<div class="form-group">
<label for="confirm-password">Confirmation</label>
<input type="password" name="confirm-password" class="form-control" placeholder="Enter password again">
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
@endsection

View File

@ -0,0 +1,62 @@
@extends('layout.master')
@section('title')
Settings -
@endsection
@section('content')
<img src="https://www.gravatar.com/avatar/{{ md5(strtolower(trim($_SESSION['email']))) }}?s=100&d=mm&r=pg" class="avatar">
<form method="POST" action="">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<input type="hidden" name="id" value="{{ $_SESSION['id'] }}">
<table class="table">
<tbody>
<tr>
<th scope="row">Name:</th>
<td><input type="text" name="username" value="{{ $_SESSION['username'] }}" class="form-control"></td>
</tr>
<tr>
<th scope="row">E-mail:</th>
<td><input type="email" name="email" value="{{ $_SESSION['email'] }}" class="form-control"></td>
</tr>
<tr>
<th scope="row">Password:</th>
<td><input type="password" name="password" placeholder="Leave blank for no change" class="form-control"></td>
</tr>
<tr>
<th scope="row">Confirmation:</th>
<td><input type="password" name="confirm-password" placeholder="Leave blank for no change" class="form-control"></td>
</tr>
<tr>
<td></td>
<td><button type="submit" class="btn btn-primary float-right">Save</button></td>
</tr>
</tbody>
</table>
</form>
<a href="#" class="text-muted text-danger" data-toggle="modal" data-target="#account-delete-modal">Delete account</a>
<div class="modal fade" id="account-delete-modal" tabindex="-1" role="dialog" aria-labelledby="confirm-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="account-delete-label">Confirmation</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
Are you sure you want to delete your account?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">No</button>
<form method="POST" action="/user/delete">
<input type="hidden" name="_token" value="{{ $_SESSION['_token'] }}">
<button type="submit" class="btn btn-danger">Yes</button>
</form>
</div>
</div>
</div>
</div>
@endsection