<?php
/*
Plugin Name: AWS S3
Description: Provides support for managing files in AWS S3
Version: 0.02
Requires at least: 3.04
*/

// PLUGIN SETTINGS
// ===============

// whether or not to provide our copy of SSL Certificate Authority Root Certificates (in case server/OS doesn't already have them setup)
// if you get an "AWS S3: Error authenticating: Unknown AWS Error!", try setting this to true
$GLOBALS['AWS_S3_PROVIDE_CACERT'] = true;

// turning this off is not recommended!
$GLOBALS['AWS_S3_SKIP_AUTH_ERROR_CHECKING'] = false;

/* === DO NOT EDIT ANYTHING BELOW THIS LINE! === */


//LOAD DOCUMENTATION
//==================
// Show plugin menu options
if (defined('IS_CMS_ADMIN') && (@$_REQUEST['action'] == 'plugins' || @$_REQUEST['_pluginAction'])) { // if in CMS Plugins Menu
  require_once("_display_readme.php");
  
  pluginAction_addHandler('_aws_s3_getting_started', 'admins');
  pluginAction_addHandlerAndLink(t('Read Me'), '_aws_s3_getting_started', 'admins');

}

// LOAD AUTH DETAILS
// =================

list($authDetailsPath) = getPluginPathAndUrl("auth_details.php");
if (file_exists($GLOBALS['PROGRAM_DIR'] . "/plugins/$authDetailsPath")) {
  require_once("auth_details.php");
}


addAction('section_init', function() {
  if (!@$GLOBALS['AWS_S3_KEY']) {
    alert("AWS S3 Plugin: auth_details.php file is missing! You must rename the auth_details_SAMPLE.php file and update its details.<br>");
  }
});

// TEST PAGE
// =========

// The Test Page is accessed from the Plugins menu
require_once("testPage_functions.php");
pluginAction_addHandlerAndLink('Run Tests', 'aws_s3_testPage', 'admins');


// PRIVATE FUNCTIONS
// =================

// _aws_s3_init()
// 
// This is automatically called when any aws_s3_* function is called
// It creates (and memoizes) the Aws\S3\S3Client object
// 
// Example usage:
//   $s3client = _aws_s3_init();
function _aws_s3_init() {
  static $s3client;
  if ($s3client) { return $s3client; }  // memoize

  // load aws library
  require_once('lib/aws-autoloader.php');
  
  // set up authentication info
  $s3ClientOptions = [
    'version'     => '2006-03-01',
    'region'      => $GLOBALS['AWS_S3_REGION'],
    'credentials' => [
      'key'         => $GLOBALS['AWS_S3_KEY'],
      'secret'      => $GLOBALS['AWS_S3_SECRET'],
    ],
  ];
  
  // Provide SSL Certificate Authority Root Certificates in case server/OS doesn't already have them setup
  if (@$GLOBALS['AWS_S3_PROVIDE_CACERT']) {
    list($certPath) = getPluginPathAndUrl('cacert.pem');  // download from cURL authors: http://curl.haxx.se/ca/cacert.pem
    $certPath = SCRIPT_DIR . '/plugins/' . $certPath;
    $s3ClientOptions['http'] = [ 'verify' => $certPath ];
  }

  // instantiate S3 client object
  $s3client = new Aws\S3\S3Client($s3ClientOptions);

  // check for connection/authentication errors
  if (!@$GLOBALS['AWS_S3_SKIP_AUTH_ERROR_CHECKING']) {
    try { $response = $s3client->headBucket(['Bucket' => $GLOBALS['AWS_S3_BUCKET']]); } // perform arbitrary operation to check if we're connected successfully
    catch (Aws\Exception\AwsException $e) {
      $errorCode = $e->getAwsErrorCode() ?: 'Unknown AWS Error'; // this always is an error in a try block
      if ($errorCode == 'NotFound') { trigger_error("AWS S3: Bucket not found!", E_USER_NOTICE); }
      else                          { trigger_error("AWS S3: Error authenticating: $errorCode!", E_USER_NOTICE); }
    }
  }

  return $s3client;
}

