Version 3 release. Major re-write

This commit is contained in:
David Wakelin 2021-08-09 17:33:50 +01:00
commit f9b49002c7
1507 changed files with 6592 additions and 94204 deletions

View file

@ -0,0 +1,7 @@
<?php
namespace SpacesAPI\Exceptions;
use Exception;
class AuthenticationException extends Exception {}

View file

@ -0,0 +1,9 @@
<?php
namespace SpacesAPI\Exceptions;
use Exception;
class FileDoesntExistException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace SpacesAPI\Exceptions;
use Exception;
class SpaceDoesntExistException extends Exception
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace SpacesAPI\Exceptions;
use Exception;
class SpaceExistsException extends Exception {}

View file

@ -0,0 +1,9 @@
<?php
namespace SpacesAPI\Exceptions;
use Exception;
class SpacesException extends Exception
{
}

266
SpacesAPI/File.php Normal file
View file

@ -0,0 +1,266 @@
<?php
namespace SpacesAPI;
use SpacesAPI\Exceptions\FileDoesntExistException;
/**
* Represents a single file
*
* You wouldn't normally instantiate this class directly,
* Rather obtain an instance from `\SpacesAPI\Space::list()`, `\SpacesAPI\Spaces::file()`, `\SpacesAPI\Spaces::uploadText()` or `\SpacesAPI\Spaces::uploadFile()`
*
* @property string $expiration
* @property string $e_tag
* @property int $last_modified
* @property string $content_type
* @property int $content_length
*/
class File
{
use StringFunctions;
/**
* @var \SpacesAPI\Space
*/
private $space;
/**
* The name of the current space
*
* @var string
*/
private $space_name;
/**
* @var \Aws\S3\S3Client
*/
private $s3;
private $_expiration;
private $_e_tag;
private $_filename;
private $_last_modified;
private $_content_type;
private $_content_length;
/**
* @param \SpacesAPI\Space $space
* @param string $filename
* @param array $info
*
* @throws \SpacesAPI\Exceptions\FileDoesntExistException
*/
public function __construct(Space $space, string $filename, array $info = [])
{
$this->space = $space;
$this->space_name = $space->getName();
$this->s3 = $space->getS3Client();
$this->_filename = $filename;
if (!$this->s3->doesObjectExist($this->space_name, $filename)) {
throw new FileDoesntExistException("File $filename doesn't exist");
}
if (count($info) > 0) {
$this->setFileInfo($info);
}
}
/**
* Magic getter to make the properties read-only
*
* @param string $name
*
* @return null
*/
public function __get(string $name)
{
if (!property_exists($this, "_$name")) {
trigger_error("Undefined property: SpacesAPI\File::$name", E_USER_NOTICE);
return null;
}
if (!$this->{"_$name"}) {
$this->fetchFileInfo();
}
return $this->{"_$name"};
}
/**
* @param array $info
*/
private function setFileInfo(array $info): void
{
foreach ($info as $_property => $value) {
$property = "_" . $this->pascalCaseToCamelCase($_property);
if ($property == 'size') {
$property = 'content_length';
}
if (property_exists($this, $property)) {
$this->$property = $value;
}
}
}
/**
*
*/
private function fetchFileInfo(): void
{
$this->setFileInfo(
Result::parse(
$this->s3->headObject([
"Bucket" => $this->space_name,
"Key" => $this->_filename,
])
)
);
}
/**
* Is this file publicly accessible
*
* @return bool
*/
public function isPublic(): bool
{
$acl = Result::parse(
$this->s3->getObjectAcl([
"Bucket" => $this->space_name,
"Key" => $this->_filename,
])
);
return (
isset($acl['Grants'][0]['Grantee']['URI']) &&
$acl['Grants'][0]['Grantee']['URI'] == "http://acs.amazonaws.com/groups/global/AllUsers" &&
$acl['Grants'][0]['Permission'] == "READ"
);
}
/**
* Make a file public or privately accessible
*
* @param bool $public
*/
private function updatePrivacy(bool $public): void
{
$this->s3->putObjectAcl([
"Bucket" => $this->space_name,
"Key" => $this->_filename,
"ACL" => ($public) ? "public-read" : "private",
]);
}
/**
* Make file publicly accessible
*/
public function makePublic(): void
{
$this->updatePrivacy(true);
}
/**
* Make file non-publicly accessible
*/
public function makePrivate(): void
{
$this->updatePrivacy(false);
}
/**
* Get the file contents as a string
*
* @return string
*/
public function getContents(): string
{
return $this->s3->getObject([
"Bucket" => $this->space_name,
"Key" => $this->_filename,
])["Body"]->getContents();
}
/**
* Download the file to a local location
*
* @param string $saveAs
*
* @return void
*/
public function download(string $saveAs): void
{
$this->s3->getObject([
"Bucket" => $this->space_name,
"Key" => $this->_filename,
"SaveAs" => $saveAs,
]);
}
/**
* Copy the file on the space
*
* @param string $newFilename
* @param false $public
*
* @return \SpacesAPI\File
*/
public function copy(string $newFilename, bool $public = false): File
{
$this->s3->copy(
$this->space_name,
$this->_filename,
$this->space_name,
$newFilename,
($public) ? 'public-read' : 'private'
);
return new self($this->space, $newFilename);
}
/**
* Get the public URL
* This URL will not work if the file is private
*
* @return string
* @see getSignedURL
*
*/
public function getURL(): string
{
return $this->s3->getObjectUrl($this->space_name, $this->_filename);
}
/**
* Get a signed URL, which will work for private files
*
* @param string|\DateTime|int $validFor Can be any string recognised by strtotime(), an instance of DateTime or a unix timestamp
*
* @return string
*/
public function getSignedURL($validFor = "15 minutes"): string
{
return (string)$this->s3->createPresignedRequest(
$this->s3->getCommand("GetObject", [
"Bucket" => $this->space_name,
"Key" => $this->_filename,
]),
$validFor
)->getUri();
}
/**
* Permanently delete this file
*/
public function delete(): void
{
$this->s3->deleteObject([
"Bucket" => $this->space_name,
"Key" => $this->_filename,
]);
}
}

