Civil ID Upload Flow#
Audience: Backend and frontend developers working on the StudentHub platform.
Last updated: 2026-04-27
Overview#
The Civil ID upload flow handles how candidates submit front and back photos of their government-issued Civil ID card through any of the StudentHub frontend apps. The implementation follows a dual-bucket S3 architecture that routes files directly from the user's browser or mobile device to AWS S3 — completely bypassing the backend server.
How it works at a high level#
- Frontend uploads directly to a temporary S3 bucket. The frontend app fetches short-lived AWS credentials from the backend on startup, then uses the AWS JS SDK to push the file straight to a publicly-writable S3 bucket (
studenthub-public-anyone-can-upload-24hr-expiry). The backend server never touches the raw file bytes. - Frontend sends only the S3 filename to the backend. Once the direct upload succeeds, the app posts the generated S3 key (filename) to the backend API endpoint (
POST /v1/account/update-civil-photo-frontorPOST /v1/account/update-civil-photo-back). - Backend copies the file server-side to the permanent bucket. The backend validates that the file exists in the temp bucket, then uses the AWS PHP SDK's
copyObject()call to move it to the permanentstudenthub-uploadsbucket (orstudenthub-uploads-dev-serveron dev). The copy is performed using an IAM role — no access keys are needed on the production server.
The two S3 buckets#
| Bucket | Purpose | Auth |
|---|---|---|
studenthub-public-anyone-can-upload-24hr-expiry | Temporary landing zone; accepts direct uploads from browsers/apps | Access key + secret (key pair) |
studenthub-uploads (prod) / studenthub-uploads-dev-server (dev) | Permanent, versioned storage for processed Civil ID images | IAM Role (no keys in server config) |
Both buckets are located in eu-west-2 (London).
Objects in the temporary bucket carry a 24-hour lifecycle expiry, so any file that is never claimed by the backend (e.g., the user abandons mid-flow) is automatically purged. Files successfully promoted to the permanent bucket are stored under the photos/ prefix.
Architecture Diagram#
The sequence below covers the complete Civil ID upload flow, from app startup credential fetching through to the final server-side copy to the permanent bucket.
Note: The same flow applies to the back-side photo, with the endpoint changing to
POST /v1/account/update-civil-photo-backand the field tocivil_photo_back.
S3 Bucket Configuration#
Temporary Bucket#
The temporary bucket is configured as a Yii2 component named temporaryBucketResourceManager in common/config/main.php. It uses static key-and-secret credentials :
// common/config/main.php
'temporaryBucketResourceManager' => [
'class' => 'common\components\S3ResourceManager',
'region' => 'eu-west-2', // Bucket based in London
'key' => 'AKIAWMITDJRKVN5ODY2X',
'secret' => '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',
'bucket' => 'studenthub-public-anyone-can-upload-24hr-expiry'
// Access URL: https://studenthub-public-anyone-can-upload-24hr-expiry.s3.amazonaws.com/
],
⚠️ The key/secret for the temp bucket are committed to
common/config/main.php. These credentials are deliberately limited to upload-only access on a bucket with a 24-hour object expiry policy and no sensitive data. That said, consider migrating these to environment variables (AWS_TEMP_BUCKET_KEY/AWS_TEMP_BUCKET_SECRET) for consistency with production practice.
The key and secret are also read from environment variables for the parameters array that is served to the frontend :
// common/config/params.php
"aws_temp_access_key_id" => getenv('AWS_TEMP_BUCKET_KEY') ?: '',
"aws_temp_secret_access_key" => getenv('AWS_TEMP_BUCKET_SECRET') ?: '',
Permanent Buckets#
The permanent bucket is registered as resourceManager and its configuration differs between environments.
Production (environments/prod/common/config/main-local.php) :
'resourceManager' => [
'class' => 'common\components\S3ResourceManager',
'authMethod' => \common\components\S3ResourceManager::AUTH_VIA_IAM_ROLE,
'region' => 'eu-west-2',
'bucket' => 'studenthub-uploads',
// Access URL: https://studenthub-uploads.s3.amazonaws.com/
],
Dev / Staging (environments/dev/common/config/main-local.php) :
'resourceManager' => [
'class' => 'common\components\S3ResourceManager',
'authMethod' => \common\components\S3ResourceManager::AUTH_VIA_IAM_ROLE,
'region' => 'eu-west-2',
'bucket' => 'studenthub-uploads-dev-server',
// Access URL: https://studenthub-uploads-dev-server.s3.amazonaws.com/
],
Both environments use AUTH_VIA_IAM_ROLE, meaning no access key or secret is stored in config — the EC2/ECS instance's attached IAM role is used automatically by the AWS SDK.
S3ResourceManager: Authentication Modes#
common\components\S3ResourceManager supports two authentication strategies :
| Constant | Value | Used By |
|---|---|---|
AUTH_VIA_KEY_AND_SECRET | 1 | Temporary bucket (all environments) |
AUTH_VIA_IAM_ROLE | 2 | Permanent bucket on dev and production servers |
When AUTH_VIA_IAM_ROLE is selected, the component omits the credentials key from the S3Client factory parameters and relies on the SDK's default credential chain (instance metadata service).
Environment Variables Reference#
| Variable | Mapped To | Used For |
|---|---|---|
AWS_TEMP_BUCKET_KEY | params['aws_temp_access_key_id'] | Temp bucket key served to frontend via /aws/config |
AWS_TEMP_BUCKET_SECRET | params['aws_temp_secret_access_key'] | Temp bucket secret served to frontend via /aws/config |
AWS_TEXTRACT_ACCESS_KEY_ID | idExpiryDateExtractor.key | AWS Textract for future ID OCR extraction |
AWS_TEXTRACT_SECRET_ACCESS_KEY | idExpiryDateExtractor.secret | AWS Textract for future ID OCR extraction |
Sources:
Frontend Upload Flow#
The upload logic lives in AwsService (src/app/providers/logged-in/aws.service.ts), which is shared across all four frontend apps (candidate, admin, staff, company). Civil ID-specific UI pages exist only in the candidate app.
Step 1 — Credential Bootstrap (App Startup)#
Every app registers AwsService.setConfig() as an APP_INITIALIZER, so credentials are fetched once on boot before any user interaction occurs :
setConfig() {
return new Promise((resolve, reject) => {
this.getConfig().subscribe(config => {
this._region = config.region;
this._access_key_uuid = config.key;
this._secret_access_key = config.secret;
this._bucket_name = config.bucket;
AWS.config.region = this._region;
AWS.config.accessKeyId = this._access_key_uuid;
AWS.config.secretAccessKey = this._secret_access_key;
resolve(true);
}, err => {
reject(err);
});
});
}
getConfig() calls GET {apiEndpoint}/aws/config which returns the temp bucket's region, key, secret, and bucket name.
Step 2 — File Selection#
The candidate app provides two dedicated modal pages for Civil ID upload:
civil-id-front.page.ts—src/app/pages/logged-in/civil-id-front/civil-id-front.page.tscivil-id-back.page.ts—src/app/pages/logged-in/civil-id-back/civil-id-back.page.ts
Both pages detect the platform and fork upload paths:
| Platform | Trigger | Method |
|---|---|---|
| Mobile (Capacitor) | mobileUpload() → camera / photo library picker | uploadFileViaNativeFilePath(uri) → awsService.uploadNativePath(uri) |
| Browser | <input type="file"> change event | browserUpload(event) → awsService.uploadFile(fileList[0]) |
The browser path validates that the selected file is of type image before proceeding.
Step 3 — File Naming#
Before upload, uploadFile() generates a unique S3 key :
let extension = this.getFileExtension(file.name);
let prefix = this._getFileNameWithoutExtension(file.name);
if (!prefix) { prefix = 'file'; }
let key = prefix + "-" + Date.now() + "." + extension;
The prefix is normalized by normalizeFileName(), which replaces spaces, %20, and any non-alphanumeric character with hyphens :
normalizeFileName(fileName) {
return fileName.replace(/ /g, "-")
.replace(/%20/g, "-")
.replace(/([^a-z0-9 ]+)/gi, '-');
}
Example: My Civil ID.jpg → My-Civil-ID-1732000000000.jpg
Step 4 — Direct S3 Upload#
uploadFile() constructs an upload request using the AWS JS SDK :
let params = {
Body: file,
ACL: "public-read", // uploaded file is publicly accessible
Bucket: this._bucket_name,
Key: key,
ContentType: file.type,
Metadata: metadata
};
const currUpload = s3.upload(params);
currUpload.on('httpUploadProgress', (progress) => { observer.next(progress); });
currUpload.send((err, data) => { ... observer.next(data) ... });
Upload size limits :
| Limit | Value | Applies to |
|---|---|---|
maxUploadSize | 18 MB (18,874,368 bytes) | All file types (candidate, admin, company apps) |
maxImageUploadSize | 5 MB (5,000,000 bytes) | Image MIME type specifically |
The staff app uses a 5 MB limit for its upload size cap.
Step 5 — Handling the S3 Response#
When the SDK emits an event with a non-empty event.Key, the upload is complete. _handleFileSuccess() then :
- Waits for the image to fully load in the browser (
imgLarge.onload) to confirm it's renderable. - Sets form controls:
civil_photo_front_path=event.Location(full HTTPS URL, used for preview display)civil_photo_front=event.Key(S3 filename only, sent to backend)
- Calls
accountService.updateCivilPhotoFront(event.Key)to notify the backend.
// civil-id-front.page.ts — _handleFileSuccess excerpt
this.form.controls.civil_photo_front_path.setValue(event.Location);
this.form.controls.civil_photo_front.setValue(event.Key);
this.accountService.updateCivilPhotoFront(event.Key).subscribe(async response => {
if (response.operation != 'success') {
// show error alert, reset form
} else {
// update candidate model, emit civilUpdated$ event, dismiss modal
this.candidate.candidate_civil_photo_front = response.candidate_civil_photo_front;
this.candidate.candidate_civil_expiry_date = response.candidate_civil_expiry_date;
this.candidate.candidate_civil_id = response.candidate_civil_id;
this.dismiss({ refresh: true, candidate: this.candidate });
}
});
The back photo page follows the identical pattern using civil_photo_back and accountService.updateCivilPhotoBack().
Backend Processing#
AwsController — Credential Endpoint#
File: candidate/modules/v1/controllers/AwsController.php (and equivalent in each module)
The /aws/config endpoint is intentionally unauthenticated. The authenticator behavior is removed, leaving only a CORS filter that restricts access to allowedOrigins :
// Remove authentication filter for cors to work
unset($behaviors['authenticator']);
$behaviors['corsFilter'] = [
'class' => \yii\filters\Cors::class,
'cors' => [
'Origin' => Yii::$app->params['allowedOrigins'],
// ...
],
];
actionConfig() returns the temp bucket credentials :
public function actionConfig()
{
// todo: key with expiry
return [
'region' => Yii::$app->temporaryBucketResourceManager->region,
'key' => Yii::$app->params['aws_temp_access_key_id'],
'secret' => Yii::$app->params['aws_temp_secret_access_key'],
'bucket' => Yii::$app->temporaryBucketResourceManager->bucket
];
}
⚠️ The
// todo: key with expirycomment indicates that rotating/expiring credentials (e.g., via AWS STSAssumeRole) is a known improvement that has not yet been implemented.
AccountController — Civil Photo Endpoints#
File: candidate/modules/v1/controllers/AccountController.php
Upload Endpoints#
Both upload endpoints follow the same pattern. Here is actionUpdateCivilPhotoFront() :
public function actionUpdateCivilPhotoFront() {
$model = Candidate::findOne(Yii::$app->user->getId());
if (!$model) {
throw new \yii\web\HttpException(404, ...);
}
$model->scenario = "updateCivilPhotoFront";
$model->candidate_civil_photo_front = urldecode(
Yii::$app->request->getBodyParam('civil_photo_front')
);
if (!$model->candidate_civil_photo_front
|| $model->candidate_civil_photo_front == "undefined") {
return ['operation' => 'error', 'message' => '...'];
}
$model->updateCivilId('front');
// Reset old OCR-extracted data — forces re-extraction on next check
$model->candidate_civil_expiry_date = null;
$model->candidate_civil_id = null;
if (!$model->save()) {
return ['operation' => 'error', 'message' => $model->getErrors()];
}
return [
'operation' => 'success',
'candidate_civil_photo_front' => $model->candidate_civil_photo_front,
'candidate_civil_photo_back' => $model->candidate_civil_photo_back,
'candidate_civil_expiry_date' => $model->candidate_civil_expiry_date,
'candidate_civil_id' => $model->candidate_civil_id,
'civilExpired' => $model->candidate_civil_expiry_date &&
(strtotime($model->candidate_civil_expiry_date) < strtotime(date('Y-m-d'))),
'message' => 'Civil Photo Front Uploaded Successfully'
];
}
actionUpdateCivilPhotoBack() is identical except it uses the civil_photo_back field and updateCivilId('back').
Why are candidate_civil_expiry_date and candidate_civil_id reset to null? These fields are populated by an OCR/extraction process (AWS Textract, via IdExpiryDateExtractor). Uploading a new image makes the previously extracted values stale, so they are cleared to force re-extraction.
Remove Endpoints#
actionRemoveCivilPhotoFront() and actionRemoveCivilPhotoBack() allow candidates to delete their Civil ID photos:
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.'));
}
$oldFileName = $model->candidate_civil_photo_front;
$model->candidate_civil_photo_front = null;
$model->candidate_civil_need_verification = true;
$model->scenario = 'updateCivilPhotoFront';
try {
if (!$model->save(false)) {
return [
'operation' => 'error',
'message' => $model->getErrors(),
];
}
} catch (\Throwable $e) {
Yii::error([
'action' => 'actionRemoveCivilPhotoFront',
'candidate_id' => $model->candidate_id,
'side' => 'front',
'exception' => get_class($e),
'message' => $e->getMessage(),
], 'candidate.civil-id');
return [
'operation' => 'error',
'message' => Yii::t('candidate', 'Could not remove civil photo front.'),
];
}
// DB committed first — only then best-effort remove the orphan object.
if ($oldFileName !== null && $oldFileName !== '') {
$s3Key = Candidate::normalizeCivilIdPermanentS3Key($oldFileName);
if ($s3Key !== '') {
try {
Yii::$app->resourceManager->delete($s3Key);
} catch (\Throwable $e) {
Yii::warning([
'action' => 'actionRemoveCivilPhotoFront',
'candidate_id' => $model->candidate_id,
'side' => 'front',
'filename' => $oldFileName,
's3_key' => $s3Key,
'reason' => Candidate::classifyS3DeleteThrowable($e),
'exception' => get_class($e),
'message' => $e->getMessage(),
], 'candidate.civil-id');
}
}
}
return [
'operation' => 'success'
];
}
Key behaviours:
- 404 guard: Throws
HttpException(404)if the candidate is not found - DB saved first, then S3 delete: The DB is committed before attempting to delete the S3 object, so the operation succeeds even if the S3 delete fails
- Verification flag: Sets
candidate_civil_need_verification = trueto flag the profile for admin review - Best-effort S3 delete: The S3 delete is non-fatal. Missing-object failures are logged as warnings using
classifyS3DeleteThrowable() - Error handling: Catches
\Throwablearound the save operation with structured logging, returns user-friendly error messages
Update Expiry Date Endpoints#
actionUpdateCivilExpiryDate() and actionUpdateCivilIdExpiryDate() update the Civil ID number and expiry date. Both endpoints share similar validation logic:
public function actionUpdateCivilIdExpiryDate() {
$candidate = Candidate::findOne(Yii::$app->user->getId());
if (!$candidate) {
throw new \yii\web\HttpException(404, ...);
}
$candidate_civil_id = Yii::$app->request->getBodyParam('civil_id');
$candidate_civil_expiry_date = Yii::$app->request->getBodyParam('civil_expiry_date');
// Input validation: never raw 500 for invalid client payloads.
if (!is_string($candidate_civil_id) || trim($candidate_civil_id) === '') {
return [
'operation' => 'error',
'message' => Yii::t('candidate', 'Civil ID is required.'),
];
}
if (!is_string($candidate_civil_expiry_date) || trim($candidate_civil_expiry_date) === '') {
return [
'operation' => 'error',
'message' => Yii::t('candidate', 'Civil ID expiry date is required.'),
];
}
$expiryDt = $this->parseStrictCivilExpiryDateUtc(trim($candidate_civil_expiry_date));
if ($expiryDt === null) {
return [
'operation' => 'error',
'message' => Yii::t('candidate', 'Civil ID expiry date is invalid.'),
];
}
$candidate->candidate_civil_id = trim($candidate_civil_id);
$candidate->candidate_civil_expiry_date = $expiryDt->format('Y-m-d');
$candidate->candidate_civil_need_verification = true;
$candidate->scenario = 'updateCivilExpiryDateAndCivilID';
try {
if (!$candidate->save()) {
return [
'operation' => 'error',
'message' => $candidate->errors,
];
}
} catch (\Throwable $e) {
Yii::error([
'action' => 'actionUpdateCivilIdExpiryDate',
'candidate_id' => $candidate->candidate_id,
'exception' => get_class($e),
'message' => $e->getMessage(),
], 'candidate.civil-id');
return [
'operation' => 'error',
'message' => Yii::t('candidate', 'Could not update civil id and expiry date.'),
];
}
return [
'operation' => 'success',
'candidate_civil_expiry_date' => $candidate->candidate_civil_expiry_date,
'message' => Yii::t('candidate', 'Civil ID And Expiry Date Updated Successfully'),
];
}
Key behaviours:
- Input validation: Both
civil_idandcivil_expiry_dateare validated as non-empty strings. Invalid dates are rejected with specific error messages instead of 500 errors. - Strict date parsing: Uses
parseStrictCivilExpiryDateUtc()helper to parse expiry dates in strict ISO 8601 formats (Y-m-d, Y-m-d\TH:i\Z, Y-m-d\TH:i.u\Z). Rejects relative phrases and silently-normalized invalid calendar dates. - Storage format: Dates are stored in Y-m-d format after parsing
- Error handling: Catches
\Throwablearound the save operation with structured error logging, returns user-friendly error messages
Candidate Model — updateCivilId()#
File: common/models/Candidate.php
This method orchestrates the file promotion from temp to permanent bucket :
public function updateCivilId($side = 'front') {
$idSide = ($side == 'front')
? 'candidate_civil_photo_front'
: 'candidate_civil_photo_back';
$fileName = $this->$idSide;
$sourceBucket = Yii::$app->temporaryBucketResourceManager->bucket;
$targetPath = self::normalizeCivilIdPermanentS3Key($fileName);
if ($targetPath === '') {
$this->addError($idSide, Yii::t('app', 'file not available to save.'));
return false;
}
// 1) Copy new file from temp bucket to permanent bucket FIRST.
// Only after a successful copy do we touch the old file.
try {
Yii::$app->resourceManager->copy($fileName, $targetPath, $sourceBucket);
} catch (\Throwable $e) {
Yii::error([
'action' => 'Candidate::updateCivilId',
'candidate_id' => $this->candidate_id ?? null,
'side' => $side,
'source_key' => $fileName,
'source_bucket'=> $sourceBucket,
'target_key' => $targetPath,
'exception' => get_class($e),
'message' => $e->getMessage(),
], 'candidate.civil-id');
$this->addError($idSide, Yii::t('app', 'file not available to save.'));
return false;
}
// 2) Optional post-copy verification (non-fatal)
try {
if (!Yii::$app->resourceManager->fileExists($targetPath)) {
Yii::warning([
'action' => 'Candidate::updateCivilId',
'candidate_id' => $this->candidate_id ?? null,
'side' => $side,
'target_key' => $targetPath,
'reason' => 'post-copy verification failed',
], 'candidate.civil-id');
}
} catch (\Throwable $e) {
// verification is best-effort; never fail the upload because of it
}
// 3) Best-effort delete of the old permanent-bucket file.
$oldNorm = self::normalizeCivilIdPermanentS3Key($this->oldAttributes[$idSide] ?? '');
$newNorm = self::normalizeCivilIdPermanentS3Key((string)$fileName);
if ($oldNorm !== '' && $oldNorm !== $newNorm) {
$this->deleteFile('civil-id', $side);
}
return true;
}
Key behaviours:
- Path normalization: Uses
normalizeCivilIdPermanentS3Key()to ensure the target path is alwaysphotos/<basename>, preventing duplicate prefixes likephotos/photos/or legacycandidate-civil-id/paths. - Copy-first strategy: Files are copied from temp to permanent bucket before attempting to delete the old file. This prevents data loss if the copy operation fails.
- Post-copy verification: An optional
fileExists()check is performed after the copy (non-fatal, best-effort). - Conditional old-file delete: Only attempts to delete the old file if its normalized key differs from the new one, and only after the new file has been successfully copied.
- Error handling: Catches
\Throwableinstead of specific exceptions. Errors are logged with structured context arrays to thecandidate.civil-idcategory and surfaced as model validation errors.
Candidate Model — normalizeCivilIdPermanentS3Key()#
File: common/models/Candidate.php
This static helper normalizes a Civil ID object key stored in the database to the permanent bucket path format:
public static function normalizeCivilIdPermanentS3Key($stored): string
{
if ($stored === null || $stored === '') {
return '';
}
$v = ltrim(trim((string)$stored), '/');
if ($v === '') {
return '';
}
if (strpos($v, 'photos/') === 0) {
return $v;
}
if (strpos($v, 'candidate-civil-id/') === 0) {
$rest = substr($v, strlen('candidate-civil-id/'));
$basename = basename($rest);
return $basename === '' ? '' : ('photos/' . $basename);
}
return 'photos/' . $v;
}
Key behaviours:
- Input: Takes a raw DB value which can be a filename only,
photos/..., or the legacycandidate-civil-id/...format - Output: Returns a normalized S3 key always as
photos/<basename>without duplicate prefixes - Usage: Called in
updateCivilId()for the target path and indeleteFile()when computing the S3 key for civil-id type files
Candidate Model — deleteFile()#
File: common/models/Candidate.php
This method deletes a file from the permanent S3 bucket. The implementation now distinguishes between resume and civil-id file types:
public function deleteFile($type = 'resume', $side = 'front') {
$file = null;
$errorAttribute = 'candidate_resume';
if ($type === 'civil-id') {
$errorAttribute = ($side === 'back')
? 'candidate_civil_photo_back'
: 'candidate_civil_photo_front';
}
try {
if (isset($this->oldPrimaryKey)) {
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 = self::normalizeCivilIdPermanentS3Key($this->oldAttributes['candidate_civil_photo_front']);
} else if ($type == 'civil-id' && $side == 'back' && isset($this->oldAttributes['candidate_civil_photo_back'])) {
$file = self::normalizeCivilIdPermanentS3Key($this->oldAttributes['candidate_civil_photo_back']);
}
if ($file) {
Yii::$app->resourceManager->delete($file);
}
}
return true;
} catch (\Throwable $e) {
// Missing-object deletes must not break remove/replace flows
$reason = ($type === 'civil-id')
? self::classifyS3DeleteThrowable($e)
: 's3_delete_failed';
Yii::warning([
'action' => 'Candidate::deleteFile',
'candidate_id' => $this->candidate_id ?? null,
'type' => $type,
'side' => $side,
'reason' => $reason,
's3_key' => $file,
'exception' => get_class($e),
'message' => $e->getMessage(),
], 'candidate.civil-id');
if ($type === 'resume') {
$this->addError($errorAttribute, Yii::t('app', 'file not available to delete.'));
return false;
}
return true;
}
}
Key behaviours:
- Path normalization: For civil-id type files, uses
normalizeCivilIdPermanentS3Key()to compute the S3 key - Missing-object handling: Missing-object deletes (NoSuchKey, 404) are now non-fatal for civil-id files and logged as warnings. This prevents lifecycle policy race conditions from breaking upload/remove flows.
- Failure classification: Uses
classifyS3DeleteThrowable()helper to distinguish betweens3_object_missingands3_delete_failedfor structured logging - Type-specific error handling: Resume file deletes still return false on errors (fatal), but civil-id deletes return true even when the object is missing (non-fatal)
- Exception handling: Catches
\Throwableinstead of specific exception types, with detailed context arrays logged tocandidate.civil-id
Candidate Model — classifyS3DeleteThrowable()#
File: common/models/Candidate.php
This static helper classifies S3 delete failures for logging purposes:
public static function classifyS3DeleteThrowable(\Throwable $e): string
{
if ($e instanceof \Aws\S3\Exception\S3Exception) {
$code = $e->getAwsErrorCode();
if ($code === 'NoSuchKey' || $code === 'NotFound') {
return 's3_object_missing';
}
}
$msg = $e->getMessage();
if (stripos($msg, 'NoSuchKey') !== false || stripos($msg, 'not found') !== false) {
return 's3_object_missing';
}
if (preg_match('/\b404\b/', $msg)) {
return 's3_object_missing';
}
return 's3_delete_failed';
}
Key behaviours:
- Input: Takes any
\Throwableexception from S3 delete operations - Output: Returns either
s3_object_missingors3_delete_failed - Detection: Checks AWS error codes, message strings, and HTTP status patterns to identify missing-object scenarios
- Usage: Called by
deleteFile()when handling civil-id delete failures
S3ResourceManager — copy()#
File: common/components/S3ResourceManager.php
The copy() method wraps AWS SDK's copyObject() :
public function copy($oldFile, $newFile, $sourceBucket = "", $options = [])
{
// Default source bucket to this component's bucket if not specified
$sourceBucket = $sourceBucket ? $sourceBucket : $this->bucket;
$options = ArrayHelper::merge([
'Bucket' => $this->bucket, // destination bucket
'Key' => $newFile, // destination path
'CopySource' => urlencode($sourceBucket . "/" . $oldFile), // URL-encoded source
'ACL' => 'public-read',
], $options);
return $this->getClient()->copyObject($options);
}
In the Civil ID context, the call is:
Yii::$app->resourceManager->copy(
$fileName, // e.g. "my-civil-id-1732000000000.jpg" (source key in temp bucket)
$targetPath, // e.g. "photos/my-civil-id-1732000000000.jpg" (dest key in perm bucket)
$sourceBucket // "studenthub-public-anyone-can-upload-24hr-expiry"
);
S3FileExistValidator#
File: common/components/S3FileExistValidator.php
Applied via the updateCivilPhotoFront / updateCivilPhotoBack model scenario rules, this validator confirms the uploaded file actually exists in the temp bucket before the backend attempts to copy it :
public function validateAttribute($model, $attribute)
{
$filename = $model->$attribute;
if (!$filename || !$this->resourceManager) { return null; }
if (!$this->resourceManager->fileExists($this->filePath . $filename)) {
$this->addError($model, $attribute, Yii::t('app', $this->message));
}
// Optional: validate extension
if ($this->extensions) { /* ... */ }
// Optional: validate max size
if ($this->maxSize) { /* ... */ }
}
The validator uses an HTTP HEAD request against the public S3 URL to check existence, and optionally validates file extension and size.
Code Reference#
Frontend Files#
| App | File Path | Purpose |
|---|---|---|
| candidate | src/app/providers/logged-in/aws.service.ts | Core S3 upload service; credential fetching, file naming, direct upload |
| admin | src/app/providers/logged-in/aws.service.ts | Identical S3 upload service for admin app |
| staff | src/app/providers/logged-in/aws.service.ts | Identical S3 upload service for staff app |
| company | src/app/providers/logged-in/aws.service.ts | Identical S3 upload service for company app |
| candidate | src/app/pages/logged-in/civil-id-front/civil-id-front.page.ts | Civil ID front photo upload page |
| candidate | src/app/pages/logged-in/civil-id-back/civil-id-back.page.ts | Civil ID back photo upload page |
| candidate | src/app/pages/logged-in/id-card/id-card.page.ts | Manual Civil ID number + expiry date entry |
Backend Files#
| File Path | Purpose |
|---|---|
candidate/modules/v1/controllers/AwsController.php | Serves AWS temp credentials via GET /aws/config |
candidate/modules/v1/controllers/AccountController.php | actionUpdateCivilPhotoFront() and actionUpdateCivilPhotoBack() endpoints |
common/models/Candidate.php | updateCivilId($side) — deletes old, copies new from temp to permanent bucket |
common/components/S3ResourceManager.php | AWS SDK wrapper; copy(), save(), delete(), fileExists() |
common/components/S3FileExistValidator.php | Yii2 validator that checks file existence in S3 |
common/config/main.php | Defines temporaryBucketResourceManager component |
environments/prod/common/config/main-local.php | Production resourceManager (IAM role, studenthub-uploads) |
environments/dev/common/config/main-local.php | Dev resourceManager (IAM role, studenthub-uploads-dev-server) |
common/config/params.php | Maps AWS_TEMP_BUCKET_KEY / AWS_TEMP_BUCKET_SECRET env vars to params array |
API Endpoints#
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /aws/config | None (CORS only) | Returns temp S3 credentials for direct upload |
POST | /v1/account/update-civil-photo-front | Candidate JWT | Validates S3 key, copies front photo to permanent bucket |
POST | /v1/account/update-civil-photo-back | Candidate JWT | Validates S3 key, copies back photo to permanent bucket |
POST | /v1/account/remove-civil-photo-front | Candidate JWT | Removes front Civil ID photo from DB and S3, sets need_verification flag |
POST | /v1/account/remove-civil-photo-back | Candidate JWT | Removes back Civil ID photo from DB and S3, sets need_verification flag |
POST | /v1/account/update-civil-id-expiry-date | Candidate JWT | Updates Civil ID number and expiry date with strict validation |
Request bodies:
// POST /v1/account/update-civil-photo-front
{ "civil_photo_front": "my-civil-id-1732000000000.jpg" }
// POST /v1/account/update-civil-photo-back
{ "civil_photo_back": "my-civil-id-back-1732000000001.jpg" }
// POST /v1/account/remove-civil-photo-front
// (no body required)
// POST /v1/account/remove-civil-photo-back
// (no body required)
// POST /v1/account/update-civil-id-expiry-date
{
"civil_id": "123456789012",
"civil_expiry_date": "2026-12-31" // Y-m-d, Y-m-d\TH:i:s\Z, or Y-m-d\TH:i:s.u\Z
}
Success response:
// update-civil-photo-front / update-civil-photo-back
{
"operation": "success",
"candidate_civil_photo_front": "my-civil-id-1732000000000.jpg",
"candidate_civil_photo_back": "my-civil-id-back-1732000000001.jpg",
"candidate_civil_expiry_date": null,
"candidate_civil_id": null,
"civilExpired": false,
"message": "Civil Photo Front Uploaded Successfully"
}
// remove-civil-photo-front / remove-civil-photo-back
{
"operation": "success"
}
// update-civil-id-expiry-date
{
"operation": "success",
"candidate_civil_expiry_date": "2026-12-31",
"message": "Civil ID And Expiry Date Updated Successfully"
}
Key Functions#
| Function | Location | Description |
|---|---|---|
AwsService.setConfig() | aws.service.ts:53 | Fetches credentials from backend and configures AWS SDK |
AwsService.uploadFile() | aws.service.ts:189 | Uploads file directly to S3; returns Observable of upload events |
AwsService.normalizeFileName() | aws.service.ts:258 | Sanitizes filename for S3 key |
AwsService.uploadNativePath() | aws.service.ts:88 | Resolves native mobile file path to JS File blob, then delegates to uploadFile() |
CivilIdFrontPage.browserUpload() | civil-id-front.page.ts:303 | Browser file input handler |
CivilIdFrontPage._handleFileSuccess() | civil-id-front.page.ts:361 | Processes S3 success event; notifies backend |
AwsController::actionConfig() | AwsController.php:56 | Endpoint that returns temp bucket credentials |
AccountController::actionUpdateCivilPhotoFront() | AccountController.php:1335 | Receives S3 key, triggers model update |
AccountController::actionUpdateCivilPhotoBack() | AccountController.php:1283 | Receives S3 key, triggers model update |
AccountController::actionRemoveCivilPhotoFront() | AccountController.php:447 | Removes front Civil ID photo, throws 404 if candidate not found |
AccountController::actionRemoveCivilPhotoBack() | AccountController.php:382 | Removes back Civil ID photo, throws 404 if candidate not found |
AccountController::actionUpdateCivilIdExpiryDate() | AccountController.php:1522 | Updates Civil ID and expiry date with strict validation |
AccountController::parseStrictCivilExpiryDateUtc() | AccountController.php:1926 | Parses expiry date in strict ISO 8601 formats (Y-m-d, Y-m-d\TH:i |
Candidate::updateCivilId() | Candidate.php:2739 | Copies file temp→permanent bucket, then conditionally deletes old file |
Candidate::deleteFile() | Candidate.php:2757 | Deletes file from permanent bucket (non-fatal for missing civil-id objects) |
Candidate::normalizeCivilIdPermanentS3Key() | Candidate.php:2699 | Normalizes DB value to photos/ format |
Candidate::classifyS3DeleteThrowable() | Candidate.php:2731 | Classifies S3 delete failures (s3_object_missing vs s3_delete_failed) |
S3ResourceManager::copy() | S3ResourceManager.php:138 | Server-side S3 copyObject() wrapper |
S3FileExistValidator::validateAttribute() | S3FileExistValidator.php:46 | Validates file existence in S3 bucket |
Environment Variables#
| Variable | Required On | Description |
|---|---|---|
AWS_TEMP_BUCKET_KEY | All environments | IAM access key ID for the temporary upload bucket |
AWS_TEMP_BUCKET_SECRET | All environments | IAM secret access key for the temporary upload bucket |
AWS_TEXTRACT_ACCESS_KEY_ID | All environments | Access key for AWS Textract (ID OCR extraction) |
AWS_TEXTRACT_SECRET_ACCESS_KEY | All environments | Secret key for AWS Textract (ID OCR extraction) |
Sources:
Why This Architecture?#
The dual-bucket, direct-to-S3 pattern is a deliberate architectural decision that trades a small amount of complexity (two buckets, credential bootstrap, server-side copy) for several significant operational and cost benefits.
1. Files Never Pass Through the Backend Server#
In a traditional upload architecture, the backend acts as a proxy: the client sends the file to the server, the server writes it to storage, and the server responds. This means:
- Every MB of uploaded data consumes backend CPU time, memory, and outbound bandwidth.
- Backend request timeouts can fail large uploads mid-flight.
- Backend instances need to be sized for peak concurrent upload traffic, not just API request volume.
With the direct-to-S3 approach, the backend only processes a short JSON body containing the S3 filename string — the bytes never touch the application layer. The backend can be sized for API throughput, not file transfer.
2. Cheaper Infrastructure#
S3 data transfer costs are substantially lower than equivalent EC2/container bandwidth. Uploading directly from the user's device to S3 avoids double-billing the data (client → server → S3). For a platform with many candidates uploading Civil ID documents, profile photos, and resumes, this adds up.
3. Better Upload Performance#
Direct-to-S3 uploads benefit from AWS's global edge network. Users in Kuwait uploading to eu-west-2 (London) get a direct path to S3 rather than routing through an application server first.
4. Automatic Cleanup via Temp Bucket Lifecycle#
If a user picks a file, it uploads to the temp bucket, but then they abandon the flow (or the backend API call fails), the file does not accumulate in permanent storage. The 24-hour lifecycle policy on studenthub-public-anyone-can-upload-24hr-expiry automatically purges unclaimed uploads.
5. IAM Role for Production — No Keys on Server#
The permanent bucket uses AUTH_VIA_IAM_ROLE in both dev and production . The server never needs to store an AWS access key for write access to production data. This follows the AWS security best practice of using instance roles over long-lived credentials.
6. Storage Layer Is Swappable#
All S3 interaction is encapsulated in S3ResourceManager. If the project ever needs to switch storage providers (e.g., to Cloudflare R2, Google Cloud Storage, or a different region), only this component needs to change. Frontend apps swap their aws/config endpoint response and SDK initialization; backend swaps the resourceManager component.
7. Progress Reporting Without Polling#
The AWS JS SDK's s3.upload() emits httpUploadProgress events natively, giving the frontend real-time upload progress without requiring the file to pass through a backend that would need to stream progress updates.
Security Considerations#
Current Posture#
Temporary Bucket: Intentionally Public Write#
The temp bucket (studenthub-public-anyone-can-upload-24hr-expiry) is designed to accept uploads from anyone who holds the key/secret pair returned by /aws/config. This is intentional — the bucket exists specifically so the frontend can upload without routing through the backend.
Mitigating factors:
- Objects have a 24-hour expiry, so unintended uploads are automatically purged.
- The credentials returned by
/aws/configshould be scoped tos3:PutObjecton this bucket only (verify this in your IAM policy). - The CORS filter on
/aws/configrestricts the response toallowedOrigins— browser-based attackers from other origins cannot retrieve the credentials.
Permanent Bucket: IAM Role, No Keys in Config#
Production and dev servers access the permanent bucket via an attached IAM role. No access key or secret is stored in the server's configuration files for resourceManager. This is the correct posture.
S3FileExistValidator Guards the Copy Operation#
Before the backend copies a file from the temp bucket, it validates that the key actually exists in S3 (HEAD request via GuzzleHttp). This prevents an attacker from specifying an arbitrary S3 key and tricking the backend into copying a file they did not upload.
Old Files Deleted After Copy#
updateCivilId() copies the new file from temp to permanent bucket before attempting to delete the old file. This change prevents data loss if the copy operation fails — the candidate retains their existing Civil ID photo until the new one has been successfully stored. The old file delete is performed only when the old and new normalized keys differ, and is best-effort: missing-object failures (NoSuchKey, 404) are logged as warnings but do not fail the upload. This prevents race conditions with S3 lifecycle policies from breaking the upload flow.
Known Issues and Recommended Improvements#
🔴 High Priority: Civil ID Photos Are Public-Read#
All files copied to the permanent bucket are set with ACL: 'public-read'. Civil ID documents contain sensitive personal information (national ID number, photo, date of birth, expiry date). Anyone who knows or guesses the S3 key can access the document directly via its HTTPS URL.
Recommendation: Store Civil ID documents with private ACL and access them via pre-signed URLs with short expiry times (e.g., 15 minutes). This requires changes to S3ResourceManager::copy() and any place where the URL is displayed on the frontend.
🔴 High Priority: Temp Bucket Credentials Are Not Time-Limited#
The /aws/config endpoint returns long-lived static credentials . The // todo: key with expiry comment in the controller acknowledges this gap. If these credentials are leaked or intercepted, they remain valid indefinitely.
Recommendation: Replace the static key/secret with AWS STS AssumeRole temporary credentials that expire in 15–60 minutes. The /aws/config endpoint would call sts:AssumeRole each request and return short-lived AccessKeyId, SecretAccessKey, and SessionToken.
🟡 Medium Priority: Temp Bucket Credentials Hardcoded in Source#
The temporaryBucketResourceManager component in common/config/main.php contains a hardcoded key and secret . While the params array reads from environment variables for the frontend-facing values, the component definition itself does not.
Recommendation: Change common/config/main.php to read the key and secret from environment variables, matching the pattern already used in params.php.
🟡 Medium Priority: No Rate Limiting on Upload Endpoints#
POST /v1/account/update-civil-photo-front and POST /v1/account/update-civil-photo-back have no rate limiting. An authenticated candidate could issue many upload requests in quick succession.
Recommendation: Apply Yii2's rate limiter behavior to these endpoints, or configure API gateway-level rate limiting.
🟢 Low Priority: No Virus Scanning#
Uploaded files are not scanned for malware before being promoted to the permanent bucket. Civil ID uploads are expected to be images, but the S3FileExistValidator only checks extension and size — it does not validate MIME content or scan for malicious payloads.
Recommendation: Consider integrating an AWS Lambda function triggered on temp-bucket PutObject events to run ClamAV or a commercial AV solution before the backend is permitted to copy the file.
🟢 Low Priority: fileExists() Uses HTTP HEAD (Public Access Required)#
The S3FileExistValidator checks file existence using a GuzzleHttp HEAD request to the public URL of the file . This means the file must be publicly accessible in the temp bucket for validation to work. If the ACL recommendation above is ever applied to the temp bucket, this validation approach would need to be replaced with an SDK headObject call using credentials.