diff --git a/README.md b/README.md index eb0a5ca6..7656e5db 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,17 @@ Detailed documentation is available in the `docs/` directory: - Access backend container: `docker-compose exec backend bash` - Code generator: http://localhost:8888/bawes/studenthub/admin/web/gii +## S3 Environment Variables + +The application expects S3 credentials to be supplied through environment variables rather than committed config values. + +- `AWS_TEMP_BUCKET_KEY` +- `AWS_TEMP_BUCKET_SECRET` +- `AWS_PERMANENT_S3_ACCESS_KEY_ID` +- `AWS_PERMANENT_S3_SECRET_ACCESS_KEY` +- `AWS_PERMANENT_S3_REGION` +- `AWS_PERMANENT_S3_BUCKET` + ## allow access from docker to local mysql server `GRANT ALL PRIVILEGES ON *.* TO 'root'@'192.168.1.5' IDENTIFIED BY 'root' WITH GRANT OPTION;` @@ -204,4 +215,3 @@ END $$ DELIMITER; - diff --git a/candidate/modules/v1/controllers/AccountController.php b/candidate/modules/v1/controllers/AccountController.php index 171d01ff..2cc624fb 100644 --- a/candidate/modules/v1/controllers/AccountController.php +++ b/candidate/modules/v1/controllers/AccountController.php @@ -382,19 +382,33 @@ public function actionRemovePhoto() { public function actionRemoveCivilPhotoBack() { $model = Candidate::findOne(Yii::$app->user->getId()); + + if (!$model) { + throw new \yii\web\HttpException(404, Yii::t('candidate', 'The requested Item could not be found.')); + } if ($model->candidate_civil_photo_back) { $model->deleteFile('civil-id', 'back'); } $model->candidate_civil_photo_back = null; + $model->candidate_civil_need_verification = true; $model->scenario = 'updateCivilPhotoBack'; - if (!$model->save(false)) { + try { + if (!$model->save(false)) { + return [ + 'operation' => 'error', + 'message' => $model->getErrors() + ]; + } + } catch (\Throwable $e) { + Yii::error('Failed to remove back civil photo for candidate_id=' . $model->candidate_id . ': ' . $e->getMessage(), 'candidate'); + return [ 'operation' => 'error', - 'message' => $model->getErrors() + 'message' => Yii::t('candidate', 'Unable to remove Civil ID photo. Please try again.') ]; } @@ -409,18 +423,32 @@ public function actionRemoveCivilPhotoBack() { public function actionRemoveCivilPhotoFront() { $model = Candidate::findOne(Yii::$app->user->getId()); + if (!$model) { + throw new \yii\web\HttpException(404, Yii::t('candidate', 'The requested Item could not be found.')); + } + if ($model->candidate_civil_photo_front) { $model->deleteFile('civil-id', 'front'); } $model->candidate_civil_photo_front = null; + $model->candidate_civil_need_verification = true; $model->scenario = 'updateCivilPhotoFront'; - if (!$model->save(false)) { + try { + if (!$model->save(false)) { + return [ + 'operation' => 'error', + 'message' => $model->getErrors() + ]; + } + } catch (\Throwable $e) { + Yii::error('Failed to remove front civil photo for candidate_id=' . $model->candidate_id . ': ' . $e->getMessage(), 'candidate'); + return [ 'operation' => 'error', - 'message' => $model->getErrors() + 'message' => Yii::t('candidate', 'Unable to remove Civil ID photo. Please try again.') ]; } @@ -1301,7 +1329,12 @@ public function actionUpdateCivilPhotoBack() { ]; } - $model->updateCivilId('back'); + if (!$model->updateCivilId('back')) { + return [ + 'operation' => 'error', + 'message' => $model->getErrors() + ]; + } //reset to remove old id's data $model->candidate_civil_expiry_date = null; @@ -1353,7 +1386,12 @@ public function actionUpdateCivilPhotoFront() { ]; } - $model->updateCivilId('front'); + if (!$model->updateCivilId('front')) { + return [ + 'operation' => 'error', + 'message' => $model->getErrors() + ]; + } //reset to remove old id's data $model->candidate_civil_expiry_date = null; @@ -1430,24 +1468,52 @@ public function actionUpdateCivilIdExpiryDate() { throw new \yii\web\HttpException(404, Yii::t('candidate', 'The requested Item could not be found.')); } - $candidate_civil_id = Yii::$app->request->getBodyParam('civil_id'); + $candidate_civil_id = trim((string) Yii::$app->request->getBodyParam('civil_id')); - $candidate_civil_expiry_date = Yii::$app->request->getBodyParam('civil_expiry_date'); + $candidate_civil_expiry_date = trim((string) Yii::$app->request->getBodyParam('civil_expiry_date')); - $candidate->candidate_civil_id = $candidate_civil_id; + if ($candidate_civil_id === '') { + return [ + "operation" => "error", + "message" => Yii::t('candidate', "Civil ID is required"), + ]; + } - if($candidate_civil_expiry_date) - $candidate->candidate_civil_expiry_date = date('Y-m-d', strtotime($candidate_civil_expiry_date)); + if ($candidate_civil_expiry_date === '') { + return [ + "operation" => "error", + "message" => Yii::t('candidate', "Civil ID Expiry Date is required"), + ]; + } - $candidate->candidate_civil_need_verification = true; + $candidate_civil_expiry_timestamp = strtotime($candidate_civil_expiry_date); - $candidate->scenario = "updateCivilExpiryDateAndCivilID"; + if ($candidate_civil_expiry_timestamp === false) { + return [ + "operation" => "error", + "message" => Yii::t('candidate', "Civil ID Expiry Date is invalid"), + ]; + } - if (!$candidate->save()) { + try { + $candidate->candidate_civil_id = $candidate_civil_id; + $candidate->candidate_civil_expiry_date = date('Y-m-d', $candidate_civil_expiry_timestamp); + $candidate->candidate_civil_need_verification = true; + + $candidate->scenario = "updateCivilExpiryDateAndCivilID"; + + if (!$candidate->save()) { + return [ + "operation" => "error", + "message" => $candidate->errors + ]; + } + } catch (\Throwable $e) { + Yii::error('Failed to update Civil ID expiry for candidate_id=' . $candidate->candidate_id . ': ' . $e->getMessage(), 'candidate'); return [ "operation" => "error", - "message" => $candidate->errors + "message" => Yii::t('candidate', "Unable to update Civil ID details. Please try again.") ]; } diff --git a/candidate/tests/functional/AccountCest.php b/candidate/tests/functional/AccountCest.php index b25e116a..ab8c12d4 100644 --- a/candidate/tests/functional/AccountCest.php +++ b/candidate/tests/functional/AccountCest.php @@ -465,6 +465,33 @@ public function tryUpdateCivilExpiry(FunctionalTester $I) $I->seeResponseCodeIs(HttpCode::OK); // 200 } + public function tryUpdateCivilExpiryRejectsMissingCivilId(FunctionalTester $I) + { + $I->amGoingTo('try to update civil id expiry date without civil id'); + $I->sendPOST('v1/account/update-civil-id-expiry-date', [ + 'civil_expiry_date' => '3033-12-12' + ]); + $I->seeResponseCodeIs(HttpCode::OK); // 200 + $I->seeResponseContainsJson([ + 'operation' => 'error', + 'message' => 'Civil ID is required' + ]); + } + + public function tryUpdateCivilExpiryRejectsInvalidDate(FunctionalTester $I) + { + $I->amGoingTo('try to update civil id expiry date with invalid date'); + $I->sendPOST('v1/account/update-civil-id-expiry-date', [ + 'civil_id' => '70', + 'civil_expiry_date' => 'not-a-date' + ]); + $I->seeResponseCodeIs(HttpCode::OK); // 200 + $I->seeResponseContainsJson([ + 'operation' => 'error', + 'message' => 'Civil ID Expiry Date is invalid' + ]); + } + public function tryUpdateKuwaitiNational(FunctionalTester $I) { $I->amGoingTo('try to update if mother kuwaity'); @@ -547,4 +574,3 @@ public function tryUpdatePreferredTime(FunctionalTester $I) // $I->seeResponseCodeIs(HttpCode::OK); // 200 // } } - diff --git a/common/components/S3ResourceManager.php b/common/components/S3ResourceManager.php index 608a407e..d64d7759 100644 --- a/common/components/S3ResourceManager.php +++ b/common/components/S3ResourceManager.php @@ -162,22 +162,50 @@ public function delete($name) 'Key' => $name ]); - return $result['DeleteMarker']; + // deleteObject success is determined by request success (no exception). + return true; } /** - * Checks whether a file exists or not. This method only works for public resources, private resources will throw - * a 403 error exception. + * Checks whether a file exists or not. + * S3 object keys are checked with HeadObject; URLs are checked with an HTTP HEAD request. * @param string $filenameOrUrl the name or url of the file * @return boolean */ public function fileExists($filenameOrUrl) { + if (!$filenameOrUrl) { + return false; + } + $isUrl = false; - if (strpos($filenameOrUrl, 'http') !== false) { + if (strpos($filenameOrUrl, 'http') === 0) { $isUrl = true; } + if (!$isUrl) { + try { + $this->getClient()->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $filenameOrUrl, + ]); + + return true; + } catch (AwsException $e) { + if ((int) $e->getStatusCode() === 404 || $e->getAwsErrorCode() === 'NoSuchKey' || $e->getAwsErrorCode() === 'NotFound') { + return false; + } + + Yii::warning('Unable to check S3 object existence for bucket=' . $this->bucket . ' key=' . $filenameOrUrl . ': ' . $e->getMessage(), 's3'); + + return false; + } catch (\Exception $e) { + Yii::warning('Unable to check S3 object existence for bucket=' . $this->bucket . ' key=' . $filenameOrUrl . ': ' . $e->getMessage(), 's3'); + + return false; + } + } + $http = new \GuzzleHttp\Client(['base_uri' => $isUrl ? $filenameOrUrl : $this->getUrl($filenameOrUrl)]); try { $response = $http->request('HEAD'); diff --git a/common/config/main.php b/common/config/main.php index 793bdb35..073faa4b 100644 --- a/common/config/main.php +++ b/common/config/main.php @@ -8,8 +8,8 @@ 'temporaryBucketResourceManager' => [ 'class' => 'common\components\S3ResourceManager', 'region' => 'eu-west-2', // Bucket based in London - 'key' => 'AKIAWMITDJRKVN5ODY2X', - 'secret' => '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', + 'key' => getenv('AWS_TEMP_BUCKET_KEY') ?: '', + 'secret' => getenv('AWS_TEMP_BUCKET_SECRET') ?: '', 'bucket' => 'studenthub-public-anyone-can-upload-24hr-expiry' /** * You can access the Temporary bucket with: diff --git a/common/models/Candidate.php b/common/models/Candidate.php index b1c5694b..c2093abe 100644 --- a/common/models/Candidate.php +++ b/common/models/Candidate.php @@ -118,8 +118,8 @@ class Candidate extends \yii\db\ActiveRecord implements \yii\web\IdentityInterfa // Array of attribute names and folder names to store them in the permanent bucket public $FILE_ATTRIBUTES = [ 'candidate_personal_photo' => 'photos', - 'candidate_civil_photo_front' => 'civil-id', - 'candidate_civil_photo_back' => 'civil-id' + 'candidate_civil_photo_front' => 'photos', + 'candidate_civil_photo_back' => 'photos' ]; /** @@ -2697,39 +2697,60 @@ public function setProfileByUrl($url) { */ public function deleteFile($type = 'resume', $side = 'front') { + $file = null; + $attribute = $type == 'civil-id' && $side == 'back' + ? 'candidate_civil_photo_back' + : ($type == 'civil-id' ? 'candidate_civil_photo_front' : 'candidate_resume'); + try { if (isset($this->oldPrimaryKey)) { - $file = null; - if ($type == 'resume' && isset($this->oldAttributes['candidate_resume'])) { $file = "candidate-resume/" . $this->oldAttributes['candidate_resume']; } else if ($type == 'civil-id' && $side == 'front' && isset($this->oldAttributes['candidate_civil_photo_front'])) { - $file = "candidate-civil-id/" . $this->oldAttributes['candidate_civil_photo_front']; + $fileName = $this->oldAttributes['candidate_civil_photo_front']; + $file = strpos($fileName, 'photos/') === 0 ? $fileName : "photos/" . $fileName; } else if ($type == 'civil-id' && $side == 'back' && isset($this->oldAttributes['candidate_civil_photo_back'])) { - $file = "candidate-civil-id/" . $this->oldAttributes['candidate_civil_photo_back']; + $fileName = $this->oldAttributes['candidate_civil_photo_back']; + $file = strpos($fileName, 'photos/') === 0 ? $fileName : "photos/" . $fileName; } if ($file) { + if ($type == 'civil-id' && !Yii::$app->resourceManager->fileExists($file)) { + Yii::warning('Civil ID file already missing before delete; route=' . Yii::$app->requestedRoute . ' candidate_id=' . $this->candidate_id . ' file=' . $file, 'candidate'); + + return true; + } + Yii::$app->resourceManager->delete($file); } } + return true; + } catch (\Aws\S3\Exception\S3Exception $e) { - Yii::error($e->getMessage(), 'candidate'); + Yii::error('Failed deleting file from S3; route=' . Yii::$app->requestedRoute . ' candidate_id=' . $this->candidate_id . ' file=' . $file . ': ' . $e->getMessage(), 'candidate'); - $this->addError('candidate_resume', Yii::t('app', 'file not available to delete.')); + if ($type != 'civil-id') { + $this->addError($attribute, Yii::t('app', 'file not available to delete.')); - return false; + return false; + } + + return true; } catch (\Exception $e) { - Yii::error($e->getMessage(), 'candidate'); + Yii::error('Failed deleting file from S3; route=' . Yii::$app->requestedRoute . ' candidate_id=' . $this->candidate_id . ' file=' . $file . ': ' . $e->getMessage(), 'candidate'); - $this->addError('candidate_resume', Yii::t('app', 'file not available to delete.')); + if ($type != 'civil-id') { + $this->addError($attribute, Yii::t('app', 'file not available to delete.')); - return false; + return false; + } + + return true; } } @@ -2740,25 +2761,37 @@ public function updateCivilId($side = 'front') { $idSide = ($side == 'front') ? 'candidate_civil_photo_front' : 'candidate_civil_photo_back'; - if (!empty($this->oldAttributes[$idSide])) { - $this->deleteFile('civil-id', $side); - } - $fileName = $this->$idSide; + if (!$fileName) { + $this->addError($idSide, Yii::t('app', 'file not available to save.')); + + return false; + } + $sourceBucket = Yii::$app->temporaryBucketResourceManager->bucket; - $targetPath = "photos/" . $fileName; + $targetPath = strpos($fileName, 'photos/') === 0 ? $fileName : "photos/" . $fileName; // Copy using S3ResourceManager Component try { - return Yii::$app->resourceManager->copy($fileName, $targetPath, $sourceBucket); + $result = Yii::$app->resourceManager->copy($fileName, $targetPath, $sourceBucket); + + if (!Yii::$app->resourceManager->fileExists($targetPath)) { + throw new \RuntimeException('Copied Civil ID file was not found at destination.'); + } + + if (!empty($this->oldAttributes[$idSide]) && $this->oldAttributes[$idSide] !== $fileName) { + $this->deleteFile('civil-id', $side); + } + + return $result; } catch (\Aws\S3\Exception\S3Exception $e) { - Yii::error($e->getMessage(), 'candidate'); + Yii::error('Failed copying Civil ID file; route=' . Yii::$app->requestedRoute . ' candidate_id=' . $this->candidate_id . ' file=' . $fileName . ': ' . $e->getMessage(), 'candidate'); $this->addError($idSide, Yii::t('app', 'file not available to save.')); @@ -2766,7 +2799,7 @@ public function updateCivilId($side = 'front') { } catch (\Exception $e) { - Yii::error($e->getMessage(), 'candidate'); + Yii::error('Failed copying Civil ID file; route=' . Yii::$app->requestedRoute . ' candidate_id=' . $this->candidate_id . ' file=' . $fileName . ': ' . $e->getMessage(), 'candidate'); $this->addError($idSide, Yii::t('app', 'file not available to save.')); diff --git a/environments/circle-ci/common/config/main-local.php b/environments/circle-ci/common/config/main-local.php index 3950e0d0..cbb34c1d 100644 --- a/environments/circle-ci/common/config/main-local.php +++ b/environments/circle-ci/common/config/main-local.php @@ -71,8 +71,8 @@ 'resourceManager' => [ 'class' => 'common\components\S3ResourceManager', 'region' => 'eu-west-2', // Bucket based in London - 'key' => 'AKIAWMITDJRKVN5ODY2X', - 'secret' => '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', + 'key' => getenv('AWS_TEMP_BUCKET_KEY') ?: null, + 'secret' => getenv('AWS_TEMP_BUCKET_SECRET') ?: null, 'bucket' => 'studenthub-public-anyone-can-upload-24hr-expiry' /** * You can access the Temporary bucket with: diff --git a/environments/dev-server-railway/common/config/main-local.php b/environments/dev-server-railway/common/config/main-local.php index e9d8bc3c..fe20074e 100644 --- a/environments/dev-server-railway/common/config/main-local.php +++ b/environments/dev-server-railway/common/config/main-local.php @@ -104,10 +104,10 @@ 'resourceManager' => [ 'class' => 'common\components\S3ResourceManager', 'authMethod' => \common\components\S3ResourceManager::AUTH_VIA_KEY_AND_SECRET, - 'region' => 'eu-west-2', // Bucket based in London - 'bucket' => 'studenthub-uploads-dev-server', - 'key' => 'AKIAWMITDJRKWZZEWCUM',//railway-s3-access - 'secret' => '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', + 'region' => getenv('AWS_PERMANENT_S3_REGION') ?: 'eu-west-2', // Bucket based in London + 'bucket' => getenv('AWS_PERMANENT_S3_BUCKET') ?: 'studenthub-uploads-dev-server', + 'key' => getenv('AWS_PERMANENT_S3_ACCESS_KEY_ID') ?: null, // railway-s3-access + 'secret' => getenv('AWS_PERMANENT_S3_SECRET_ACCESS_KEY') ?: null, /** * For Dev and Production servers, access is via server embedded IAM roles so no key/secret required * diff --git a/environments/docker/common/config/main-local.php b/environments/docker/common/config/main-local.php index 2e5d0821..c97ded83 100644 --- a/environments/docker/common/config/main-local.php +++ b/environments/docker/common/config/main-local.php @@ -89,10 +89,10 @@ ], 'resourceManager' => [ 'class' => 'common\components\S3ResourceManager', - 'region' => 'eu-west-2', // Bucket based in London - 'key' => 'AKIAWMITDJRKVN5ODY2X', - 'secret' => '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', - 'bucket' => 'studenthub-uploads-dev-server', + 'region' => getenv('AWS_PERMANENT_S3_REGION') ?: 'eu-west-2', // Bucket based in London + 'key' => getenv('AWS_PERMANENT_S3_ACCESS_KEY_ID') ?: null, + 'secret' => getenv('AWS_PERMANENT_S3_SECRET_ACCESS_KEY') ?: null, + 'bucket' => getenv('AWS_PERMANENT_S3_BUCKET') ?: 'studenthub-uploads-dev-server', /** * For Local Development, we access using key and secret * For Dev and Production servers, access is via server embedded IAM roles so no key/secret required diff --git a/environments/krushn-nginx/common/config/main-local.php b/environments/krushn-nginx/common/config/main-local.php index 8573aa6b..e92baeeb 100644 --- a/environments/krushn-nginx/common/config/main-local.php +++ b/environments/krushn-nginx/common/config/main-local.php @@ -91,10 +91,10 @@ ], 'resourceManager' => [ 'class' => 'common\components\S3ResourceManager', - 'region' => 'eu-west-2', // Bucket based in London - 'key' => 'AKIAWMITDJRKVN5ODY2X', - 'secret' => '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', - 'bucket' => 'studenthub-uploads-dev-server', + 'region' => getenv('AWS_PERMANENT_S3_REGION') ?: 'eu-west-2', // Bucket based in London + 'key' => getenv('AWS_PERMANENT_S3_ACCESS_KEY_ID') ?: null, + 'secret' => getenv('AWS_PERMANENT_S3_SECRET_ACCESS_KEY') ?: null, + 'bucket' => getenv('AWS_PERMANENT_S3_BUCKET') ?: 'studenthub-uploads-dev-server', /** * For Local Development, we access using key and secret * For Dev and Production servers, access is via server embedded IAM roles so no key/secret required diff --git a/environments/krushn/common/config/main-local.php b/environments/krushn/common/config/main-local.php index 6a2fb61a..d0bdc620 100644 --- a/environments/krushn/common/config/main-local.php +++ b/environments/krushn/common/config/main-local.php @@ -90,10 +90,10 @@ ], 'resourceManager' => [ 'class' => 'common\components\S3ResourceManager', - 'region' => 'eu-west-2', // Bucket based in London - 'key' => 'AKIAWMITDJRKVN5ODY2X', - 'secret' => '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', - 'bucket' => 'studenthub-uploads-dev-server', + 'region' => getenv('AWS_PERMANENT_S3_REGION') ?: 'eu-west-2', // Bucket based in London + 'key' => getenv('AWS_PERMANENT_S3_ACCESS_KEY_ID') ?: null, + 'secret' => getenv('AWS_PERMANENT_S3_SECRET_ACCESS_KEY') ?: null, + 'bucket' => getenv('AWS_PERMANENT_S3_BUCKET') ?: 'studenthub-uploads-dev-server', /** * For Local Development, we access using key and secret * For Dev and Production servers, access is via server embedded IAM roles so no key/secret required diff --git a/environments/prod-railway/common/config/main-local.php b/environments/prod-railway/common/config/main-local.php index f8b14d07..93fd7d0b 100644 --- a/environments/prod-railway/common/config/main-local.php +++ b/environments/prod-railway/common/config/main-local.php @@ -152,10 +152,10 @@ 'resourceManager' => [ 'class' => 'common\components\S3ResourceManager', 'authMethod' => \common\components\S3ResourceManager::AUTH_VIA_KEY_AND_SECRET, - 'region' => 'eu-west-2', // Bucket based in London - 'bucket' => 'studenthub-uploads', - 'key' => 'AKIAWMITDJRKWZZEWCUM',//railway-s3-access - 'secret' => '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', + 'region' => getenv('AWS_PERMANENT_S3_REGION') ?: 'eu-west-2', // Bucket based in London + 'bucket' => getenv('AWS_PERMANENT_S3_BUCKET') ?: 'studenthub-uploads', + 'key' => getenv('AWS_PERMANENT_S3_ACCESS_KEY_ID') ?: null, // railway-s3-access + 'secret' => getenv('AWS_PERMANENT_S3_SECRET_ACCESS_KEY') ?: null, /** * For Local Development, we access using key and secret * For Dev and Production servers, access is via server embedded IAM roles so no key/secret required