Documents
Civil ID Upload Flow
Civil ID Upload Flow
Type
Document
Status
Published
Created
Apr 27, 2026
Updated
May 18, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

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#

  1. 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.
  2. 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-front or POST /v1/account/update-civil-photo-back).
  3. 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 permanent studenthub-uploads bucket (or studenthub-uploads-dev-server on dev). The copy is performed using an IAM role — no access keys are needed on the production server.

The two S3 buckets#

BucketPurposeAuth
studenthub-public-anyone-can-upload-24hr-expiryTemporary landing zone; accepts direct uploads from browsers/appsAccess key + secret (key pair)
studenthub-uploads (prod) / studenthub-uploads-dev-server (dev)Permanent, versioned storage for processed Civil ID imagesIAM 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.

Loading diagram...

Note: The same flow applies to the back-side photo, with the endpoint changing to POST /v1/account/update-civil-photo-back and the field to civil_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 :

ConstantValueUsed By
AUTH_VIA_KEY_AND_SECRET1Temporary bucket (all environments)
AUTH_VIA_IAM_ROLE2Permanent 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#

VariableMapped ToUsed For
AWS_TEMP_BUCKET_KEYparams['aws_temp_access_key_id']Temp bucket key served to frontend via /aws/config
AWS_TEMP_BUCKET_SECRETparams['aws_temp_secret_access_key']Temp bucket secret served to frontend via /aws/config
AWS_TEXTRACT_ACCESS_KEY_IDidExpiryDateExtractor.keyAWS Textract for future ID OCR extraction
AWS_TEXTRACT_SECRET_ACCESS_KEYidExpiryDateExtractor.secretAWS 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.tssrc/app/pages/logged-in/civil-id-front/civil-id-front.page.ts
  • civil-id-back.page.tssrc/app/pages/logged-in/civil-id-back/civil-id-back.page.ts

Both pages detect the platform and fork upload paths:

PlatformTriggerMethod
Mobile (Capacitor)mobileUpload() → camera / photo library pickeruploadFileViaNativeFilePath(uri)awsService.uploadNativePath(uri)
Browser<input type="file"> change eventbrowserUpload(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.jpgMy-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 :

LimitValueApplies to
maxUploadSize18 MB (18,874,368 bytes)All file types (candidate, admin, company apps)
maxImageUploadSize5 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 :

  1. Waits for the image to fully load in the browser (imgLarge.onload) to confirm it's renderable.
  2. 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)
  3. 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 expiry comment indicates that rotating/expiring credentials (e.g., via AWS STS AssumeRole) 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 = true to 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 \Throwable around 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_id and civil_expiry_date are 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 \Throwable around 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 always photos/<basename>, preventing duplicate prefixes like photos/photos/ or legacy candidate-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 \Throwable instead of specific exceptions. Errors are logged with structured context arrays to the candidate.civil-id category 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 legacy candidate-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 in deleteFile() 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 between s3_object_missing and s3_delete_failed for 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 \Throwable instead of specific exception types, with detailed context arrays logged to candidate.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 \Throwable exception from S3 delete operations
  • Output: Returns either s3_object_missing or s3_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#

AppFile PathPurpose
candidatesrc/app/providers/logged-in/aws.service.tsCore S3 upload service; credential fetching, file naming, direct upload
adminsrc/app/providers/logged-in/aws.service.tsIdentical S3 upload service for admin app
staffsrc/app/providers/logged-in/aws.service.tsIdentical S3 upload service for staff app
companysrc/app/providers/logged-in/aws.service.tsIdentical S3 upload service for company app
candidatesrc/app/pages/logged-in/civil-id-front/civil-id-front.page.tsCivil ID front photo upload page
candidatesrc/app/pages/logged-in/civil-id-back/civil-id-back.page.tsCivil ID back photo upload page
candidatesrc/app/pages/logged-in/id-card/id-card.page.tsManual Civil ID number + expiry date entry

Backend Files#

