From 797630d8bd12715b2f879058ae31101f8a16df76 Mon Sep 17 00:00:00 2001 From: Jepp9350 <2jepp9350@gmail.com> Date: Tue, 11 Feb 2025 14:25:26 +0100 Subject: [PATCH] Add backup and email enhancements with PHPMailer integration Introduced a backup system managing database and metadata files using Minio/S3, alongside routes for handling these. Integrated PHPMailer for robust email functionalities, including configuration testing and SMTP handling. Updated Dockerfile, composer dependencies, and minor code improvements to support new features. --- services/nginx/app/classes/backup_store.php | 241 ++++++++++++++++++ services/nginx/app/classes/db.php | 37 +++ services/nginx/app/classes/email.php | 56 ++++ services/nginx/app/composer.json | 3 +- services/nginx/app/composer.lock | 83 +++++- services/nginx/app/index.php | 1 + .../nginx/app/interfaces/minio_backups_i.php | 87 +++++++ .../email/config/email_smtp_port_c.php | 2 +- services/nginx/app/modules/email/email_c.php | 1 + .../nginx/app/routes/moduleBackupsRoute.php | 59 +++++ .../nginx/app/routes/moduleConfigRoute.php | 27 ++ services/nginx/app/routes/ordersRoute.php | 4 +- services/php/Dockerfile | 1 + 13 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 services/nginx/app/classes/backup_store.php create mode 100644 services/nginx/app/interfaces/minio_backups_i.php create mode 100644 services/nginx/app/routes/moduleBackupsRoute.php diff --git a/services/nginx/app/classes/backup_store.php b/services/nginx/app/classes/backup_store.php new file mode 100644 index 00000000..5408b102 --- /dev/null +++ b/services/nginx/app/classes/backup_store.php @@ -0,0 +1,241 @@ +doesBucketExist(self::getBucket())) { + // throw an exception + throw new Exception('The bucket does not exist in the S3 service! Missing: ' . self::getBucket()); + } + + } + + /** + * @inheritDoc + */ + public function backup_exists(string $backup_uuid): bool + { + // Check if the file exists + $objects = self::getS3Client()->listObjects([ + 'Bucket' => self::getBucket(), + 'Prefix' => self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip' + ]); + return count($objects['Contents'] ?? []) > 0; + } + + /** + * @inheritDoc + */ + public function getBackupDownloadUrl(string $backup_uuid): string + { + // Get the presigned URL for the backup + return self::getPresignedUrl('backup_' . $backup_uuid . '.zip'); + } + + /** + * @inheritDoc + */ + public function download(string $backup_uuid): string + { + // Download the backup + $path = self::local_backup_path . 'backup_' . $backup_uuid . '.zip'; + $result = self::getS3Client()->getObject([ + 'Bucket' => self::getBucket(), + 'Key' => self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip', + 'SaveAs' => $path + ]); + return $path; + } + + /** + * @inheritDoc + * @throws Exception + */ + public function createBackup($backup_name = null, $backup_description = null): string + { + // Check if the backup name is set + if (!$backup_name) { + $backup_name = 'Backup ' . date('Y-m-d H:i:s'); + } + // Check if the backup description is set + if (!$backup_description) { + $backup_description = 'Backup created on ' . date('Y-m-d H:i:s'); + } + // Generate the backup file + $backup_uuid = self::generateBackupFile(); + // Upload the backup file + self::getS3Client()->putObject([ + 'Bucket' => self::getBucket(), + 'Key' => self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip', + 'SourceFile' => self::local_backup_path . 'backup_' . $backup_uuid . '.zip' + ]); + // Update the metadata + self::createBackupMetadata($backup_uuid, $backup_name, $backup_description); + // Clean up the backup file + self::cleanUpBackupFile($backup_uuid); + // Return the backup UUID + return $backup_uuid; + } + + /** + * @inheritDoc + */ + public function generateBackupFile(): string + { + // Generate a UUID for the backup + $backup_uuid = uniqid(); + + // Prepare the backup directories + if (!is_dir(self::local_backup_path)) { + mkdir(self::local_backup_path); + } + if (!is_dir(self::local_backup_path . 'backup_' . $backup_uuid)) { + mkdir(self::local_backup_path . 'backup_' . $backup_uuid); + } + + // Backup the database + $database_path = $this->backupDatabase($backup_uuid); + + // Create the backup path + $backup_path = self::local_backup_path . 'backup_' . $backup_uuid . '.zip'; + + // Create the backups directory + if (!is_dir(self::local_backup_path)) { + mkdir(self::local_backup_path); + } + + // Create the backup directory + if (!is_dir(self::local_backup_path . 'backup_' . $backup_uuid)) { + mkdir(self::local_backup_path . 'backup_' . $backup_uuid); + } + + // Add the database backup to the backup directory + copy($database_path, self::local_backup_path . 'backup_' . $backup_uuid . '/database.sql'); + + // Zip the backup directory + exec('zip -r ' . $backup_path . ' ' . self::local_backup_path . 'backup_' . $backup_uuid); + + // Return the backup UUID + return $backup_uuid; + } + + /** + * @inheritDoc + * @throws Exception + */ + public function backupDatabase(string $backup_uuid): string + { + global /** @var db $db */ + $db; + // Backup the database + $path = self::local_backup_path . 'backup_' . $backup_uuid; + $result = $db->backupDatabase($path); + if (!$result) { + throw new Exception('Failed to backup the database!'); + } + return $path; + } + + /** + * @inheritDoc + */ + public function createBackupMetadata(string $backup_uuid, string $backup_name, string $backup_description): void + { + // Create the metadata file + $metadata = [ + 'backup_name' => $backup_name, + 'backup_description' => $backup_description, + 'backup_date' => date('Y-m-d H:i:s'), + 'backup_size' => filesize(self::local_backup_path . 'backup_' . $backup_uuid . '.zip'), + 'hash' => md5_file(self::local_backup_path . 'backup_' . $backup_uuid . '.zip') + ]; + file_put_contents( + self::local_backup_path . 'backup_' . $backup_uuid . '.json', + json_encode($metadata) + ); + // Upload the metadata file + self::getS3Client()->putObject([ + 'Bucket' => self::getBucket(), + 'Key' => self::metadata_s3_prefix . 'backup_' . $backup_uuid . '.json', + 'SourceFile' => self::local_backup_path . 'backup_' . $backup_uuid . '.json' + ]); + } + + /** + * @inheritDoc + */ + public function cleanupBackupFile(string $backup_uuid): void + { + /** Clean up the backup files, and remove the temporary directories and files */ + + // Remove the backup directory + unlink(self::local_backup_path . 'backup_' . $backup_uuid . '.zip'); + + // Remove the metadata file + unlink(self::local_backup_path . 'backup_' . $backup_uuid . '.json'); + + // Remove the database backup + unlink(self::local_backup_path . 'backup_' . $backup_uuid . '/database.sql'); + } + + /** + * @inheritDoc + */ + public function listBackups(): array + { + // List the backups + $backups = []; + $objects = self::getS3Client()->listObjects([ + 'Bucket' => self::getBucket(), + 'Prefix' => self::metadata_s3_prefix + ]); + // Check if there are any backups + if (!isset($objects['Contents'])) { + return []; + } + foreach ( $objects['Contents'] as $object ) { + $backup_uuid = str_replace([self::metadata_s3_prefix, '.json'], '', $object['Key']); + $backups[] = $this->getBackupMetadata($backup_uuid); + } + return $backups; + } + + /** + * @inheritDoc + */ + public function getBackupMetadata(string $backup_uuid): array + { + // Get the metadata file + $metadata = self::getS3Client()->getObject([ + 'Bucket' => self::getBucket(), + 'Key' => self::metadata_s3_prefix . 'backup_' . $backup_uuid . '.json' + ]); + return json_decode($metadata['Body'], true); + } +} \ No newline at end of file diff --git a/services/nginx/app/classes/db.php b/services/nginx/app/classes/db.php index 9d9940cd..7dc53101 100644 --- a/services/nginx/app/classes/db.php +++ b/services/nginx/app/classes/db.php @@ -113,4 +113,41 @@ class db { return $result->num_rows; } + + public function getUsername() + { + return $this->user; + } + + public function getPassword() + { + return $this->password; + } + + public function getHost() + { + return $this->host; + } + + public function getDatabase() + { + return $this->database; + } + + public function backupDatabase(string $path): bool + { + // Check if the path is writable + if (!is_writable($path)) { + throw new Exception('Path is not writable or does not exist, unable to backup database! Path: ' . $path); + } + // Add the file name to the path + $path .= '/database.sql'; + // Save the database to the path + $command = "mysqldump -h {$this->host} -u {$this->user} -p '{$this->password}' '{$this->database}' > $path"; + exec($command, $output, $return); + echo $command; + return $return === 0; + } + + } \ No newline at end of file diff --git a/services/nginx/app/classes/email.php b/services/nginx/app/classes/email.php index 319f5096..698a82e8 100644 --- a/services/nginx/app/classes/email.php +++ b/services/nginx/app/classes/email.php @@ -5,6 +5,8 @@ require_once WD . '/modules/email/email_c.php'; use email\email_c; use interfaces\email_i; +use PHPMailer\PHPMailer\Exception; +use PHPMailer\PHPMailer\PHPMailer; class email implements email_i { @@ -14,8 +16,62 @@ class email implements email_i */ public email_c $config; + /** + * PHPMailer + * @var PHPMailer + */ + public PHPMailer $mailer; + public function __construct() { $this->config = new email_c(); + $this->mailer = new PHPMailer(true); + try { + self::setupMailer(); + } catch (\Exception $e) { + throw new \Exception('Email error: ' . $e->getMessage()); + } + } + + private function setupMailer(): void + { + $this->mailer->isSMTP(); + $this->mailer->SMTPAuth = true; + $this->mailer->Host = $this->config->smtp_host->getVariableValue(); + $this->mailer->Username = $this->config->smtp_username->getVariableValue(); + $this->mailer->Password = $this->config->smtp_password->getVariableValue(); + $this->mailer->Port = $this->config->smtp_port->getVariableValue(); + $this->mailer->setFrom($this->config->smtp_from->getVariableValue(), $this->config->smtp_from_name->getVariableValue()); + } + + public function testConfigRequest(string $test_recipient): string + { + if ($test_recipient) { + try { + $this->sendEmail($test_recipient, 'Test email', 'This is a test email'); + return 'Email sent successfully to ' . $test_recipient; + } catch (\Exception $e) { + return 'Email error: ' . $e->getMessage(); + } + } else { + return 'No test recipient provided'; + } + } + + /** + * @throws \Exception + */ + public function sendEmail($to, $subject, $message): void + { + if ($this->config->enabled->isTrue()) { + try { + $this->mailer->addAddress($to); + $this->mailer->Subject = $subject; + $this->mailer->Body = $message; + $this->mailer->send(); + } catch (Exception $e) { + throw new \Exception('Email error: ' . $e->getMessage()); + } + } } } \ No newline at end of file diff --git a/services/nginx/app/composer.json b/services/nginx/app/composer.json index 7b14f32d..20f4d90f 100644 --- a/services/nginx/app/composer.json +++ b/services/nginx/app/composer.json @@ -8,6 +8,7 @@ "ext-curl": "*", "ext-json": "*", "aws/aws-sdk-php": "^3.0", - "predis/predis": "*" + "predis/predis": "*", + "phpmailer/phpmailer": "^6.9" } } diff --git a/services/nginx/app/composer.lock b/services/nginx/app/composer.lock index 7b45bbab..ce31fbe7 100644 --- a/services/nginx/app/composer.lock +++ b/services/nginx/app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "00925965cc45d732b603e3a25c5d5047", + "content-hash": "ccdb2011a0cba46aac7345fbab9a674a", "packages": [ { "name": "aws/aws-crt-php", @@ -549,6 +549,87 @@ }, "time": "2024-09-04T18:46:31+00:00" }, + { + "name": "phpmailer/phpmailer", + "version": "v6.9.3", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/2f5c94fe7493efc213f643c23b1b1c249d40f47e", + "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.3" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2024-11-24T18:04:13+00:00" + }, { "name": "predis/predis", "version": "v2.3.0", diff --git a/services/nginx/app/index.php b/services/nginx/app/index.php index ff290594..5e3c86f5 100644 --- a/services/nginx/app/index.php +++ b/services/nginx/app/index.php @@ -57,6 +57,7 @@ require_once 'classes/economic.php'; require_once 'classes/statistics.php'; require_once 'classes/recaptcha.php'; require_once 'classes/email.php'; +require_once 'classes/backup_store.php'; /** * Modules diff --git a/services/nginx/app/interfaces/minio_backups_i.php b/services/nginx/app/interfaces/minio_backups_i.php new file mode 100644 index 00000000..48695504 --- /dev/null +++ b/services/nginx/app/interfaces/minio_backups_i.php @@ -0,0 +1,87 @@ +smtp_from = new email_smtp_from_c(); $this->smtp_from_name = new email_smtp_from_name_c(); } + } \ No newline at end of file diff --git a/services/nginx/app/routes/moduleBackupsRoute.php b/services/nginx/app/routes/moduleBackupsRoute.php new file mode 100644 index 00000000..c85808c3 --- /dev/null +++ b/services/nginx/app/routes/moduleBackupsRoute.php @@ -0,0 +1,59 @@ + Backups > GET */ + $this->get('/modules/backup', function () { + global $response; + $this->requirePermission('modules_backup_list'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('modules_backup', 'global', 1, $user->id, 'MODULES_BACKUP', 'Successfully fetched backup modules'); + $response->success( + (new backup_store())->listBackups() + ); + } else { + (new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }); + + /** Modules > Backups > POST */ + $this->post('/modules/backup', function () { + global $response; + $this->requirePermission('modules_backup_create'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('modules_backup', 'global', 1, $user->id, 'MODULES_BACKUP', 'Successfully created backup module'); + // Check if the request contains a name, and description parameter + $name = $this->fromRequest('name'); + $description = $this->fromRequest('description'); + // Create the backup + $response->success( + (new backup_store())->createBackup($name, $description) + ); + } else { + (new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }); + } +} \ No newline at end of file diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index d60d4b7a..5d148ae0 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -118,5 +118,32 @@ class moduleConfigRoute } }); + /** Email config > TEST */ + $this->post('/email/config/test', function () { + global $response; + $this->requirePermission('email_config'); + $user = (new authentication())->get_user(); + if ($user) { + $test_recipient = $this->fromRequest('email'); + if (!$test_recipient) { + (new logs_o())->add('email_config', 'global', 1, $user->id, 'EMAIL_CONFIG', 'Test email recipient not set'); + $response->error('Test email recipient not set', 400); + } + // Check if the test recipient is a valid email + if (!filter_var($test_recipient, FILTER_VALIDATE_EMAIL)) { + (new logs_o())->add('email_config', 'global', 1, $user->id, 'EMAIL_CONFIG', 'Invalid test email recipient'); + $response->error('Invalid test email recipient', 400); + } + // Log the incident + (new logs_o())->add('email_config', 'global', 1, $user->id, 'EMAIL_CONFIG', 'Successfully tested email config'); + $response->success( + (new email())->testConfigRequest($test_recipient) + ); + } else { + (new logs_o())->add('email_config', 'global', 1, 0, 'EMAIL_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }); + } } \ No newline at end of file diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index 59067fa2..eead50f1 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -35,6 +35,8 @@ class ordersRoute function ($order) { // Add the invoice status to the order $order['economic_invoice_module'] = (new economic_module_orders())->getByOrderId($order['id'])->asArray(); + // Add the customer name to the order + $order['customer_name'] = (new users_o())->getCustomerName($order['customer_id']); /** @var array $order */ return $order; } @@ -47,7 +49,7 @@ class ordersRoute $response->error('Invalid session', 400); } }); - + $this->post('/orders', function () { // Require the user to be logged in diff --git a/services/php/Dockerfile b/services/php/Dockerfile index 3944b515..5e0ef84e 100644 --- a/services/php/Dockerfile +++ b/services/php/Dockerfile @@ -17,6 +17,7 @@ RUN apt-get update && apt-get install -y \ git \ curl \ libzip-dev \ + default-mysql-client \ && docker-php-ext-install mbstring exif pcntl bcmath gd pdo_mysql mysqli zip # Copy application files into the container