From 769ea469c95744c7846f387cbdf130cbfa63e49f Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Thu, 8 Oct 2020 16:38:30 +0000
Subject: [PATCH] refactor: update install logic and add missing cache config
 step

- add `.env.example` and `INSTALL.md` to castopod bundle for installation docs
- update seeders to be silent on insert errors
- update install layout
- add manual config instructions when .env file is not writable
- fix eslint error in Charts.ts module

closes #32
---
 .env.example                              |  27 ++
 .rsync-filter                             |   2 +
 INSTALL.md                                |  73 +++++
 app/Config/Routes.php                     |  10 +-
 app/Controllers/Admin/Podcast.php         |   2 +-
 app/Controllers/Admin/PodcastSettings.php |   2 +-
 app/Controllers/Install.php               | 320 ++++++++++++++--------
 app/Database/Seeds/AuthSeeder.php         |  11 +-
 app/Database/Seeds/CategorySeeder.php     |   5 +-
 app/Database/Seeds/LanguageSeeder.php     |   5 +-
 app/Database/Seeds/PlatformSeeder.php     |   5 +-
 app/Helpers/url_helper.php                |  22 ++
 app/Language/en/Install.php               |  41 ++-
 app/Language/en/Validation.php            |   2 +
 app/Validation/Rules.php                  |  18 ++
 app/Views/_assets/icons/arrow-right.svg   |   6 +
 app/Views/_assets/icons/check.svg         |   6 +
 app/Views/_assets/install.ts              |   3 +
 app/Views/install/_layout.php             |   3 +-
 app/Views/install/cache_config.php        |  44 +++
 app/Views/install/create_superadmin.php   |  52 ++++
 app/Views/install/database_config.php     |  80 ++++++
 app/Views/install/env.php                 |  98 -------
 app/Views/install/error.php               |   9 -
 app/Views/install/instance_config.php     |  59 ++++
 app/Views/install/manual_config.php       |  14 +
 app/Views/install/superadmin.php          |  53 ----
 27 files changed, 675 insertions(+), 297 deletions(-)
 create mode 100644 .env.example
 create mode 100644 INSTALL.md
 create mode 100644 app/Helpers/url_helper.php
 create mode 100644 app/Views/_assets/icons/arrow-right.svg
 create mode 100644 app/Views/_assets/icons/check.svg
 create mode 100644 app/Views/_assets/install.ts
 create mode 100644 app/Views/install/cache_config.php
 create mode 100644 app/Views/install/create_superadmin.php
 create mode 100644 app/Views/install/database_config.php
 delete mode 100644 app/Views/install/env.php
 delete mode 100644 app/Views/install/error.php
 create mode 100644 app/Views/install/instance_config.php
 create mode 100644 app/Views/install/manual_config.php
 delete mode 100644 app/Views/install/superadmin.php

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000..a1b2a68103
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,27 @@
+#--------------------------------------------------------------------
+# Example Environment Configuration file
+# 
+# This file can be used as a starting point for
+# your castopod instance settings.
+#
+# For manual configuration:
+#   - copy this file's contents to a file named `.env`
+#   - replace all the default settings with your values
+#   - go to `/cp-install` to complete installation
+#--------------------------------------------------------------------
+
+# Instance configuration
+app.baseURL="https://YOUR_DOMAIN_NAME/"
+app.adminGateway="cp-admin"
+app.authGateway="cp-auth"
+
+# Database configuration
+database.default.hostname="localhost"
+database.default.database="castopod"
+database.default.username="root"
+database.default.password="root"
+database.default.DBPrefix="cp_"
+
+# Cache configuration (advanced)
+# Keep as is if you don't know what this means
+cache.handler="file"
diff --git a/.rsync-filter b/.rsync-filter
index 1ee766c269..606c8a9106 100644
--- a/.rsync-filter
+++ b/.rsync-filter
@@ -5,7 +5,9 @@
 + public/***
 + vendor/***
 + writable/***
++ .env.example
 + DEPENDENCIES.md
 + LICENSE
 + README.md
++ INSTALL.md
 - **
diff --git a/INSTALL.md b/INSTALL.md
new file mode 100644
index 0000000000..3f07f1aada
--- /dev/null
+++ b/INSTALL.md
@@ -0,0 +1,73 @@
+# How to install Castopod
+
+Castopod was thought to be easy to install. Whether using dedicated or shared hosting, you can install it on most PHP-MySQL compatible web servers.
+
+- [Install instructions](#install-instructions)
+  - [(optional) Manual configuration](#optional-manual-configuration)
+- [Web Server Requirements](#web-server-requirements)
+  - [PHP v7.2 or higher](#php-v72-or-higher)
+  - [MySQL compatible database](#mysql-compatible-database)
+  - [(Optional) Other recommendations](#optional-other-recommendations)
+- [Security concerns](#security-concerns)
+
+## Install instructions
+
+0. Create a MySQL database for Castopod with a user having access and modification privileges (for more info, see [Web Server Requirements](#web-server-requirements)).
+1. Download and unzip the Castopod package onto the web server if you haven’t already.
+   - ⚠️ Set the web server document root to the `public/` sub-folder.
+2. Run the Castopod install script by going to the install wizard page (`https://your_domain_name.com/cp-install`) in your favorite web browser.
+3. Follow the instructions on your screen.
+
+All done, start podcasting!
+
+### (optional) Manual configuration
+
+Before uploading Castopod files to your web server:
+
+1. Rename the `.env.example` file to `.env` and update the default values with your own.
+2. Upload the Castopod files with `.env`
+3. Go to `/cp-install` to finish the install process.
+
+## Web Server Requirements
+
+### PHP v7.2 or higher
+
+PHP version 7.2 or higher is required, with the following extensions installed:
+
+- [intl](http://php.net/manual/en/intl.requirements.php)
+- [libcurl](http://php.net/manual/en/curl.requirements.php) if you plan to use the HTTP\CURLRequest library
+- [mbstring](http://php.net/manual/en/mbstring.installation.php)
+
+Additionally, make sure that the following extensions are enabled in your PHP:
+
+- json (enabled by default - don't turn it off)
+- xml (enabled by default - don't turn it off)
+- [mysqlnd](http://php.net/manual/en/mysqlnd.install.php)
+
+### MySQL compatible database
+
+> We recommend using [MariaDB](https://mariadb.org)
+
+You will need the server hostname, database name, username and password to complete the installation process. If you do not have these, please contact your server administrator.
+
+#### Privileges
+
+User must have at least these privileges on the database for Castopod to work: `ALTER`, `DELETE`, `EXECUTE`, `INDEX`, `INSERT`, `SELECT`, `UPDATE`.
+
+### (Optional) Other recommendations
+
+- Redis for better cache performances.
+- CDN for better performances.
+- e-mail gateway for lost passwords.
+
+## Security concerns
+
+Castopod is built on top of Codeigniter, a PHP framework that encourages [good security practices](https://codeigniter.com/user_guide/concepts/security.html).
+
+To maximize your instance safety and prevent any malicious attack, we recommend you update all your Castopod files permissions:
+
+- `writable/` folder must be **readable** and **writable**.
+- `public/media/` folder must be **readable** and **writable**.
+- any other file must be set to **readonly**.
+
+// TODO: add instructions on how to set file permissions.
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 159afc4764..b580e3f4ed 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -45,8 +45,14 @@ $routes->get('/', 'Home::index', ['as' => 'home']);
 // Install Wizard route
 $routes->group(config('App')->installGateway, function ($routes) {
     $routes->get('/', 'Install', ['as' => 'install']);
-    $routes->post('generate-env', 'Install::attemptCreateEnv', [
-        'as' => 'generate-env',
+    $routes->post('instance-config', 'Install::attemptInstanceConfig', [
+        'as' => 'instance-config',
+    ]);
+    $routes->post('database-config', 'Install::attemptDatabaseConfig', [
+        'as' => 'database-config',
+    ]);
+    $routes->post('cache-config', 'Install::attemptCacheConfig', [
+        'as' => 'cache-config',
     ]);
     $routes->post('create-superadmin', 'Install::attemptCreateSuperAdmin', [
         'as' => 'create-superadmin',
diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php
index 64594e0743..1e68a84437 100644
--- a/app/Controllers/Admin/Podcast.php
+++ b/app/Controllers/Admin/Podcast.php
@@ -176,7 +176,7 @@ class Podcast extends BaseController
         helper(['media', 'misc']);
 
         $rules = [
-            'imported_feed_url' => 'required|valid_url',
+            'imported_feed_url' => 'required|validate_url',
             'season_number' => 'is_natural_no_zero|permit_empty',
             'max_episodes' => 'is_natural_no_zero|permit_empty',
         ];
diff --git a/app/Controllers/Admin/PodcastSettings.php b/app/Controllers/Admin/PodcastSettings.php
index f4b65a8935..e9c079c92a 100644
--- a/app/Controllers/Admin/PodcastSettings.php
+++ b/app/Controllers/Admin/PodcastSettings.php
@@ -64,7 +64,7 @@ class PodcastSettings extends BaseController
             $platformLinkUrl = $platformLink['url'];
             if (
                 !empty($platformLinkUrl) &&
-                $validation->check($platformLinkUrl, 'valid_url')
+                $validation->check($platformLinkUrl, 'validate_url')
             ) {
                 $platformId = $platformModel->getPlatformId($platformName);
                 array_push($platformLinksData, [
diff --git a/app/Controllers/Install.php b/app/Controllers/Install.php
index fd5f4f6835..712033c22c 100644
--- a/app/Controllers/Install.php
+++ b/app/Controllers/Install.php
@@ -9,12 +9,26 @@
 namespace App\Controllers;
 
 use App\Models\UserModel;
+use CodeIgniter\Controller;
 use Config\Services;
 use Dotenv\Dotenv;
-use Exception;
 
-class Install extends BaseController
+class Install extends Controller
 {
+    protected $helpers = ['form', 'components', 'svg'];
+
+    /**
+     * Constructor.
+     */
+    public function initController(
+        \CodeIgniter\HTTP\RequestInterface $request,
+        \CodeIgniter\HTTP\ResponseInterface $response,
+        \Psr\Log\LoggerInterface $logger
+    ) {
+        // Do Not Edit This Line
+        parent::initController($request, $response, $logger);
+    }
+
     /**
      * Every operation goes through this method to handle
      * the install logic.
@@ -26,25 +40,61 @@ class Install extends BaseController
     {
         try {
             // Check if .env is created and has all required fields
-            $dotenv = Dotenv::createImmutable(ROOTPATH);
+            $dotenv = Dotenv::createUnsafeImmutable(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
+        // Check if the created .env file is writable to continue install process
+        if (is_writable(ROOTPATH . '.env')) {
+            try {
+                $dotenv->required([
+                    'app.baseURL',
+                    'app.adminGateway',
+                    'app.authGateway',
+                ]);
+            } catch (\Dotenv\Exception\ValidationException $e) {
+                // form to input instance configuration
+                return $this->instanceConfig();
+            }
+
+            try {
+                $dotenv->required([
+                    'database.default.hostname',
+                    'database.default.database',
+                    'database.default.username',
+                    'database.default.password',
+                    'database.default.DBPrefix',
+                ]);
+            } catch (\Dotenv\Exception\ValidationException $e) {
+                return $this->databaseConfig();
+            }
+
+            try {
+                $dotenv->required('cache.handler');
+            } catch (\Dotenv\Exception\ValidationException $e) {
+                return $this->cacheConfig();
+            }
+        } else {
+            try {
+                $dotenv->required([
+                    'app.baseURL',
+                    'app.adminGateway',
+                    'app.authGateway',
+                    'database.default.hostname',
+                    'database.default.database',
+                    'database.default.username',
+                    'database.default.password',
+                    'database.default.DBPrefix',
+                    'cache.handler',
+                ]);
+            } catch (\Dotenv\Exception\ValidationException $e) {
+                return view('install/manual_config');
+            }
+        }
+
         try {
             $db = db_connect();
 
@@ -57,10 +107,14 @@ class Install extends BaseController
                 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'),
-            ]);
+            // Could not connect to the database
+            // show database config view to fix value
+            session()->setFlashdata(
+                'error',
+                lang('Install.messages.databaseConnectError')
+            );
+
+            return view('install/database_config');
         }
 
         // migrate if no user has been created
@@ -77,92 +131,108 @@ class Install extends BaseController
      */
     public function createEnv()
     {
-        helper('form');
+        // create empty .env file
+        try {
+            $envFile = fopen(ROOTPATH . '.env', 'w');
+            fclose($envFile);
+        } catch (\Throwable $e) {
+            // Could not create the .env file, redirect to a view with manual instructions on how to add it
+            return view('install/manual_config');
+        }
 
-        return view('install/env');
+        return redirect()->back();
     }
 
