Appearance
AWS S3 Storage
Overview
The AWS S3 integration provides secure, scalable cloud storage for digital assets. It supports direct uploads, multipart uploads for large files, presigned URLs for secure access, and automatic thumbnail generation. This document covers the complete S3 setup, configuration, and implementation.
Architecture
Components
- S3 Bucket: Primary storage for all digital assets
- S3 API Service: Express.js endpoints for S3 operations
- Presigned URLs: Temporary secure access to files
- Multipart Upload: Chunked uploads for large files
- Thumbnail Service: Automatic thumbnail generation
S3 Configuration
Bucket Setup
Bucket Structure:
{workspace_id}/
digital_assets/
{asset_id}.{extension}
thumbnails/
{asset_id}.jpg
versions/
{asset_id}/
v1.{extension}
v2.{extension}CORS Configuration:
json
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "POST", "PUT", "DELETE", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-amz-server-side-encryption"],
"MaxAgeSeconds": 3000
}
]
}Bucket Policy:
json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowWorkspaceAccess",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT_ID:user/S3_USER"
},
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
}
]
}Backend Implementation
S3 Service
javascript
// services/s3.js
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListPartsCommand } = require('@aws-sdk/client-s3')
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
class S3Service {
constructor() {
this.client = new S3Client({
region: process.env.AWS_DEFAULT_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
})
this.bucket = process.env.AWS_BUCKET
}
getKey(workspaceId, type, assetId, extension = '') {
return `${workspaceId}/${type}/${assetId}${extension ? '.' + extension : ''}`
}
async startMultipartUpload(workspaceId, assetId, fileType) {
const key = this.getKey(workspaceId, 'digital_assets', assetId, this.getExtension(fileType))
const command = new CreateMultipartUploadCommand({
Bucket: this.bucket,
Key: key,
ContentType: fileType,
Metadata: {
workspace_id: String(workspaceId),
asset_id: String(assetId)
}
})
const response = await this.client.send(command)
return {
uploadId: response.UploadId,
key: key
}
}
async getUploadPartUrl(workspaceId, assetId, uploadId, partNumber) {
const key = this.getKey(workspaceId, 'digital_assets', assetId)
const command = new UploadPartCommand({
Bucket: this.bucket,
Key: key,
UploadId: uploadId,
PartNumber: partNumber
})
const url = await getSignedUrl(this.client, command, { expiresIn: 3600 })
return url
}
async completeMultipartUpload(workspaceId, assetId, uploadId, parts) {
const key = this.getKey(workspaceId, 'digital_assets', assetId)
const command = new CompleteMultipartUploadCommand({
Bucket: this.bucket,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.map(part => ({
ETag: part.ETag,
PartNumber: part.PartNumber
}))
}
})
const response = await this.client.send(command)
return {
location: response.Location,
key: key,
etag: response.ETag
}
}
async abortMultipartUpload(workspaceId, assetId, uploadId) {
const key = this.getKey(workspaceId, 'digital_assets', assetId)
const command = new AbortMultipartUploadCommand({
Bucket: this.bucket,
Key: key,
UploadId: uploadId
})
await this.client.send(command)
}
async getPresignedDownloadUrl(workspaceId, assetId, expiresIn = 3600) {
const key = this.getKey(workspaceId, 'digital_assets', assetId)
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key
})
const url = await getSignedUrl(this.client, command, { expiresIn })
return url
}
async uploadThumbnail(workspaceId, assetId, thumbnailBuffer) {
const key = this.getKey(workspaceId, 'thumbnails', assetId, 'jpg')
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: thumbnailBuffer,
ContentType: 'image/jpeg',
CacheControl: 'max-age=31536000'
})
await this.client.send(command)
return key
}
async deleteAsset(workspaceId, assetId) {
const assetKey = this.getKey(workspaceId, 'digital_assets', assetId)
const thumbnailKey = this.getKey(workspaceId, 'thumbnails', assetId, 'jpg')
await Promise.all([
this.client.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: assetKey
})),
this.client.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: thumbnailKey
}))
])
}
getExtension(mimeType) {
const extensions = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'video/mp4': 'mp4',
'video/quicktime': 'mov',
'application/pdf': 'pdf'
}
return extensions[mimeType] || 'bin'
}
}
module.exports = new S3Service()S3 API Endpoints
javascript
// api/s3.js
const express = require('express')
const router = express.Router()
const s3Service = require('../services/s3')
const { authenticate } = require('../middleware/auth')
// Start multipart upload
router.get('/start-upload', authenticate, async (req, res) => {
try {
const { workspaceId, fileType } = req.query
// Validate workspace access
if (!req.user.accessibleWorkspaces.includes(parseInt(workspaceId))) {
return res.status(403).json({ error: 'Access denied' })
}
// Generate asset ID
const assetId = await generateAssetId()
const { uploadId, key } = await s3Service.startMultipartUpload(
workspaceId,
assetId,
fileType
)
res.json({
uploadId,
assetId,
key
})
} catch (error) {
console.error('Start upload error:', error)
res.status(500).json({ error: 'Failed to start upload' })
}
})
// Get presigned URL for upload part
router.get('/get-upload-url', authenticate, async (req, res) => {
try {
const { PartNumber, UploadId, assetId, workspaceId } = req.query
const url = await s3Service.getUploadPartUrl(
workspaceId,
assetId,
UploadId,
parseInt(PartNumber)
)
res.json({
url,
expiresIn: 3600
})
} catch (error) {
console.error('Get upload URL error:', error)
res.status(500).json({ error: 'Failed to get upload URL' })
}
})
// Complete multipart upload
router.post('/complete-upload', authenticate, async (req, res) => {
try {
const { uploadId, assetId, workspaceId, parts } = req.body
const result = await s3Service.completeMultipartUpload(
workspaceId,
assetId,
uploadId,
parts
)
res.json({
success: true,
location: result.location,
key: result.key,
etag: result.etag
})
} catch (error) {
console.error('Complete upload error:', error)
res.status(500).json({ error: 'Failed to complete upload' })
}
})
// Get presigned download URL
router.get('/download-url', authenticate, async (req, res) => {
try {
const { workspaceId, assetId, expiresIn } = req.query
const url = await s3Service.getPresignedDownloadUrl(
workspaceId,
assetId,
expiresIn ? parseInt(expiresIn) : 3600
)
res.json({
url,
expiresIn: expiresIn || 3600
})
} catch (error) {
console.error('Get download URL error:', error)
res.status(500).json({ error: 'Failed to get download URL' })
}
})
// Delete asset
router.delete('/delete', authenticate, async (req, res) => {
try {
const { workspaceId, assetId } = req.query
await s3Service.deleteAsset(workspaceId, assetId)
res.json({ success: true })
} catch (error) {
console.error('Delete error:', error)
res.status(500).json({ error: 'Failed to delete asset' })
}
})
module.exports = routerFrontend Implementation
Multipart Upload Service
javascript
// utils/multipartUpload.js
export class MultipartUploadService {
constructor(axios, workspaceId) {
this.axios = axios
this.workspaceId = workspaceId
this.chunkSize = 5 * 1024 * 1024 // 5MB chunks
}
async uploadFile(file, onProgress) {
// Start multipart upload
const { data: uploadInfo } = await this.axios.get('/s3/start-upload', {
params: {
workspaceId: this.workspaceId,
fileType: file.type
}
})
const { uploadId, assetId } = uploadInfo
// Split file into chunks
const chunks = this.splitFile(file)
const parts = []
try {
// Upload each chunk
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]
// Get presigned URL for this part
const { data: urlData } = await this.axios.get('/s3/get-upload-url', {
params: {
PartNumber: i + 1,
UploadId: uploadId,
assetId: assetId,
workspaceId: this.workspaceId
}
})
// Upload chunk directly to S3
const etag = await this.uploadChunk(urlData.url, chunk)
parts.push({
ETag: etag,
PartNumber: i + 1
})
// Update progress
const progress = ((i + 1) / chunks.length) * 100
if (onProgress) {
onProgress({
loaded: (i + 1) * this.chunkSize,
total: file.size,
percentage: progress
})
}
}
// Complete multipart upload
const { data: result } = await this.axios.post('/s3/complete-upload', {
uploadId,
assetId,
workspaceId: this.workspaceId,
parts
})
return {
assetId,
key: result.key,
location: result.location
}
} catch (error) {
// Abort upload on error
await this.axios.post('/s3/abort-upload', {
uploadId,
assetId,
workspaceId: this.workspaceId
})
throw error
}
}
splitFile(file) {
const chunks = []
let start = 0
while (start < file.size) {
const end = Math.min(start + this.chunkSize, file.size)
chunks.push(file.slice(start, end))
start = end
}
return chunks
}
async uploadChunk(url, chunk) {
const response = await fetch(url, {
method: 'PUT',
body: chunk,
headers: {
'Content-Type': 'application/octet-stream'
}
})
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`)
}
// Extract ETag from response headers
const etag = response.headers.get('ETag')
return etag.replace(/"/g, '')
}
}File Upload Component
vue
<template>
<div class="file-upload">
<input
ref="fileInput"
type="file"
multiple
@change="handleFileSelect"
style="display: none"
/>
<v-btn @click="$refs.fileInput.click()">Select Files</v-btn>
<v-list v-if="uploadQueue.length > 0">
<v-list-item
v-for="item in uploadQueue"
:key="item.id"
>
<v-list-item-content>
<v-list-item-title>{{ item.file.name }}</v-list-item-title>
<v-progress-linear
:value="item.progress"
:color="item.status === 'error' ? 'error' : 'primary'"
/>
<v-list-item-subtitle>
{{ item.status }} - {{ item.progress }}%
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn
v-if="item.status === 'error'"
icon
@click="retryUpload(item)"
>
<RefreshIcon /> <!-- Custom SVG icon component - import from @/components/svg/RefreshIcon.vue -->
</v-btn>
<v-btn
v-if="item.status !== 'uploading'"
icon
@click="removeFromQueue(item)"
>
<CloseIcon /> <!-- Custom SVG icon component - import from @/components/svg/CloseIcon.vue -->
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
</div>
</template>
<script>
import { MultipartUploadService } from '~/utils/multipartUpload'
export default {
data() {
return {
uploadQueue: [],
uploadService: null
}
},
mounted() {
this.uploadService = new MultipartUploadService(
this.$axios,
this.$route.params.workspace_id
)
},
methods: {
handleFileSelect(event) {
const files = Array.from(event.target.files)
files.forEach(file => {
this.addToQueue(file)
})
},
addToQueue(file) {
const item = {
id: Date.now() + Math.random(),
file,
progress: 0,
status: 'pending'
}
this.uploadQueue.push(item)
this.uploadFile(item)
},
async uploadFile(item) {
item.status = 'uploading'
try {
const result = await this.uploadService.uploadFile(
item.file,
(progress) => {
item.progress = progress.percentage
}
)
item.status = 'completed'
item.result = result
// Notify parent component
this.$emit('upload-complete', result)
} catch (error) {
item.status = 'error'
item.error = error.message
this.$toast.error(`Upload failed: ${error.message}`)
}
},
retryUpload(item) {
item.status = 'pending'
item.progress = 0
item.error = null
this.uploadFile(item)
},
removeFromQueue(item) {
const index = this.uploadQueue.findIndex(i => i.id === item.id)
if (index > -1) {
this.uploadQueue.splice(index, 1)
}
}
}
}
</script>API Design
Start Multipart Upload
Endpoint: GET /s3/start-upload
Query Parameters:
workspaceId(required) - Workspace identifierfileType(required) - MIME type of the file
Response:
json
{
"uploadId": "2~abc123def456",
"assetId": "789",
"key": "456/digital_assets/789.jpg"
}Get Upload Part URL
Endpoint: GET /s3/get-upload-url
Query Parameters:
PartNumber(required) - Part number (1-indexed)UploadId(required) - Multipart upload IDassetId(required) - Asset identifierworkspaceId(required) - Workspace identifier
Response:
json
{
"url": "https://bucket.s3.amazonaws.com/456/digital_assets/789.jpg?X-Amz-Algorithm=...",
"expiresIn": 3600
}Complete Multipart Upload
Endpoint: POST /s3/complete-upload
Request Body:
json
{
"uploadId": "2~abc123def456",
"assetId": "789",
"workspaceId": "456",
"parts": [
{
"ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"PartNumber": 1
},
{
"ETag": "\"e4d909c290d0fb1ca068ffaddf22cbd0\"",
"PartNumber": 2
}
]
}Response:
json
{
"success": true,
"location": "https://bucket.s3.amazonaws.com/456/digital_assets/789.jpg",
"key": "456/digital_assets/789.jpg",
"etag": "\"abc123def456\""
}Get Download URL
Endpoint: GET /s3/download-url
Query Parameters:
workspaceId(required) - Workspace identifierassetId(required) - Asset identifierexpiresIn(optional) - URL expiration in seconds (default: 3600)
Response:
json
{
"url": "https://bucket.s3.amazonaws.com/456/digital_assets/789.jpg?X-Amz-Algorithm=...",
"expiresIn": 3600
}Workflow
Multipart Upload Flow
1. Frontend calls /s3/start-upload
↓
2. Backend creates multipart upload
↓
3. Returns uploadId and assetId
↓
4. Frontend splits file into chunks (5MB each)
↓
5. For each chunk:
- Call /s3/get-upload-url
- Get presigned URL
- Upload chunk directly to S3
- Store ETag and PartNumber
↓
6. After all chunks uploaded:
- Call /s3/complete-upload
- Send all part ETags
↓
7. Backend completes multipart upload
↓
8. Asset record created in database
↓
9. Thumbnail generated (async)
↓
10. Asset indexed in Typesense (async)Direct Upload Flow (Small Files)
1. Frontend creates FormData with file
↓
2. POST to /api/assets/upload
↓
3. Backend validates file
↓
4. Backend uploads to S3 using PutObject
↓
5. Asset record created
↓
6. Thumbnail generated
↓
7. Asset indexed
↓
8. Response with asset dataSample Data
Upload Request Example
javascript
{
workspaceId: "456",
fileType: "image/jpeg",
fileName: "summer-campaign-banner.jpg",
fileSize: 5242880 // 5MB
}Multipart Upload Parts
javascript
[
{
ETag: "\"d41d8cd98f00b204e9800998ecf8427e\"",
PartNumber: 1,
Size: 5242880
},
{
ETag: "\"e4d909c290d0fb1ca068ffaddf22cbd0\"",
PartNumber: 2,
Size: 5242880
},
{
ETag: "\"f5e9a0d391e11b1db079ffbeef33cce1\"",
PartNumber: 3,
Size: 1048576 // Last chunk
}
]S3 Object Metadata
javascript
{
Key: "456/digital_assets/789.jpg",
Bucket: "my-dam-bucket",
ETag: "\"abc123def456\"",
Location: "https://my-dam-bucket.s3.amazonaws.com/456/digital_assets/789.jpg",
Metadata: {
workspace_id: "456",
asset_id: "789"
}
}Related Documentation
- File Upload - Upload system
- API Services - S3 API reference