Created
June 8, 2025 19:36
-
-
Save colorwebdesigner/8282e85d70d7ed81e2bce0b1d3f2994a to your computer and use it in GitHub Desktop.
SecureDownload - Protected File Download with Counter for MODX Revolution
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* SecureDownload - Protected File Download with Counter for MODX Revolution | |
* | |
* Features: | |
* 1. Generates secure time-limited download links | |
* 2. Tracks download counts in a Template Variable | |
* 3. Automatically resolves file paths from Media Source | |
* 4. Prevents direct access to files | |
* 5. Detailed logging for debugging | |
* | |
* @author Colorwebdesigner | |
* @version 2.0 | |
* | |
* =================================================================================== | |
* HOW TO USE: | |
* =================================================================================== | |
* | |
* 1. CONFIGURATION: | |
* - Set the TV names in the configuration section: | |
* $tvFilePath = 'your_file_tv'; // TV containing the file reference | |
* $tvDownloadCount = 'download_count'; // TV to store download counter | |
* - Optional: Adjust cache time (default 24 hours) | |
* | |
* 2. SNIPPET CALL: | |
* Use in templates or MIGx fields: | |
* [[!SecureDownload? | |
* &file=`[[*your_file_tv]]` // TV with file path | |
* &resource=`[[*id]]` // Resource ID for counter | |
* ]] | |
* | |
* 3. MEDIA SOURCE SETUP: | |
* - Ensure your file TV is connected to a Media Source | |
* - The Media Source must have a valid basePath | |
* | |
* 4. DEBUGGING: | |
* - Set $debugMode = true to enable detailed logs | |
* - Logs appear in MODX system log (filter by "SecureDownload") | |
* | |
* | |
* =================================================================================== | |
* SECURITY NOTES: | |
* =================================================================================== | |
* | |
* - Tokens expire after 24 hours by default | |
* - File paths are never exposed to users | |
* - Direct file access is prevented | |
* - Counter updates are atomic | |
*/ | |
// ===== CONFIGURATION ===== | |
$debugMode = false; // Enable detailed logging for debugging | |
$cacheTime = 86400; // Token lifetime in seconds (24 hours) | |
$tvFilePath = 'package-offers-item_kp'; // TV name containing file path | |
$tvDownloadCount = 'download_count'; // TV name for download counter | |
// ===== END CONFIG ===== | |
// Initialize logging | |
$requestId = uniqid(); | |
$log = function($message, $level = modX::LOG_LEVEL_INFO) use ($modx, $requestId, $debugMode) { | |
if ($debugMode) { | |
$modx->log($level, "[$requestId] $message", '', 'SecureDownload'); | |
} | |
}; | |
// Override settings from snippet parameters | |
$cacheTime = (int)$modx->getOption('cacheTime', $scriptProperties, $cacheTime); | |
$customTvName = $modx->getOption('tvDownloadCount', $scriptProperties, ''); | |
if (!empty($customTvName)) { | |
$tvDownloadCount = $customTvName; | |
$log("Using custom counter TV: $tvDownloadCount"); | |
} | |
/** | |
* Get absolute file path from Media Source | |
* @param string $fileValue - File path from TV | |
* @return string|bool Absolute path or false on error | |
*/ | |
$getFilePath = function($fileValue) use ($modx, $tvFilePath, $log) { | |
// Get the TV object | |
$tv = $modx->getObject('modTemplateVar', ['name' => $tvFilePath]); | |
if (!$tv) { | |
$log("ERROR: TV '$tvFilePath' not found", modX::LOG_LEVEL_ERROR); | |
return false; | |
} | |
// Get Media Source ID | |
$sourceId = $tv->get('source'); | |
$log("Media Source ID: $sourceId"); | |
if (empty($sourceId)) { | |
$log("WARNING: No Media Source assigned to TV", modX::LOG_LEVEL_WARN); | |
return false; | |
} | |
// Load Media Source | |
$source = $modx->getObject('sources.modMediaSource', $sourceId); | |
if (!$source) { | |
$log("ERROR: Media Source #$sourceId not found", modX::LOG_LEVEL_ERROR); | |
return false; | |
} | |
$source->initialize(); | |
$properties = $source->getPropertyList(); | |
// Get base path with trailing slash | |
$basePath = isset($properties['basePath']) | |
? rtrim($properties['basePath'], '/') . '/' | |
: ''; | |
$log("Media Source basePath: $basePath"); | |
$log("File value from TV: $fileValue"); | |
// Build absolute path | |
return MODX_BASE_PATH . ltrim($basePath, '/') . ltrim($fileValue, '/'); | |
}; | |
// Download handler mode | |
if (isset($_GET['download_token'])) { | |
$token = $_GET['download_token']; | |
$log("DOWNLOAD HANDLER: Token received - $token"); | |
// Retrieve cached data | |
$cacheData = $modx->cacheManager->get($token); | |
if (!$cacheData || !isset($cacheData['file']) || !isset($cacheData['resource'])) { | |
$log("ERROR: Invalid or expired token", modX::LOG_LEVEL_ERROR); | |
header('HTTP/1.1 404 Not Found'); | |
exit('Invalid download token'); | |
} | |
$fileName = $cacheData['file']; | |
$resourceId = (int)$cacheData['resource']; | |
$log("File: $fileName | Resource ID: $resourceId"); | |
// Build absolute file path from Media Source | |
$absolutePath = $getFilePath($fileName); | |
if (!$absolutePath) { | |
$log("ERROR: Could not resolve file path", modX::LOG_LEVEL_ERROR); | |
header('HTTP/1.1 500 Internal Server Error'); | |
exit('Configuration error'); | |
} | |
$log("Absolute path: $absolutePath"); | |
// Validate file | |
if (!file_exists($absolutePath) || !is_readable($absolutePath)) { | |
$log("ERROR: File not found or inaccessible", modX::LOG_LEVEL_ERROR); | |
header('HTTP/1.1 404 Not Found'); | |
exit('File not found: ' . $fileName); | |
} | |
// Update download counter | |
$log("Updating download counter for resource $resourceId"); | |
$tv = $modx->getObject('modTemplateVar', ['name' => $tvDownloadCount]); | |
if ($tv) { | |
$tvr = $modx->getObject('modTemplateVarResource', [ | |
'tmplvarid' => $tv->get('id'), | |
'contentid' => $resourceId | |
]); | |
if ($tvr) { | |
$count = (int)$tvr->get('value'); | |
$tvr->set('value', $count + 1); | |
$tvr->save(); | |
$log("Counter updated: $count → " . ($count + 1)); | |
} else { | |
$tvr = $modx->newObject('modTemplateVarResource'); | |
$tvr->set('tmplvarid', $tv->get('id')); | |
$tvr->set('contentid', $resourceId); | |
$tvr->set('value', 1); | |
$tvr->save(); | |
$log("New counter created: 1"); | |
} | |
} else { | |
$log("WARNING: TV '$tvDownloadCount' not found", modX::LOG_LEVEL_WARN); | |
} | |
// Send file to browser | |
$log("Sending file: $fileName"); | |
// Detect MIME type | |
$mimeType = mime_content_type($absolutePath) ?: 'application/octet-stream'; | |
header('Content-Description: File Transfer'); | |
header('Content-Type: ' . $mimeType); | |
header('Content-Disposition: attachment; filename="' . $fileName . '"'); | |
header('Content-Length: ' . filesize($absolutePath)); | |
header('Expires: 0'); | |
header('Cache-Control: must-revalidate'); | |
header('Pragma: public'); | |
readfile($absolutePath); | |
exit; | |
} | |
// Link generator mode | |
$fileInput = $modx->getOption('file', $scriptProperties, ''); | |
if (empty($fileInput)) { | |
$log("WARNING: Empty file parameter", modX::LOG_LEVEL_WARN); | |
return ''; | |
} | |
// Extract clean filename | |
$fileName = basename($fileInput); | |
$log("LINK GENERATOR: File input: '$fileInput' | Clean name: '$fileName'"); | |
// Get resource ID (current resource by default) | |
$resourceId = (int)$modx->getOption('resource', $scriptProperties, $modx->resource->get('id')); | |
$log("Resource ID: $resourceId"); | |
// Create unique token | |
$token = md5($fileName . microtime() . uniqid()); | |
$log("Token generated: $token"); | |
// Store data in cache | |
$cacheData = [ | |
'file' => $fileName, | |
'resource' => $resourceId | |
]; | |
$modx->cacheManager->set($token, $cacheData, $cacheTime); | |
$log("Cache data stored: " . print_r($cacheData, true)); | |
// Generate secure download URL | |
$url = $modx->makeUrl( | |
$modx->resource->get('id'), | |
'', | |
['download_token' => $token], | |
'full' | |
); | |
$log("Secure URL generated: $url"); | |
return $url; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment