Commit cba871c5 authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat: add install wizard form to bootstrap database and create the first superadmin user

- generate .env file to configure instance's environment
- add phpdotenv dependency to verify .env file
- add AppSeeder to call all required seeds at once
- add env and superadmin form views using form helpers

closes #2
parent 14dd44d0
......@@ -11,6 +11,7 @@ PHP Dependencies:
- [getID3](https://github.com/JamesHeinrich/getID3) ([GNU General Public License v3](https://github.com/JamesHeinrich/getID3/blob/2.0/licenses/license.gpl-30.txt))
- [myth-auth](https://github.com/lonnieezell/myth-auth) ([MIT license](https://github.com/lonnieezell/myth-auth/blob/develop/LICENSE.md))
- [commonmark](https://commonmark.thephpleague.com/) ([BSD 3-Clause "New" or "Revised" License](https://github.com/thephpleague/commonmark/blob/latest/LICENSE))
- [phpdotenv](https://github.com/vlucas/phpdotenv) ([ BSD-3-Clause License ](https://github.com/vlucas/phpdotenv/blob/master/LICENSE))
Javascript dependencies:
......
......@@ -34,7 +34,7 @@ class App extends BaseConfig
| variable so that it is blank.
|
*/
public $indexPage = 'index.php';
public $indexPage = '';
/*
|--------------------------------------------------------------------------
......@@ -281,7 +281,7 @@ class App extends BaseConfig
|--------------------------------------------------------------------------
| Defines a base route for all admin pages
*/
public $adminGateway = 'admin';
public $adminGateway = 'cp-admin';
/*
|--------------------------------------------------------------------------
......@@ -289,5 +289,5 @@ class App extends BaseConfig
|--------------------------------------------------------------------------
| Defines a base route for all authentication related pages
*/
public $authGateway = 'auth';
public $authGateway = 'cp-auth';
}
......@@ -38,7 +38,7 @@ class Database extends \CodeIgniter\Database\Config
'password' => '',
'database' => '',
'DBDriver' => 'MySQLi',
'DBPrefix' => '',
'DBPrefix' => 'cp_',
'pConnect' => false,
'DBDebug' => ENVIRONMENT !== 'production',
'cacheOn' => false,
......
......@@ -31,7 +31,6 @@ $routes->setAutoRoute(false);
$routes->addPlaceholder('podcastName', '[a-zA-Z0-9\_]{1,191}');
$routes->addPlaceholder('episodeSlug', '[a-zA-Z0-9\-]{1,191}');
$routes->addPlaceholder('username', '[a-zA-Z0-9 ]{3,}');
/**
* --------------------------------------------------------------------
......@@ -43,6 +42,17 @@ $routes->addPlaceholder('username', '[a-zA-Z0-9 ]{3,}');
// route since we don't have to scan directories.
$routes->get('/', 'Home::index', ['as' => 'home']);
// Install Wizard route
$routes->group('cp-install', function ($routes) {
$routes->get('/', 'Install', ['as' => 'install']);
$routes->post('generate-env', 'Install::attemptCreateEnv', [
'as' => 'install_generate_env',
]);
$routes->post('create-superadmin', 'Install::attemptCreateSuperAdmin', [
'as' => 'install_create_superadmin',
]);
});
// Public routes
$routes->group('@(:podcastName)', function ($routes) {
$routes->get('/', 'Podcast/$1', ['as' => 'podcast']);
......@@ -68,7 +78,7 @@ $routes->group(
['namespace' => 'App\Controllers\Admin'],
function ($routes) {
$routes->get('/', 'Home', [
'as' => 'admin_home',
'as' => 'admin',
]);
$routes->get('my-podcasts', 'Podcast::myPodcasts', [
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Controllers;
use App\Models\UserModel;
use Config\Services;
use Dotenv\Dotenv;
use Exception;
class Install extends BaseController
{
/**
* Every operation goes through this method to handle
* the install logic.
*
* If all required actions have already been performed,
* the install route will show a 404 page.
*/
public function index()
{
try {
// Check if .env is created and has all required fields
$dotenv = Dotenv::createImmutable(ROOTPATH);
$dotenv->load();
$dotenv->required([
'app.baseURL',
'app.adminGateway',
'app.authGateway',
'database.default.hostname',
'database.default.database',
'database.default.username',
'database.default.password',
'database.default.DBPrefix',
]);
} catch (\Throwable $e) {
// Invalid .env file
return $this->createEnv();
}
// Check if database configuration is ok
try {
$db = db_connect();
// Check if superadmin has been created, meaning migrations and seeds have passed
if (
$db->tableExists('users') &&
(new UserModel())->countAll() > 0
) {
// if so, show a 404 page
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
// return an error view to
return view('install/error', [
'error' => lang('Install.messages.databaseConnectError'),
]);
}
// migrate if no user has been created
$this->migrate();
// Check if all seeds have succeeded
$this->seed();
return $this->createSuperAdmin();
}
/**
* Returns the form to generate the .env config file for the instance.
*/
public function createEnv()
{
helper('form');
return view('install/env');
}
/**
* Verifies that all fields have been submitted correctly and
* creates the .env file after user submits the install form.
*/
public function attemptCreateEnv()
{
if (
!$this->validate([
'hostname' => 'required|valid_url',
'admin_gateway' => 'required|differs[auth_gateway]',
'auth_gateway' => 'required|differs[admin_gateway]',
'db_hostname' => 'required',
'db_name' => 'required',
'db_username' => 'required',
'db_password' => 'required',
])
) {
return redirect()
->back()
->with('errors', $this->validator->getErrors());
}
// Create .env file with post data
try {
$envFile = fopen(ROOTPATH . '.env', 'w');
if (!$envFile) {
throw new Exception('File open failed.');
}
$envMapping = [
[
'key' => 'app.baseURL',
'value' => $this->request->getPost('hostname'),
],
[
'key' => 'app.adminGateway',
'value' => $this->request->getPost('admin_gateway'),
],
[
'key' => 'app.authGateway',
'value' => $this->request->getPost('auth_gateway'),
],
[
'key' => 'database.default.hostname',
'value' => $this->request->getPost('db_hostname'),
],
[
'key' => 'database.default.database',
'value' => $this->request->getPost('db_name'),
],
[
'key' => 'database.default.username',
'value' => $this->request->getPost('db_username'),
],
[
'key' => 'database.default.password',
'value' => $this->request->getPost('db_password'),
],
[
'key' => 'database.default.DBPrefix',
'value' => $this->request->getPost('db_prefix'),
],
];
foreach ($envMapping as $envVar) {
if ($envVar['value']) {
fwrite(
$envFile,
$envVar['key'] . '="' . $envVar['value'] . '"' . PHP_EOL
);
}
}
return redirect()->back();
} catch (\Throwable $e) {
return redirect()
->back()
->with('error', $e->getMessage());
} finally {
fclose($envFile);
}
}
/**
* Runs all database migrations required for instance.
*/
public function migrate()
{
$migrations = \Config\Services::migrations();
if (
!$migrations->setNamespace('Myth\Auth')->latest() or
!$migrations->setNamespace(APP_NAMESPACE)->latest()
) {
return view('install/error', [
'error' => lang('Install.messages.migrationError'),
]);
}
}
/**
* Runs all database seeds required for instance.
*/
public function seed()
{
try {
$seeder = \Config\Database::seeder();
// Seed database
$seeder->call('AppSeeder');
} catch (\Throwable $e) {
return view('install/error', [
'error' => lang('Install.messages.seedError'),
]);
}
}
/**
* Returns the form to create a the first superadmin user for the instance.
*/
public function createSuperAdmin()
{
helper('form');
return view('install/superadmin');
}
/**
* Creates the first superadmin user or redirects back to form if any error.
*
* After creation, user is redirected to login page to input its credentials.
*/
public function attemptCreateSuperAdmin()
{
$userModel = new UserModel();
// Validate here first, since some things,
// like the password, can only be validated properly here.
$rules = array_merge(
$userModel->getValidationRules(['only' => ['username']]),
[
'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|strong_password',
]
);
if (!$this->validate($rules)) {
return redirect()
->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
// Save the user
$user = new \App\Entities\User($this->request->getPost());
// Activate user
$user->activate();
$db = \Config\Database::connect();
$db->transStart();
if (!($userId = $userModel->insert($user, true))) {
$db->transComplete();
return redirect()
->back()
->withInput()
->with('errors', $userModel->errors());
}
// add newly created user to superadmin group
$authorization = Services::authorization();
$authorization->addUserToGroup($userId, 'superadmin');
$db->transComplete();
// Success!
// set redirect url to admin page after being redirected to login page
$_SESSION['redirect_url'] = route_to('admin');
return redirect()
->route('login')
->with('message', lang('Install.messages.createSuperAdminSuccess'));
}
}
<?php
/**
* Class AppSeeder
* Calls all required seeders for castopod to work properly
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Controllers;
namespace App\Database\Seeds;
use CodeIgniter\Controller;
use CodeIgniter\Database\Seeder;
class Migrate extends Controller
class AppSeeder extends Seeder
{
public function index()
public function run()
{
$migrate = \Config\Services::migrations();
try {
$migrate->latest();
} catch (\Exception $e) {
// Do something with the error here...
}
$this->call('AuthSeeder');
$this->call('CategorySeeder');
$this->call('LanguageSeeder');
$this->call('PlatformSeeder');
}
}
......@@ -10,7 +10,7 @@ return [
'dashboard' => 'Dashboard',
'podcasts' => 'Podcasts',
'users' => 'Users',
'admin_home' => 'Home',
'admin' => 'Home',
'my_podcasts' => 'My podcasts',
'podcast_list' => 'All podcasts',
'podcast_create' => 'New podcast',
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'form' => [
'castopod_config' => 'Castopod configuration',
'hostname' => 'Hostname',
'admin_gateway' => 'Admin gateway',
'auth_gateway' => 'Auth gateway',
'db_config' => 'Database configuration',
'db_hostname' => 'Database hostname',
'db_name' => 'Database name',
'db_username' => 'Database username',
'db_password' => 'Database password',
'db_prefix' => 'Database prefix',
'submit_install' => 'Install!',
'create_superadmin' => 'Create your superadmin account',
'email' => 'Email',
'username' => 'Username',
'password' => 'Password',
'submit_create_superadmin' => 'Create superadmin!',
],
'messages' => [
'migrateSuccess' =>
'Database has been created successfully, and all required data have been stored!',
'createSuperAdminSuccess' =>
'Your superadmin account has been created successfully. Let\'s login to the admin area!',
'databaseConnectError' =>
'Unable to connect to the database. Make sure the values in .env are correct. If not, edit them and refresh the page or delete the .env file to restart install.',
'migrationError' =>
'There was an issue during migration. Make sure the values in .env are correct. If not, edit them and refresh the page or delete the .env file to restart install.',
'seedError' =>
'There was an issue when seeding the database. Make sure the values in .env are correct. If not, edit them and refresh the page or delete the .env file to restart install.',
'error' =>
'<strong>An error occurred during install</strong><br/> {message}',
],
];
<header class="<?= $class ?>">
<div class="w-64">
<a href="<?= route_to(
'admin_home'
'admin'
) ?>" class="inline-flex items-center text-xl">
<?= svg('logo-castopod', 'text-3xl mr-2') ?>
Admin
......
<?php
$navigation = [
'dashboard' => ['icon' => 'dashboard', 'items' => ['admin_home']],
'dashboard' => ['icon' => 'dashboard', 'items' => ['admin']],
'podcasts' => [
'icon' => 'mic',
'items' => ['my_podcasts', 'podcast_list', 'podcast_create'],
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Castopod</title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/index.css"/>
</head>
<body class="flex flex-col min-h-screen mx-auto">
<header class="border-b">
<div class="container flex items-center justify-between px-2 py-4 mx-auto">
Castopod installer
</div>
</header>
<main class="container flex-1 px-4 py-10 mx-auto">
<?= view('_message_block') ?>
<?= $this->renderSection('content') ?>
</main>
<footer class="container px-2 py-4 mx-auto text-sm text-right border-t">
Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative.
</footer>
</body>
<?= $this->extend('install/_layout') ?>
<?= $this->section('content') ?>
<?= form_open(route_to('install_generate_env'), [
'class' => 'flex flex-col max-w-sm mx-auto',
]) ?>
<?= form_fieldset('', ['class' => 'flex flex-col mb-6']) ?>
<legend class="mb-4 text-xl"><?= lang(
'Install.form.castopod_config'
) ?></legend>
<?= form_label(lang('Install.form.hostname'), 'hostname') ?>
<?= form_input([
'id' => 'hostname',
'name' => 'hostname',
'class' => 'form-input mb-4',
'value' => config('App')->baseURL,
]) ?>
<?= form_label(lang('Install.form.admin_gateway'), 'admin_gateway') ?>
<?= form_input([
'id' => 'admin_gateway',
'name' => 'admin_gateway',
'class' => 'form-input mb-4',
'value' => config('App')->adminGateway,
]) ?>
<?= form_label(lang('Install.form.auth_gateway'), 'auth_gateway') ?>
<?= form_input([
'id' => 'auth_gateway',
'name' => 'auth_gateway',
'class' => 'form-input',
'value' => config('App')->authGateway,
]) ?>
<?= form_fieldset_close() ?>
<?= form_fieldset('', ['class' => 'flex flex-col mb-6']) ?>
<legend class="mb-4 text-xl"><?= lang('Install.form.db_config') ?></legend>
<?= form_label(lang('Install.form.db_hostname'), 'db_hostname') ?>
<?= form_input([
'id' => 'db_hostname',
'name' => 'db_hostname',
'class' => 'form-input mb-4',
'value' => config('Database')->default['hostname'],
]) ?>
<?= form_label(lang('Install.form.db_name'), 'db_name') ?>
<?= form_input([
'id' => 'db_name',
'name' => 'db_name',
'class' => 'form-input mb-4',
'value' => config('Database')->default['database'],
]) ?>
<?= form_label(lang('Install.form.db_username'), 'db_username') ?>
<?= form_input([
'id' => 'db_username',
'name' => 'db_username',
'class' => 'form-input mb-4',
'value' => config('Database')->default['username'],
]) ?>
<?= form_label(lang('Install.form.db_password'), 'db_password') ?>
<?= form_input([
'id' => 'db_password',
'name' => 'db_password',
'class' => 'form-input mb-4',
'value' => config('Database')->default['password'],
]) ?>
<?= form_label(lang('Install.form.db_prefix'), 'db_prefix') ?>
<?= form_input([
'id' => 'db_prefix',
'name' => 'db_prefix',
'class' => 'form-input',
'value' => config('Database')->default['DBPrefix'],
]) ?>
<?= form_fieldset_close() ?>
<?= form_button([
'content' => lang('Install.form.submit_install'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= form_close() ?>
<?= $this->endSection() ?>
<?= $this->extend('install/_layout') ?>
<?= $this->section('content') ?>
<div class="px-4 py-2 mb-4 font-semibold text-red-900 bg-red-200 border border-red-700">
<?= lang('Install.messages.error', ['message' => $error]) ?>
</div>
<?= $this->endSection() ?>
<?= $this->extend('install/_layout') ?>
<?= $this->section('content') ?>
<?= form_open(route_to('install_create_superadmin'), [
'class' => 'flex flex-col max-w-sm mx-auto',
]) ?>
<?= form_fieldset('', ['class' => 'flex flex-col mb-6']) ?>
<legend class="mb-4 text-xl"><?= lang(
'Install.form.create_superadmin'
) ?></legend>
<?= form_label(lang('Install.form.email'), 'email') ?>
<?= form_input([
'id' => 'email',
'name' => 'email',
'class' => 'form-input mb-4',
'type' => 'email',
]) ?>
<?= form_label(lang('Install.form.username'), 'username') ?>
<?= form_input([
'id' => 'username',
'name' => 'username',
'class' => 'form-input mb-4',
]) ?>
<?= form_label(lang('Install.form.password'), 'password') ?>
<?= form_input([
'id' => 'password',