From 5d4dcefc7d4d1693883b7654296bfe7f5ffb8f41 Mon Sep 17 00:00:00 2001 From: Osahenrumwen Aigbogun Date: Thu, 17 Oct 2024 10:35:26 +0100 Subject: [PATCH 1/4] Added file validation to upload handler --- .../FileUpload/Enums/FileUploadExtension.php | 10 +- src/Libs/FileUpload/FileUpload.php | 166 ++++++++++++++++-- src/Libs/FileUpload/Traits/Doc.php | 37 ++-- src/Libs/FileUpload/Traits/Image.php | 12 +- 4 files changed, 185 insertions(+), 40 deletions(-) diff --git a/src/Libs/FileUpload/Enums/FileUploadExtension.php b/src/Libs/FileUpload/Enums/FileUploadExtension.php index e1f606b..3f2f34a 100644 --- a/src/Libs/FileUpload/Enums/FileUploadExtension.php +++ b/src/Libs/FileUpload/Enums/FileUploadExtension.php @@ -4,10 +4,18 @@ enum FileUploadExtension : string { + // Docs case PDF = 'application/pdf'; case CSV = 'text/csv'; case EXCEL = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; - case EXCEL_OLD = 'application/vnd.ms-excel'; case ZIP = 'application/zip'; + + // Extra doc extensions + case EXCEL_OLD = 'application/vnd.ms-excel'; case ZIP_OLD = 'application/x-zip-compressed'; + + // Images + case PNG = "image/png"; + case JPEG = "image/jpeg"; + case HEIC = "image/heic"; } diff --git a/src/Libs/FileUpload/FileUpload.php b/src/Libs/FileUpload/FileUpload.php index 0b8329c..4eca07e 100644 --- a/src/Libs/FileUpload/FileUpload.php +++ b/src/Libs/FileUpload/FileUpload.php @@ -12,9 +12,22 @@ use JetBrains\PhpStorm\ArrayShape; final class FileUpload { + #[ArrayShape([ + 'uploaded' => 'bool', + 'dev_error' => 'string', + 'error' => 'string', + 'error_type' => "BrickLayer\\Lay\\Libs\\FileUpload\\Enums\\FileUploadErrors", + 'upload_type' => "BrickLayer\\Lay\\Libs\\FileUpload\\Enums\\FileUploadType", + 'storage' => "BrickLayer\\Lay\\Libs\\FileUpload\\Enums\\FileUploadStorage", + 'url' => 'string', + 'size' => 'int', + 'width' => 'int', + 'height' => 'int', + ])] public ?array $response = null; - protected FileUploadStorage $storage; + protected ?FileUploadStorage $storage = null; + protected ?FileUploadType $upload_type = null; use Image; use Doc; @@ -23,10 +36,79 @@ final class FileUpload { * @throws \Exception */ public function __construct( - protected ?FileUploadType $upload_type = null, + #[ArrayShape([ + // Name of file from the form + 'post_name' => 'string', + + // New name and file extension of file after upload + 'new_name' => 'string', + + //< 'string', + 'permission' => 'int', + //< 'string', + + // Use this to force bucket upload in development environment + 'upload_on_dev' => 'bool', + + // File limit in bytes + 'file_limit' => 'int', + + // If nothing is provided the system will not validate for the extension type + 'extension' => 'BrickLayer\Lay\Libs\FileUpload\Enums\FileUploadExtension', + + // An array of BrickLayer\Lay\Libs\FileUpload\Enums\FileUploadExtension + 'extension_list' => 'array', + + // Use this to add a custom MIME that does not exist in the extension key above + 'custom_mime' => 'array', // ['application/zip', 'application/x-zip-compressed'] + + // The type of storage the file should be uploaded to + 'storage' => 'BrickLayer\Lay\Libs\FileUpload\Enums\FileUploadStorage', + + // Add last modified time to the returned url key, so that your browser can cache it. + // This is necessary if you are using the same 'new_name' for multiple versions of a file + // The new file will overwrite the old file, and the last_mod_time will force the browser to update its copy + 'add_mod_time' => 'bool', + + // The compression quality to produce after uploading an image: [10 - 100] + 'quality' => 'int', + + // The dimension an image should maintain: [max_width, max_height] + 'dimension' => 'array', + + // If the php temporary file should be moved or copied. This is necessary if you want to generate a thumbnail + // and other versions of the image from one upload file + 'copy_tmp_file' => 'bool', + ])] array $opts = [] ) { + $req = $this->check_all_requirements( + post_name: $opts['post_name'], + custom_mime: $opts['custom_mime'] ?? null, + extension_list: $opts['extension_list'] ?? null, + ); + + if($req) + return $this->response = $req; + + if( !$req ) { + $mime = mime_content_type($_FILES[$opts['post_name']]['tmp_name']); + + if(str_starts_with($mime, "image/")) + $this->upload_type = FileUploadType::IMG; + + elseif(str_starts_with($mime, "video/")) + $this->upload_type = FileUploadType::VIDEO; + + else + $this->upload_type = FileUploadType::DOC; + } + if($this->upload_type == FileUploadType::IMG) $this->response = $this->image_upload($opts); @@ -115,6 +197,7 @@ private function check_all_requirements( ?int $file_limit = null, FileUploadExtension|null|string $extension = null, ?array $custom_mime = null, + ?array $extension_list = null, ) : ?array { if(!isset($_FILES[$post_name])) @@ -156,35 +239,82 @@ private function check_all_requirements( ); } + if($extension_list) { + $mime = mime_content_type($file); + $found = false; + + foreach ($extension_list as $list) { + if(!($list instanceof FileUploadExtension)) { + $extension_list = var_export($extension_list, true); + $this->exception("extension_list must be of type " . FileUploadExtension::class . "; extension_list: [$extension_list]. File Mime: [$mime]"); + } + + if($list->value == $mime) { + $found = true; + break; + } + } + + if(!$found) { + $extension_list = var_export($extension_list, true); + + return $this->upload_response( + false, + [ + "dev_error" => "Uploaded file had: [$mime], but required mime types are: [$extension_list]; Class: " . self::class, + "error" => "File type is invalid", + "error_type" => FileUploadErrors::WRONG_FILE_TYPE + ] + ); + } + } + if($extension || $custom_mime) { $mime = mime_content_type($file); - if(!$custom_mime) + if(!$custom_mime) { $pass = match ($extension) { - FileUploadExtension::PDF => $mime == FileUploadExtension::PDF, - FileUploadExtension::CSV => $mime == FileUploadExtension::CSV, + FileUploadExtension::PDF => $mime == FileUploadExtension::PDF->value, + FileUploadExtension::CSV => $mime == FileUploadExtension::CSV->value, FileUploadExtension::ZIP, FileUploadExtension::ZIP_OLD => - $mime == FileUploadExtension::ZIP || $mime == FileUploadExtension::ZIP_OLD, + $mime == FileUploadExtension::ZIP->value || $mime == FileUploadExtension::ZIP_OLD->value, FileUploadExtension::EXCEL, FileUploadExtension::EXCEL_OLD => - $mime == FileUploadExtension::EXCEL_OLD || $mime == FileUploadExtension::EXCEL + $mime == FileUploadExtension::EXCEL_OLD->value || $mime == FileUploadExtension::EXCEL->value, + + FileUploadExtension::PNG => $mime == FileUploadExtension::PNG->value, + FileUploadExtension::JPEG => $mime == FileUploadExtension::JPEG->value, + FileUploadExtension::HEIC => $mime == FileUploadExtension::HEIC->value, }; - else - $pass = in_array($mime, $custom_mime, true); - if(!$pass) { - $extension = is_string($extension) ? $extension : $extension->name; + if(!$pass) { + $extension = is_string($extension) ? $extension : $extension->name; + return $this->upload_response( + false, + [ + "dev_error" => "Uploaded file had: [$mime], but required mime type is: [$extension]; Class: " . self::class, + "error" => "Uploaded file does not match the required file type: [$extension]", + "error_type" => FileUploadErrors::WRONG_FILE_TYPE + ] + ); + } + } + else { + if (!in_array($mime, $custom_mime, true)) { + $custom_mime = implode(",", $custom_mime); - return $this->upload_response( - false, - [ - "error" => "Uploaded file does not match the required file type: [$extension]", - "error_type" => FileUploadErrors::WRONG_FILE_TYPE - ] - ); + return $this->upload_response( + false, + [ + "dev_error" => "Uploaded file had: [$mime], but required mime types are: [$custom_mime]; Class: " . self::class, + "error" => "Uploaded file is not accepted", + "error_type" => FileUploadErrors::WRONG_FILE_TYPE + ] + ); + } } } diff --git a/src/Libs/FileUpload/Traits/Doc.php b/src/Libs/FileUpload/Traits/Doc.php index 2c7477a..d1a312f 100644 --- a/src/Libs/FileUpload/Traits/Doc.php +++ b/src/Libs/FileUpload/Traits/Doc.php @@ -18,17 +18,7 @@ trait Doc { /** - * ### @$options - * - **post_name (string):** $_FILES[post_name] *(REQUIRED)* - * - **new_name (string):** The name you wish to call this newly uploaded file (REQUIRED)* - * - **directory (string):** The directory where the file should be uploaded to (REQUIRED)* - * - **permission (int):** The permission to apply to the directory and file *(default: 0755)* - * - **quality (int):** The result quality, on a scale if 1 - 100; *(default: 80)* - * - **dimension (array[int,int]):** [Max Width, Max Height] *(default: [800,800])* - * - **copy_tmp_file (bool):** On true, function copies the upload temp file instead of moving it in case the developer wants to further process it *(default: false)* - * - * This function moves your uploaded image, creates the directory, - * resizes the image and returns the image name and extension (image.webp) + * This function handles documents uploads like pdf, * @param array $options * @return array * @throws \Exception @@ -73,6 +63,21 @@ public function doc_upload( // The type of storage the file should be uploaded to 'storage' => 'BrickLayer\Lay\Libs\FileUpload\Enums\FileUploadStorage', + + // Add last modified time to the returned url key, so that your browser can cache it. + // This is necessary if you are using the same 'new_name' for multiple versions of a file + // The new file will overwrite the old file, and the last_mod_time will force the browser to update its copy + 'add_mod_time' => 'bool', + + // The compression quality to produce after uploading an image: [10 - 100] + 'quality' => 'int', + + // The dimension an image should maintain: [max_width, max_height] + 'dimension' => 'array', + + // If the php temporary file should be moved or copied. This is necessary if you want to generate a thumbnail + // and other versions of the image from one upload file + 'copy_tmp_file' => 'bool', ])] array $options ): array @@ -88,9 +93,9 @@ public function doc_upload( if( $check = $this->check_all_requirements( $post_name, - $file_limit, - $extension, - $custom_mime + $file_limit ?? null, + $extension ?? null, + $custom_mime ?? null ) ) return $check; @@ -98,6 +103,7 @@ public function doc_upload( $file_size = $file['size']; $tmp_file = $file['tmp_name']; + $add_mod_time ??= true; if($extension) { $file_ext = is_string($extension) ? $extension : $extension->name; @@ -108,7 +114,8 @@ public function doc_upload( $file_ext = "." . strtolower(end($ext)); } - $new_name = Escape::clean(LayFn::rtrim_word($new_name, $file_ext) . $file_ext,EscapeType::P_URL); + $add_mod_time = $add_mod_time ? "-" . filemtime($file['tmp_name']) . $file_ext : $file_ext; + $new_name = Escape::clean(LayFn::rtrim_word($new_name, $file_ext),EscapeType::P_URL) . $add_mod_time; if($storage == FileUploadStorage::BUCKET) { if(!$bucket_path) diff --git a/src/Libs/FileUpload/Traits/Image.php b/src/Libs/FileUpload/Traits/Image.php index 6a49b3b..c632e15 100644 --- a/src/Libs/FileUpload/Traits/Image.php +++ b/src/Libs/FileUpload/Traits/Image.php @@ -162,6 +162,11 @@ public function image_upload( // The type of storage the file should be uploaded to 'storage' => 'BrickLayer\Lay\Libs\FileUpload\Enums\FileUploadStorage', + // Add last modified time to the returned url key, so that your browser can cache it. + // This is necessary if you are using the same 'new_name' for multiple versions of a file + // The new file will overwrite the old file, and the last_mod_time will force the browser to update its copy + 'add_mod_time' => 'bool', + // The compression quality to produce after uploading an image: [10 - 100] 'quality' => 'int', @@ -171,11 +176,6 @@ public function image_upload( // If the php temporary file should be moved or copied. This is necessary if you want to generate a thumbnail // and other versions of the image from one upload file 'copy_tmp_file' => 'bool', - - // Add last modified time to the returned url key, so that your browser can cache it. - // This is necessary if you are using the same 'new_name' for multiple versions of a file - // The new file will overwrite the old file, and the last_mod_time will force the browser to update its copy - 'add_mod_time' => 'bool', ])] array $options ) : array @@ -191,7 +191,7 @@ public function image_upload( if( $check = $this->check_all_requirements( $post_name, - $file_limit, + $file_limit ?? null, ) ) return $check; From 6b2d3cba450611286da876aa2051a3735947b8b3 Mon Sep 17 00:00:00 2001 From: Osahenrumwen Aigbogun Date: Thu, 17 Oct 2024 14:24:16 +0100 Subject: [PATCH 2/4] Polished FileUpload --- src/Libs/FileUpload/FileUpload.php | 38 ++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/Libs/FileUpload/FileUpload.php b/src/Libs/FileUpload/FileUpload.php index 4eca07e..10a9feb 100644 --- a/src/Libs/FileUpload/FileUpload.php +++ b/src/Libs/FileUpload/FileUpload.php @@ -273,25 +273,39 @@ private function check_all_requirements( $mime = mime_content_type($file); if(!$custom_mime) { - $pass = match ($extension) { - FileUploadExtension::PDF => $mime == FileUploadExtension::PDF->value, - FileUploadExtension::CSV => $mime == FileUploadExtension::CSV->value, + $pass = false; + $test_multiple = function (FileUploadExtension ...$ext) use ($extension, $mime) : bool { + if(!in_array($extension, $ext, true)) + return false; - FileUploadExtension::ZIP, - FileUploadExtension::ZIP_OLD => - $mime == FileUploadExtension::ZIP->value || $mime == FileUploadExtension::ZIP_OLD->value, + $pass = false; - FileUploadExtension::EXCEL, - FileUploadExtension::EXCEL_OLD => - $mime == FileUploadExtension::EXCEL_OLD->value || $mime == FileUploadExtension::EXCEL->value, + foreach ($ext as $e) { + if($pass) + break; - FileUploadExtension::PNG => $mime == FileUploadExtension::PNG->value, - FileUploadExtension::JPEG => $mime == FileUploadExtension::JPEG->value, - FileUploadExtension::HEIC => $mime == FileUploadExtension::HEIC->value, + $pass = $mime == $e->value; + } + + return $pass; }; + foreach (FileUploadExtension::cases() as $case) { + if($pass) + break; + + if($pass = $test_multiple(FileUploadExtension::ZIP, FileUploadExtension::ZIP_OLD)) + break; + + if($pass = $test_multiple(FileUploadExtension::EXCEL, FileUploadExtension::EXCEL_OLD)) + break; + + $pass = $mime == $case->value; + } + if(!$pass) { $extension = is_string($extension) ? $extension : $extension->name; + return $this->upload_response( false, [ From 1b012b9ef12243e75d330c82f4957f211c6bfc12 Mon Sep 17 00:00:00 2001 From: Osahenrumwen Aigbogun Date: Thu, 17 Oct 2024 15:08:35 +0100 Subject: [PATCH 3/4] Made Cron respect existing jobs --- src/Core/LayException.php | 5 ++++ src/Libs/Cron/LayCron.php | 37 ++++++++++++++++++++++++++++-- src/Libs/FileUpload/FileUpload.php | 4 ++-- src/Libs/LayFn.php | 11 +++++---- 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 src/Core/LayException.php diff --git a/src/Core/LayException.php b/src/Core/LayException.php new file mode 100644 index 0000000..929dcc5 --- /dev/null +++ b/src/Core/LayException.php @@ -0,0 +1,5 @@ + "", "jobs" => [], @@ -149,12 +151,43 @@ private function db_data_save() : bool { return (bool) file_put_contents($this->cron_db(), json_encode($data)); } + private function project_server_jobs(string $mailto, string $cron_jobs) : string + { + $server_jobs = file_get_contents(self::CRON_FILE); + + $all_jobs = ""; + $app_id = LayConfig::app_id(); + + foreach (explode(PHP_EOL, $server_jobs) as $i => $job) { + if($i == 0 && $job == 'MAILTO=""') { + $all_jobs .= $mailto; + continue; + } + + if(LayFn::extract_cli_tag(self::APP_ID_KEY, true, $job) != $app_id) { + $all_jobs .= $job . PHP_EOL; + } + } + + if(empty($all_jobs)) + $all_jobs = $mailto; + + $all_jobs .= $cron_jobs; + + return $all_jobs; + } + private function crontab_save() : bool { $mailto = $this->report_email ? 'MAILTO=' . $this->report_email : 'MAILTO=""'; $mailto .= PHP_EOL; $cron_jobs = implode("", $this->jobs_list); - $exec = @file_put_contents(self::CRON_FILE, $mailto . $cron_jobs); + $data = $this->project_server_jobs( + mailto: $mailto, + cron_jobs: $cron_jobs + ); + + $exec = @file_put_contents(self::CRON_FILE, $data); if($exec) { exec("crontab '" . self::CRON_FILE . "' 2>&1", $out); @@ -202,7 +235,7 @@ private function make_job(string $job) : string { private function add_job(string $job) : void { $add = str_contains(shell_exec("crontab -l 2>&1"), "no crontab for"); - $job .= " --LAY_APP_ID '" . LayConfig::app_id() ."'"; + $job .= rtrim($job, PHP_EOL) . " " . self::APP_ID_KEY . " '" . LayConfig::app_id() ."'" . PHP_EOL; if(!$add && !$this->db_job_exists($job)['found']) { if(isset($this->job_id)) diff --git a/src/Libs/FileUpload/FileUpload.php b/src/Libs/FileUpload/FileUpload.php index 10a9feb..0adf03f 100644 --- a/src/Libs/FileUpload/FileUpload.php +++ b/src/Libs/FileUpload/FileUpload.php @@ -245,7 +245,7 @@ private function check_all_requirements( foreach ($extension_list as $list) { if(!($list instanceof FileUploadExtension)) { - $extension_list = var_export($extension_list, true); + $extension_list = implode(",", $extension_list); $this->exception("extension_list must be of type " . FileUploadExtension::class . "; extension_list: [$extension_list]. File Mime: [$mime]"); } @@ -256,7 +256,7 @@ private function check_all_requirements( } if(!$found) { - $extension_list = var_export($extension_list, true); + $extension_list = implode(",", $extension_list); return $this->upload_response( false, diff --git a/src/Libs/LayFn.php b/src/Libs/LayFn.php index e7096a2..958a724 100644 --- a/src/Libs/LayFn.php +++ b/src/Libs/LayFn.php @@ -84,15 +84,18 @@ public static function rtrim_word(string $string, string $word) : string * * @return string|bool|int|null */ - public static function extract_cli_tag(string $key, bool $has_value): string|null|bool|int + public static function extract_cli_tag(string $key, bool $has_value, ?string $argument = null): string|null|bool|int { - global $argv; + $arg_values = $GLOBALS['argv'] ?? null; - $tag_key = array_search($key, $argv); + if($argument) + $arg_values = explode(" ", $argument); + + $tag_key = array_search($key, $arg_values); $value = null; if ($tag_key !== false) - $value = $has_value ? $argv[$tag_key + 1] : true; + $value = $has_value ? $arg_values[$tag_key + 1] : true; return $value; } From 472bf5916b8381d09ab0522076004047882ab808 Mon Sep 17 00:00:00 2001 From: Osahenrumwen Aigbogun Date: Thu, 17 Oct 2024 16:36:48 +0100 Subject: [PATCH 4/4] Made Cron respect existing jobs of other projects in a shared host --- src/Libs/Cron/LayCron.php | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/Libs/Cron/LayCron.php b/src/Libs/Cron/LayCron.php index 593e5e2..c215524 100644 --- a/src/Libs/Cron/LayCron.php +++ b/src/Libs/Cron/LayCron.php @@ -8,8 +8,6 @@ use BrickLayer\Lay\Libs\LayDate; use BrickLayer\Lay\Libs\LayFn; -//TODO: Attach a project ID to all cron jobs, so that the system can identify all jobs created by a particular project -//The id is the one generated for each lay project final class LayCron { private const CRON_FILE = "/tmp/crontab.txt"; @@ -71,7 +69,10 @@ public static function dump_crontab(bool $suppress_win_exception = false) : stri "); } - exec("cat " . self::CRON_FILE, $out); + $out = self::new()->get_crontab(); + + if(!$out) + return false; return implode(PHP_EOL, $out); } @@ -159,12 +160,18 @@ private function project_server_jobs(string $mailto, string $cron_jobs) : string $app_id = LayConfig::app_id(); foreach (explode(PHP_EOL, $server_jobs) as $i => $job) { + if(empty($job)) + continue; + if($i == 0 && $job == 'MAILTO=""') { $all_jobs .= $mailto; continue; } - if(LayFn::extract_cli_tag(self::APP_ID_KEY, true, $job) != $app_id) { + $job_app_id = LayFn::extract_cli_tag(self::APP_ID_KEY, true, $job); + $job_app_id = $job_app_id ? trim($job_app_id) : $job_app_id; + + if($job_app_id != $app_id) { $all_jobs .= $job . PHP_EOL; } } @@ -234,21 +241,17 @@ private function make_job(string $job) : string { } private function add_job(string $job) : void { - $add = str_contains(shell_exec("crontab -l 2>&1"), "no crontab for"); - $job .= rtrim($job, PHP_EOL) . " " . self::APP_ID_KEY . " '" . LayConfig::app_id() ."'" . PHP_EOL; + $job = rtrim($job, PHP_EOL) . " " . self::APP_ID_KEY . " " . LayConfig::app_id() . PHP_EOL; + + $job_exists = $this->db_job_exists($job)['found']; - if(!$add && !$this->db_job_exists($job)['found']) { + if(!$job_exists) { if(isset($this->job_id)) $this->jobs_list[$this->job_id] = $job; else $this->jobs_list[] = $job; - - $add = true; } - if(!$add && $this->db_email_exists()) - return; - $this->commit(); } @@ -405,11 +408,14 @@ public function get_job(string|int $uid) : ?array { ]; } - public function get_crontab() : string { - if(!file_exists(self::CRON_FILE)) - return ""; + public function get_crontab() : ?array + { + exec("crontab -l 2>&1", $out); + + if(str_contains($out[0], "no crontab for")) + return null; - return file_get_contents(self::CRON_FILE); + return $out; } public function unset(string|int $uid_or_job) : bool {