-    /**
-     * Verifies that all fields have been submitted correctly and
-     * creates the .env file after user submits the install form.
-     */
-    public function attemptCreateEnv()
+    public function instanceConfig()
     {
-        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 view('install/instance_config');
+    }
+
+    public function attemptInstanceConfig()
+    {
+        $rules = [
+            'hostname' => 'required|validate_url',
+            'admin_gateway' => 'required',
+            'auth_gateway' => 'required|differs[admin_gateway]',
+        ];
+
+        if (!$this->validate($rules)) {
             return redirect()
                 ->back()
+                ->withInput()
                 ->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.');
-            }
+        self::writeEnv([
+            'app.baseURL' => $this->request->getPost('hostname'),
+            'app.adminGateway' => $this->request->getPost('admin_gateway'),
+            'app.authGateway' => $this->request->getPost('auth_gateway'),
+        ]);
 
-            $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();
+    }
 
-            return redirect()->back();
-        } catch (\Throwable $e) {
+    public function databaseConfig()
+    {
+        return view('install/database_config');
+    }
+
+    public function attemptDatabaseConfig()
+    {
+        $rules = [
+            'db_hostname' => 'required',
+            'db_name' => 'required',
+            'db_username' => 'required',
+            'db_password' => 'required',
+        ];
+
+        if (!$this->validate($rules)) {
             return redirect()
                 ->back()
-                ->with('error', $e->getMessage());
-        } finally {
-            fclose($envFile);
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
         }
+
+        self::writeEnv([
+            'database.default.hostname' => $this->request->getPost(
+                'db_hostname'
+            ),
+            'database.default.database' => $this->request->getPost('db_name'),
+            'database.default.username' => $this->request->getPost(
+                'db_username'
+            ),
+            'database.default.password' => $this->request->getPost(
+                'db_password'
+            ),
+            'database.default.DBPrefix' => $this->request->getPost('db_prefix'),
+        ]);
+
+        return redirect()->back();
+    }
+
+    public function cacheConfig()
+    {
+        return view('install/cache_config');
+    }
+
+    public function attemptCacheConfig()
+    {
+        $rules = [
+            'cache_handler' => 'required',
+        ];
+
+        if (!$this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
+        }
+
+        self::writeEnv([
+            'cache.handler' => $this->request->getPost('cache_handler'),
+        ]);
+
+        return redirect()->back();
     }
 
     /**
@@ -172,14 +242,8 @@ class Install extends BaseController
     {
         $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'),
-            ]);
-        }
+        !$migrations->setNamespace('Myth\Auth')->latest();
+        !$migrations->setNamespace(APP_NAMESPACE)->latest();
     }
 
     /**
@@ -187,16 +251,10 @@ class Install extends BaseController
      */
     public function seed()
     {
-        try {
-            $seeder = \Config\Database::seeder();
+        $seeder = \Config\Database::seeder();
 
-            // Seed database
-            $seeder->call('AppSeeder');
-        } catch (\Throwable $e) {
-            return view('install/error', [
-                'error' => lang('Install.messages.seedError'),
-            ]);
-        }
+        // Seed database
+        $seeder->call('AppSeeder');
     }
 
     /**
@@ -204,9 +262,7 @@ class Install extends BaseController
      */
     public function createSuperAdmin()
     {
-        helper('form');
-
-        return view('install/superadmin');
+        return view('install/create_superadmin');
     }
 
     /**
@@ -245,7 +301,7 @@ class Install extends BaseController
 
         $db->transStart();
         if (!($userId = $userModel->insert($user, true))) {
-            $db->transComplete();
+            $db->transRollback();
 
             return redirect()
                 ->back()
@@ -260,11 +316,47 @@ class Install extends BaseController
         $db->transComplete();
 
         // Success!
-        // set redirect url to admin page after being redirected to login page
-        $_SESSION['redirect_url'] = route_to('admin');
+        // set redirect_url session as admin area to go to after login
+        session()->set('redirect_url', route_to('admin'));
 
         return redirect()
             ->route('login')
             ->with('message', lang('Install.messages.createSuperAdminSuccess'));
     }
+
+    /**
+     * writes config values in .env file
+     * overwrites any existing key and appends new ones
+     *
+     * @param array $data key/value config pairs
+     *
+     * @return void
+     */
+    public static function writeEnv($configData)
+    {
+        $envData = file(ROOTPATH . '.env'); // reads an array of lines
+
+        foreach ($configData as $key => $value) {
+            $replaced = false;
+            $keyVal = $key . '="' . $value . '"' . PHP_EOL;
+            $envData = array_map(function ($line) use (
+                $key,
+                $keyVal,
+                &$replaced
+            ) {
+                if (strpos($line, $key) === 0) {
+                    $replaced = true;
+                    return $keyVal;
+                }
+                return $line;
+            },
+            $envData);
+
+            if (!$replaced) {
+                array_push($envData, $keyVal);
+            }
+        }
+
+        file_put_contents(ROOTPATH . '.env', implode('', $envData));
+    }
 }
diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php
index c07833b31e..8c509669f5 100644
--- a/app/Database/Seeds/AuthSeeder.php
+++ b/app/Database/Seeds/AuthSeeder.php
@@ -247,10 +247,17 @@ class AuthSeeder extends Seeder
             }
         }
 
-        $this->db->table('auth_permissions')->insertBatch($dataPermissions);
-        $this->db->table('auth_groups')->insertBatch($dataGroups);
+        $this->db
+            ->table('auth_permissions')
+            ->ignore(true)
+            ->insertBatch($dataPermissions);
+        $this->db
+            ->table('auth_groups')
+            ->ignore(true)
+            ->insertBatch($dataGroups);
         $this->db
             ->table('auth_groups_permissions')
+            ->ignore(true)
             ->insertBatch($dataGroupsPermissions);
     }
 }
diff --git a/app/Database/Seeds/CategorySeeder.php b/app/Database/Seeds/CategorySeeder.php
index a62113981a..f1c33c136b 100644
--- a/app/Database/Seeds/CategorySeeder.php
+++ b/app/Database/Seeds/CategorySeeder.php
@@ -797,6 +797,9 @@ class CategorySeeder extends Seeder
             ],
         ];
 
-        $this->db->table('categories')->insertBatch($data);
+        $this->db
+            ->table('categories')
+            ->ignore(true)
+            ->insertBatch($data);
     }
 }
