Initial commit
This commit is contained in:
commit
5ed6195cc1
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
/vendor
|
||||
/views/cache/*.php
|
||||
Config.php
|
||||
sample.sql
|
||||
tags
|
1
.local.vimrc
Normal file
1
.local.vimrc
Normal file
@ -0,0 +1 @@
|
||||
setlocal wildignore-=Config.php
|
20
Config.php.example
Normal file
20
Config.php.example
Normal 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
19
README.md
Normal 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
67
Routes.php
Normal 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
19
composer.json
Normal 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
1191
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
composer.phar
Normal file
BIN
composer.phar
Normal file
Binary file not shown.
35
core/Bootstrap.php
Normal file
35
core/Bootstrap.php
Normal 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
118
core/Helpers.php
Normal 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
7
core/Record.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Record
|
||||
{
|
||||
}
|
85
core/controllers/Comment.php
Normal file
85
core/controllers/Comment.php
Normal 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;
|
||||
}
|
||||
}
|
105
core/controllers/Password.php
Normal file
105
core/controllers/Password.php
Normal 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
280
core/controllers/Post.php
Normal 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);
|
||||
}
|
||||
}
|
39
core/controllers/Search.php
Normal file
39
core/controllers/Search.php
Normal 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
41
core/controllers/Tag.php
Normal 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
330
core/controllers/User.php
Normal 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;
|
||||
}
|
||||
}
|
20
core/database/Connection.php
Normal file
20
core/database/Connection.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
25
core/database/QueryBuilder.php
Normal file
25
core/database/QueryBuilder.php
Normal 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
178
public/css/main.css
Normal 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
23
public/index.php
Normal 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
64
public/js/main.js
Normal 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
69
schema.sql
Normal 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
168
seed.php
Normal 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
13
views/404.blade.php
Normal 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
0
views/cache/.gitkeep
vendored
Normal file
5
views/layout/footer.blade.php
Normal file
5
views/layout/footer.blade.php
Normal file
@ -0,0 +1,5 @@
|
||||
<div class="footer">
|
||||
<div class="container">
|
||||
<h4 class="text-secondary">Blog App</h4>
|
||||
</div>
|
||||
</div>
|
30
views/layout/header.blade.php
Normal file
30
views/layout/header.blade.php
Normal 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>
|
46
views/layout/master.blade.php
Normal file
46
views/layout/master.blade.php
Normal 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>
|
19
views/layout/sidebar.blade.php
Normal file
19
views/layout/sidebar.blade.php
Normal 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
|
23
views/password/create.blade.php
Normal file
23
views/password/create.blade.php
Normal 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
|
||||
|
16
views/password/reset.blade.php
Normal file
16
views/password/reset.blade.php
Normal 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
|
3
views/post/create.blade.php
Normal file
3
views/post/create.blade.php
Normal file
@ -0,0 +1,3 @@
|
||||
@extends('post.edit')
|
||||
|
||||
@section('submit') Publish @endsection
|
38
views/post/edit.blade.php
Normal file
38
views/post/edit.blade.php
Normal 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">×</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
|
59
views/post/index.blade.php
Normal file
59
views/post/index.blade.php
Normal 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 }}">← Prev</a></li>
|
||||
@endif
|
||||
|
||||
@if(! $is_last)
|
||||
<li class="next"><a href="{{ $pager_uri }}/{{ $page + 1 }}">Next →</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">×</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
139
views/post/show.blade.php
Normal 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">×</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">×</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">×</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
16
views/redirect.blade.php
Normal 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
|
1
views/tag/index.blade.php
Normal file
1
views/tag/index.blade.php
Normal file
@ -0,0 +1 @@
|
||||
@extends('post.index')
|
21
views/user/login.blade.php
Normal file
21
views/user/login.blade.php
Normal 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
|
28
views/user/register.blade.php
Normal file
28
views/user/register.blade.php
Normal 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
|
62
views/user/settings.blade.php
Normal file
62
views/user/settings.blade.php
Normal 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">×</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
|
Loading…
Reference in New Issue
Block a user