Registration sketch
This commit is contained in:
parent
eebd297a24
commit
3a77bb7c59
18 changed files with 558 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
db.sqlite
|
||||
db.sqlite-journal
|
25
config.ini
Normal file
25
config.ini
Normal file
|
@ -0,0 +1,25 @@
|
|||
[chibi]
|
||||
userCodeDir=./src/
|
||||
prettyPrint=1
|
||||
|
||||
[main]
|
||||
dbPath=./db.sqlite
|
||||
|
||||
[registration]
|
||||
emailActivation = 0
|
||||
adminActivation = 0
|
||||
passMinLength = 5
|
||||
passRegex = "/^.+$/"
|
||||
userNameMinLength = 3
|
||||
userNameRegex = "/^[\w_-]+$/ui"
|
||||
salt = "1A2/$_4xVa"
|
||||
|
||||
activationEmailSenderName = "{host} registration engine"
|
||||
activationEmailSenderEmail = "noreply@{host}"
|
||||
activationEmailSubject = "{host} activation"
|
||||
activationEmailBody = "Hello,
|
||||
|
||||
You received this e-mail because someone registered an user with this address at {host}. If it's you, visit {link} to finish registration process, otherwise you may ignore and delete this e-mail.
|
||||
|
||||
Kind regards,
|
||||
{host} registration engine"
|
12
public_html/.htaccess
Normal file
12
public_html/.htaccess
Normal file
|
@ -0,0 +1,12 @@
|
|||
DirectorySlash Off
|
||||
Options -Indexes
|
||||
|
||||
RewriteEngine On
|
||||
ErrorDocument 403 /dispatch.php?request=error/http&code=403
|
||||
ErrorDocument 404 /dispatch.php?request=error/http&code=404
|
||||
ErrorDocument 500 /dispatch.php?request=error/http&code=500
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^.*$ /dispatch.php
|
||||
RewriteRule ^/?$ /dispatch.php
|
27
public_html/dispatch.php
Normal file
27
public_html/dispatch.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
chdir('..');
|
||||
require_once 'redbean/RedBean/redbean.inc.php';
|
||||
require_once 'chibi-core/Facade.php';
|
||||
|
||||
date_default_timezone_set('UTC');
|
||||
setlocale(LC_CTYPE, 'en_US.UTF-8');
|
||||
|
||||
class Bootstrap
|
||||
{
|
||||
public function workWrapper($workCallback)
|
||||
{
|
||||
$this->config->chibi->baseUrl = 'http://' . rtrim($_SERVER['HTTP_HOST'], '/') . '/';
|
||||
R::setup('sqlite:' . $this->config->main->dbPath);
|
||||
$workCallback();
|
||||
}
|
||||
}
|
||||
|
||||
$query = $_SERVER['REQUEST_URI'];
|
||||
$configPaths =
|
||||
[
|
||||
__DIR__ . DIRECTORY_SEPARATOR . '../config.ini',
|
||||
__DIR__ . DIRECTORY_SEPARATOR . '../local.ini'
|
||||
];
|
||||
$configPaths = array_filter($configPaths, 'file_exists');
|
||||
|
||||
\Chibi\Facade::run($query, $configPaths, new Bootstrap());
|
49
src/Controllers/AbstractController.php
Normal file
49
src/Controllers/AbstractController.php
Normal file
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
abstract class AbstractController
|
||||
{
|
||||
protected function attachUser()
|
||||
{
|
||||
$this->context->loggedIn = false;
|
||||
if (isset($_SESSION['user-id']))
|
||||
{
|
||||
$this->context->user = R::findOne('user', 'id = ?', [$_SESSION['user-id']]);
|
||||
if (!empty($this->context->user))
|
||||
{
|
||||
$this->context->loggedIn = true;
|
||||
}
|
||||
}
|
||||
if (empty($this->context->user))
|
||||
{
|
||||
#todo: construct anonymous user
|
||||
$this->context->user = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function workWrapper($workCallback)
|
||||
{
|
||||
session_start();
|
||||
$this->context->layoutName = isset($_GET['json'])
|
||||
? 'layout-json'
|
||||
: 'layout-normal';
|
||||
$this->context->transport = new StdClass;
|
||||
$this->context->transport->success = null;
|
||||
|
||||
$this->attachUser();
|
||||
|
||||
try
|
||||
{
|
||||
$workCallback();
|
||||
}
|
||||
catch (SimpleException $e)
|
||||
{
|
||||
$this->context->transport->errorMessage = rtrim($e->getMessage(), '.') . '.';
|
||||
$this->context->transport->exception = $e;
|
||||
$this->context->transport->success = false;
|
||||
}
|
||||
catch (Exception $e)
|
||||
{
|
||||
$this->context->exception = $e;
|
||||
$this->context->viewName = 'error-exception';
|
||||
}
|
||||
}
|
||||
}
|
201
src/Controllers/AuthController.php
Normal file
201
src/Controllers/AuthController.php
Normal file
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
class AuthController extends AbstractController
|
||||
{
|
||||
private static function hashPassword($pass, $salt2)
|
||||
{
|
||||
$salt1 = \Chibi\Registry::getConfig()->registration->salt;
|
||||
return sha1($salt1 . $salt2 . $pass);
|
||||
}
|
||||
|
||||
/**
|
||||
* @route /auth/login
|
||||
*/
|
||||
public function loginAction()
|
||||
{
|
||||
//check if already logged in
|
||||
if ($this->context->loggedIn)
|
||||
{
|
||||
\Chibi\HeadersHelper::set('Location', \Chibi\UrlHelper::route('post', 'search'));
|
||||
return;
|
||||
}
|
||||
|
||||
$suppliedUser = InputHelper::get('user');
|
||||
$suppliedPass = InputHelper::get('pass');
|
||||
if ($suppliedUser !== null and $suppliedPass !== null)
|
||||
{
|
||||
$dbUser = R::findOne('user', 'name = ?', [$suppliedUser]);
|
||||
if ($dbUser === null)
|
||||
throw new SimpleException('Invalid username');
|
||||
|
||||
$suppliedPassHash = self::hashPassword($suppliedPass, $dbUser->pass_salt);
|
||||
if ($suppliedPassHash != $dbUser->pass_hash)
|
||||
throw new SimpleException('Invalid password');
|
||||
|
||||
if (!$dbUser->admin_confirmed)
|
||||
throw new SimpleException('An admin hasn\'t confirmed your registration yet');
|
||||
|
||||
if (!$dbUser->email_confirmed)
|
||||
throw new SimpleException('You haven\'t confirmed your e-mail address yet');
|
||||
|
||||
$_SESSION['user-id'] = $dbUser->id;
|
||||
\Chibi\HeadersHelper::set('Location', \Chibi\UrlHelper::route('post', 'search'));
|
||||
$this->context->transport->success = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @route /auth/logout
|
||||
*/
|
||||
public function logoutAction()
|
||||
{
|
||||
$this->context->viewName = null;
|
||||
$this->context->viewName = null;
|
||||
unset($_SESSION['user-id']);
|
||||
\Chibi\HeadersHelper::set('Location', \Chibi\UrlHelper::route('post', 'search'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @route /register
|
||||
*/
|
||||
public function registerAction()
|
||||
{
|
||||
//check if already logged in
|
||||
if ($this->context->loggedIn)
|
||||
{
|
||||
\Chibi\HeadersHelper::set('Location', \Chibi\UrlHelper::route('post', 'search'));
|
||||
return;
|
||||
}
|
||||
|
||||
$suppliedUser = InputHelper::get('user');
|
||||
$suppliedPass1 = InputHelper::get('pass1');
|
||||
$suppliedPass2 = InputHelper::get('pass2');
|
||||
$suppliedEmail = InputHelper::get('email');
|
||||
$this->context->suppliedUser = $suppliedUser;
|
||||
$this->context->suppliedPass1 = $suppliedPass1;
|
||||
$this->context->suppliedPass2 = $suppliedPass2;
|
||||
$this->context->suppliedEmail = $suppliedEmail;
|
||||
|
||||
$regConfig = $this->config->registration;
|
||||
$passMinLength = intval($regConfig->passMinLength);
|
||||
$passRegex = $regConfig->passRegex;
|
||||
$userNameMinLength = intval($regConfig->userNameMinLength);
|
||||
$userNameRegex = $regConfig->userNameRegex;
|
||||
$emailActivation = $regConfig->emailActivation;
|
||||
$adminActivation = $regConfig->adminActivation;
|
||||
|
||||
$this->context->transport->adminActivation = $adminActivation;
|
||||
$this->context->transport->emailActivation = $emailActivation;
|
||||
|
||||
if ($suppliedUser !== null)
|
||||
{
|
||||
$dbUser = R::findOne('user', 'name = ?', [$suppliedUser]);
|
||||
if ($dbUser !== null)
|
||||
{
|
||||
if (!$dbUser->email_confirmed)
|
||||
throw new SimpleException('User with this name is already registered and awaits e-mail confirmation');
|
||||
|
||||
if (!$dbUser->admin_confirmed)
|
||||
throw new SimpleException('User with this name is already registered and awaits admin confirmation');
|
||||
|
||||
throw new SimpleException('User with this name is already registered');
|
||||
}
|
||||
|
||||
if ($suppliedPass1 != $suppliedPass2)
|
||||
throw new SimpleException('Specified passwords must be the same');
|
||||
|
||||
if (strlen($suppliedPass1) < $passMinLength)
|
||||
throw new SimpleException(sprintf('Password must have at least %d characters', $passMinLength));
|
||||
|
||||
if (!preg_match($passRegex, $suppliedPass1))
|
||||
throw new SimpleException('Password contains invalid characters');
|
||||
|
||||
if (strlen($suppliedUser) < $userNameMinLength)
|
||||
throw new SimpleException(sprintf('User name must have at least %d characters', $userNameMinLength));
|
||||
|
||||
if (!preg_match($userNameRegex, $suppliedUser))
|
||||
throw new SimpleException('User name contains invalid characters');
|
||||
|
||||
if (empty($suppliedEmail) and $emailActivation)
|
||||
throw new SimpleException('E-mail address is required - you will be sent confirmation e-mail.');
|
||||
|
||||
if (!empty($suppliedEmail) and !TextHelper::isValidEmail($suppliedEmail))
|
||||
throw new SimpleException('E-mail address appears to be invalid');
|
||||
|
||||
|
||||
//register the user
|
||||
$dbUser = R::dispense('user');
|
||||
$dbUser->name = $suppliedUser;
|
||||
$dbUser->pass_salt = md5(mt_rand() . uniqid());
|
||||
$dbUser->pass_hash = self::hashPassword($suppliedPass1, $dbUser->pass_salt);
|
||||
$dbUser->email = $suppliedEmail;
|
||||
$dbUser->admin_confirmed = $adminActivation ? false : true;
|
||||
$dbUser->email_confirmed = $emailActivation ? false : true;
|
||||
$dbUser->email_token = md5(mt_rand() . uniqid());
|
||||
$dbUser->access_rank = R::findOne('user') === null ? AccessRank::Admin : AccessRank::Registered;
|
||||
|
||||
//send the e-mail
|
||||
if ($emailActivation)
|
||||
{
|
||||
$tokens = [];
|
||||
$tokens['host'] = $_SERVER['HTTP_HOST'];
|
||||
$tokens['link'] = \Chibi\UrlHelper::route('auth', 'activation', ['token' => $dbUser->email_token]);
|
||||
|
||||
$body = wordwrap(TextHelper::replaceTokens($regConfig->activationEmailBody, $tokens), 70);
|
||||
$subject = TextHelper::replaceTokens($regConfig->activationEmailSubject, $tokens);
|
||||
$senderName = TextHelper::replaceTokens($regConfig->activationEmailSenderName, $tokens);
|
||||
$senderEmail = $regConfig->activationEmailSenderEmail;
|
||||
|
||||
$headers = [];
|
||||
$headers[] = sprintf('From: %s <%s>', $senderName, $senderEmail);
|
||||
$headers[] = sprintf('Subject: %s', $subject);
|
||||
$headers[] = sprintf('X-Mailer: PHP/%s', phpversion());
|
||||
mail($dbUser->email, $subject, $body, implode("\r\n", $headers));
|
||||
}
|
||||
|
||||
//save the user to db if everything went okay
|
||||
R::store($dbUser);
|
||||
$this->context->transport->success = true;
|
||||
|
||||
if (!$emailActivation and !$adminActivation)
|
||||
{
|
||||
$_SESSION['user-id'] = $dbUser->id;
|
||||
$this->attachUser();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @route /activation/{token}
|
||||
*/
|
||||
public function activationAction($token)
|
||||
{
|
||||
//check if already logged in
|
||||
if ($this->context->loggedIn)
|
||||
{
|
||||
\Chibi\HeadersHelper::set('Location', \Chibi\UrlHelper::route('post', 'search'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($token))
|
||||
throw new SimpleException('Invalid activation token');
|
||||
|
||||
$dbUser = R::findOne('user', 'email_token = ?', [$token]);
|
||||
if ($dbUser === null)
|
||||
throw new SimpleException('No user with such activation token');
|
||||
|
||||
if ($dbUser->email_confirmed)
|
||||
throw new SimpleException('This user was already activated');
|
||||
|
||||
$dbUser->email_confirmed = true;
|
||||
R::store($dbUser);
|
||||
$this->context->transport->success = true;
|
||||
|
||||
$adminActivation = $this->config->registration->adminActivation;
|
||||
$this->context->transport->adminActivation = $adminActivation;
|
||||
if (!$adminActivation)
|
||||
{
|
||||
$_SESSION['user-id'] = $dbUser->id;
|
||||
$this->attachUser();
|
||||
}
|
||||
}
|
||||
}
|
16
src/Controllers/PostController.php
Normal file
16
src/Controllers/PostController.php
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
class PostController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @route /
|
||||
* @route /index
|
||||
*/
|
||||
public function searchAction()
|
||||
{
|
||||
$tp = new StdClass;
|
||||
$tp->posts = [];
|
||||
$tp->posts []= 1;
|
||||
$tp->posts []= 2;
|
||||
$this->context->transport = $tp;
|
||||
}
|
||||
}
|
23
src/Helpers/InputHelper.php
Normal file
23
src/Helpers/InputHelper.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
class InputHelper
|
||||
{
|
||||
public static function get($keyName)
|
||||
{
|
||||
if (isset($_POST[$keyName]))
|
||||
{
|
||||
return $_POST[$keyName];
|
||||
}
|
||||
|
||||
if (isset($_GET[$keyName]))
|
||||
{
|
||||
return $_GET[$keyName];
|
||||
}
|
||||
|
||||
if (isset($_COOKIE[$keyName]))
|
||||
{
|
||||
return $_COOKIE[$keyName];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
19
src/Helpers/TextHelper.php
Normal file
19
src/Helpers/TextHelper.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
class TextHelper
|
||||
{
|
||||
public static function isValidEmail($email)
|
||||
{
|
||||
$emailRegex = '/^[^@]+@[^@]+\.[^@]+$/';
|
||||
return preg_match($emailRegex, $email);
|
||||
}
|
||||
|
||||
public static function replaceTokens($text, array $tokens)
|
||||
{
|
||||
foreach ($tokens as $key => $value)
|
||||
{
|
||||
$token = '{' . $key . '}';
|
||||
$text = str_replace($token, $value, $text);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
}
|
9
src/Models/AccessRank.php
Normal file
9
src/Models/AccessRank.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
class AccessRank
|
||||
{
|
||||
const Anonymous = 0;
|
||||
const Registered = 1;
|
||||
const PowerUser = 2;
|
||||
const Moderator = 3;
|
||||
const Admin = 4;
|
||||
}
|
4
src/SimpleException.php
Normal file
4
src/SimpleException.php
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?php
|
||||
class SimpleException extends Exception
|
||||
{
|
||||
}
|
10
src/Views/auth-activation.phtml
Normal file
10
src/Views/auth-activation.phtml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?php if ($this->context->transport->success === true): ?>
|
||||
<p>Activation completed successfully.</p>
|
||||
<?php if ($this->context->transport->adminActivation): ?>
|
||||
<p>However, you still need to be approved by admin.</p>
|
||||
<?php endif ?>
|
||||
<?php endif ?>
|
||||
|
||||
<?php if (isset($this->context->transport->errorMessage)): ?>
|
||||
<p class="alert alert-error"><?php echo $this->context->transport->errorMessage ?></p>
|
||||
<?php endif ?>
|
19
src/Views/auth-login.phtml
Normal file
19
src/Views/auth-login.phtml
Normal file
|
@ -0,0 +1,19 @@
|
|||
<form action="<?php echo \Chibi\UrlHelper::route('auth', 'login') ?>" method="post">
|
||||
<div>
|
||||
<label for="user">User:</label>
|
||||
<input id="user" name="user"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pass">Password:</label>
|
||||
<input type="password" id="pass" name="pass"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="submit" value="Login">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (isset($this->context->transport->errorMessage)): ?>
|
||||
<p class="alert alert-error"><?php echo $this->context->transport->errorMessage ?></p>
|
||||
<?php endif ?>
|
39
src/Views/auth-register.phtml
Normal file
39
src/Views/auth-register.phtml
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?php if ($this->context->transport->success === true): ?>
|
||||
<p>Congratulations, you are registered.</p>
|
||||
<?php if ($this->context->transport->emailActivation): ?>
|
||||
<p>Please wait for activation e-mail.</p>
|
||||
<?php endif ?>
|
||||
<?php if ($this->context->transport->adminActivation): ?>
|
||||
<p>After this, an admin will have to confirm your registration.</p>
|
||||
<?php endif ?>
|
||||
<?php else: ?>
|
||||
<form action="<?php echo \Chibi\UrlHelper::route('auth', 'register') ?>" method="post">
|
||||
<div>
|
||||
<label for="user">User:</label>
|
||||
<input id="user" name="user" value="<?php echo $this->context->suppliedUser ?>"/ placeholder="e.g. darth_vader" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pass">E-mail address<?php if ($this->context->transport->emailActivation) echo ' (required)' ?>:</label>
|
||||
<input id="email" name="email" value="<?php echo $this->context->suppliedEmail ?>" placeholder="e.g. vader@empire.gov"/ autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pass">Password:</label>
|
||||
<input type="password" id="pass" name="pass1" value="<?php echo $this->context->suppliedPass1 ?>" placeholder="e.g. <?php echo str_repeat('●', 8) ?>"/ autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pass">Password (repeat):</label>
|
||||
<input type="password" id="pass" name="pass2" value="<?php echo $this->context->suppliedPass2 ?>" placeholder="e.g. <?php echo str_repeat('●', 8) ?>"/ autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="submit" value="Register">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (isset($this->context->transport->errorMessage)): ?>
|
||||
<p class="alert alert-error"><?php echo $this->context->transport->errorMessage ?></p>
|
||||
<?php endif ?>
|
||||
<?php endif ?>
|
72
src/Views/error-exception.phtml
Normal file
72
src/Views/error-exception.phtml
Normal file
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
echo '<h1>Unhandled exception</h1>';
|
||||
echo '<p>';
|
||||
printf('%s „%s” thrown at %s:%d',
|
||||
get_class($this->context->exception),
|
||||
$this->context->exception->getMessage(),
|
||||
$this->context->exception->getFile(),
|
||||
$this->context->exception->getLine());
|
||||
echo '</p>';
|
||||
|
||||
echo '<ul>';
|
||||
$count = 0;
|
||||
foreach ($this->context->exception->getTrace() as $frame)
|
||||
{
|
||||
$args = '';
|
||||
if (isset($frame['args']))
|
||||
{
|
||||
$args = array();
|
||||
foreach ($frame['args'] as $arg)
|
||||
{
|
||||
if (is_string($arg))
|
||||
{
|
||||
$args[] = "'" . $arg . "'";
|
||||
}
|
||||
elseif (is_array($arg))
|
||||
{
|
||||
$args[] = "Array";
|
||||
}
|
||||
elseif (is_null($arg))
|
||||
{
|
||||
$args[] = 'NULL';
|
||||
}
|
||||
elseif (is_bool($arg))
|
||||
{
|
||||
$args[] = ($arg) ? "true" : "false";
|
||||
}
|
||||
elseif (is_object($arg))
|
||||
{
|
||||
$args[] = get_class($arg);
|
||||
}
|
||||
elseif (is_resource($arg))
|
||||
{
|
||||
$args[] = get_resource_type($arg);
|
||||
}
|
||||
else
|
||||
{
|
||||
$args[] = $arg;
|
||||
}
|
||||
}
|
||||
$args = join(', ', $args);
|
||||
}
|
||||
|
||||
echo '<li>';
|
||||
printf('#%s %s(%s): %s(%s)<br>',
|
||||
$count,
|
||||
isset($frame['file'])
|
||||
? $frame['file']
|
||||
: 'unknown file',
|
||||
|
||||
isset($frame['line'])
|
||||
? $frame['line']
|
||||
: 'unknown line',
|
||||
|
||||
isset($frame['class'])
|
||||
? $frame['class'] . $frame['type'] . $frame['function']
|
||||
: $frame['function'],
|
||||
$args);
|
||||
echo '</li>';
|
||||
|
||||
$count++;
|
||||
}
|
||||
echo '</ul>';
|
1
src/Views/layout-json.phtml
Normal file
1
src/Views/layout-json.phtml
Normal file
|
@ -0,0 +1 @@
|
|||
<?php echo json_encode($this->context->transport, true) ?>
|
29
src/Views/layout-normal.phtml
Normal file
29
src/Views/layout-normal.phtml
Normal file
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>no title yet...</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<?php if (empty($this->context->user)): ?>
|
||||
<a href="<?php echo \Chibi\UrlHelper::route('auth', 'login') ?>">
|
||||
login
|
||||
</a>
|
||||
or
|
||||
<a href="<?php echo \Chibi\UrlHelper::route('auth', 'register') ?>">
|
||||
register
|
||||
</a>
|
||||
<?php else: ?>
|
||||
logged in as <?php echo $this->context->user->name ?>
|
||||
|
||||
<a href="<?php echo \Chibi\UrlHelper::route('auth', 'logout') ?>">
|
||||
logout
|
||||
</a>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
|
||||
<?php echo $this->renderView() ?>
|
||||
</body>
|
||||
</html>
|
1
src/Views/post-search.phtml
Normal file
1
src/Views/post-search.phtml
Normal file
|
@ -0,0 +1 @@
|
|||
Todo: view posts
|
Loading…
Reference in a new issue