diff --git a/app/Database/Seeds/LanguageSeeder.php b/app/Database/Seeds/LanguageSeeder.php
index da497e5e30..31fee8fc7c 100644
--- a/app/Database/Seeds/LanguageSeeder.php
+++ b/app/Database/Seeds/LanguageSeeder.php
@@ -633,6 +633,9 @@ class LanguageSeeder extends Seeder
             ['code' => 'zu', 'name' => 'Zulu', 'native_name' => 'isiZulu'],
         ];
 
-        $this->db->table('languages')->insertBatch($data);
+        $this->db
+            ->table('languages')
+            ->ignore(true)
+            ->insertBatch($data);
     }
 }
diff --git a/app/Database/Seeds/PlatformSeeder.php b/app/Database/Seeds/PlatformSeeder.php
index 11e6b96b1d..274e1e6090 100644
--- a/app/Database/Seeds/PlatformSeeder.php
+++ b/app/Database/Seeds/PlatformSeeder.php
@@ -163,6 +163,9 @@ class PlatformSeeder extends Seeder
                 'icon_filename' => 'tunein.svg',
             ],
         ];
-        $this->db->table('platforms')->insertBatch($data);
+        $this->db
+            ->table('platforms')
+            ->ignore(true)
+            ->insertBatch($data);
     }
 }
