Files
IPB/system/File/Amazon.php
T
2025-12-19 04:57:54 -08:00

411 lines
12 KiB
PHP

<?php
/**
* @brief File Handler: Amazon S3
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
* @license http://www.invisionpower.com/legal/standards/
* @package IPS Social Suite
* @since 12 Jul 2013
* @version SVN_VERSION_NUMBER
*/
namespace IPS\File;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* File Handler: Amazon S3
*/
class _Amazon extends \IPS\File
{
/* !ACP Configuration */
/**
* Settings
*
* @param array $configuration Configuration if editing a setting, or array() if creating a setting.
* @return array
*/
public static function settings( $configuration=array() )
{
$default = ( isset( $configuration['custom_url'] ) and ! empty( $configuration['custom_url'] ) ) ? TRUE : FALSE;
return array(
'bucket' => 'Text',
'bucket_path' => 'Text',
'access_key' => 'Text',
'secret_key' => 'Text',
'toggle' => array( 'type' => 'YesNo', 'default' => $default, 'options' => array(
'togglesOn' => array( 'Amazon_custom_url' )
) ),
'custom_url' => array( 'type' => 'Text', 'default' => '' )
);
}
/**
* Test Settings
*
* @param array $values The submitted values
* @return void
* @throws \LogicException
*/
public static function testSettings( &$values )
{
$values['bucket_path'] = trim( $values['bucket_path'], '/' );
$values['bucket'] = trim( $values['bucket'], '/' );
$filename = md5( uniqid() ) . '.ips.txt';
try
{
$response = static::makeRequest( "test/{$filename}", 'PUT', $values, NULL, "OK" );
}
catch ( \IPS\Http\Request\Exception $e )
{
throw new \DomainException( \IPS\Member::loggedIn()->language()->addToStack( 'file_storage_test_error_amazon_unreachable', FALSE, array( 'sprintf' => array( $values['bucket'] ) ) ) );
}
if ( $response->httpResponseCode != 200 AND $response->httpResponseCode != 307 )
{
throw new \DomainException( \IPS\Member::loggedIn()->language()->addToStack( 'file_storage_test_error_amazon', FALSE, array( 'sprintf' => array( $values['bucket'], $response->httpResponseCode ) ) ) );
}
$response = static::makeRequest( "test/{$filename}", 'DELETE', $values, NULL );
if ( $response->httpResponseCode == 403 )
{
throw new \DomainException( \IPS\Member::loggedIn()->language()->addToStack( 'file_storage_test_error_amazon_d403', FALSE, array( 'sprintf' => array( $values['bucket'], $response->httpResponseCode ) ) ) );
}
if ( ! $values['toggle'] )
{
$values['custom_url'] = NULL;
}
if ( ! empty( $values['custom_url'] ) )
{
if ( mb_substr( $values['custom_url'], 0, 2 ) !== '//' AND mb_substr( $values['custom_url'], 0, 4 ) !== 'http' )
{
$values['custom_url'] = '//' . $values['custom_url'];
}
$test = $values['custom_url'];
if ( mb_substr( $test, 0, 2 ) === '//' )
{
$test = 'http:' . $test;
}
if ( filter_var( $test, FILTER_VALIDATE_URL ) === false )
{
throw new \DomainException( \IPS\Member::loggedIn()->language()->addToStack( 'url_is_not_real', FALSE, array( 'sprintf' => array( $values['custom_url'] ) ) ) );
}
}
}
/**
* Determine if the change in configuration warrants a move process
*
* @param array $configuration New Storage configuration
* @param array $oldConfiguration Existing Storage Configuration
* @return boolean
*/
public static function moveCheck( $configuration, $oldConfiguration )
{
foreach( array( 'bucket', 'bucket_path' ) as $field )
if ( $configuration[ $field ] !== $oldConfiguration[ $field ] )
{
return TRUE;
}
return FALSE;
}
/**
* Display name
*
* @param array $settings Configuration settings
* @return string
*/
public static function displayName( $settings )
{
return \IPS\Member::loggedIn()->language()->addToStack( 'filehandler_display_name', FALSE, array( 'sprintf' => array( \IPS\Member::loggedIn()->language()->addToStack('filehandler__Amazon'), $settings['bucket'] ) ) );
}
/* !File Handling */
/**
* Constructor
*
* @param array $configuration Storage configuration
* @return void
*/
public function __construct( $configuration )
{
$this->container = 'monthly_' . date( 'Y' ) . '_' . date( 'm' );
parent::__construct( $configuration );
}
/**
* Return the base URL
*
* @return string
*/
public function baseUrl()
{
return preg_replace( '#^http(s)?://#', '//', ( empty( $this->configuration['custom_url'] ) ) ? rtrim( static::buildBaseUrl( $this->configuration ), '/' ) : $this->configuration['custom_url'] );
}
/**
* Save File
*
* @return void
*/
public function save()
{
/* @link http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMetadata.html regarding cleaning filenames */
if ( preg_match( '#[^a-zA-Z0-9!\-_\.\*\(\)]#', $this->filename ) )
{
$this->filename = uniqid() . '_' . preg_replace( '#[^a-zA-Z0-9!\-_\.\*\(\)]#', '', $this->filename );
}
$this->container = trim( $this->container, '/' );
$this->url = static::buildBaseUrl( $this->configuration ) . "{$this->container}/{$this->filename}";
$response = static::makeRequest( "{$this->container}/{$this->filename}", 'PUT', $this->configuration, $this->configurationId, (string) $this->contents() );
if ( $response->httpResponseCode != 200 )
{
throw new \RuntimeException('COULD_NOT_SAVE_FILE');
}
}
/**
* Get Contents
*
* @param bool $refresh If TRUE, will fetch again
* @return string
*/
public function contents( $refresh=FALSE )
{
if ( $this->contents === NULL or $refresh === TRUE )
{
$this->contents = (string) static::makeRequest( "{$this->container}/{$this->filename}", 'GET', $this->configuration, $this->configurationId );
}
return $this->contents;
}
/**
* Delete
*
* @return void
*/
public function delete()
{
$this->container = trim( $this->container, '/' );
$response = static::makeRequest( "{$this->container}/{$this->filename}", 'DELETE', $this->configuration, $this->configurationId );
if ( $response->httpResponseCode == 204 )
{
/* Ok */
return;
}
if ( $response->httpResponseCode != 200 )
{
$this->log( 'COULD_NOT_DELETE_FILE', 'delete', array( $response->httpResponseCode, $response->httpResponseText ), 'error' );
throw new \RuntimeException('COULD_NOT_DELETE_FILE');
}
}
/**
* Delete Container
*
* @param string $container Key
* @return void
*/
public function deleteContainer( $container )
{
$_strip = array( '_strip_querystring' => TRUE, 'bucket_path' => NULL );
if ( $this->configuration['bucket_path'] )
{
$container = $this->configuration['bucket_path'] . '/' . $container;
}
$response = static::makeRequest( "?prefix=" . urlencode( $container . "/" ), 'GET', array_merge( $this->configuration, $_strip ), $this->configurationId );
/* Parse XML document */
$document = \IPS\Xml\SimpleXML::loadString( $response );
/* Loop over dom document */
foreach( $document->Contents as $result )
{
if ( $this->configuration['bucket_path'] )
{
$result->Key = mb_substr( $result->Key, ( mb_strlen( $this->configuration['bucket_path'] ) + 1 ) );
}
static::makeRequest( $result->Key, 'DELETE', $this->configuration, $this->configurationId );
}
}
/* !Amazon Utility Methods */
/**
* Sign and make request
*
* @param string $uri The URI (relative to the bucket)
* @param string $verb The HTTP verb to use
* @param array $configuration The configuration for this instance
* @param int $configurationId The configuration ID
* @param string|null $content The content to send
* @return \IPS\Http\Response
* @throws \IPS\Http\Request\Exception
*/
protected static function makeRequest( $uri, $verb, $configuration, $configurationId, $content=NULL )
{
/* Build a request */
$request = \IPS\Http\Url::external( static::buildBaseUrl( $configuration ) . $uri )->request();
$date = date('r');
$headers = array( 'Date' => $date, 'Content-Type' => \IPS\File::getMimeType( $uri ), 'Content-MD5' => base64_encode( md5( $content, TRUE ) ), 'x-amz-acl' => 'public-read' );
if( mb_strtoupper( $verb ) === 'PUT' )
{
$headers['Content-Length'] = \strlen( $content );
}
/* We need to strip query string parameters for the signature, but not always (e.g. a subresource such as ?acl needs to be included and multi-
object delete requests must include the query string params). Let the callee decide to do this or not. */
if( isset( $configuration['_strip_querystring'] ) AND $configuration['_strip_querystring'] === TRUE )
{
$uri = preg_replace( "/^(.*?)\?.*$/", "$1", $uri );
}
$string = implode( "\n", array(
mb_strtoupper( $verb ),
$headers['Content-MD5'],
$headers['Content-Type'],
$date,
"x-amz-acl:{$headers['x-amz-acl']}",
"/{$configuration['bucket']}" . ( ( isset( $configuration['bucket_path'] ) AND ! empty( $configuration['bucket_path'] ) ) ? '/' . trim( $configuration['bucket_path'], '/' ) . '/' : '/' ) . $uri
) );
/* Build a signature */
$signature = base64_encode( hash_hmac( 'sha1', $string, $configuration['secret_key'], true ) );
/* Sign the request */
$headers['Authorization'] = "AWS {$configuration['access_key']}:{$signature}";
$request->setHeaders( $headers );
/* Make the request */
$verb = mb_strtolower( $verb );
$response = $request->$verb( $content );
/* Need a different region? */
if ( $response->httpResponseCode == 307 AND $configurationId )
{
$xml = $response->decodeXml();
$configuration['region'] = mb_substr( $xml->Endpoint, mb_strlen( $configuration['bucket'] ) + 1, -mb_strlen( '.amazonaws.com' ) );
\IPS\Db::i()->update( 'core_file_storage', array( 'configuration' => json_encode( $configuration ) ), array( 'id=?', $configurationId ) );
unset( \IPS\Data\Store::i()->storageConfigurations );
return static::makeRequest( $uri, $verb, $configuration, $configurationId, $content );
}
/* Return */
return $response;
}
/**
* Build up the base Amazon URL
* @param array $configuration Configuration data
* @return string
*/
public static function buildBaseUrl( $configuration )
{
return (
\IPS\Request::i()->isSecure() ? "https" : "http" )
. "://{$configuration['bucket']}"
. ( ( isset( $configuration['region'] ) AND ! empty( $configuration['region'] ) ) ? ".{$configuration['region']}" : '.s3' )
. ".amazonaws.com"
. ( ( isset( $configuration['bucket_path'] ) AND ! empty( $configuration['bucket_path'] ) ) ? '/' . trim( $configuration['bucket_path'], '/' ) . '/' : '/' );
}
/**
* Remove orphaned files
*
* @param int $fileIndex The file offset to start at in a listing
* @param array $engines All file storage engine extension objects
* @return array
*/
public function removeOrphanedFiles( $fileIndex, $engines )
{
/* Start off our results array */
$results = array(
'_done' => FALSE,
'fileIndex' => $fileIndex,
);
$checked = 0;
$skipped = 0;
$_strip = array( '_strip_querystring' => TRUE, 'bucket_path' => NULL );
if( $fileIndex )
{
$response = static::makeRequest( "?marker={$fileIndex}&max-keys=100", 'GET', array_merge( $this->configuration, $_strip ), $this->configurationId );
}
else
{
$response = static::makeRequest( "?max-keys=100", 'GET', array_merge( $this->configuration, $_strip ), $this->configurationId );
}
/* Parse XML document */
$document = \IPS\Xml\SimpleXML::loadString( $response );
/* Loop over dom document */
foreach( $document->Contents as $result )
{
$checked++;
if ( $this->configuration['bucket_path'] )
{
$result->Key = mb_substr( $result->Key, ( mb_strlen( $this->configuration['bucket_path'] ) + 1 ) );
}
/* Next we will have to loop through each storage engine type and call it to see if the file is valid */
foreach( $engines as $engine )
{
/* If this file is valid for the engine, skip to the next file */
if( $engine->isValidFile( $result->Key ) )
{
continue 2;
}
}
/* If we are still here, the file was not valid. Delete and increment count. */
$this->logOrphanedFile( $result->Key );
$_lastKey = $result->Key;
}
if( $document->IsTruncated == 'true' AND $checked == 100 )
{
$results['fileIndex'] = $_lastKey;
}
/* Are we done? */
if( !$checked OR $checked < 100 )
{
$results['_done'] = TRUE;
}
return $results;
}
}