File PathPurpose
candidate/modules/v1/controllers/AwsController.phpServes AWS temp credentials via GET /aws/config
candidate/modules/v1/controllers/AccountController.phpactionUpdateCivilPhotoFront() and actionUpdateCivilPhotoBack() endpoints
common/models/Candidate.phpupdateCivilId($side) — deletes old, copies new from temp to permanent bucket
common/components/S3ResourceManager.phpAWS SDK wrapper; copy(), save(), delete(), fileExists()
common/components/S3FileExistValidator.phpYii2 validator that checks file existence in S3
common/config/main.phpDefines temporaryBucketResourceManager component
environments/prod/common/config/main-local.phpProduction resourceManager (IAM role, studenthub-uploads)
environments/dev/common/config/main-local.phpDev resourceManager (IAM role, studenthub-uploads-dev-server)
common/config/params.phpMaps AWS_TEMP_BUCKET_KEY / AWS_TEMP_BUCKET_SECRET env vars to params array

API Endpoints#

MethodEndpointAuthDescription
GET/aws/configNone (CORS only)Returns temp S3 credentials for direct upload
POST/v1/account/update-civil-photo-frontCandidate JWTValidates S3 key, copies front photo to permanent bucket
POST/v1/account/update-civil-photo-backCandidate JWTValidates S3 key, copies back photo to permanent bucket
POST/v1/account/remove-civil-photo-frontCandidate JWTRemoves front Civil ID photo from DB and S3, sets need_verification flag
POST/v1/account/remove-civil-photo-backCandidate JWTRemoves back Civil ID photo from DB and S3, sets need_verification flag
POST/v1/account/update-civil-id-expiry-dateCandidate JWTUpdates 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#

FunctionLocationDescription
AwsService.setConfig()aws.service.ts:53Fetches credentials from backend and configures AWS SDK
AwsService.uploadFile()aws.service.ts:189Uploads file directly to S3; returns Observable of upload events
AwsService.normalizeFileName()aws.service.ts:258Sanitizes filename for S3 key
AwsService.uploadNativePath()aws.service.ts:88Resolves native mobile file path to JS File blob, then delegates to uploadFile()
CivilIdFrontPage.browserUpload()civil-id-front.page.ts:303Browser file input handler
CivilIdFrontPage._handleFileSuccess()civil-id-front.page.ts:361Processes S3 success event; notifies backend
AwsController::actionConfig()AwsController.php:56Endpoint that returns temp bucket credentials
AccountController::actionUpdateCivilPhotoFront()AccountController.php:1335Receives S3 key, triggers model update
AccountController::actionUpdateCivilPhotoBack()AccountController.php:1283Receives S3 key, triggers model update
AccountController::actionRemoveCivilPhotoFront()AccountController.php:447Removes front Civil ID photo, throws 404 if candidate not found
AccountController::actionRemoveCivilPhotoBack()AccountController.php:382Removes back Civil ID photo, throws 404 if candidate not found
AccountController::actionUpdateCivilIdExpiryDate()AccountController.php:1522Updates Civil ID and expiry date with strict validation
AccountController::parseStrictCivilExpiryDateUtc()AccountController.php:1926Parses expiry date in strict ISO 8601 formats (Y-m-d, Y-m-d\TH:i\Z, Y-m-d\TH:i.u\Z)
Candidate::updateCivilId()Candidate.php:2739Copies file temp→permanent bucket, then conditionally deletes old file
Candidate::deleteFile()Candidate.php:2757Deletes file from permanent bucket (non-fatal for missing civil-id objects)
Candidate::normalizeCivilIdPermanentS3Key()Candidate.php:2699Normalizes DB value to photos/ format
Candidate::classifyS3DeleteThrowable()Candidate.php:2731Classifies S3 delete failures (s3_object_missing vs s3_delete_failed)
S3ResourceManager::copy()S3ResourceManager.php:138Server-side S3 copyObject() wrapper
S3FileExistValidator::validateAttribute()S3FileExistValidator.php:46Validates file existence in S3 bucket

Environment Variables#

VariableRequired OnDescription
AWS_TEMP_BUCKET_KEYAll environmentsIAM access key ID for the temporary upload bucket
AWS_TEMP_BUCKET_SECRETAll environmentsIAM secret access key for the temporary upload bucket
AWS_TEXTRACT_ACCESS_KEY_IDAll environmentsAccess key for AWS Textract (ID OCR extraction)
AWS_TEXTRACT_SECRET_ACCESS_KEYAll environmentsSecret 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/config should be scoped to s3:PutObject on this bucket only (verify this in your IAM policy).
  • The CORS filter on /aws/config restricts the response to allowedOrigins — 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.


🔴 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.