// _aws_s3_try()
// 
// This function provides easy exception-handling support for all aws_s3_* functions
// All aws_s3_* functions use _aws_s3_try(), none of them work with the Aws\S3\S3Client object directly
// Note that this function forces all api calls to use the one bucket (region is S3Client-specific, but bucket is supplied in every method call)
// 
// Example usage:
//   list($response, $error) = _aws_s3_try('getObject', [ 'Key' => '123' ]);
//   if ($error === 'NoSuchKey') { showme("Key not found"); }
//   showme(@$response['ContentType'] ?: "No ContentType in response");
function _aws_s3_try($methodName, $methodArgs = []) {
  
  // define return values
  $response = null;
  $error    = null;
  
  // for convenience, always set the Bucket in the methodArgs here
  $methodArgs['Bucket'] = $GLOBALS['AWS_S3_BUCKET'];
  
  // capture errors
  try {
    
    // ensure Aws\S3\S3Client object is initialized
    $s3client = _aws_s3_init();
    
    // handle special-case method signature (i.e. ->method($bucket, $key) instead of ->method(['Bucket' => $bucket, 'Key' => $key]))
    if ($methodName === 'doesObjectExist') {
      $response = $s3client->doesObjectExist($methodArgs['Bucket'], $methodArgs['Key']);
    }
    
    // handle default method signature (i.e. ->method([]))
    else {
      $response = $s3client->$methodName($methodArgs);
    }
    
  }
  catch (Aws\Exception\AwsException $e) {
    
    // attempt to get the error "code" from the AwsException object
    $error = $e->getAwsErrorCode() ?: 'Unknown Aws Error';
    
    // report error to error logging system
    $expectedErrors = ['NoSuchKey'];
    if (!in_array($error, $expectedErrors)) {
      @trigger_error("AWS S3: $error", E_USER_NOTICE);
    }
  }
  catch (exception $e) {
    // catch other error
    @trigger_error($e->getMessage(), E_USER_NOTICE);
  }
  
  return [ $response, $error ];
}


// PUBLIC API
// ==========

// aws_s3_put($key, $contentType, $body)
// 
// Upload an S3 object (e.g. file), providing both content and ContentType
// 
// Example Usage:
//   $url = aws_s3_put('key', 'text/html', 'Hello world!');

function aws_s3_put($key, $contentType, $body, $moreMethodArgs = []) {
  
  // build method arguments array for S3 putObject call
  $methodArgs = [
    'Key'         => $key,
    'ContentType' => $contentType,
    'Body'        => $body,
    'ACL'	  => 'public-read',
  ];
  
  // allow caller to supply extra method arguments
  $methodArgs = array_merge($methodArgs, $moreMethodArgs);
  
  // call S3 putObject method
  list($response, $error) = _aws_s3_try('putObject', $methodArgs);
  
  // return URL
  return @$response['ObjectURL'];
}

// aws_s3_get($key)
// 
// Download an S3 object (e.g. file), returning content and ContentType
// 
// Example Usage:
//   list($content, $contentType) = aws_s3_get('key');

function aws_s3_get($key) {
  
  // call S3 getObject method
  list($response, $error) = _aws_s3_try('getObject', [ 'Key' => $key ]);
  
  // provide return values
  if ($error) {
    return [ null, null ];
  }
  else {
    return [ $response['Body']->getContents(), $response['ContentType'] ];
  }
}

// aws_s3_get_saveToFile($key, $saveAsFilePath)
// 
// Download an S3 object (e.g. file), saving its content directly to a file on the local filesystem
// 
// Example Usage:
//   $success = aws_s3_get_saveToFile('key', 'tmp/filepath.png');