37
SpacesAPI/Result.php Normal file
View file

@ -0,0 +1,37 @@
<?php
namespace SpacesAPI;
use Aws\Api\DateTimeResult;
/**
* AWS Results parser
*/
class Result
{
/**
* Convert AWS result object into plain, multidimensional array
*
* @param $data
*
* @return array|mixed
*/
public static function parse($data) {
if (gettype($data) == "object" && get_class($data) == \Aws\Result::class) {
$data = $data->toArray();
}
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = self::parse($value);
continue;
}
if (gettype($value) == "object" && get_class($value) == DateTimeResult::class) {
$data[$key] = strtotime($value);
}
}
return $data;
}
}

352
SpacesAPI/Space.php Normal file
View file

@ -0,0 +1,352 @@
<?php
namespace SpacesAPI;
use Aws\S3\Exception\S3Exception;
use Aws\S3\S3Client;
use SpacesAPI\Exceptions\SpaceDoesntExistException;
/**
* Represents a space once connected/created
*
* You wouldn't normally instantiate this class directly,
* Rather obtain an instance from `\SpacesAPI\Spaces::space()` or `\SpacesAPI\Spaces::create()`
*/
class Space
{
/**
* AWS S3 client
*
* @var \Aws\S3\S3Client
*/
private $s3;
/**
* The name of the current space
*
* @var string
*/
private $name;
/**
* Load a space
*
* You wouldn't normally call this directly,
* rather obtain an instance from `\SpacesAPI\Spaces::space()` or `\SpacesAPI\Spaces::create()`
*
* @param \Aws\S3\S3Client $s3 An authenticated S3Client instance
* @param string $name Space name
*
* @throws \SpacesAPI\Exceptions\SpaceDoesntExistException
*/
public function __construct(S3Client $s3, string $name)
{
$this->s3 = $s3;
$this->name = $name;
if (!$this->s3->doesBucketExist($name)) {
throw new SpaceDoesntExistException("Space '$this->name' does not exist");
}
}
/**
* Get the current AWS S3 client instance
*
* For internal library use
*
* @return \Aws\S3\S3Client
*/
public function getS3Client(): S3Client
{
return $this->s3;
}
/**
* Get the name of this space
*
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* Update space privacy
*
* @param bool $public
*/
private function updatePrivacy(bool $public): void
{
$this->s3->putBucketAcl([
"Bucket" => $this->name,
"ACL" => ($public) ? "public-read" : "private",
]);
}
/**
* Enable file listing
*/
public function makePublic(): void
{
$this->updatePrivacy(true);
}
/**
* Disable file listing
*/
public function makePrivate(): void
{
$this->updatePrivacy(false);
}
/**
* Is file listing enabled?
*
* @return bool
*/
public function isPublic(): bool
{
$acl = Result::parse($this->s3->getBucketAcl(["Bucket" => $this->name]));
return (
isset($acl['Grants'][0]['Grantee']['URI']) &&
$acl['Grants'][0]['Grantee']['URI'] == "http://acs.amazonaws.com/groups/global/AllUsers" &&
$acl['Grants'][0]['Permission'] == "READ"
);
}
/**
* Destroy/Delete this space, along with all files
*/
public function destroy(): void
{
$this->s3->deleteMatchingObjects($this->name, "", "(.*?)");
$this->s3->deleteBucket(["Bucket" => $this->name]);
}
/**
* Get the CORS configuration for the space
*
* @return array|null An array of CORS rules or null if no rules exist
*/
public function getCORS(): ?array
{
try {
return Result::parse(
$this->s3->getBucketCors([
"Bucket" => $this->name,
])
)['CORSRules'];
} catch (S3Exception $e) {
return null;
}
}
/**
* Get the CORS rules, removing the origin specified
*
* @param string $origin
*
* @return array
*/
private function getCORSRemovingOrigin(string $origin): array
{
if (!$CORSRules = $this->getCORS()) {
return [];
}
foreach ($CORSRules as $i => $cors) {
if ($cors['AllowedOrigins'][0] == $origin) {
array_splice($CORSRules, $i, 1);
}
}
return $CORSRules;
}
/**
* Set the CORS rules
*
* @param array $rules
*/
private function putCORS(array $rules): void
{
$this->s3->putBucketCors([
"Bucket" => $this->name,
"CORSConfiguration" => [
"CORSRules" => $rules,
],
]);
}
/**
* Add an origin to the CORS settings on this space
*
* @param string $origin eg `http://example.com`
* @param array $methods Array items must be one of `GET`, `PUT`, `DELETE`, `POST` and `HEAD`
* @param int $maxAge Access Control Max Age
* @param array $headers Allowed Headers
*/
public function addCORSOrigin(string $origin, array $methods, int $maxAge = 0, array $headers = []): void
{
$rules = $this->getCORSRemovingOrigin($origin);
$this->putCORS(
array_merge($rules, [
[
"AllowedHeaders" => $headers,
"AllowedMethods" => $methods,
"AllowedOrigins" => [$origin],
"MaxAgeSeconds" => $maxAge,
],
])
);
}
/**
* Remove an origin from the CORS settings on this space
*
* @param string $origin eg `http://example.com`
*/
public function removeCORSOrigin(string $origin): void
{
$rules = $this->getCORSRemovingOrigin($origin);
if (empty($rules)) {
$this->removeAllCORSOrigins();
} else {
$this->putCORS($rules);
}
}
/**
* Delete all CORS rules
*/
public function removeAllCORSOrigins(): void
{
$this->s3->deleteBucketCors([
'Bucket' => $this->name,
]);
}
/**
* List all files in the space (recursively)
*
* @param string $directory The directory to list files in. Empty string for root directory
* @param string|null $continuationToken Used internally to work around request limits (1000 files per request)
*
* @return array
* @throws \SpacesAPI\Exceptions\FileDoesntExistException
*/
public function listFiles(string $directory = "", ?string $continuationToken = null): array
{
$data = Result::parse(
$this->s3->listObjectsV2([
"Bucket" => $this->name,
"Prefix" => $directory,
"MaxKeys" => 1000,
// "StartAfter" => 0, // For skipping files, maybe for future limit/skip ability
"FetchOwner" => false,
"ContinuationToken" => $continuationToken,
])
);
if (!isset($data['Contents'])) {
return ['files' => []];
}
$files = [
'files' => $data['Contents'],
];
foreach ($files['files'] as $index => $fileInfo) {
$files['files'][$index] = new File($this, $fileInfo['Key'], $fileInfo);
}
if (isset($data["NextContinuationToken"]) && $data["NextContinuationToken"] != "") {
$files = array_merge($files['files'], $this->listFiles($directory, $data["NextContinuationToken"])['files']);
}
return $files;
}
/**
* Upload a string of text to file
*
* @param string $text The text to upload
* @param string $filename The filepath/name to save to
* @param array $params Any extra parameters. [See here](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#upload-property)
*
* @return \SpacesAPI\File
*/
public function uploadText(string $text, string $filename, array $params = []): File
{
$this->s3->upload($this->name, $filename, $text, 'private', $params);
return new File($this, $filename);
}
/**
* Upload a file
*
* @param string $filepath The path to the file, including the filename. Relative and absolute paths are accepted.
* @param string|null $filename The remote filename. If `null`, the local filename will be used.
*
* @return \SpacesAPI\File
*/
public function uploadFile(string $filepath, ?string $filename = null): File
{
$this->s3->putObject([
'Bucket' => $this->name,
'Key' => ($filename) ?: basename($filepath),
'SourceFile' => $filepath,
]);
return new File($this, ($filename) ?: basename($filepath));
}
/**
* Get an instance of \SpacesAPI\File for a given filename
*
* @param string $filename
*
* @return \SpacesAPI\File
* @throws \SpacesAPI\Exceptions\FileDoesntExistException Thrown if the file doesn't exist
*/
public function file(string $filename): File
{
return new File($this, $filename);
}
/**
* Recursively upload an entire directory
*
* @param string $local The local directory to upload
* @param string|null $remote The remote directory to place the files in. `null` to place in the root
*/
public function uploadDirectory(string $local, ?string $remote = null): void
{
$this->s3->uploadDirectory($local, $this->name, $remote);
}
/**
* Recursively download an entire directory.
*
* @param string $local The local directory to save the directories/files in
* @param string|null $remote The remote directory to download. `null` to download the entire space
*/
public function downloadDirectory(string $local, ?string $remote = null): void
{
$this->s3->downloadBucket($local, $this->name, $remote);
}
/**
* Delete an entire directory, including its contents
*
* @param string $path The directory to delete
*/
public function deleteDirectory(string $path): void
{
$this->s3->deleteMatchingObjects($this->name, $path);
}
}

