Ravindra BagaleCourses & study guides

16. Live Project: Building a Reels App with EC2, S3 and RDS

16.7 Uploading Videos and Text Posts

Ha project cha heart aahe. upload.php don forms dakhavto – video aani text. Video sathi PHP size check karto, file chya content varun type olakhto (finfo), server swatah random S3 key banvto, aani putObject ne S3 madhe pathavto.

public/upload.php

<?php
// public/upload.php – GET shows two forms; POST stores a video in S3 or a text post in RDS
require __DIR__ . '/../src/bootstrap.php';

use Aws\Exception\AwsException;

$user   = require_login();
$error  = '';
// allow-lists: never accept raw CSS colours or unknown file types from users
$COLORS = ['#0E7C86', '#1F3A5F', '#6A1B9A', '#C62828', '#2E7D32'];
$TYPES  = ['video/mp4' => 'mp4', 'video/webm' => 'webm', 'video/quicktime' => 'mov'];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_check();
    $type    = (string) ($_POST['type'] ?? '');
    $caption = trim((string) ($_POST['caption'] ?? ''));

    if (mb_strlen($caption) > 500) {
        $error = 'Caption is too long (max 500)';
    } elseif ($type === 'text') {
        $body  = trim((string) ($_POST['body_text'] ?? ''));
        $color = $_POST['bg_color'] ?? '';
        $color = in_array($color, $COLORS, true) ? $color : $COLORS[0];
        if ($body === '' || mb_strlen($body) > 1000) {
            $error = 'Text post must be 1-1000 characters';
        } else {
            $stmt = db()->prepare('INSERT INTO posts (user_id, post_type, caption, body_text, bg_color)
                                   VALUES (?, \'text\', ?, ?, ?)');
            $stmt->execute([$user['id'], $caption, $body, $color]);
            header('Location: /');
            exit;
        }
    } elseif ($type === 'video') {
        $f = $_FILES['video'] ?? null;
        if (!$f || !is_array($f) || $f['error'] !== UPLOAD_ERR_OK) {
            $code  = (int) ($f['error'] ?? -1);
            $error = 'Upload failed (error code ' . $code . ') - check the file size';
        } elseif ($f['size'] <= 0 || $f['size'] > cfg('max_video_bytes')) {
            $error = 'Video must be smaller than ' . (cfg('max_video_bytes') / 1048576) . ' MB';
        } else {
            // trust the file CONTENT, not the name or the browser's Content-Type
            $mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);
            if (!isset($TYPES[$mime])) {
                $error = 'Only MP4, WebM or MOV videos are allowed';
            } else {
                // server-generated key: users never choose the S3 path
                $key = cfg('s3_prefix') . date('Y/m/') . bin2hex(random_bytes(16))
                     . '.' . $TYPES[$mime];
                try {
                    s3()->putObject([
                        'Bucket'      => cfg('s3_bucket'),
                        'Key'         => $key,
                        'SourceFile'  => $f['tmp_name'],
                        'ContentType' => $mime,
                    ]);
                    $stmt = db()->prepare('INSERT INTO posts
                        (user_id, post_type, caption, s3_key, mime_type, size_bytes)
                        VALUES (?, \'video\', ?, ?, ?, ?)');
                    $stmt->execute([$user['id'], $caption, $key, $mime, (int) $f['size']]);
                    header('Location: /');
                    exit;
                } catch (AwsException $ex) {
                    error_log('S3 upload failed: ' . $ex->getAwsErrorCode() . ' '
                              . $ex->getMessage());
                    $error = 'Could not store the video, please try again';
                }
            }
        }
    } else {
        $error = 'Unknown post type';
    }
}

page_top('New post');
if ($error !== '') {
    echo '<p class="err">' . e($error) . '</p>';
}
$token = e(csrf_token());
?>
<h2>Video reel</h2>
<form method="post" enctype="multipart/form-data">
  <input type="hidden" name="csrf" value="<?= $token ?>">
  <input type="hidden" name="type" value="video">
  <label>Video (MP4/WebM/MOV, max <?= (int) (cfg('max_video_bytes') / 1048576) ?> MB)
    <input type="file" name="video" accept="video/mp4,video/webm,video/quicktime" required></label>
  <label>Caption <input name="caption" maxlength="500"></label>
  <button type="submit">Upload video</button>
</form>

<h2>Text post</h2>
<form method="post">
  <input type="hidden" name="csrf" value="<?= $token ?>">
  <input type="hidden" name="type" value="text">
  <label>Text <textarea name="body_text" maxlength="1000" rows="4" required></textarea></label>
  <label>Background
    <select name="bg_color">
      <?php foreach ($COLORS as $c): ?>
        <option value="<?= e($c) ?>"><?= e($c) ?></option>
      <?php endforeach; ?>
    </select></label>
  <label>Caption <input name="caption" maxlength="500"></label>
  <button type="submit">Post text</button>
</form>
<p><a href="/">&larr; Back to reels</a></p>
<?php page_bottom();
Check in upload.php Attack it stops
csrf_check() Forged uploads from another site
Size limit (PHP + Nginx + app) Disk/memory exhaustion (DoS)
finfo on the file content, allow-list of MIME types shell.php renamed to video.mp4
Random server-generated S3 key Path tricks (../), overwriting other users' files, guessing URLs
Colours from an allow-list CSS injection through bg_color
Prepared statements SQL injection in captions and text
error_log + generic message Leaking bucket names or AWS errors to users

For very large videos, the SDK's Aws\S3\MultipartUploader sends the file in parts; for this project putObject with a 50 MB limit is enough.

Why this matters for security

Unrestricted file upload (अनिर्बंधित फाइल अपलोड) is a classic path to full server compromise: if an attacker can upload a .php file into the web root and open it, they run commands on your server. This design is safe by construction – files never touch the web root, they go to S3, and S3 never executes code.

Ravindra Bagale's Tip

Students check only the extension (.mp4) or the $_FILES['video']['type'] sent by the browser – an attacker can change both! We check the file's real bytes with finfo. And if you get a 413 "Request Entity Too Large", check the limit in three places: Nginx client_max_body_size, PHP upload_max_filesize/post_max_size, and the app's max_video_bytes.

Lab

Upload a small MP4 and a text post, then check the object with aws s3 ls s3://ravindra-reels-media-pune/videos/ --recursive and the row with SELECT * FROM posts. Rename a text file to test.mp4 and confirm the upload is rejected.