diff --git a/app/Helpers/url_helper.php b/app/Helpers/url_helper.php
new file mode 100644
index 0000000000..7f9e157bad
--- /dev/null
+++ b/app/Helpers/url_helper.php
@@ -0,0 +1,22 @@
+<?php
+
+if (!function_exists('host_url')) {
+    /**
+     * Return the host URL to use in views
+     *
+     * @return string|false
+     */
+    function host_url()
+    {
+        if (isset($_SERVER['host'])) {
+            $protocol =
+                (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
+                $_SERVER['SERVER_PORT'] == 443
+                    ? 'https://'
+                    : 'http://';
+            return $protocol + $_SERVER['host'];
+        }
+
+        return false;
+    }
+}
diff --git a/app/Language/en/Install.php b/app/Language/en/Install.php
index ede3aa9391..10ae23a3bb 100644
--- a/app/Language/en/Install.php
+++ b/app/Language/en/Install.php
@@ -7,18 +7,39 @@
  */
 
 return [
+    'manual_config' => 'Manual configuration',
+    'manual_config_subtitle' =>
+        'Create a `.env` file with your settings and refresh the page to continue installation.',
     'form' => [
-        'castopod_config' => 'Castopod configuration',
+        'instance_config' => 'Instance configuration',
         'hostname' => 'Hostname',
         'admin_gateway' => 'Admin gateway',
+        'admin_gateway_hint' =>
+            'The route to access the admin area (eg. https://example.com/cp-admin). It is set by default as cp-admin, we recommend you change it for security reasons.',
         'auth_gateway' => 'Auth gateway',
-        'db_config' => 'Database configuration',
+        'auth_gateway_hint' =>
+            'The route to access the authentication pages (eg. https://example.com/cp-auth). It is set by default as cp-auth, we recommend you change it for security reasons.',
+        'database_config' => 'Database configuration',
+        'database_config_hint' =>
+            'Castopod needs to connect to your MySQL (or MariaDB) database. If you do not have these required info, please contact your server administrator.',
         'db_hostname' => 'Database hostname',
         'db_name' => 'Database name',
         'db_username' => 'Database username',
         'db_password' => 'Database password',
         'db_prefix' => 'Database prefix',
-        'submit_install' => 'Install!',
+        'db_prefix_hint' =>
+            'The prefix of the Castopod table names, leave as is if you don\'t know what it means.',
+        'cache_config' => 'Cache configuration',
+        'cache_config_hint' =>
+            'Choose your preferred cache handler. Leave it as the default value if you have no clue what it means.',
+        'cache_handler' => 'Cache handler',
+        'cacheHandlerOptions' => [
+            'file' => 'File',
+            'redis' => 'Redis',
+            'memcached' => 'Memcached',
+        ],
+        'next' => 'Next',
+        'submit' => 'Finish install',
         'create_superadmin' => 'Create your superadmin account',
         'email' => 'Email',
         'username' => 'Username',
@@ -26,17 +47,11 @@ return [
         '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!',
+            'Your superadmin account has been created successfully. Login to start podcasting!',
         '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}',
+            'Castopod could not connect to your database. Edit your database configuration and try again.',
+        'writeError' =>
+            'Couldn\'t create/write the `.env` file. You must create it manually by following the `.env.example` file template in the Castopod package.',
     ],
 ];
diff --git a/app/Language/en/Validation.php b/app/Language/en/Validation.php
index f987763075..4fb156366d 100644
--- a/app/Language/en/Validation.php
+++ b/app/Language/en/Validation.php
@@ -13,4 +13,6 @@ return [
         '{field} is either not an image, or it is not wide or tall enough.',
     'is_image_squared' =>
         '{field} is either not an image, or it is not squared (width and height differ).',
+    'validate_url' =>
+        'The {field} field must be a valid URL (eg. https://example.com/).',
 ];
diff --git a/app/Validation/Rules.php b/app/Validation/Rules.php
index 4e98e01f5b..9b8ce5a405 100644
--- a/app/Validation/Rules.php
+++ b/app/Validation/Rules.php
@@ -29,4 +29,22 @@ class Rules
     }
 
     //--------------------------------------------------------------------
+
+    /**
+     * Checks a URL to ensure it's formed correctly.
+     *
+     * @param string $str
+     *
+     * @return boolean
+     */
+    public function validate_url(string $str = null): bool
+    {
+        if (empty($str)) {
+            return false;
+        }
+
+        return filter_var($str, FILTER_VALIDATE_URL) !== false;
+    }
+
+    //--------------------------------------------------------------------
 }
