Skip to content

Commit

Permalink
Allow downloadable files to be hosted externally, including AWS
Browse files Browse the repository at this point in the history
Merges zencart#327 and zencart#347

- added support for AWS-hosted downloads (files on AWS S3) ... simply give `aws:bucketname/filename.ext` as the filename in attributes controller, with expiring links to prevent theft
- added support for URL-based downloads, such as Dropbox or any other http/https URL which requires no authentication by the customer (NOTE: offers no theft prevention or download-count-tracking)
- cleaned up the My Account list of downloads, for simpler styling
- changed both admin and catalog to allow observers to provide details of file availability
- moved download-by-redirect and download-by-streaming to observer class

Notes:
- If using with Dropbox, set the `&dl=0` to `&dl=1` on the "sharing link" that Dropbox gives you, so that the file is immediately downloaded for the customer.
- If sharing from Google Drive, be sure to configure the Sharing Permissions properly, and obtain a shareable link that's got read-only access.

Dev tip:
- For AWS, to keep credentials out of version-control, put them into the `extra-configures` folder, and prefix the filename with `dev-` since that's in the project's `.gitignore` already.
  • Loading branch information
drbyte committed Dec 23, 2017
1 parent a848cdf commit 76ddf23
Show file tree
Hide file tree
Showing 11 changed files with 958 additions and 307 deletions.
17 changes: 10 additions & 7 deletions admin/attributes_controller.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<?php
/**
* @package admin
* @copyright Copyright 2003-2016 Zen Cart Development Team
* @copyright Copyright 2003-2017 Zen Cart Development Team
* @copyright Portions Copyright 2003 osCommerce
* @license http://www.zen-cart.com/license/2_0.txt GNU Public License V2.0
* @version $Id: Author: DrByte Thu Mar 3 12:16:32 2016 -0500 Modified in v1.5.5 $
* @version GIT: $Id: Author: DrByte Modified in v1.5.6 $
*/
require('includes/application_top.php');

Expand Down Expand Up @@ -71,10 +71,13 @@
}