function aws_s3_get_saveToFile($key, $saveAsFilePath) {
  
  // call S3 getObject method, specifying "SaveAs"
  list($response, $error) = _aws_s3_try('getObject', [ 'Key' => $key, 'SaveAs' => $saveAsFilePath ]);
  
  if ($error === 'NoSuchKey'){
    @trigger_error("AWS S3: $error", E_USER_NOTICE);
  }
  
  // if there's an error, return false, otherwise assume success
  return !$error;
}

// aws_s3_exists($key)
// 
// Check if an S3 object (e.g. file) exists
// 
// Example Usage:
//   $keyExists = aws_s3_exists('key');

function aws_s3_exists($key) {
  
  // call S3 doesObjectExist method
  list($response, $error) = _aws_s3_try('doesObjectExist', [ 'Key' => $key ]);
  
  return $response;
}

// aws_s3_delete($key)
// 
// Delete an S3 object (e.g. file)
// Note that the $completed return value does not tell you whether the object previously existed. Deleting a non-existent key is not an error.
// 
// Example Usage:
//   $completed = aws_s3_delete('key');

function aws_s3_delete($key) {
  
  // call S3 deleteObject method
  list($response, $error) = _aws_s3_try('deleteObject', [ 'Key' => $key ]);
  
  return !$error;
}

// aws_s3_deleteMultiple($keyList)
// 
// Delete multiple S3 objects (e.g. files)
// Note that the $completed return value does not tell you whether any objects previously existed. Deleting non-existent keys is not an error.
// 
// Example Usage:
//   $completed = aws_s3_deleteMultiple(['key1', 'key2']);

function aws_s3_deleteMultiple($keys = []) {
  
  // if more than 1000 keys were supplied, recursively call ourselves with 1000 keys at a time
  if (count($keys) > 1000) {
    $completed = true;
    $keyLists = array_chunk($keys, 1000); // split into arrays of 1000 keys at most
    foreach ($keyLists as $keyList) {
      $currentCompleted = aws_s3_deleteMultiple($keyList); // recurse!
      if (!$currentCompleted) { $completed = false; }      // if one (sub-)call reports failure, we will report failure
    }
    return $completed;
  }
  
  // map key array to what's expected in method args: ['key1', 'key2'] to [['Key' => 'key1'], ['Key' => 'key2']]
  $objectSpecs = array_map(function($key) { return ['Key' => $key]; }, $keys);
  
  // call S3 deleteObjects method
  list($response, $error) = _aws_s3_try('deleteObjects', [ 'Delete' => [ 'Objects' => $objectSpecs ] ]);
  
  return !$error;
}

// aws_s3_listPage()
// 
// Return a list of S3 objects, with their "Key"s and some meta data (such as "Size" and "LastModified").
// Can do server-side filtering using a key prefix. This is most useful if you use directory-path-like keys (e.g. "images/foo/asdf.png".)
// Returns a maximum of 1000 rows, see example usage below (or aws_s3_list()) for how to get more.
// If you need the contents of an S3 object, use its "Key" to call aws_s3_get() or aws_s3_get_saveToFile().
//
// Call Signature:
//   list($allRows, $nextPageKey) = aws_s3_listPage($prefix = '', $pageKey = '', $maxKeys = 1000)
// 
// Example Usage:
//   // list up to 1000 objects in the bucket
//   list($rows) = aws_s3_listPage();
// 
// Example Usage:
//   // list up to 50 objects in the bucket
//   list($rows) = aws_s3_listPage('', '', 50);
// 
// Example Usage:
//   // list up to 1000 objects within a "directory"
//   list($rows) = aws_s3_listPage('testing/');
// 
// Example Usage:
//   // list ALL objects in the bucket
//   $keyPrefix = '';
//   list($allRows, $nextPageKey) = aws_s3_listPage($keyPrefix);
//   while ($nextPageKey) {
//     list($moreRows, $nextPageKey) = aws_s3_listPage($keyPrefix, $nextPageKey); // advance $nextPageKey
//     $allRows = array_merge($allRows, $moreRows);                           // append $moreRows
//   }
// 
// Sample Return Value:
//   [
//     [
//       'Key' => 'sample.jpg',
//       'LastModified' => Aws\Api\DateTimeResult Object(
//         'date'          => '2016-02-25 23:41:29',
//         'timezone_type' => 2,
//         'timezone'      => 'Z',
//       ),
//       'ETag'         => "3ee1700976363bef1f466d1b85077eb5",
//       'Size'         => 14645,
//       'StorageClass' => 'STANDARD',
//       'Owner'        => [
//         'DisplayName'  => 'myusername',
//         'ID'           => '156da8927c0747afea15e2ec0513cc3906f88c903505242452c1efee846fb53e',
//        ]
//     ],
//   ]

function aws_s3_listPage($prefix = '', $pageKey = '', $maxRows = 1000) {
  
  // don't let caller ask for more than 1000 rows
  if ($maxRows > 1000) { die(__FUNCTION__ . ": cannot return more than 1000 rows"); }
  
  // call S3 listObjects method
  list($response, $error) = _aws_s3_try('listObjects', [ 'Prefix' => $prefix, 'MaxKeys' => $maxRows, 'Marker' => $pageKey ]);
  
  // get rows from response
  $rows = @$response['Contents'] ?: [];
  
  // determine $nextPageKey (if IsTruncated)
  $lastRow     = @$rows[ sizeof($rows) - 1 ];
  $nextPageKey = @$response['IsTruncated'] ? @$lastRow['Key'] : null;
  
  // return
  return [ $rows, $nextPageKey ];
}

// aws_s3_list()
// 
// Return a list of S3 objects, with their "Key"s and some meta data (such as "Size" and "LastModified").
// Can do server-side filtering using a key prefix. This is most useful if you use directory-path-like keys (e.g. "images/foo/asdf.png".)
// Avoids the 1000 row limit of aws_s3_listPage() by calling it repeatedly, as required
// Supports a maximum number of results, but does not support pagination after that limit (use aws_s3_listPage() if pagination is required)
// 
// Example Usage:
//   $allRows = aws_s3_list('testing/');
// 
// Example Usage:
//   $allRows = aws_s3_list('', 5000);

function aws_s3_list($prefix = '', $maxRowsRequested = null) {
  
  // aws_s3_listPage() can support only 1000 rows at a time
  $maxRowsHardLimit = 1000;
  
  // calculate how many rows to request for our first page of results (either 1000 or less if caller asked for fewer than 1000 rows)
  $maxRowsForNextPage = $maxRowsRequested ? min($maxRowsHardLimit, $maxRowsRequested) : $maxRowsHardLimit;
  
  // get first page of rows
  list($allRows, $nextPageKey) = aws_s3_listPage($prefix, '', $maxRowsForNextPage);
  
  // while a next page is reported by aws_s3_listPage()...
  while ($nextPageKey) {
    
    // calculate how many more keys to request (e.g. if the caller wants 1003 and we already have 1000, only request 3)
    $maxRowsForNextPage = $maxRowsRequested ? min($maxRowsHardLimit, $maxRowsRequested - count($allRows)) : $maxRowsHardLimit;
    
    // if a next page was reported, but we already have all the rows we want, we're done
    // in the above example, requesting the last 3 rows would (probably) report a next page, so we would have looped back to here
    if ($maxRowsForNextPage < 1) { break; }
    
    // get the next page of rows (advancing $nextPageKey)
    list($moreRows, $nextPageKey) = aws_s3_listPage($prefix, $nextPageKey, $maxRowsForNextPage);
    
    // append rows to return value
    $allRows = array_merge($allRows, $moreRows);
  }
  
  return $allRows;
}

// eof