diff --git a/app/Views/_assets/icons/arrow-right.svg b/app/Views/_assets/icons/arrow-right.svg
new file mode 100644
index 0000000000..f46779f733
--- /dev/null
+++ b/app/Views/_assets/icons/arrow-right.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <g>
+        <path fill="none" d="M0 0h24v24H0z"/>
+        <path d="M16.172 11l-5.364-5.364 1.414-1.414L20 12l-7.778 7.778-1.414-1.414L16.172 13H4v-2z"/>
+    </g>
+</svg>
diff --git a/app/Views/_assets/icons/check.svg b/app/Views/_assets/icons/check.svg
new file mode 100644
index 0000000000..a28368fca9
--- /dev/null
+++ b/app/Views/_assets/icons/check.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <g>
+        <path fill="none" d="M0 0h24v24H0z"/>
+        <path d="M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z"/>
+    </g>
+</svg>
diff --git a/app/Views/_assets/install.ts b/app/Views/_assets/install.ts
new file mode 100644
index 0000000000..e3bb9d53f4
--- /dev/null
+++ b/app/Views/_assets/install.ts
@@ -0,0 +1,3 @@
+import Tooltip from "./modules/Tooltip";
+
+Tooltip();
diff --git a/app/Views/install/_layout.php b/app/Views/install/_layout.php
index 20bd2d2a4f..6cd4cb1e5a 100644
--- a/app/Views/install/_layout.php
+++ b/app/Views/install/_layout.php
@@ -8,6 +8,7 @@
     <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"/>
+    <script src="/assets/install.js" type="module" defer></script>
 </head>
 
 <body class="flex flex-col min-h-screen mx-auto">
@@ -16,7 +17,7 @@
             Castopod installer
         </div>
     </header>
-    <main class="container flex-1 px-4 py-10 mx-auto">
+    <main class="container flex flex-col items-center justify-center flex-1 px-4 py-10 mx-auto">
         <?= view('_message_block') ?>
         <?= $this->renderSection('content') ?>
     </main>
