AssetConnector
File: config/core/asset-connector.php
Version: 10.01
Type: Singleton PHP class
Purpose: One unified class to upload ANY file to crm_assets from anywhere in the system.
Why It Exists
Before AssetConnector, every module that needed to upload files (Assets Manager, AirBlog, AirMail, FotoTrafiX API, Company Management logo upload, AI image generation) had its own copy of the upload logic — 300+ lines of metadata extraction, S3 upload, DB insert, and collection linking, each with slightly different path construction, error handling, and validation. This caused recurring bugs (wrong paths, missing thumbnails, broken S3 keys) and made maintenance painful.
AssetConnector centralizes all of this into a single class with one main method: store().
Quick Start
// Get the singleton
$connector = AssetConnector::getInstance($mysqli);
// Upload from browser form ($_FILES)
$result = $connector->storeUpload('file', $companyId, $siteId, $userId);
// Upload from a local file path
$result = $connector->storeFile('/tmp/photo.jpg', $companyId, $siteId, $userId);
// Upload from binary content (API response, AI-generated, etc.)
$result = $connector->storeContent($binaryData, 'image.png', $companyId, $siteId, $userId);
All three return the same format:
// Success
[
'success' => true,
'asset_id' => 447,
'asset' => [
'id' => 447,
'company_id' => 10,
'site_id' => 21,
'filename' => 'my_photo_6613a4b2e1f3c.jpg',
'original_filename' => 'My Photo.jpg',
'file_path' => 'https://s3.us-west-1.wasabisys.com/pilot-air4.media/companies/copa/protected/sites/21/assets/2026/03/my_photo_6613a4b2e1f3c.jpg',
'thumbnail_path' => 'https://s3.us-west-1.wasabisys.com/pilot-air4.media/companies/copa/public/sites/21/assets/2026/03/thumbs/thumb_6613a4b2e1f3c.jpg',
's3_key' => 'companies/copa/protected/sites/21/assets/2026/03/my_photo_6613a4b2e1f3c.jpg',
's3_key_thumb' => 'companies/copa/public/sites/21/assets/2026/03/thumbs/thumb_6613a4b2e1f3c.jpg',
'file_size' => 2456789,
'mime_type' => 'image/jpeg',
'asset_type' => 'image',
'width' => 4032,
'height' => 3024,
'title' => 'Sunset at Venice Beach',
'collection_id' => 12,
],
]
// Failure
[
'success' => false,
'error' => 'File too large. Max: 50 MB, got: 72.3 MB',
]
The store() Method — Full Options
$result = $connector->store([
// ── SOURCE (one required) ──
'file_key' => 'logo', // $_FILES key
'local_path' => '/tmp/file.jpg', // absolute path on server
'content' => $binaryData, // raw file content
// ── REQUIRED ──
'company_id' => 10, // company that owns this asset
'site_id' => 21, // site association
'user_id' => 7, // who uploaded it
// ── FILE INFO (optional) ──
'filename' => 'custom.png', // required with 'content', optional otherwise
'original_name'=> 'My File.png', // display name (auto-detected if omitted)
// ── METADATA OVERRIDES (optional) ──
'title' => 'Company Logo', // overrides EXIF title
'description' => 'Our brand logo', // overrides EXIF description
'tags' => 'logo,branding', // MERGED with EXIF tags (not replaced)
'category' => 'branding', // asset category
'copyright' => '© 2026 Copa', // overrides EXIF copyright
'creator' => 'John Smith', // overrides EXIF creator
'alt_text' => 'Copa logo', // accessibility text
// ── S3 STORAGE (optional) ──
'module' => 'assets', // S3 path segment: assets, airblog, airmail, etc.
'visibility' => 'protected', // 'public' or 'protected' (default: protected)
'subfolder' => 'logos', // extra path segment in S3 key
// ── COLLECTION (optional) ──
'collection' => 'Company', // name (auto-creates album) or int (collection_id)
'collection_type' => 'album', // type for auto-created collections
// ── PORTAL (optional) ──
'portal_visible' => false, // show in AirPortal client view
'download_enabled' => true, // allow portal download
'account_id' => null, // CRM account FK
'project_id' => null, // CRM project FK
// ── PROCESSING (optional) ──
'skip_metadata' => false, // skip EXIF/IPTC extraction
'skip_thumbnail' => false, // skip thumbnail generation
'skip_transcription' => true, // skip Whisper transcription (default: true)
// ── VALIDATION (optional) ──
'max_size' => 10485760, // override max file size (bytes)
'allowed_types' => ['jpg','png'], // override allowed extensions
]);
Supported File Types
| Type | Extensions | Max Size |
|---|---|---|
| Image | jpg, jpeg, png, gif, webp, svg, bmp, tiff, tif, ico | 50 MB |
| Video | mp4, mov, avi, webm, mkv, m4v, wmv, flv, mpg, mpeg | 500 MB |
| Audio | mp3, wav, aac, ogg, flac, m4a, wma | 100 MB |
| Document | pdf, doc, docx, xls, xlsx, ppt, pptx, txt, csv, rtf, odt, zip, gz, etc | 100 MB |
What It Does Automatically
1. File Validation
- Checks file exists and uploaded without error
- Validates extension against allowed types
- Enforces size limits (per-type defaults or custom override)
2. Metadata Extraction (via MediaProcessor)
- Images: EXIF, IPTC, XMP — title, description, keywords, copyright, creator, camera make/model, capture date, ISO, aperture, focal length, GPS coordinates
- Videos: Duration, dimensions, codec info + optional Whisper transcription + AI keyword extraction
- Documents/Audio: MIME type detection only
- All metadata stored in
crm_assets.exif_dataas JSON - Search content auto-generated from title + description + tags + transcript
3. Thumbnail Generation (via MediaProcessor)
- Images: JPEG at 400px width, quality 85
- Videos: WebP at 400px width, quality 65
- SVG: No thumbnail needed (renders at any size)
- Documents/Audio: No thumbnail
4. S3 Upload (via StorageService)
- Original file →
protected/prefix (orpublic/if specified) - Thumbnail →
public/prefix (always accessible) - Path convention:
companies/{slug}/{visibility}/sites/{siteId}/{module}/YYYY/MM/{filename} - Storage quota enforced (increments
companies.storage_used_bytes) - Thumbnails don't count toward quota
5. Database Insert
- Full record in
crm_assetswith all metadata columns - Both
file_path(full URL) ands3_key(raw key) stored processing_statusset tocompletedstorage_providerset towasabi
6. Collection Linking (optional)
- Pass
'collection' => 'Company'to auto-find or create an album - Pass
'collection' => 42to link to existing collection by ID - Auto-sets cover image if collection has none
- Uses
crm_asset_collection_itemsjunction table withdisplay_order
Convenience Methods
storeUpload($fileKey, $companyId, $siteId, $userId, $extra = [])
Shorthand for $_FILES uploads:
$result = $connector->storeUpload('file', 10, 21, 7, [
'collection' => 'Company',
'visibility' => 'public',
]);
storeFile($localPath, $companyId, $siteId, $userId, $extra = [])
Shorthand for server-side files:
$result = $connector->storeFile('/var/www/air4.media/tmp/export.pdf', 10, 21, 7, [
'category' => 'exports',
'skip_metadata' => true,
]);
storeContent($content, $filename, $companyId, $siteId, $userId, $extra = [])
Shorthand for binary data:
// AI-generated image
$result = $connector->storeContent($dalleResponse, 'ai-generated.png', 10, 21, 7, [
'title' => 'AI Generated: sunset beach',
'tags' => 'ai,generated,dalle',
'collection' => 'AI Generated',
]);
ensureCollection($name, $companyId, $siteId, $userId, $type = 'album')
Get or create a collection by name. Returns collection ID:
$collectionId = $connector->ensureCollection('Company', 10, 21, 7);
// Returns existing ID if "company" slug exists, creates new album otherwise
delete($assetId)
Delete an asset and all its S3 files:
$result = $connector->delete(447);
// Deletes: original from S3, thumbnail from S3, DB record
// Cascade: removes from crm_asset_collection_items
// Decrements: companies.storage_used_bytes
Usage Examples
Company Logo Upload
require_once $resolver->resolve('config/core/asset-connector.php');
$connector = AssetConnector::getInstance($mysqli);
$result = $connector->storeUpload('logo', $companyId, $siteId, $userId, [
'collection' => 'Company',
'visibility' => 'public',
'title' => $companyName . ' Logo',
'tags' => 'logo,branding,company',
'category' => 'branding',
'skip_metadata' => true,
'skip_thumbnail' => false,
'allowed_types' => ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'],
'max_size' => 5 * 1024 * 1024, // 5MB
]);
if ($result['success']) {
// Update company record with the S3 URL
$stmt = $mysqli->prepare("UPDATE companies SET logo_path = ? WHERE id = ?");
$logoUrl = $result['asset']['file_path'];
$stmt->bind_param('si', $logoUrl, $companyId);
$stmt->execute();
}
Blog Post Image
$result = $connector->storeUpload('featured_image', $companyId, $siteId, $userId, [
'module' => 'airblog',
'visibility' => 'public',
'collection' => 'Blog Images',
'title' => $postTitle . ' — Featured Image',
]);
FotoTrafiX Batch Import
foreach ($files as $filePath) {
$result = $connector->storeFile($filePath, $companyId, $siteId, $userId, [
'collection' => $eventName,
'skip_transcription' => true,
]);
if (!$result['success']) {
logError("Import failed for {$filePath}: " . $result['error']);
}
}
Email Attachment
$result = $connector->storeContent($attachmentData, $attachmentName, $companyId, $siteId, $userId, [
'module' => 'airmail',
'category' => 'email-attachment',
'subfolder' => 'attachments',
]);
Architecture
AssetConnector (singleton)
├── resolveSource() → normalize file from $_FILES / path / binary
├── detectAssetType() → image / video / audio / document / other
├── extractMetadata() → MediaProcessor (EXIF/IPTC/XMP)
├── generateThumbnail() → MediaProcessor (ImageMagick/FFmpeg)
├── uploadToS3() → StorageService → WasabiApi
├── insertRecord() → crm_assets INSERT
├── resolveCollection() → find or create crm_asset_collections
└── linkToCollection() → crm_asset_collection_items INSERT
Dependencies
| Class | File | Role |
|---|---|---|
StorageService |
config/core/storage-service.php |
S3 path construction, upload, quota |
WasabiApi |
config/core/wasabi-api.php |
Low-level S3 SDK wrapper |
MediaProcessor |
config/core/media-processor.php |
EXIF extraction, thumbnail generation |
PathResolver |
config/core/path-resolver.php |
Resolve file paths |
Database Tables
| Table | Role |
|---|---|
crm_assets |
Main asset records |
crm_asset_collections |
Albums, galleries, catalogs |
crm_asset_collection_items |
Junction table (asset ↔ collection) |
companies |
Storage quota tracking (storage_used_bytes) |
company_storage_log |
Storage change audit trail |
Error Handling
- All errors are caught and returned as
['success' => false, 'error' => '...'] - Errors are logged via
logError() - Temp files are always cleaned up, even on failure
- Non-fatal errors (metadata extraction, thumbnail generation) are logged as warnings but don't fail the upload
- S3 upload failure IS fatal — no orphaned DB records
S3 Path Convention
companies/{company-slug}/{visibility}/sites/{siteId}/{module}/YYYY/MM/{filename}
│ │
│ └─ assets, airblog, airmail, etc.
└─ public (thumbnails, public assets)
protected (originals, private assets)
Thumbnails:
companies/{company-slug}/public/sites/{siteId}/{module}/YYYY/MM/thumbs/{filename}
Notes
- Singleton: Use
AssetConnector::getInstance($mysqli). CallAssetConnector::reset()if you need a fresh instance (rare). - Tags are merged: If EXIF has keywords AND you pass
'tags' => 'custom', both are kept. - Title/description override: If you pass these options, they REPLACE what EXIF extracted.
- Collection auto-creation: Collections are matched by
slug(lowercased, hyphenated). "Company" → slug "company". If found, reuses existing. If not, creates new. - No double-uploads: Files are sanitized with uniqid suffix, so same-name files never collide.
- Storage quota: Enforced by StorageService before S3 upload. If company is over quota, upload fails.