diff --git a/common/components/CloudinaryManager.php b/common/components/CloudinaryManager.php index 361f737a..59b7bec7 100644 --- a/common/components/CloudinaryManager.php +++ b/common/components/CloudinaryManager.php @@ -2,11 +2,10 @@ namespace common\components; -use Yii; -use Cloudinary\Cloudinary; use Cloudinary\Configuration\Configuration; use Cloudinary\Api\Upload\UploadApi; use Cloudinary\Api\Admin\AdminApi; +use yii\base\InvalidConfigException; /** * @@ -21,7 +20,7 @@ class CloudinaryManager extends \yii\base\Component { public $api_key; public $api_secret; - private $cloudinary; + private $configured = false; /** * @inheritdoc @@ -30,13 +29,16 @@ public function init() { parent::init(); - foreach (['cloud_name', 'api_key', 'api_secret'] as $attribute) { - if ($this->$attribute === null) { - throw new yii\base\InvalidConfigException(strtr('"{class}::{attribute}" cannot be empty.', [ - '{class}' => static::class, - '{attribute}' => '$' . $attribute - ])); - } + $this->cloud_name = $this->normalizeConfigValue($this->cloud_name); + $this->api_key = $this->normalizeConfigValue($this->api_key); + $this->api_secret = $this->normalizeConfigValue($this->api_secret); + + $this->configured = $this->cloud_name !== null + && $this->api_key !== null + && $this->api_secret !== null; + + if (!$this->configured) { + return; } /*define('CLOUDINARY_CLOUD_NAME', $this->cloud_name); @@ -55,6 +57,24 @@ public function init() ]); } + private function normalizeConfigValue($value) + { + if ($value === null) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function assertConfigured() + { + if (!$this->configured) { + throw new InvalidConfigException('Cloudinary credentials are not configured.'); + } + } + /** * Upload image * @param string $filePath @@ -63,7 +83,9 @@ public function init() */ public function upload($filePath, $options) { - return (new uploadApi())->upload( + $this->assertConfigured(); + + return (new UploadApi())->upload( $filePath, $options ); @@ -76,6 +98,8 @@ public function upload($filePath, $options) */ public function delete($path, $type = "image") { + $this->assertConfigured(); + //remove extension from path to get public_id $ext = pathinfo($path, PATHINFO_EXTENSION); @@ -83,7 +107,7 @@ public function delete($path, $type = "image") $public_id = str_replace(".".$ext, "", $path); //$this->cloudinary->delete - $result = (new uploadApi())->destroy($public_id, [ + $result = (new UploadApi())->destroy($public_id, [ "invalidate" => true,//remove from CDN cache if any "resource_type" => $type ]); @@ -98,7 +122,9 @@ public function delete($path, $type = "image") */ public function getUrl($public_id, $type = "image") { - $result = (new adminApi())->asset($public_id); + $this->assertConfigured(); + + $result = (new AdminApi())->asset($public_id); if ($result['secure_url']) { return $result['secure_url']; diff --git a/common/config/main.php b/common/config/main.php index 793bdb35..a0b5727f 100644 --- a/common/config/main.php +++ b/common/config/main.php @@ -55,9 +55,9 @@ ], 'cloudinaryManager' => [ 'class' => 'common\components\CloudinaryManager', - 'cloud_name' => 'studenthub', - 'api_key' => '251218449868375', - 'api_secret' => 'FILAex7q93GUB-q1bEe1pAKOIvY' + 'cloud_name' => getenv('CLOUDINARY_CLOUD_NAME') ?: null, + 'api_key' => getenv('CLOUDINARY_API_KEY') ?: null, + 'api_secret' => getenv('CLOUDINARY_API_SECRET') ?: null /** * You can access the bucket with: * http://res.cloudinary.com/studenthub/ diff --git a/docs/demo/studenthub-55-cloudinary-hardening-demo.gif b/docs/demo/studenthub-55-cloudinary-hardening-demo.gif new file mode 100644 index 00000000..e16b392c Binary files /dev/null and b/docs/demo/studenthub-55-cloudinary-hardening-demo.gif differ diff --git a/docs/setup.md b/docs/setup.md index 53550fa5..49675b53 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -56,4 +56,26 @@ cd console && ../yii algolia/index candidate ```bash ./yii cron/update-candidate-stats ./yii cron/update-company-stats -``` \ No newline at end of file +``` + +## Cloudinary Runtime Configuration + +Profile photos, brand logos, and company documents are stored in Cloudinary through +`common\components\CloudinaryManager`. Do not commit Cloudinary credentials to +the repository. Configure them through the runtime environment instead: + +```bash +CLOUDINARY_CLOUD_NAME=your-cloud-name +CLOUDINARY_API_KEY=your-api-key +CLOUDINARY_API_SECRET=your-api-secret +``` + +If any of these variables are missing, Cloudinary upload, delete, and asset URL +lookups fail before calling Cloudinary. This prevents partial configuration from +accidentally using stale checked-in credentials. + +Before submitting config changes, run: + +```bash +python scripts/check-cloudinary-hardening.py +``` diff --git a/scripts/check-cloudinary-hardening.py b/scripts/check-cloudinary-hardening.py new file mode 100644 index 00000000..98347aa7 --- /dev/null +++ b/scripts/check-cloudinary-hardening.py @@ -0,0 +1,74 @@ +from pathlib import Path +import re +import sys + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = ROOT / "common" / "config" / "main.php" +MANAGER = ROOT / "common" / "components" / "CloudinaryManager.php" +DOCS = ROOT / "docs" / "setup.md" + + +def fail(message): + print(f"Cloudinary hardening check failed: {message}", file=sys.stderr) + raise SystemExit(1) + + +config = CONFIG.read_text(encoding="utf-8") +manager = MANAGER.read_text(encoding="utf-8") +docs = DOCS.read_text(encoding="utf-8") + +for variable in [ + "CLOUDINARY_CLOUD_NAME", + "CLOUDINARY_API_KEY", + "CLOUDINARY_API_SECRET", +]: + if variable not in config: + fail(f"{variable} is not wired in common/config/main.php") + if variable not in docs: + fail(f"{variable} is not documented in docs/setup.md") + +cloudinary_block_match = re.search( + r"'cloudinaryManager'\s*=>\s*\[(.*?)\n\s*\],", + config, + flags=re.S, +) +if not cloudinary_block_match: + fail("cloudinaryManager config block was not found") + +cloudinary_block = cloudinary_block_match.group(1) + +literal_patterns = [ + (r"'cloud_name'\s*=>\s*['\"][^'\"]+['\"]", "cloud_name literal"), + (r"'api_key'\s*=>\s*['\"][^'\"]+['\"]", "api_key literal"), + (r"'api_secret'\s*=>\s*['\"][^'\"]+['\"]", "api_secret literal"), +] +for pattern, description in literal_patterns: + if re.search(pattern, cloudinary_block): + fail(f"Cloudinary {description} remains in config") + +required_manager_snippets = [ + "private $configured = false;", + "normalizeConfigValue", + "assertConfigured", + "Cloudinary credentials are not configured.", +] +for snippet in required_manager_snippets: + if snippet not in manager: + fail(f"CloudinaryManager is missing {snippet!r}") + +for method in ["upload", "delete", "getUrl"]: + method_match = re.search( + rf"public function {method}\([^)]*\)\s*\{{(.*?)\n\s*\}}", + manager, + flags=re.S, + ) + if not method_match: + fail(f"CloudinaryManager::{method} was not found") + if "$this->assertConfigured();" not in method_match.group(1): + fail(f"CloudinaryManager::{method} does not assert configuration") + +if "new uploadApi()" in manager or "new adminApi()" in manager: + fail("Cloudinary API classes should use their imported class names") + +print("Cloudinary hardening check passed.")