diff --git a/app/Views/install/cache_config.php b/app/Views/install/cache_config.php
new file mode 100644
index 0000000000..ddd95d8e4b
--- /dev/null
+++ b/app/Views/install/cache_config.php
@@ -0,0 +1,44 @@
+<?= $this->extend('install/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<?= form_open(route_to('cache-config'), [
+    'class' => 'flex flex-col max-w-sm w-full',
+]) ?>
+<?= csrf_field() ?>
+
+<h1 class="mb-4 text-xl"><span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider text-green-700 border-4 border-green-500 rounded-full">3/4</span><?= lang(
+    'Install.form.cache_config'
+) ?></h1>
+
+<p class="mb-4 text-sm text-gray-600"><?= lang(
+    'Install.form.cache_config_hint'
+) ?></p>
+
+<?= form_label(lang('Install.form.cache_handler'), 'db_prefix') ?>
+<?= form_dropdown(
+    'cache_handler',
+    [
+        'file' => lang('Install.form.cacheHandlerOptions.file'),
+        'redis' => lang('Install.form.cacheHandlerOptions.redis'),
+        'memcached' => lang('Install.form.cacheHandlerOptions.memcached'),
+    ],
+    old('cache_handler', 'file'),
+    [
+        'id' => 'cache_handler',
+        'name' => 'cache_handler',
+        'class' => 'form-select mb-6',
+        'value' => config('Database')->default['DBPrefix'],
+    ]
+) ?>
+
+<?= button(
+    lang('Install.form.next') . icon('arrow-right', 'ml-2'),
+    null,
+    ['variant' => 'primary'],
+    ['type' => 'submit', 'class' => 'self-end']
+) ?>
+
+<?= form_close() ?>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/install/create_superadmin.php b/app/Views/install/create_superadmin.php
new file mode 100644
index 0000000000..6a56077332
--- /dev/null
+++ b/app/Views/install/create_superadmin.php
@@ -0,0 +1,52 @@
+<?= $this->extend('install/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<?= form_open(route_to('create-superadmin'), [
+    'class' => 'flex flex-col max-w-sm w-full',
+]) ?>
+<?= csrf_field() ?>
+
+<h1 class="mb-4 text-xl"><span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider text-green-700 border-4 border-green-500 rounded-full">4/4</span><?= lang(
+    'Install.form.create_superadmin'
+) ?></h1>
+
+<?= form_label(lang('Install.form.email'), 'email') ?>
+<?= form_input([
+    'id' => 'email',
+    'name' => 'email',
+    'class' => 'form-input mb-4',
+    'type' => 'email',
+    'required' => 'required',
+    'value' => old('email'),
+]) ?>
+
+<?= form_label(lang('Install.form.username'), 'username') ?>
+<?= form_input([
+    'id' => 'username',
+    'name' => 'username',
+    'class' => 'form-input mb-4',
+    'required' => 'required',
+    'value' => old('username'),
+]) ?>
+
+<?= form_label(lang('Install.form.password'), 'password') ?>
+<?= form_input([
+    'id' => 'password',
+    'name' => 'password',
+    'class' => 'form-input mb-4',
+    'type' => 'password',
+    'required' => 'required',
+    'autocomplete' => 'new-password',
+]) ?>
+
+<?= button(
+    icon('check', 'mr-2') . lang('Install.form.submit'),
+    null,
+    ['variant' => 'primary'],
+    ['type' => 'submit', 'class' => 'self-end']
+) ?>
+
+<?= form_close() ?>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/install/database_config.php b/app/Views/install/database_config.php
new file mode 100644
index 0000000000..169419576d
--- /dev/null
+++ b/app/Views/install/database_config.php
@@ -0,0 +1,80 @@
+<?= $this->extend('install/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<?= form_open(route_to('database-config'), [
+    'class' => 'flex flex-col max-w-sm w-full',
+    'autocomplete' => 'off',
+]) ?>
+<?= csrf_field() ?>
+
+<h1 class="mb-2 text-xl"><span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider text-green-700 border-4 border-green-500 rounded-full">2/4</span><?= lang(
+    'Install.form.database_config'
+) ?></h1>
+
+<p class="mb-4 text-sm text-gray-600"><?= lang(
+    'Install.form.database_config_hint'
+) ?></p>
+
+<?= form_label(lang('Install.form.db_hostname'), 'db_hostname') ?>
+<?= form_input([
+    'id' => 'db_hostname',
+    'name' => 'db_hostname',
+    'class' => 'form-input mb-4',
+    'value' => old('db_hostname', config('Database')->default['hostname']),
+    'required' => 'required',
+]) ?>
+
+<?= form_label(lang('Install.form.db_name'), 'db_name') ?>
+<?= form_input([
+    'id' => 'db_name',
+    'name' => 'db_name',
+    'class' => 'form-input mb-4',
+    'value' => old('db_name', config('Database')->default['database']),
+    'required' => 'required',
+]) ?>
+
+<?= form_label(lang('Install.form.db_username'), 'db_username') ?>
+<?= form_input([
+    'id' => 'db_username',
+    'name' => 'db_username',
+    'class' => 'form-input mb-4',
+    'value' => old('db_username', config('Database')->default['username']),
+    'required' => 'required',
+    'autocomplete' => 'off',
+]) ?>
+
+<?= form_label(lang('Install.form.db_password'), 'db_password') ?>
+<?= form_input([
+    'id' => 'db_password',
+    'name' => 'db_password',
+    'class' => 'form-input mb-4',
+    'value' => old('db_password', config('Database')->default['password']),
+    'type' => 'password',
+    'required' => 'required',
+    'autocomplete' => 'off',
+]) ?>
+
+<?= form_label(
+    lang('Install.form.db_prefix'),
+    'db_prefix',
+    [],
+    lang('Install.form.db_prefix_hint')
+) ?>
+<?= form_input([
+    'id' => 'db_prefix',
+    'name' => 'db_prefix',
+    'class' => 'form-input mb-6',
+    'value' => old('db_prefix', config('Database')->default['DBPrefix']),
+]) ?>
+
+<?= button(
+    lang('Install.form.next') . icon('arrow-right', 'ml-2'),
+    null,
+    ['variant' => 'primary'],
+    ['type' => 'submit', 'class' => 'self-end']
+) ?>
+
+<?= form_close() ?>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/install/env.php b/app/Views/install/env.php
deleted file mode 100644
index 25db9b5d7b..0000000000
--- a/app/Views/install/env.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?= $this->extend('install/_layout') ?>
-
-<?= $this->section('content') ?>
-
-<?= form_open(route_to('generate-env'), [
-    'class' => 'flex flex-col max-w-sm mx-auto',
-]) ?>
-<?= csrf_field() ?>
-
-<?= 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,
-        'required' => 'required',
-    ]) ?>
-
-    <?= 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,
-        'required' => 'required',
-    ]) ?>
-
-    <?= form_label(lang('Install.form.auth_gateway'), 'auth_gateway') ?>
-    <?= form_input([
-        'id' => 'auth_gateway',
-        'name' => 'auth_gateway',
-        'class' => 'form-input',
-        'value' => config('App')->authGateway,
-        'required' => 'required',
-    ]) ?>
-<?= 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'],
-        'required' => 'required',
-    ]) ?>
-
-    <?= 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'],
-        'required' => 'required',
-    ]) ?>
-
-    <?= 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'],
-        'required' => 'required',
-    ]) ?>
-
-    <?= 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'],
-        'required' => 'required',
-    ]) ?>
-
-    <?= 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() ?>
-
-<?= button(
-    lang('Install.form.submit_install'),
-    null,
-    ['variant' => 'primary'],
-    ['type' => 'submit', 'class' => 'self-end']
-) ?>
-
-<?= form_close() ?>
-
-<?= $this->endSection() ?>
diff --git a/app/Views/install/error.php b/app/Views/install/error.php
deleted file mode 100644
index b4f8bf0ac0..0000000000
--- a/app/Views/install/error.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?= $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() ?>
diff --git a/app/Views/install/instance_config.php b/app/Views/install/instance_config.php
new file mode 100644
index 0000000000..19416b9f89
--- /dev/null
+++ b/app/Views/install/instance_config.php
@@ -0,0 +1,59 @@
+<?= $this->extend('install/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<form action="<?= '/' .
+    config('App')->installGateway .
+    '/instance-config' ?>" class="flex flex-col w-full max-w-sm" method="post" accept-charset="utf-8">
+<?= csrf_field() ?>
+
+<h1 class="mb-4 text-xl"><span class="inline-flex items-center justify-center w-12 h-12 mr-2 text-sm font-semibold tracking-wider text-green-700 border-4 border-green-500 rounded-full">1/4</span><?= lang(
+    'Install.form.instance_config'
+) ?></h1>
+<?= form_label(lang('Install.form.hostname'), 'hostname') ?>
+<?= form_input([
+    'id' => 'hostname',
+    'name' => 'hostname',
+    'class' => 'form-input mb-4',
+    'value' => old('hostname', set_value(host_url(), config('App')->baseURL)),
+    'required' => 'required',
+]) ?>
+
+<?= form_label(
+    lang('Install.form.admin_gateway'),
+    'admin_gateway',
+    [],
+    lang('Install.form.admin_gateway_hint')
+) ?>
+<?= form_input([
+    'id' => 'admin_gateway',
+    'name' => 'admin_gateway',
+    'class' => 'form-input mb-4',
+    'value' => old('admin_gateway', config('App')->adminGateway),
+    'required' => 'required',
+]) ?>
+
+<?= form_label(
+    lang('Install.form.auth_gateway'),
+    'auth_gateway',
+    [],
+    lang('Install.form.auth_gateway_hint')
+) ?>
+<?= form_input([
+    'id' => 'auth_gateway',
+    'name' => 'auth_gateway',
+    'class' => 'form-input mb-6',
+    'value' => old('auth_gateway', config('App')->authGateway),
+    'required' => 'required',
+]) ?>
+
+<?= button(
+    lang('Install.form.next') . icon('arrow-right', 'ml-2'),
+    null,
+    ['variant' => 'primary'],
+    ['type' => 'submit', 'class' => 'self-end']
+) ?>
+
+<?= form_close() ?>
+
+<?= $this->endSection() ?>
diff --git a/app/Views/install/manual_config.php b/app/Views/install/manual_config.php
new file mode 100644
index 0000000000..446eee8631
--- /dev/null
+++ b/app/Views/install/manual_config.php
@@ -0,0 +1,14 @@
+<?= $this->extend('install/_layout') ?>
+
+<?= $this->section('content') ?>
+
+<h1 class="mb-2 text-xl font-bold"><?= lang('Install.manual_config') ?></h1>
+<div class="inline-flex items-baseline max-w-2xl px-4 py-2 mb-4 font-semibold text-red-900 bg-red-200 border border-red-700">
+<?= icon('alert', 'mr-2 flex-shrink-0') . lang('Install.messages.writeError') ?>
+</div>
+<p class="mb-4 font-semibold text-gray-600"><?= lang(
+    'Install.manual_config_subtitle'
+) ?></p>
+
+<?= $this->endSection()
+?>
diff --git a/app/Views/install/superadmin.php b/app/Views/install/superadmin.php
deleted file mode 100644
index 6b32c32cc3..0000000000
--- a/app/Views/install/superadmin.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?= $this->extend('install/_layout') ?>
-
-<?= $this->section('content') ?>
-
-<?= form_open(route_to('create-superadmin'), [
-    'class' => 'flex flex-col max-w-sm mx-auto',
-]) ?>
-<?= csrf_field() ?>
-
-<?= 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',
-        'required' => 'required',
-        'value' => old('email'),
-    ]) ?>
-    
-    <?= form_label(lang('Install.form.username'), 'username') ?>
-    <?= form_input([
-        'id' => 'username',
-        'name' => 'username',
-        'class' => 'form-input mb-4',
-        'required' => 'required',
-        'value' => old('username'),
-    ]) ?>
-
-    <?= form_label(lang('Install.form.password'), 'password') ?>
-    <?= form_input([
-        'id' => 'password',
-        'name' => 'password',
-        'class' => 'form-input mb-4',
-        'type' => 'password',
-        'required' => 'required',
-        'autocomplete' => 'new-password',
-    ]) ?>
-<?= form_fieldset_close() ?>
-
-<?= button(
-    lang('Install.form.submit_create_superadmin'),
-    null,
-    ['variant' => 'primary'],
-    ['type' => 'submit', 'class' => 'self-end']
-) ?>
-
-<?= form_close() ?>
-
-<?= $this->endSection() ?>
-- 
GitLab