if ($action == 'new_cat') {
$sql = "SELECT *
FROM " . TABLE_PRODUCTS_TO_CATEGORIES . "
WHERE categories_id = '" . $current_category_id . "'
ORDER BY products_id";
$sql = "SELECT ptc.*, products_name
FROM " . TABLE_PRODUCTS_TO_CATEGORIES . " ptc
LEFT JOIN " . TABLE_PRODUCTS_DESCRIPTION . " pd
ON ptc.products_id = pd.products_id
AND pd.language_id = '" . (int)$_SESSION['languages_id'] . "'
WHERE ptc.categories_id = '" . $current_category_id . "'
ORDER BY products_name";
$new_product_query = $db->Execute($sql);
$products_filter = (!$new_product_query->EOF) ? $new_product_query->fields['products_id'] : '';
zen_redirect(zen_href_link(FILENAME_ATTRIBUTES_CONTROLLER, 'products_filter=' . $products_filter . '&current_category_id=' . $current_category_id));
Expand Down Expand Up @@ -1644,7 +1647,7 @@ function init() {
$download_display = $db->Execute($download_display_query_raw);
if ($download_display->RecordCount() > 0) {
$filename_is_missing = '';
if (!file_exists(DIR_FS_DOWNLOAD . $download_display->fields['products_attributes_filename'])) {
if ( !zen_orders_products_downloads($download_display->fields['products_attributes_filename']) ) {
$filename_is_missing = zen_image(DIR_WS_IMAGES . 'icon_status_red.gif');
} else {
$filename_is_missing = zen_image(DIR_WS_IMAGES . 'icon_status_green.gif');
Expand Down
19 changes: 8 additions & 11 deletions admin/downloads_manager.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<?php
/**
* @package admin
* @copyright Copyright 2003-2014 Zen Cart Development Team
* @copyright Copyright 2003-2015 Zen Cart Development Team
* @copyright Portions Copyright 2003 osCommerce
* @license http://www.zen-cart.com/license/2_0.txt GNU Public License V2.0
* @version GIT: $Id: Author: DrByte Jun 30 2014 Modified in v1.5.4 $
* @version $Id: Modified in v1.6.0 $
*/

require('includes/application_top.php');
Expand Down Expand Up @@ -147,15 +147,12 @@ function init()
$padInfo = new objectInfo($padInfo_array);
}

// Moved to /admin/includes/configure.php
if (!defined('DIR_FS_DOWNLOAD')) define('DIR_FS_DOWNLOAD', DIR_FS_CATALOG . 'download/');

$filename_is_missing='';
if ( !file_exists(DIR_FS_DOWNLOAD . $products_downloads_query->fields['products_attributes_filename']) ) {
$filename_is_missing = zen_image(DIR_WS_IMAGES . 'icon_status_red.gif');
} else {
$filename_is_missing = zen_image(DIR_WS_IMAGES . 'icon_status_green.gif');
}
$filename_is_missing='';
if ( !zen_orders_products_downloads($products_downloads_query->fields['products_attributes_filename']) ) {
$filename_is_missing = zen_image(DIR_WS_IMAGES . 'icon_status_red.gif');
} else {
$filename_is_missing = zen_image(DIR_WS_IMAGES . 'icon_status_green.gif');
}
?>
<?php
if (isset($padInfo) && is_object($padInfo) && ($products_downloads_query->fields['products_attributes_id'] == $padInfo->products_attributes_id)) {
Expand Down
42 changes: 32 additions & 10 deletions admin/includes/functions/general.php
Original file line number Diff line number Diff line change
Expand Up @@ -2601,7 +2601,7 @@ function zen_has_product_attributes_downloads($products_id, $check_valid=false)
if ($check_valid == true) {
$valid_downloads = '';
while (!$download_display->EOF) {
if (!file_exists(DIR_FS_DOWNLOAD . $download_display->fields['products_attributes_filename'])) {
if (!file_exists(zen_get_download_handler($download_display->fields['products_attributes_filename']))) {
$valid_downloads .= '<br />&nbsp;&nbsp;' . zen_image(DIR_WS_IMAGES . 'icon_status_red.gif') . ' Invalid: ' . $download_display->fields['products_attributes_filename'];
// break;
} else {
Expand Down Expand Up @@ -3361,19 +3361,41 @@ function zen_date_diff($date1, $date2) {
* check that the specified download filename exists on the filesystem
*/
function zen_orders_products_downloads($check_filename) {
global $db;
global $zco_notifier;

$valid_downloads = true;
if (!defined('DIR_FS_DOWNLOAD')) define('DIR_FS_DOWNLOAD', DIR_FS_CATALOG . 'download/');
$handler = zen_get_download_handler($check_filename);

if (!file_exists(DIR_FS_DOWNLOAD . $check_filename)) {
$valid_downloads = false;
// break;
} else {
$valid_downloads = true;
if ($handler == 'local') {
return file_exists(DIR_FS_DOWNLOAD . $check_filename);
}

return $valid_downloads;
/**
* An observer hooking this notifier should set $handler to blank if it tries a validation and fails.
* Or, if validation passes, simply set $handler to the service name (first chars before first colon in filename)
* Or, or there is no way to verify, do nothing to $handler.
*/
$zco_notifier->notify('NOTIFY_TEST_DOWNLOADABLE_FILE_EXISTS', $check_filename, $handler);

// if handler is set but isn't local (internal) then we simply return true since there's no way to "test"
if ($handler != '') return true;

// else if the notifier caused $handler to be empty then that means it failed verification, so we return false
return false;
}

/**
* check if the specified download filename matches a handler for an external download service
* If yes, it will be because the filename contains colons as delimiters ... service:filename:filesize
*/
function zen_get_download_handler($filename) {
$file_parts = explode(':', $filename);

// if the filename doesn't contain any colons, then there's no delimiter to return, so must be using built-in file handling
if (sizeof($file_parts) < 2) {
return 'local';
}

return $file_parts[0];
}

/**
Expand Down
237 changes: 237 additions & 0 deletions includes/classes/observers/auto.downloads_via_aws.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
<?php
/**
* @package plugins
* @copyright Copyright 2003-2015 Zen Cart Development Team
* @license http://www.zen-cart.com/license/2_0.txt GNU Public License V2.0
* @version $Id: Designed for v1.6.0 $
*/

/**
* This observer class is intended to allow downloadable files to be served
* from Amazon AWS S3 buckets, and also automatically expire the links
* so that customers can't share them or otherwise steal the files
*
*/
class zcObserverDownloadsViaAws extends base {

// this is where you can configure your AWS settings:
// --------------------------------------------------
// Alternatively, you can define them as constants in
// the extra_configures folder: AMAZON_S3_ACCESS_KEY
// and AMAZON_S3_ACCESS_SECRET
// --------------------------------------------------
/**
* Set your Amazon AWS S3 Access Key and Secret Key here
* @var string
*/
private $aws_key = "MY_AMAZON_S3_ACCESS_KEY";
private $aws_secret = "MY_AMAZON_S3_SECRET_XXXXXXXXX";

/**
* This is used to calculate a link that's good for 30 seconds,
* which is plenty of time for it to get started, & prevents
* unauthorized sharing and theft. Default is 30 seconds.
* @var integer
*/
protected $link_expiry_time = 30;

/**
* Class constructor
*/
public function __construct() {
// read config from constants if available
if ($this->aws_key == 'MY_AMAZON_S3_ACCESS_KEY' && defined('AMAZON_S3_ACCESS_KEY')) $this->aws_key = AMAZON_S3_ACCESS_KEY;
if ($this->aws_secret == 'MY_AMAZON_S3_SECRET_XXXXXXXXX' && defined('AMAZON_S3_ACCESS_SECRET')) $this->aws_secret = AMAZON_S3_ACCESS_SECRET;

// if not configured, then don't activate
if ($this->aws_key == 'MY_AMAZON_S3_ACCESS_KEY' || $this->aws_key == '' || $this->aws_secret == '' || $this->aws_secret == 'MY_AMAZON_S3_SECRET_XXXXXXXXX') return false;

// attach listener
$this->attach($this, array('NOTIFY_CHECK_DOWNLOAD_HANDLER', 'NOTIFY_DOWNLOAD_READY_TO_START', 'NOTIFY_MODULE_DOWNLOAD_TEMPLATE_DETAILS', 'NOTIFY_TEST_DOWNLOADABLE_FILE_EXISTS'));
}


/**
* Parse the file details for display on template page
*
* @param string $eventID name of the observer event fired
* @param array $array $download->fields data
* @param array $data array passed by reference
*/
protected function updateNotifyModuleDownloadTemplateDetails(&$class, $eventID, $array, &$data)
{
// available fields:
// $data['service'] = 'local'
// $data['filename'] = db query result from orders_products_filename
// $data['expiry_timestamp']
// $data['expiry']
// $data['downloads_remaining']
// $data['unlimited_downloads']
// $data['file_exists'] = file_exists(DIR_FS_DOWNLOAD . $data['filename']);
// $data['is_downloadable'] = $data['file_exists'] && ($data['downloads_remaining'] > 0 && $data['expiry_timestamp'] > time()) || $data['unlimited_downloads'];
// $data['filesize'] = ($data['file_exists']) ? filesize(DIR_FS_DOWNLOAD . $file['orders_products_filename']) : 'Unknown';
// $data['date_purchased_day']
// $data['download_maxdays']
// $data['products_name']
// $data['orders_products_download_id'] = id for URL link
// $data['download_count']

$file_parts = $this->parseFileParts($data['filename']);

if ($file_parts === false) return;
if ($file_parts[0] != 'aws') return;

$data['service'] = $file_parts[0];

// use just the filename portion, skipping the bucket name for customer-facing display purposes
$data['filename'] = substr($file_parts[1], strrpos($file_parts[1], '/') + 1);

$data['filesize'] = isset($file_parts[2]) ? number_format($file_parts[2], 0) : '';
$data['filesize_units'] = '';

// could optionally add an AWS SDK call to actually check that the object exists
$data['is_downloadable'] = $data['file_exists'] = true;

}

/**
* This observer should set $handler to blank if it fails to validate whether $filename exists on the external service.
* If validation passes, simply set $handler to the service name (first chars before first colon in filename) (or do nothing since it's probably already correct).
* If there is no way to verify, do nothing to $handler.
*
* @param string $eventID name of the observer event fired
* @param string $filename filename to verify exists
* @param string $handler name of external service handler
*/
protected function updateNotifyTestDownloadableFileExists(&$class, $eventID, $filename, &$handler)
{
$result = $this->testFileExists($filename);

if ($result === false) {
$handler = '';
}
}

/**
*
* @param string $eventID name of the observer event fired
* @param array $var deprecated array, used only for backward compatibility
* @param array $fields data feeding all download activities
* @param string $origin_filename (mutable)
* @param string $browser_filename (mutable)
* @param string $source_directory (mutable)
* @param boolean $file_exists (mutable)
*/
protected function updateNotifyCheckDownloadHandler(&$class, $eventID, $var, &$fields, &$origin_filename, &$browser_filename, &$source_directory, &$file_exists, &$service, &$isExpired, &$download_timestamp)
{
// // compatibility for ZC versions older than v1.6.0:
// if (PROJECT_VERSION_MAJOR == '1' && PROJECT_DB_VERSION_MINOR < '6.0') {
// $fields = $var->fields;
// $browser_filename = $origin_filename = $fields['orders_products_filename'];
// $source_directory = DIR_FS_DOWNLOAD;
// }

$file_parts = $this->parseFileParts($origin_filename);
if ($file_parts[0] == 'aws') {
$origin_filename = $file_parts[1];
$browser_filename = substr($origin_filename, strrpos($origin_filename, '/') + 1);
$source_directory = $file_parts[0];
$file_exists = true;
$service = $file_parts[0];
}
}

/**
* This fires when the download module wants to redirect to the external download service
* So, this method parses the passed file, obtains the URL, and does the redirect
*
* @param string $eventID name of the observer event fired
* @param string $ipaddress customer IP
* @param string $service (mutable)
* @param string $origin_filename (mutable)
* @param string $browser_filename (mutable)
* @param string $source_directory (mutable)
* @param integer $downloadFilesize (mutable)
* @param string $mime_type (mutable)
* @param array $fields array of data from db query feeding the download page
* @param string $browser_headers (mutable)
*/
protected function updateNotifyDownloadReadyToStart(&$class, $eventID, $ipaddress, &$service, &$origin_filename, &$browser_filename, &$source_directory, &$downloadFilesize, $mime_type, $fields, $browser_headers)
{
// // compatibility for ZC versions older than v1.6.0:
// if (PROJECT_VERSION_MAJOR == '1' && PROJECT_DB_VERSION_MINOR < '6.0') {
// list($origin_filename, $browser_filename, $downloadFilesize, $ipaddress, $fields) = each($array);
// }
// if (isset($source_directory) && $source_directory != '') $this->source_directory = $source_directory;


// verify that the passed file is indeed intended for aws
if ($source_directory != 'aws') {
$file_parts = $this->parseFileParts($origin_filename);
if ($file_parts[0] != 'aws') return;
$origin_filename = $file_parts[1];
$browser_filename = substr($origin_filename, strrpos($origin_filename, '/') + 1);
$source_directory = $file_parts[0];
$downloadFilesize = $file_parts[2];
}

// prepare AWS URL
$url = $this->buildRedirectUrl($origin_filename);

// redirect to external download script
header("HTTP/1.1 303 See Other");
zen_redirect($url);

zen_exit();
}

/**
* parse file details to determine if its download should be handled by AWS
* If AWS, the filename will use colons as delimiters ... aws:bucket/filename:filesize
*
* @param string $filename
* @return boolean|array
*/
private function parseFileParts($filename) {

$file_parts = explode(':', $filename);

if (sizeof($file_parts) == 1) return false;

return $file_parts;
}

/**
* Prepare signed expiring URL for AWS redirect
*
* @param string $bucketAndFilename
* @return string $url
*/
private function buildRedirectUrl($bucketAndFilename) {
$aws_server = "s3.amazonaws.com";

// this calculates a link that's good for 30 seconds, which is plenty of time for it to get started, and prevents theft
$expires = time() + $this->link_expiry_time;

$raw_request = "GET\n\n\n" . $expires . "\n/" . $bucketAndFilename;
$sig = urlencode(base64_encode((hash_hmac("sha1", utf8_encode($raw_request), $this->aws_secret, true))));

$params = 'AWSAccessKeyId=' . $this->aws_key . '&Expires=' . $expires . '&Signature=' . $sig;

return 'http://' . $aws_server . '/' . $bucketAndFilename . '?' . $params;
}

/**
* Use AWS SDK to test whether the bucket+file (designated by $filename) exists
* If it does not exist, return false
*
* @param string $filename
* @return boolean Result of SDK test
*/
private function testFileExists($filename)
{
//@TODO invoke AWS SDK to test whether the bucket+file exists.
return true;
}

}

0 comments on commit 76ddf23

Please sign in to comment.