105
SpacesAPI/Spaces.php Normal file
View file

@ -0,0 +1,105 @@
<?php
namespace SpacesAPI;
use Aws\S3\Exception\S3Exception;
use Aws\S3\S3Client;
use SpacesAPI\Exceptions\AuthenticationException;
use SpacesAPI\Exceptions\SpaceExistsException;
/**
* Represents the connection to Digital Ocean spaces.
* The entry point for managing spaces.
*
* Instantiate your connection with `new \SpacesAPI\Spaces("access-key", "secret-key", "region")`
*
* Obtain your access and secret keys from the [DigitalOcean Applications & API dashboard](https://cloud.digitalocean.com/account/api/tokens)
*/
class Spaces
{
/**
* @var \Aws\S3\S3Client
*/
private $s3;
/**
* Initialise the API
*
* @param string $accessKey Digital Ocean API access key
* @param string $secretKey Digital Ocean API secret key
* @param string $region Region, defaults to ams3
* @param string $host API endpoint, defaults to digitaloceanspaces.com
*
* @throws \SpacesAPI\Exceptions\AuthenticationException Authentication failed
*/
public function __construct(string $accessKey, string $secretKey, string $region = "ams3", string $host = "digitaloceanspaces.com")
{
$this->s3 = new S3Client([
"version" => "latest",
"region" => "us-east-1",
"endpoint" => "https://$region.$host",
"credentials" => ["key" => $accessKey, "secret" => $secretKey],
"ua_append" => "SociallyDev-Spaces-API/2",
]);
try {
$this->s3->headBucket(["Bucket" => 'auth-check']);
} catch (S3Exception $e) {
if ($e->getStatusCode() == 403) {
throw new AuthenticationException("Authentication failed");
}
}
}
/**
* List all your spaces
*
* @return array An array of \SpacesAPI\Space instances
*/
public function list(): array
{
$spaces = [];
foreach (Result::parse($this->s3->listBuckets()['Buckets']) as $bucket) {
$spaces[] = new Space($this->s3, $bucket['Name']);
}
return $spaces;
}
/**
* Create a new space
*
* @param string $name The name of the new space
* @param bool $public Enable file listing. Default `false`
*
* @return \SpacesAPI\Space The newly created space
* @throws \SpacesAPI\Exceptions\SpaceExistsException The named space already exists
*/
public function create(string $name, bool $public = false): Space
{
try {
$this->s3->createBucket([
"ACL" => ($public) ? "public-read" : "private",
"Bucket" => $name,
]);
} catch (S3Exception $e) {
throw new SpaceExistsException($e->getAwsErrorMessage());
}
return new Space($this->s3, $name);
}
/**
* Use an existing space
*
* @param string $name The name of the space
*
* @return \SpacesAPI\Space The loaded space
* @throws \SpacesAPI\Exceptions\SpaceDoesntExistException The named space doesn't exist
*/
public function space(string $name): Space
{
return new Space($this->s3, $name);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace SpacesAPI;
trait StringFunctions
{
public function pascalCaseToCamelCase(string $name): string
{
return strtolower(preg_replace("/([a-z])([A-Z])/", "$1_$2", $name));
}
}