Media Handling
Overview
Q01 Core APIs provide specialized endpoints for handling binary files (images, PDFs, documents) separately from regular CRUD operations. Media endpoints support file upload, retrieval, resizing, and CDN integration.
Supported Media Types:
- Images (JPEG, PNG, GIF, WebP)
- Documents (PDF, DOC, DOCX, XLS, XLSX)
- Videos (MP4, MOV)
- Archives (ZIP, RAR)
Features:
- ✅ Multipart form data upload
- ✅ Image resizing and thumbnails
- ✅ MIME type validation
- ✅ Virus scanning
- ✅ CDN integration
- ✅ Lazy loading support
- ✅ Binary response with proper headers
Media Endpoints
Upload Media
POST /api/v4/media/upload:
POST /api/v4/media/upload
Authorization: Bearer {token}
Content-Type: multipart/form-data
--boundary
Content-Disposition: form-data; name="dimension"
PRD
--boundary
Content-Disposition: form-data; name="record_id"
123
--boundary
Content-Disposition: form-data; name="field"
XPRD_IMAGE
--boundary
Content-Disposition: form-data; name="file"; filename="product.jpg"
Content-Type: image/jpeg
[binary data]
--boundary--
Response:
{
"status": "success",
"data": {
"file_id": "uuid-...",
"filename": "product.jpg",
"mime_type": "image/jpeg",
"size": 2048576,
"url": "/api/v4/media/PRD/123/XPRD_IMAGE",
"thumbnail_url": "/api/v4/media/PRD/123/XPRD_IMAGE?size=thumbnail"
}
}
Retrieve Media
GET /api/v4/media/{dimension}/{id}/{field}:
GET /api/v4/media/PRD/123/XPRD_IMAGE
Authorization: Bearer {token}
# Response: Binary data with headers
Content-Type: image/jpeg
Content-Length: 2048576
Cache-Control: public, max-age=31536000
ETag: "abc123"
Last-Modified: Thu, 19 Dec 2025 16:00:00 GMT
Get Thumbnail
GET /api/v4/media/{dimension}/{id}/{field}?size=thumbnail:
GET /api/v4/media/PRD/123/XPRD_IMAGE?size=thumbnail
Authorization: Bearer {token}
# Response: Resized image (200x200)
Content-Type: image/jpeg
Content-Length: 15360
Size Options:
thumbnail- 200x200small- 400x400medium- 800x800large- 1600x1600original- No resizing
CoreService Media Handler
Binary Response Handler
routingController.go:
// routingGetImage handles binary media responses
func routingGetImage(c buffalo.Context) error {
dimension := c.Param("dim")
recordID := c.Param("dim_id")
field := c.Param("field")
size := c.Request().URL.Query().Get("size")
// Forward to CoreQuery
apiURL := fmt.Sprintf(
"http://%s:%s/api/%s/media/%s/%s/%s?size=%s",
os.Getenv("R_API_HOST"),
os.Getenv("R_API_PORT"),
os.Getenv("R_API_VERSION"),
dimension,
recordID,
field,
size
)
resp, err := http.Get(apiURL)
if err != nil {
return c.Error(500, err)
}
defer resp.Body.Close()
// Copy headers
c.Response().Header().Set("Content-Type", resp.Header.Get("Content-Type"))
c.Response().Header().Set("Content-Length", resp.Header.Get("Content-Length"))
c.Response().Header().Set("Cache-Control", "public, max-age=31536000")
c.Response().Header().Set("ETag", resp.Header.Get("ETag"))
// Stream binary data
c.Response().WriteHeader(resp.StatusCode)
io.Copy(c.Response(), resp.Body)
return nil
}
Media Storage
File Storage Options
1. Local Filesystem:
// MediaStorage/LocalStorage.php
class LocalStorage implements MediaStorageInterface {
private $basePath;
public function __construct(string $basePath) {
$this->basePath = $basePath;
}
public function store(string $path, $content): void {
$fullPath = $this->basePath . '/' . $path;
$dir = dirname($fullPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($fullPath, $content);
}
public function retrieve(string $path): ?string {
$fullPath = $this->basePath . '/' . $path;
if (!file_exists($fullPath)) {
return null;
}
return file_get_contents($fullPath);
}
public function delete(string $path): void {
$fullPath = $this->basePath . '/' . $path;
if (file_exists($fullPath)) {
unlink($fullPath);
}
}
}
// Usage
$storage = new LocalStorage('/var/www/media');
$storage->store('PRD/123/XPRD_IMAGE.jpg', $fileContent);
2. AWS S3:
// MediaStorage/S3Storage.php
use Aws\S3\S3Client;
class S3Storage implements MediaStorageInterface {
private $s3;
private $bucket;
public function __construct(S3Client $s3, string $bucket) {
$this->s3 = $s3;
$this->bucket = $bucket;
}
public function store(string $path, $content): void {
$this->s3->putObject([
'Bucket' => $this->bucket,
'Key' => $path,
'Body' => $content,
'ACL' => 'public-read',
'ContentType' => $this->getMimeType($path)
]);
}
public function retrieve(string $path): ?string {
try {
$result = $this->s3->getObject([
'Bucket' => $this->bucket,
'Key' => $path
]);
return $result['Body']->getContents();
} catch (\Exception $e) {
return null;
}
}
public function getPublicURL(string $path): string {
return sprintf(
'https://%s.s3.amazonaws.com/%s',
$this->bucket,
$path
);
}
}
// Configuration
$s3 = new S3Client([
'version' => 'latest',
'region' => 'us-east-1',
'credentials' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY')
]
]);
$storage = new S3Storage($s3, 'q01-media-bucket');
3. Azure Blob Storage:
// MediaStorage/AzureStorage.php
use MicrosoftAzure\Storage\Blob\BlobRestProxy;
class AzureStorage implements MediaStorageInterface {
private $blobClient;
private $container;
public function __construct(BlobRestProxy $blobClient, string $container) {
$this->blobClient = $blobClient;
$this->container = $container;
}
public function store(string $path, $content): void {
$this->blobClient->createBlockBlob(
$this->container,
$path,
$content
);
}
public function retrieve(string $path): ?string {
try {
$blob = $this->blobClient->getBlob($this->container, $path);
return stream_get_contents($blob->getContentStream());
} catch (\Exception $e) {
return null;
}
}
}
Image Processing
Image Resize
Intervention/Image library:
use Intervention\Image\ImageManager;
class ImageProcessor {
private $manager;
public function __construct() {
$this->manager = new ImageManager(['driver' => 'gd']);
}
public function resize(string $imagePath, int $width, int $height): string {
$image = $this->manager->make($imagePath);
// Resize maintaining aspect ratio
$image->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
// Save to temporary file
$tempPath = tempnam(sys_get_temp_dir(), 'img_');
$image->save($tempPath, 80); // 80% quality
return $tempPath;
}
public function thumbnail(string $imagePath, int $size = 200): string {
$image = $this->manager->make($imagePath);
// Crop to square
$image->fit($size, $size);
$tempPath = tempnam(sys_get_temp_dir(), 'thumb_');
$image->save($tempPath, 80);
return $tempPath;
}
public function optimize(string $imagePath): void {
$image = $this->manager->make($imagePath);
// Convert to WebP for better compression
$image->encode('webp', 85);
$image->save($imagePath);
}
}
// Usage
$processor = new ImageProcessor();
$thumbnailPath = $processor->thumbnail('/path/to/image.jpg', 200);
Lazy Loading Support
Generate multiple sizes on upload:
public function handleUpload(UploadedFile $file, string $dimension, string $recordID, string $field): array {
$originalPath = "{$dimension}/{$recordID}/{$field}_original.jpg";
$this->storage->store($originalPath, file_get_contents($file->getPathname()));
// Generate thumbnails
$sizes = [
'thumbnail' => 200,
'small' => 400,
'medium' => 800,
'large' => 1600
];
$urls = [];
foreach ($sizes as $sizeName => $sizePixels) {
$resizedPath = $this->processor->resize($file->getPathname(), $sizePixels, $sizePixels);
$storagePath = "{$dimension}/{$recordID}/{$field}_{$sizeName}.jpg";
$this->storage->store($storagePath, file_get_contents($resizedPath));
$urls[$sizeName] = "/api/v4/media/{$dimension}/{$recordID}/{$field}?size={$sizeName}";
unlink($resizedPath);
}
return [
'original' => "/api/v4/media/{$dimension}/{$recordID}/{$field}",
'sizes' => $urls
];
}
MIME Type Validation
Allowed MIME Types
class MimeTypeValidator {
private $allowedTypes = [
// Images
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// Videos
'video/mp4',
'video/quicktime',
// Archives
'application/zip',
'application/x-rar-compressed'
];
public function validate(UploadedFile $file): bool {
$mimeType = $file->getMimeType();
if (!in_array($mimeType, $this->allowedTypes)) {
throw new ValidationException(
"MIME type not allowed: {$mimeType}"
);
}
// Verify actual file content matches MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detectedMime = finfo_file($finfo, $file->getPathname());
finfo_close($finfo);
if ($detectedMime !== $mimeType) {
throw new ValidationException(
"MIME type mismatch: declared={$mimeType}, detected={$detectedMime}"
);
}
return true;
}
}
Virus Scanning
ClamAV Integration
use Xenolope\Quahog\Client as ClamAVClient;
class VirusScanner {
private $clamav;
public function __construct(string $socket = '/var/run/clamav/clamd.ctl') {
$this->clamav = new ClamAVClient($socket);
}
public function scan(string $filePath): bool {
$result = $this->clamav->scanFile($filePath);
if ($result['status'] === 'FOUND') {
throw new SecurityException(
"Virus detected: {$result['reason']}"
);
}
return true;
}
}
// Usage in upload handler
public function handleUpload(UploadedFile $file): void {
// Validate MIME type
$this->mimeValidator->validate($file);
// Scan for viruses
$this->virusScanner->scan($file->getPathname());
// Store file
$this->storage->store($path, file_get_contents($file->getPathname()));
}
CDN Integration
CloudFront Configuration
class CDNManager {
private $cdnURL;
public function __construct(string $cdnURL) {
$this->cdnURL = $cdnURL;
}
public function getMediaURL(string $dimension, string $recordID, string $field, string $size = 'original'): string {
return sprintf(
'%s/media/%s/%s/%s?size=%s',
$this->cdnURL,
$dimension,
$recordID,
$field,
$size
);
}
public function invalidateCache(string $path): void {
// Invalidate CloudFront cache
$cloudFront = new CloudFrontClient([
'version' => 'latest',
'region' => 'us-east-1'
]);
$cloudFront->createInvalidation([
'DistributionId' => env('CLOUDFRONT_DISTRIBUTION_ID'),
'InvalidationBatch' => [
'Paths' => [
'Quantity' => 1,
'Items' => [$path]
],
'CallerReference' => uniqid()
]
]);
}
}
Client-Side Implementation
JavaScript File Upload
class MediaUploader {
constructor(apiBase, token) {
this.apiBase = apiBase;
this.token = token;
}
async uploadImage(dimension, recordID, field, file, onProgress) {
const formData = new FormData();
formData.append('dimension', dimension);
formData.append('record_id', recordID);
formData.append('field', field);
formData.append('file', file);
const response = await fetch(`${this.apiBase}/api/v4/media/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
},
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
return response.json();
}
getMediaURL(dimension, recordID, field, size = 'original') {
return `${this.apiBase}/api/v4/media/${dimension}/${recordID}/${field}?size=${size}`;
}
}
// Usage
const uploader = new MediaUploader('https://api.example.com', token);
async function handleFileUpload(event) {
const file = event.target.files[0];
try {
const result = await uploader.uploadImage('PRD', '123', 'XPRD_IMAGE', file);
console.log('Upload successful:', result.data.url);
// Display image
document.getElementById('product-image').src = result.data.url;
} catch (error) {
console.error('Upload failed:', error.message);
}
}
React Image Component
function ProductImage({ dimension, recordID, field, size = 'medium', alt }) {
const [src, setSrc] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const url = `${apiBase}/api/v4/media/${dimension}/${recordID}/${field}?size=${size}`;
fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(response => {
if (!response.ok) throw new Error('Failed to load image');
return response.blob();
})
.then(blob => {
const objectURL = URL.createObjectURL(blob);
setSrc(objectURL);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [dimension, recordID, field, size]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <img src={src} alt={alt} />;
}
Best Practices
✅ DO:
Validate MIME types:
// ✅ Good - prevent malicious uploads
$this->mimeValidator->validate($file);
Scan for viruses:
// ✅ Good - security
$this->virusScanner->scan($file->getPathname());
Generate multiple sizes:
// ✅ Good - responsive images
foreach (['thumbnail', 'small', 'medium', 'large'] as $size) {
$this->generateSize($file, $size);
}
Use CDN for media:
// ✅ Good - performance
const url = cdn.getMediaURL(dimension, id, field);
❌ DON'T:
Don't store media in database:
// ❌ Bad - bloats database
UPDATE TB_ANAG_PRD00 SET XPRD_IMAGE = ? // Binary data!
// ✅ Good - store file path
UPDATE TB_ANAG_PRD00 SET XPRD_IMAGE_PATH = ?
Don't skip virus scanning:
// ❌ Bad - security risk
$this->storage->store($path, $file);
// ✅ Good - scan first
$this->virusScanner->scan($file);
$this->storage->store($path, $file);
Summary
- ✅ Specialized media endpoints for binary files
- ✅ Image resizing and thumbnail generation
- ✅ MIME type validation and virus scanning
- ✅ Multiple storage options (local, S3, Azure)
- ✅ CDN integration for performance
- ✅ Lazy loading with multiple sizes
- ✅ Binary response with proper headers
Key Takeaways:
- Use dedicated media endpoints (not CRUD)
- Validate MIME types and scan for viruses
- Generate multiple sizes for responsive images
- Store files externally (S3, Azure, CDN)
- Use CDN for performance
- Support lazy loading with srcset
- Set proper cache headers
Related Concepts
- Advanced Topics - Overview
- Performance Optimization - CDN and caching
- Internal Architecture - Binary response handling