Part of the MediaBridge series.
The Upload Problem
The obvious way to handle file uploads in a web app is to pipe them through the backend: browser sends the file to your server, server writes it to S3. This works. It also means every upload byte travels twice - once from the browser to your server, and again from your server to S3. Your server becomes a bottleneck, your bandwidth bill doubles, and large files tie up server connections.
The alternative is presigned URLs. The browser uploads directly to S3. The server is not in the upload path at all. But the server still controls everything about that upload: which bucket, which path, what file types are allowed, how large the file can be. All of that is baked into the URL before the server hands it to the frontend.
This is how MediaBridge handles uploads.
The Two-Step Flow
Upload happens in two steps.
Step 1: Presign request. The frontend sends a POST /upload/presign request to the backend with metadata for every file being uploaded:
[
{
"bucketId": "abc123",
"path": "projects/marketing/",
"filename": "campaign-brief.pdf",
"size": 4194304,
"contentType": "application/pdf"
}
]
The backend validates everything, generates a presigned S3 PUT URL for each file, and returns:
[
{
"presignedUrl": "https://your-bucket.s3.amazonaws.com/projects/marketing/campaign-brief.pdf?...",
"cloudfrontUrl": "https://cdn.yourdomain.com/projects/marketing/campaign-brief.pdf",
"thumbnailUrl": "https://cdn.yourdomain.com/projects/marketing/t-campaign-brief.pdf",
"filename": "campaign-brief.pdf"
}
]
Step 2: Direct PUT to S3. The frontend uses the presigned URL to upload the file bytes directly to S3 using a plain HTTP PUT. The backend is not involved.
await axios.put(presignedUrl, uploadFile.file, {
headers: {
'Content-Type': uploadFile.file.type || 'application/octet-stream',
'Content-Disposition': 'inline',
},
onUploadProgress: (e) => {
const pct = Math.round((e.loaded / (e.total || uploadFile.file.size)) * 100);
setFiles(prev => prev.map(f =>
f.file === uploadFile.file ? { ...f, progress: pct } : f
));
},
});
Multiple files upload in parallel via Promise.all. Each file has its own progress bar tracking bytes sent.
What the Server Validates
The presign endpoint does all its checks before generating any URL. Nothing gets handed to the frontend unless every check passes.
Bucket access. The backend confirms the user has an assignment to the requested bucket. Admins can access any bucket. Regular users must have an explicit assignment in user_buckets.
File size. Each file’s declared size is checked against the 25MB limit. The check happens on the declared size in the request, and the signed URL also has ContentLength baked in - S3 enforces it at the infrastructure level. A client cannot lie about size and then upload something larger.
Filename safety. Filenames containing .. or / are rejected outright. These are path traversal attempts.
Root path confinement. Every user is assigned to a root path within a bucket - a prefix they are confined to. If the constructed full path (rootPath + requestedPath) does not start with that root path, it is a path traversal attempt. The response is not a 403. It is an immediate force logout:
const fullPath = rootPath ? `${rootPath}${file.path}` : file.path;
if (rootPath && !fullPath.startsWith(rootPath)) {
await forceLogout(user.userId, reply);
return;
}
Force logout inserts a row into the force_logout table. The next time that user’s access token is validated, the check sees the record and terminates the session immediately.
What Gets Baked into the Signed URL
The presigned PUT URL is not just a URL. It is a signature over specific constraints that S3 enforces when the browser makes the actual PUT request.
const putCommand = new PutObjectCommand({
Bucket: bucket.bucket_name,
Key: s3Key,
ContentType: file.contentType,
ContentLength: file.size,
ContentDisposition: 'inline',
});
const presignedUrl = await getSignedUrl(s3Client, putCommand, { expiresIn: 300 });
ContentType is signed. If the browser sends a different content type header, S3 rejects the request. A client cannot rename a .exe to .jpg and have S3 accept it as an image.
ContentLength is signed. S3 will not accept a file larger than what was declared in the presign request. The 25MB limit is enforced at the S3 level, not just in the backend check.
ContentDisposition: inline is signed. This makes files open in the browser rather than trigger a download when accessed via a presigned GET URL later. Without this, PDFs and images would download instead of preview.
The URL expires in 300 seconds - 5 minutes. A leaked presigned URL can only be used within that window, and only to put the specific file to the specific key.
Bucket Credentials
Each bucket in MediaBridge has its own IAM credentials stored encrypted in the database. The presign endpoint decrypts them on the fly to create an S3 client for that specific bucket:
const s3Client = new S3Client({
region: bucket.region,
credentials: {
accessKeyId: decrypt(bucket.access_key_id),
secretAccessKey: decrypt(bucket.secret_access_key),
},
});
The frontend never sees these credentials. It gets a presigned URL. The credentials are decrypted server-side, used to sign the URL, and discarded.
Private vs Public Buckets
MediaBridge supports two bucket modes. Public buckets use CloudFront for file delivery. Private buckets have no CloudFront distribution - files are only accessible via presigned GET URLs.
After generating the presigned PUT URL, the backend also prepares the file’s access URL:
const isPrivate = !bucket.cloudfront_base_url;
if (isPrivate) {
fileUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: bucket.bucket_name,
Key: s3Key,
ResponseContentDisposition: 'inline',
ResponseContentType: getMimeType(s3Key),
}),
{ expiresIn: CONFIG.presignedGet.expiresInSeconds }, // 7 days
);
} else {
fileUrl = `${bucket.cloudfront_base_url}/${s3Key}`;
}
For private buckets, the access URL is itself a presigned GET URL valid for 7 days. These get stored in the URL cache so the frontend does not need to re-fetch them on every page load.
Duplicate Detection
Before triggering the presign request, the frontend checks whether any of the selected files already exist in the current folder. If duplicates are found, it stops and shows a warning listing the conflicting filenames with an overwrite confirmation step. Only if the user explicitly confirms does the upload proceed.
The check happens client-side against the file list already loaded from S3. It is a UX safeguard - the presign endpoint does not block on duplicates since S3 PUT is inherently idempotent.
Cache Eviction on Upload
A successful presign triggers cache eviction before returning. Three cache layers are touched:
The resource_cache for the upload prefix and all ancestor prefixes is deleted. When the user opens any parent folder, it will re-list from S3 instead of showing stale results.
The url_cache entry for the specific S3 key is evicted. If a cached URL exists for this key from a previous version of the file, it is cleared.
The folder_index for the upload directory is marked incomplete. The DFS search index will re-traverse this folder on the next search.
const prefixesToEvict = [fullPath];
const parts = fullPath.split('/').filter(Boolean);
for (let i = parts.length - 1; i >= 0; i--) {
prefixesToEvict.push(parts.slice(0, i).join('/') + (i > 0 ? '/' : ''));
}
for (const p of [...new Set(prefixesToEvict)]) {
await sql`DELETE FROM resource_cache WHERE bucket_id = ${bucketId} AND prefix = ${p}`;
}
await evictUrlCache(bucketId, s3Key);
await markFolderIncomplete(bucketId, fullPath);
Every upload is also written to the audit log with user ID, operation, full path, and IP address.