diff --git a/.gitignore b/.gitignore
index f72249a83..feb1500a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
# Ignore dependencies and cache
+/.idea
/node_modules
/vendor
/storage/*.key
diff --git a/app/Console/Commands/GetSettingCommand.php b/app/Console/Commands/GetSettingCommand.php
new file mode 100644
index 000000000..9be2191ad
--- /dev/null
+++ b/app/Console/Commands/GetSettingCommand.php
@@ -0,0 +1,49 @@
+argument('class');
+ $key = $this->argument('key');
+ $sameline = $this->option('sameline');
+
+ try {
+ $settings_class = "App\\Settings\\$class";
+ $settings = new $settings_class();
+
+ $this->output->write($settings->$key, !$sameline);
+
+ return Command::SUCCESS;
+ } catch (\Throwable $th) {
+ $this->error('Error: ' . $th->getMessage());
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/SetSettingCommand.php b/app/Console/Commands/SetSettingCommand.php
new file mode 100644
index 000000000..03481f93e
--- /dev/null
+++ b/app/Console/Commands/SetSettingCommand.php
@@ -0,0 +1,52 @@
+argument('class');
+ $key = $this->argument('key');
+ $value = $this->argument('value');
+
+ try {
+ $settings_class = "App\\Settings\\$class";
+ $settings = new $settings_class();
+
+ $settings->$key = $value;
+
+ $settings->save();
+
+ $this->info("Successfully updated '$key'.");
+ } catch (\Throwable $th) {
+ $this->error('Error: ' . $th->getMessage());
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index d0b1c7f7e..cd7cef5fb 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -4,8 +4,7 @@
use App\Http\Middleware\ApiAuthToken;
use App\Http\Middleware\CheckSuspended;
-use App\Http\Middleware\isAdmin;
-use App\Http\Middleware\isMod;
+use App\Http\Middleware\InstallerLock;
use App\Http\Middleware\LastSeen;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
@@ -36,6 +35,7 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
+ InstallerLock::class,
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
@@ -70,8 +70,6 @@ class Kernel extends HttpKernel
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
- 'admin' => isAdmin::class,
- 'moderator' => isMod::class,
'api.token' => ApiAuthToken::class,
'checkSuspended' => CheckSuspended::class,
'role' => \Spatie\Permission\Middlewares\RoleMiddleware::class,
diff --git a/app/Http/Middleware/InstallerLock.php b/app/Http/Middleware/InstallerLock.php
new file mode 100644
index 000000000..9390a598c
--- /dev/null
+++ b/app/Http/Middleware/InstallerLock.php
@@ -0,0 +1,24 @@
+getMessage(), 'error');
- header('LOCATION: index.php?step=2&message=' . $e->getMessage());
+ header('LOCATION: index.php?step=3&message=' . $e->getMessage());
exit();
}
@@ -44,24 +55,25 @@
setenv($key, $param);
}
- wh_log('Database connection successful', 'debug');
- header('LOCATION: index.php?step=2.5');
-}
+ wh_log('Start APP_KEY generation', 'debug');
-if (isset($_POST['checkGeneral'])) {
- wh_log('setting app settings', 'debug');
- $appname = '"' . $_POST['name'] . '"';
- $appurl = $_POST['url'];
+ try {
+ if (!str_contains(getenv('APP_KEY'), 'base64')) {
+ $logs = run_console('php artisan key:generate --force');
+ wh_log($logs, 'debug');
- if (substr($appurl, -1) === '/') {
- $appurl = substr_replace($appurl, '', -1);
+ wh_log('Created APP_KEY successful', 'debug');
+ } else {
+ wh_log('Key already exists. Skipping', 'debug');
+ }
+ } catch (Throwable $th) {
+ wh_log('Creating APP_KEY failed', 'error');
+ header("LOCATION: index.php?step=3&message=" . $th->getMessage() . "
Please check the installer.log file in /var/www/controlpanel/storage/logs !");
+ exit();
}
- setenv('APP_NAME', $appname);
- setenv('APP_URL', $appurl);
-
- wh_log('App settings set', 'debug');
- header('LOCATION: index.php?step=4');
+ wh_log('Database connection successful', 'debug');
+ header('LOCATION: index.php?step=3.5');
}
if (isset($_POST['feedDB'])) {
@@ -71,11 +83,6 @@
try {
//$logs .= run_console(setenv('COMPOSER_HOME', dirname(__FILE__, 3) . '/vendor/bin/composer'));
//$logs .= run_console('composer install --no-dev --optimize-autoloader');
- if (!str_contains(getenv('APP_KEY'), 'base64')) {
- $logs .= run_console('php artisan key:generate --force');
- } else {
- $logs .= "Key already exists. Skipping\n";
- }
$logs .= run_console('php artisan storage:link');
$logs .= run_console('php artisan migrate --seed --force');
$logs .= run_console('php artisan db:seed --class=ExampleItemsSeeder --force');
@@ -84,40 +91,98 @@
wh_log($logs, 'debug');
wh_log('Feeding the Database successful', 'debug');
- header('LOCATION: index.php?step=3');
- } catch (\Throwable $th) {
+ header('LOCATION: index.php?step=4');
+ } catch (Throwable $th) {
wh_log('Feeding the Database failed', 'error');
- header("LOCATION: index.php?step=2.5&message=" . $th->getMessage() . "
Please check the installer.log file in /var/www/ctrlpanel/storage/logs !");
+ header("LOCATION: index.php?step=3.5&message=" . $th->getMessage() . "
Please check the installer.log file in /var/www/controlpanel/storage/logs !");
}
}
+if (isset($_POST['redisSetup'])) {
+ wh_log('Setting up Redis', 'debug');
+ $redisHost = $_POST['redishost'];
+ $redisPort = $_POST['redisport'];
+ $redisPassword = $_POST['redispassword'];
+
+ $redisClient = new Client([
+ 'host' => $redisHost,
+ 'port' => $redisPort,
+ 'password' => $redisPassword,
+ 'timeout' => 1.0,
+ ]);
+
+ try {
+ $redisClient->ping();
+
+ setenv('MEMCACHED_HOST', $redisHost);
+ setenv('REDIS_HOST', $redisHost);
+ setenv('REDIS_PORT', $redisPort);
+ setenv('REDIS_PASSWORD', ($redisPassword === '' ? 'null' : $redisPassword));
+
+ wh_log('Redis connection successful. Settings updated.', 'debug');
+ header('LOCATION: index.php?step=5');
+ } catch (Throwable $th) {
+ wh_log('Redis connection failed. Settings updated.', 'debug');
+ header("LOCATION: index.php?step=4&message=Please check your credentials!
" . $th->getMessage());
+ }
+}
+
+if (isset($_POST['checkGeneral'])) {
+ wh_log('setting app settings', 'debug');
+ $appname = '"' . $_POST['name'] . '"';
+ $appurl = $_POST['url'];
+
+ $parsedUrl = parse_url($appurl);
+
+ if (!isset($parsedUrl['scheme'])) {
+ header('LOCATION: index.php?step=5&message=Please set an URL Scheme like "https://"!');
+ exit();
+ }
+
+ if (!isset($parsedUrl['host'])) {
+ header('LOCATION: index.php?step=5&message=Please set an valid URL host like "https://ctrlpanel.example.com"!');
+ exit();
+ }
+
+ $appurl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'];
+
+ setenv('APP_NAME', $appname);
+ setenv('APP_URL', $appurl);
+
+ wh_log('App settings set', 'debug');
+ header('LOCATION: index.php?step=6');
+}
+
if (isset($_POST['checkSMTP'])) {
wh_log('Checking SMTP Settings', 'debug');
try {
$mail = new PHPMailer(true);
//Server settings
- $mail->isSMTP(); // Send using SMTP
- $mail->Host = $_POST['host']; // Set the SMTP server to send through
- $mail->SMTPAuth = true; // Enable SMTP authentication
- $mail->Username = $_POST['user']; // SMTP username
- $mail->Password = $_POST['pass']; // SMTP password
- $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // Enable TLS encryption; `PHPMailer::ENCRYPTION_SMTPS` encouraged
- $mail->Port = $_POST['port']; // TCP port to connect to, use 465 for `PHPMailer::ENCRYPTION_SMTPS`
-
- //Recipients
+ // Send using SMTP
+ $mail->isSMTP();
+ $mail->Host = $_POST['host'];
+ // Enable SMTP authentication
+ $mail->SMTPAuth = true;
+ $mail->Username = $_POST['user'];
+ $mail->Password = $_POST['pass'];
+ $mail->SMTPSecure = $_POST['encryption'];
+ $mail->Port = (int) $_POST['port'];
+
+ // Test E-mail metadata
$mail->setFrom($_POST['user'], $_POST['user']);
- $mail->addAddress($_POST['user'], $_POST['user']); // Add a recipient
+ $mail->addAddress($_POST['user'], $_POST['user']);
// Content
- $mail->isHTML(true); // Set email format to HTML
- $mail->Subject = 'It Worked!';
+ // Set email format to HTML
+ $mail->isHTML(true);
+ $mail->Subject = 'It Worked! - Test E-Mail from Ctrlpanel.gg';
$mail->Body = 'Your E-Mail Settings are correct!';
$mail->send();
} catch (Exception $e) {
wh_log($mail->ErrorInfo, 'error');
- header('LOCATION: index.php?step=4&message=Something wasnt right when sending the E-Mail!');
+ header('LOCATION: index.php?step=6&message=Something went wrong while sending test E-Mail!
' . $mail->ErrorInfo);
exit();
}
@@ -126,7 +191,7 @@
$db = new mysqli(getenv('DB_HOST'), getenv('DB_USERNAME'), getenv('DB_PASSWORD'), getenv('DB_DATABASE'), getenv('DB_PORT'));
if ($db->connect_error) {
wh_log($db->connect_error, 'error');
- header('LOCATION: index.php?step=4&message=Could not connect to the Database: ');
+ header('LOCATION: index.php?step=6&message=Could not connect to the Database: ');
exit();
}
$values = [
@@ -140,12 +205,11 @@
];
foreach ($values as $key => $value) {
- $query = 'UPDATE `' . getenv('DB_DATABASE') . "`.`settings` SET `payload` = '$value' WHERE `name` = '$key' AND `group` = 'mail'";
- $db->query($query);
+ run_console("php artisan settings:set 'MailSettings' '$key' '$value'");
}
wh_log('Database updated', 'debug');
- header('LOCATION: index.php?step=5');
+ header('LOCATION: index.php?step=7');
}
if (isset($_POST['checkPtero'])) {
@@ -155,10 +219,20 @@
$key = $_POST['key'];
$clientkey = $_POST['clientkey'];
- if (substr($url, -1) === '/') {
- $url = substr_replace($url, '', -1);
+ $parsedUrl = parse_url($url);
+
+ if (!isset($parsedUrl['scheme'])) {
+ header('LOCATION: index.php?step=7&message=Please set an URL Scheme like "https://"!');
+ exit();
+ }
+
+ if (!isset($parsedUrl['host'])) {
+ header('LOCATION: index.php?step=7&message=Please set an valid URL host like "https://panel.example.com"!');
+ exit();
}
+ $url = $parsedUrl['scheme'] . '://' . $parsedUrl['host'];
+
$callpteroURL = $url . '/api/client/account';
$call = curl_init();
@@ -171,7 +245,7 @@
]);
$callresponse = curl_exec($call);
$callresult = json_decode($callresponse, true);
- curl_close($call); // Close the connection
+ curl_close($call);
$pteroURL = $url . '/api/application/users';
$ch = curl_init();
@@ -185,50 +259,46 @@
]);
$response = curl_exec($ch);
$result = json_decode($response, true);
- curl_close($ch); // Close the connection
+ curl_close($ch);
+
+ if (!is_array($result)) {
+ wh_log('No array in response found', 'error');
+ header('LOCATION: index.php?step=7&message=An unknown Error occured, please try again!');
+ }
- if (!is_array($result) and $result['errors'][0] !== null) {
- header('LOCATION: index.php?step=5&message=Couldn\'t connect to Pterodactyl. Make sure your API key has all read and write permissions!');
+ if (array_key_exists('errors', $result) && $result['errors'][0]['detail'] === 'This action is unauthorized.') {
wh_log('API CALL ERROR: ' . $result['errors'][0]['code'], 'error');
+ header('LOCATION: index.php?step=7&message=Couldn\'t connect to Pterodactyl. Make sure your Application API key has all read and write permissions!');
exit();
- } elseif (!is_array($callresult) and $callresult['errors'][0] !== null or $callresult['attributes']['admin'] == false) {
- header('LOCATION: index.php?step=5&message=Your ClientAPI Key is wrong or the account is not an admin!');
+ }
+
+ if (array_key_exists('errors', $callresult) && $callresult['errors'][0]['detail'] === 'Unauthenticated.') {
wh_log('API CALL ERROR: ' . $callresult['errors'][0]['code'], 'error');
+ header('LOCATION: index.php?step=7&message=Your ClientAPI Key is wrong or the account is not an admin!');
exit();
- } else {
- wh_log('Pterodactyl Settings are correct', 'debug');
- wh_log('Updating Database', 'debug');
-
- $key = $key;
- $clientkey = $clientkey;
-
- $query1 = 'UPDATE `' . getenv('DB_DATABASE') . "`.`settings` SET `payload` = '" . json_encode($url) . "' WHERE (`name` = 'panel_url' AND `group` = 'pterodactyl')";
- $query2 = 'UPDATE `' . getenv('DB_DATABASE') . "`.`settings` SET `payload` = '" . json_encode($key) . "' WHERE (`name` = 'admin_token' AND `group` = 'pterodactyl')";
- $query3 = 'UPDATE `' . getenv('DB_DATABASE') . "`.`settings` SET `payload` = '" . json_encode($clientkey) . "' WHERE (`name` = 'user_token' AND `group` = 'pterodactyl')";
-
- $db = new mysqli(getenv('DB_HOST'), getenv('DB_USERNAME'), getenv('DB_PASSWORD'), getenv('DB_DATABASE'), getenv('DB_PORT'));
- if ($db->connect_error) {
- wh_log($db->connect_error, 'error');
- header('LOCATION: index.php?step=5&message=Could not connect to the Database');
- exit();
- }
+ }
- if ($db->query($query1) && $db->query($query2) && $db->query($query3)) {
- wh_log('Database updated', 'debug');
- header('LOCATION: index.php?step=6');
- } else {
- wh_log($db->error, 'error');
- header('LOCATION: index.php?step=5&message=Something went wrong when communicating with the Database!');
- }
+ try {
+ run_console("php artisan settings:set 'PterodactylSettings' 'panel_url' '$url'");
+ run_console("php artisan settings:set 'PterodactylSettings' 'admin_token' '$key'");
+ run_console("php artisan settings:set 'PterodactylSettings' 'user_token' '$clientkey'");
+ wh_log('Database updated', 'debug');
+ header('LOCATION: index.php?step=8');
+ } catch (Throwable $th) {
+ wh_log("Setting Pterodactyl information failed.", 'error');
+ header("LOCATION: index.php?step=7&message=" . $th->getMessage() . "
Please check the installer.log file in /var/www/controlpanel/storage/logs!");
+ exit();
}
}
if (isset($_POST['createUser'])) {
- wh_log('Creating User', 'debug');
- $db = new mysqli(getenv('DB_HOST'), getenv('DB_USERNAME'), getenv('DB_PASSWORD'), getenv('DB_DATABASE'), getenv('DB_PORT'));
- if ($db->connect_error) {
- wh_log($db->connect_error, 'error');
- header('LOCATION: index.php?step=6&message=Could not connect to the Database');
+ wh_log('Getting Pterodactyl User', 'debug');
+
+ try {
+ $db = new mysqli(getenv('DB_HOST'), getenv('DB_USERNAME'), getenv('DB_PASSWORD'), getenv('DB_DATABASE'), getenv('DB_PORT'));
+ } catch (Throwable $th) {
+ wh_log($th->getMessage(), 'error');
+ header('LOCATION: index.php?step=8&message=Could not connect to the Database');
exit();
}
@@ -236,30 +306,37 @@
$pass = $_POST['pass'];
$repass = $_POST['repass'];
- $key = $db->query('SELECT `payload` FROM `' . getenv('DB_DATABASE') . "`.`settings` WHERE `name` = 'admin_token' AND `group` = 'pterodactyl'")->fetch_assoc();
- $key = removeQuotes($key['payload']);
- $pterobaseurl = $db->query('SELECT `payload` FROM `' . getenv('DB_DATABASE') . "`.`settings` WHERE `name` = 'panel_url' AND `group` = 'pterodactyl'")->fetch_assoc();
+ try {
+ $panelUrl = run_console("php artisan settings:get 'PterodactylSettings' 'panel_url' --sameline");
+ $adminToken = run_console("php artisan settings:get 'PterodactylSettings' 'admin_token' --sameline");
+ } catch (Throwable $th) {
+ wh_log("Getting Pterodactyl information failed.", 'error');
+ header("LOCATION: index.php?step=7&message=" . $th->getMessage() . "
Please check the installer.log file in /var/www/controlpanel/storage/logs!");
+ exit();
+ }
+
+ $panelApiUrl = $panelUrl . '/api/application/users/' . $pteroID;
- $pteroURL = removeQuotes($pterobaseurl['payload']) . '/api/application/users/' . $pteroID;
$ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $pteroURL);
+ curl_setopt($ch, CURLOPT_URL, $panelApiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json',
- 'Authorization: Bearer ' . $key,
+ 'Authorization: Bearer ' . $adminToken,
]);
$response = curl_exec($ch);
$result = json_decode($response, true);
- curl_close($ch); // Close the connection
+ curl_close($ch);
- if (!$result['attributes']['email']) {
- header('LOCATION: index.php?step=6&message=Could not find the user with pterodactyl ID ' . $pteroID);
+ if ($pass !== $repass) {
+ header('LOCATION: index.php?step=8&message=The Passwords did not match!');
exit();
}
- if ($pass !== $repass) {
- header('LOCATION: index.php?step=6&message=The Passwords did not match!');
+
+ if (array_key_exists('errors', $result)) {
+ header('LOCATION: index.php?step=8&message=Could not find the user with pterodactyl ID ' . $pteroID);
exit();
}
@@ -267,15 +344,14 @@
$name = $result['attributes']['username'];
$pass = password_hash($pass, PASSWORD_DEFAULT);
- $pteroURL = removeQuotes($pterobaseurl['payload']) . '/api/application/users/' . $pteroID;
$ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $pteroURL);
+ curl_setopt($ch, CURLOPT_URL, $panelApiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json',
- 'Authorization: Bearer ' . $key,
+ 'Authorization: Bearer ' . $adminToken,
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'email' => $mail,
@@ -286,22 +362,25 @@
]);
$response = curl_exec($ch);
$result = json_decode($response, true);
- curl_close($ch); // Close the connection
-
- if (!is_array($result) or in_array($result['errors'][0]['code'], $result)) {
- header('LOCATION: index.php?step=5&message=Couldn\'t connect to Pterodactyl. Make sure your API key has all read and write permissions!');
- exit();
- }
+ curl_close($ch);
$random = generateRandomString();
$query1 = 'INSERT INTO `' . getenv('DB_DATABASE') . "`.`users` (`name`, `role`, `credits`, `server_limit`, `pterodactyl_id`, `email`, `password`, `created_at`, `referral_code`) VALUES ('$name', 'admin', '250', '1', '$pteroID', '$mail', '$pass', CURRENT_TIMESTAMP, '$random')";
$query2 = "INSERT INTO `" . getenv('DB_DATABASE') . "`.`model_has_roles` (`role_id`, `model_type`, `model_id`) VALUES ('1', 'App\\\Models\\\User', '1')";
- if ($db->query($query1) && $db->query($query2)) {
- wh_log('Created user with Email ' . $mail . ' and pterodactyl ID ' . $pteroID, 'info');
- header('LOCATION: index.php?step=7');
- } else {
- wh_log($db->error, 'error');
- header('LOCATION: index.php?step=6&message=Something went wrong when communicating with the Database');
+ try {
+ $db->query($query1);
+ $db->query($query2);
+
+ wh_log('Created user with Email ' . $mail . ' and pterodactyl ID ' . $pteroID);
+ header('LOCATION: index.php?step=9');
+ } catch (Throwable $th) {
+ wh_log($th->getMessage(), 'error');
+ if (str_contains($th->getMessage(), 'Duplicate entry')) {
+ header('LOCATION: index.php?step=8&message=User already exists in CtrlPanel\'s Database.');
+ } else {
+ header('LOCATION: index.php?step=8&message=Something went wrong when communicating with the Database.');
+ }
+ exit();
}
}
diff --git a/public/install/functions.php b/public/install/functions.php
index 5e8b847aa..e450e8cc1 100644
--- a/public/install/functions.php
+++ b/public/install/functions.php
@@ -15,7 +15,7 @@
(new DotEnv(dirname(__FILE__, 3) . '/.env'))->load();
-$required_extensions = ['openssl', 'gd', 'mysql', 'PDO', 'mbstring', 'tokenizer', 'bcmath', 'xml', 'curl', 'zip', 'intl'];
+$required_extensions = ['openssl', 'gd', 'mysql', 'PDO', 'mbstring', 'tokenizer', 'bcmath', 'xml', 'curl', 'zip', 'intl', 'redis'];
$requirements = [
'minPhp' => '8.1',
@@ -48,36 +48,6 @@ function checkWriteable(): bool
return is_writable('../../.env');
}
-/**
- * Check if the server runs using HTTPS
- * @return bool Returns true on HTTPS or false on HTTP.
- */
-function checkHTTPS(): bool
-{
- $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $_SERVER['SERVER_PORT'] == 443;
- wh_log('https:', 'debug', (array)$isHttps);
- return $isHttps;
-}
-
-/**
- * Check if MySQL is installed and runs the correct version using a shell command
- * @return mixed|string 'OK' if required version is met, returns MySQL version if not met.
- */
-function getMySQLVersion(): mixed
-{
- global $requirements;
-
- wh_log('attempting to get mysql version', 'debug');
-
- $output = shell_exec('mysql -V') ?? '';
- preg_match('@[0-9]+\.[0-9]+\.[0-9]+@', $output, $version);
-
- $versionoutput = $version[0] ?? '0';
- wh_log('mysql version: ' . $versionoutput, 'debug');
-
- return intval($versionoutput) > intval($requirements['mysql']) ? 'OK' : $versionoutput;
-}
-
/**
* Check if zip is installed using a shell command
* @return string 'OK' on success and 'not OK' on failure.
@@ -303,3 +273,8 @@ function generateRandomString(int $length = 8): string
return $randomString;
}
+
+function determineIfRunningInDocker(): bool
+{
+ return file_exists('/.dockerenv');
+}
diff --git a/public/install/index.php b/public/install/index.php
index ee36cc4dd..a4d78b0fa 100644
--- a/public/install/index.php
+++ b/public/install/index.php
@@ -5,7 +5,7 @@
exit("The installation has been completed already. Please delete the File 'install.lock' to re-run");
}
-function cardStart($title, $subtitle = null)
+function cardStart($title, $subtitle = null): string
{
return "
$subtitle
" : ""); } -?> - +?> +