19 Commits
0.1.1 ... main

Author SHA1 Message Date
René Preuß
efeda239d7 Fix env:backup and env:restore
Build next version
2021-07-24 13:42:17 +02:00
René Preuß
c90ad43a3c Fix init for windows 2021-07-24 13:23:58 +02:00
René Preuß
26672908f1 Fix env path for windows 2021-07-24 13:00:33 +02:00
René Preuß
917af5c140 Change env storage 2021-07-23 17:40:31 +02:00
René Preuß
d25046f545 Add development status notice 2021-07-23 15:22:00 +02:00
René Preuß
380d095bdd Optimize imports 2021-07-23 15:16:24 +02:00
René Preuß
a1aa4e7334 Revert wordings in readme 2021-07-23 15:13:31 +02:00
René Preuß
331881b2cc Build 0.1.2
Add env and init command
Update documentation
2021-07-23 15:10:46 +02:00
René Preuß
feab09389a Remove phar updater 2021-07-23 13:05:51 +02:00
René Preuß
23709b9c59 Merge branch 'main' of github.com:ghostzero/bunny into main 2021-07-22 19:20:38 +02:00
René Preuß
e05b6e3801 Improve command options 2021-07-22 17:42:15 +02:00
René Preuß
4b9e117f1a Improve lock file 2021-07-22 17:28:09 +02:00
René Preuß
820287dd2c Improve caching, performance and add dry-run 2021-07-22 16:42:01 +02:00
René Preuß
bc2d0ed282 Update README.md 2021-07-21 19:41:30 +02:00
René Preuß
cc85f3309b Update README.md 2021-07-21 19:36:09 +02:00
René Preuß
9ea04160b5 Update README.md 2021-07-21 19:35:57 +02:00
René Preuß
024a92eccd Update README.md 2021-07-21 19:34:40 +02:00
René Preuß
eb8bd6623e Update composer.json 2021-07-21 19:32:15 +02:00
René Preuß
53cff7d805 Improve uploads/deletions 2021-07-21 19:27:28 +02:00
34 changed files with 3409 additions and 2487 deletions

5
.gitignore vendored
View File

@@ -3,5 +3,10 @@
/.idea
/.vscode
/.vagrant
/empty
.phpunit.result.cache
.env
*.bk
bunny-cli.lock
bunny-cli.lock.bk
/dist*

120
README.md
View File

@@ -1,21 +1,121 @@
# Bunny CLI
# Bunny CLI - Replicate and store your files to the edge!
Replicate and storage your files to the edge storage of bunny.net.
> Please use Bunny CLI for development purposes only. We have not yet released a stable release.
Bunny CLI is not affiliated with BunnyWay d.o.o.
## What is Bunny CLI?
------
Bunny CLI is a tool for the console to upload frontend frameworks such as Angular, Vue.js, React, or more recently, Blazor quickly to the Edge Store on Bunny CDN.
## Documentation
With Bunny CDN's Storage Edge, your web applications benefit from replicated storage zones, a global content delivery network that hosts files in 5 different regions worldwide and accelerates everything through a worldwide content delivery network with over 54+ PoPs.
For full documentation, visit [laravel-zero.com](https://laravel-zero.com/).
## How do I use Bunny CLI?
## Support the development
To install Bunny CLI, you need to be using Composer. For more details about Composer, see the [Composer documentation](https://getcomposer.org/doc/).
**Do you like this project? Support it by donating**
```bash
composer global require own3d/bunny-cli
```
- Patreon: [Donate](https://patreon.com/ghostzero)
If you want to update the Bunny CLI, just execute the following command:
```bash
composer global update own3d/bunny-cli
```
After you install Bunny CLI, the next step is typically run the `bunny init` command to perform initial setup tasks. You can also run `bunny init` at a later time to change your settings or create a new configuration.
### Run Bunny Init
To initialize Bunny CLI:
1. Run `bunny init`:
```bash
bunny init
```
2. Configure your API credentials.
In order for the Bunny CLI to work properly you need to store your Bunny CDN API token. You can find your API token in your [Account Settings](https://panel.bunny.net/account).
3. Choose a current Storage Zone if prompted.
If you only have access to one storage zone, including the default pull zone, `bunny init` selects it for you.
When `bunny init` finishes, it saves the environment variables in your .env file.
You can view these environment variables at any other time using the `bunny env:list` command.
### Deploy your first project
With the `bunny deploy` command, you can easily synchronize your `dist` folder with your edge storage.
> **IMPORTANT**: All files in the edge storage that are **not** in your local `dist` directory will be deleted.
```plain
➜ $ bunny deploy
- Hashing files...
✔ Finished hashing 16360 files
- CDN diffing files...
✔ CDN requesting 10875 files
- Synchronizing 10875 files
10875/10875 [============================] 100%
✔ Finished synchronizing 10875 files
- Waiting for deploy to go live...
✔ Deployment is live! (322.96s)
Website URL: https://bunny-cli.b-cdn.net/
```
## How do I integrate Bunny CLI into my GitHub Actions workflow?
We offer you a [GitHub Action for Bunny CLI](https://github.com/marketplace/actions/bunny-cli) for free. You can easily upload your distributable files to your edge storage during your deployment process with this action. Just put your storage password (`BUNNY_STORAGE_PASSWORD`) and your API key (`BUNNY_API_ACCESS_KEY`) in the secrets of your GitHub repository and adjust your workflow as follows.
```
- name: Deploy to Edge Storage
uses: own3d/bunny-action@main
env:
BUNNY_API_ACCESS_KEY: ${{ secrets.BUNNY_API_ACCESS_KEY }}
BUNNY_STORAGE_USERNAME: bunny-cli
BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
BUNNY_PULL_ZONE_ID: 466588
with:
args: deploy --dir=dist
```
## Environment Variables
You can customize your environment file at any time. The following commands are available for this purpose:
| Command | Description |
|-------------------------|--------------------------------------------------------|
| `env:list` | List all current environment variables. |
| `env:set {key} {value}` | Set and save an environment variable in the .env file. |
| `env:backup {file}` | Backup .env file into a given file. |
| `env:restore {file}` | Restore .env file from a given file. |
## Secure your `.well-known/bunny-cli.lock` file
Bunny CLI generates a lock file, which is located at `.well-known/bunny-cli.lock` by default. This file locks the files of your project to a known state. To prevent this from being publicly accessible it is recommended to create a new edge rule in your pull zone. You can use the following example as a template:
Action: `Block Request`
Condition Matching: `Match Any`
Condition: If `Request URL` `Match Any` `*/.well-known/bunny-cli.lock`
Now the file should no longer be accessible. It can take a few minutes until your Edge Rule is active.
## Frequently Asked Questions
### Q: Is this a zero-downtime deployment?
A: Depends. Only when the sync of the files is complete, the pull zone cache is cleared. Therefore if the CDN cache is not present because the cache has expired or miss, then an unanticipated event may occur.
We hope that together with Bunny CDN, we can solve this problem.
### Q: Is this an official tool of Bunny CDN?
A: No. Bunny CLI is a community-driven tool and is not affiliated with Bunny CDN.
## License
Laravel Zero is an open-source software licensed under the Apache 2.0 license.
Bunny CLI is an open-source software licensed under the Apache 2.0 license.

View File

@@ -9,6 +9,7 @@ class Client
public function __construct()
{
$this->client = new \GuzzleHttp\Client([
'http_errors' => false,
'base_uri' => 'https://api.bunny.net/',
]);
}
@@ -37,4 +38,13 @@ class Client
return new Result($response);
}
public function getStorageZones(): Result
{
return $this->request('GET', 'storagezone', [
RequestOptions::HEADERS => [
'AccessKey' => config('bunny.api.access_key'),
],
]);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Bunny\Filesystem;
class CompareOptions
{
const START = 'start';
const NO_LOCK_VERIFICATION = 'no_lock_verification';
const NO_LOCK_GENERATION = 'no_lock_generation';
const LOCK_FILE = 'lock_file';
const DRY_RUN = 'dry_run';
}

View File

@@ -5,7 +5,7 @@ namespace App\Bunny\Filesystem;
use Illuminate\Support\Str;
use stdClass;
class EdgeFile implements File
class EdgeFile implements File, FileSerialized
{
private stdClass $file;
@@ -14,6 +14,16 @@ class EdgeFile implements File
$this->file = $file;
}
public static function fromFilename(string $path, string $sha256 = null): self
{
return new self((object)[
'Path' => Str::replaceLast($basename = basename($path), '', $path),
'ObjectName' => $basename,
'IsDirectory' => Str::endsWith($path, '/'),
'Checksum' => $sha256,
]);
}
public function getFilename($search = '', $replace = ''): string
{
return Str::replaceFirst($search, $replace, $this->file->Path . $this->file->ObjectName);
@@ -21,11 +31,24 @@ class EdgeFile implements File
public function isDirectory(): bool
{
return $this->file->IsDirectory;
return $this->getChecksum() === null;
}
public function getChecksum(): string
public function getChecksum(): ?string
{
return $this->file->Checksum;
return $this->file->IsDirectory ? null : $this->file->Checksum;
}
public function toArray(): array
{
return [
'sha256' => $this->getChecksum(),
'filename' => $this->getFilename(),
];
}
public static function fromArray(array $array): self
{
return self::fromFilename($array['filename'], $array['sha256']);
}
}

View File

@@ -2,7 +2,11 @@
namespace App\Bunny\Filesystem;
use App\Bunny\Filesystem\Exceptions\FileNotFoundException;
use App\Bunny\Filesystem\Exceptions\FilesystemException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\RequestOptions;
@@ -12,15 +16,21 @@ use Psr\Http\Message\ResponseInterface;
class EdgeStorage
{
private Client $client;
private EdgeStorageCache $storageCache;
public function __construct()
{
$this->storageCache = new EdgeStorageCache($this);
$this->client = new Client([
'base_uri' => sprintf('https://%s', config('bunny.storage.hostname')),
'http_errors' => false,
'headers' => [
'User-Agent' => 'BunnyCLI/0.1',
]
]);
}
public function allFiles(string $path, &$results = array())
public function allFiles(string $path, callable $advance = null, &$results = array()): array
{
$promise = $this->client->getAsync(self::normalizePath($path, true), [
RequestOptions::HEADERS => [
@@ -29,7 +39,7 @@ class EdgeStorage
]);
$promise->then(
function (ResponseInterface $res) use (&$results) {
function (ResponseInterface $res) use ($advance, &$results) {
$files = array_map(
fn($file) => new EdgeFile($file),
json_decode($res->getBody()->getContents(), false)
@@ -38,14 +48,16 @@ class EdgeStorage
foreach ($files as $file) {
$results[] = $file;
if ($file->isDirectory()) {
$this->allFiles($file->getFilename(), $results);
$this->allFiles($file->getFilename(), $advance, $results);
}
}
if ($advance) {
$advance();
}
},
function (RequestException $e) {
echo $e->getMessage() . "\n";
echo $e->getRequest()->getMethod();
throw FilesystemException::fromPrevious($e);
}
);
@@ -54,14 +66,42 @@ class EdgeStorage
return $results;
}
public function put(LocalFile $file, string $local, string $edge): PromiseInterface
/**
* @throws FilesystemException
*/
public function get(EdgeFile $file): string
{
try {
$response = $this->client->get(self::normalizePath($file->getFilename(), $file->isDirectory()), [
RequestOptions::HEADERS => [
'AccessKey' => config('bunny.storage.password'),
],
]);
} catch (ClientException $exception) {
throw FilesystemException::fromResponse($exception->getResponse());
} catch (GuzzleException $exception) {
throw FilesystemException::fromPrevious($exception);
}
if ($response->getStatusCode() === 404) {
throw FileNotFoundException::fromFile($file);
}
if ($response->getStatusCode() !== 200) {
throw FilesystemException::fromResponse($response);
}
return $response->getBody()->getContents();
}
public function put(LocalFile $file, string $local = '', string $edge = ''): PromiseInterface
{
return $this->client->putAsync(self::normalizePath($file->getFilename($local, $edge), $file->isDirectory()), [
RequestOptions::HEADERS => [
'AccessKey' => config('bunny.storage.password'),
'Checksum' => $file->getChecksum(),
],
RequestOptions::BODY => fopen($file->getFilename(), 'r'),
RequestOptions::BODY => $file->getResource(),
]);
}
@@ -79,6 +119,11 @@ class EdgeStorage
return $this->client;
}
public function getStorageCache(): EdgeStorageCache
{
return $this->storageCache;
}
private static function normalizePath(string $filename, bool $isDirectory): string
{
if (!Str::startsWith($filename, ['/'])) {

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Bunny\Filesystem;
use App\Bunny\Filesystem\Exceptions\FileNotFoundException;
use App\Bunny\Filesystem\Exceptions\FilesystemException;
use App\Bunny\Lock\Exceptions\LockException;
use App\Bunny\Lock\Lock;
use Psr\Http\Message\ResponseInterface;
class EdgeStorageCache
{
private EdgeStorage $edgeStorage;
private string $filename = Lock::DEFAULT_FILENAME;
public function __construct(EdgeStorage $edgeStorage)
{
$this->edgeStorage = $edgeStorage;
}
/**
* @throws FilesystemException
* @throws LockException
*/
public function parse(string $path): array
{
$contents = $this->edgeStorage->get(EdgeFile::fromFilename(
sprintf('%s/%s', $path, $this->filename),
File::EMPTY_SHA256,
));
return $this->extract($contents);
}
public function save(string $local, string $edge, array $files): bool
{
$filename = sprintf('%s/%s', $edge, $this->filename);
$contents = $this->hydrate($files, $local, $edge);
$checksum = strtoupper(hash('sha256', $contents));
$promise = $this->edgeStorage->put(new LocalFile($filename, $checksum, $contents));
/** @var ResponseInterface $response */
$response = $promise->wait();
return $response->getStatusCode() === 200;
}
/**
* @throws FileNotFoundException
* @throws LockException
*/
private function extract(string $contents): array
{
$lock = Lock::parse($contents, $this->filename);
return array_map(fn(array $x) => EdgeFile::fromArray($x), $lock->getFiles());
}
private function hydrate(array $files, string $search = '', string $replace = ''): string
{
return Lock::fromFiles(
array_map(fn(LocalFile $x) => $x->toArray($search, $replace), $files)
)->toString();
}
public function setFilename(string $filename)
{
$this->filename = $filename;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Bunny\Filesystem\Exceptions;
use App\Bunny\Filesystem\File;
class FileNotFoundException extends FilesystemException
{
public static function fromFile(File $file): self
{
return new self(sprintf('The file %s was not found.', $file->getFilename()));
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Bunny\Filesystem\Exceptions;
use Exception;
use Psr\Http\Message\ResponseInterface;
class FilesystemException extends Exception
{
public static function fromResponse(ResponseInterface $response): self
{
return new self(sprintf(
'The storage api returns %s which is an invalid status code.',
$response->getStatusCode()
));
}
public static function fromPrevious(Exception $exception): self
{
return new self($exception->getMessage(), 0, $exception);
}
}

View File

@@ -4,9 +4,11 @@ namespace App\Bunny\Filesystem;
interface File
{
public const EMPTY_SHA256 = '0000000000000000000000000000000000000000000000000000000000000000';
public function getFilename($search = '', $replace = ''): string;
public function getChecksum(): string;
public function getChecksum(): ?string;
public function isDirectory(): bool;
}

View File

@@ -3,6 +3,8 @@
namespace App\Bunny\Filesystem;
use App\Bunny\Client;
use App\Bunny\Filesystem\Exceptions\FileNotFoundException;
use Exception;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Response;
@@ -10,110 +12,135 @@ use Illuminate\Console\Command;
class FileCompare
{
private const RESERVED_FILENAMES = [];
private const RESERVED_FILENAMES = [
'/.well-known',
'/.well-known/bunny-cli.lock',
];
private LocalStorage $localStorage;
private EdgeStorage $edgeStorage;
private Command $output;
private Client $apiClient;
private Command $command;
public function __construct(LocalStorage $localStorage, EdgeStorage $edgeStorage, Command $output)
public function __construct(LocalStorage $localStorage, EdgeStorage $edgeStorage, Command $command)
{
$this->localStorage = $localStorage;
$this->edgeStorage = $edgeStorage;
$this->apiClient = new Client();
$this->output = $output;
$this->command = $command;
}
public function compare(string $local, string $edge): void
/**
* @throws Exceptions\FilesystemException
*/
public function compare(string $local, string $edge, array $options): void
{
$this->output->info('- Hashing files...');
$localFiles = $this->localStorage->allFiles($local);
$this->output->info(sprintf('✔ Finished hashing %s files', count($localFiles)));
$this->output->info('- CDN diffing files...');
$edgeFiles = $this->edgeStorage->allFiles($edge);
$this->command->info('- Hashing files...');
$localFilesAndDirectories = $this->localStorage->allFiles($local);
$localFiles = array_filter($localFilesAndDirectories, fn(LocalFile $x) => !$x->isDirectory());
$this->command->info(sprintf('✔ Finished hashing %s files', count($localFiles)));
$expectedMax = count($localFilesAndDirectories) - count($localFiles);
$edgeFiles = $this->getEdgeFiles($options, $edge, $expectedMax);
$this->command->info(sprintf('✔ Finished fetching %s files and directories', count($edgeFiles)));
$this->command->info('- CDN diffing files and directories...');
$requests = [];
$requestsGroups = ['deletions' => [], 'uploads' => []];
/** @var LocalFile $localFile */
foreach ($localFiles as $localFile) {
if ($localFile->isDirectory()) {
continue;
}
$filename = $localFile->getFilename($local);
if ($match = $this->contains($edgeFiles, $filename, $edge)) {
if ($match->getChecksum() != $localFile->getChecksum()) {
$requests[] = fn() => $this->edgeStorage->put($localFile, $local, $edge);
$requestsGroups['uploads'][] = fn() => $this->edgeStorage->put($localFile, $local, $edge);
}
} else {
$requests[] = fn() => $this->edgeStorage->put($localFile, $local, $edge);
$requestsGroups['uploads'][] = fn() => $this->edgeStorage->put($localFile, $local, $edge);
}
}
/** @var EdgeFile $edgeFile */
foreach ($edgeFiles as $edgeFile) {
$filename = $edgeFile->getFilename($edge);
if (!$this->contains($localFiles, $filename, $local) && !$this->isReserved($filename)) {
$requests[] = fn() => $this->edgeStorage->delete($edgeFile);
if (!$this->contains($localFilesAndDirectories, $filename, $local) && !$this->isReserved($filename)) {
$requestsGroups['deletions'][$filename] = fn() => $this->edgeStorage->delete($edgeFile);
}
}
$this->output->info(sprintf('✔ CDN requesting %s files', $count = count($requests)));
$requestsGroups['deletions'] = Sort::unique($requestsGroups['deletions']);
if ($count > 0) {
$this->output->info(sprintf('- Synchronizing %s files', $count));
$this->command->info('✔ Finished diffing files and directories');
$bar = $this->output->getOutput()->createProgressBar($count);
foreach ($requestsGroups as $type => $requests) {
$operations = count($requests);
if ($operations > 0) {
$this->command->info(sprintf('- CDN requesting %s %s', $operations, $type));
$pool = new Pool($this->edgeStorage->getClient(), $requests, [
'concurrency' => 5,
'fulfilled' => function (Response $response, $index) use ($bar) {
$bar->advance();
},
'rejected' => function (RequestException $reason, $index) use ($bar) {
$bar->advance();
$bar = $this->command->getOutput()->createProgressBar($operations);
if ($this->rejectedDue404Deletion($reason)) {
return;
}
$pool = new Pool($this->edgeStorage->getClient(), $requests, [
'concurrency' => 5,
'fulfilled' => function (Response $response, $index) use ($bar) {
$bar->advance();
},
'rejected' => function (RequestException $reason, $index) use ($bar) {
$bar->advance();
$this->output->warn(sprintf(
'Request rejected by bunny.net. Status: %s, Message: %s',
$reason->getResponse()->getStatusCode(),
$reason->getMessage()
));
},
]);
if ($this->rejectedDue404Deletion($reason)) {
return;
}
// Initiate the transfers and create a promise
$promise = $pool->promise();
$this->command->warn(sprintf(
'Request rejected by bunny.net. Status: %s, Message: %s',
$reason->getResponse()->getStatusCode(),
$reason->getMessage()
));
},
]);
$bar->start();
$promise->wait(); // Force the pool of requests to complete.
$bar->finish();
if (!$options[CompareOptions::DRY_RUN]) {
// Initiate the transfers and create a promise
$promise = $pool->promise();
$this->output->newLine();
$bar->start();
$promise->wait(); // Force the pool of requests to complete.
$bar->finish();
$this->output->info(sprintf('✔ Finished synchronizing %s files', $count));
$this->command->newLine();
}
$this->command->info(sprintf('✔ Finished synchronizing %s', $type));
}
}
$this->output->info('- Waiting for deploy to go live...');
if (!$options[CompareOptions::NO_LOCK_GENERATION]) {
$this->command->info('- Generating cache for current deployment...');
$result = $this->apiClient->purgeCache($pullZoneId = config('bunny.pull_zone.id'));
if (!$this->edgeStorage->getStorageCache()->save($local, $edge, $localFilesAndDirectories)) {
$this->command->info('✔ Cache published successfully.');
} else {
$this->command->error('✘ Error publishing cache.');
}
}
$pullZoneId = config('bunny.pull_zone.id');
if (!$result->success()) {
$this->output->info('✔ Deploy is live (without flush)!');
return;
$this->command->info('- Waiting for deploy to go live...');
if (!$options[CompareOptions::DRY_RUN] && $pullZoneId) {
$flushResult = $this->apiClient->purgeCache($pullZoneId);
}
$result = $this->apiClient->getPullZone($pullZoneId);
$this->output->info('✔ Deploy is live!');
$this->output->newLine();
$timeElapsedSecs = microtime(true) - $options[CompareOptions::START];
$message = !isset($flushResult) || !$result->success()
? '✔ Deployment is live (without flush)! (%ss)'
: '✔ Deployment is live! (%ss)';
$this->command->info(sprintf($message, number_format($timeElapsedSecs, 2)));
$this->command->newLine();
foreach ($result->getData()->Hostnames as $hostname) {
$schema = ($hostname->ForceSSL || $hostname->HasCertificate) ? 'https' : 'http';
$this->output->info(sprintf('Website URL: %s://%s', $schema, $hostname->Value));
$this->command->info(sprintf('Website URL: %s://%s', $schema, $hostname->Value));
}
}
@@ -138,4 +165,40 @@ class FileCompare
return $reason->getRequest()->getMethod() === 'DELETE'
&& in_array($reason->getResponse()->getStatusCode(), [404, 400, 500], true);
}
/**
* @throws Exceptions\FilesystemException
*/
private function getEdgeFiles(array $options, string $edge, int $expectedMax): array
{
$this->edgeStorage->getStorageCache()->setFilename($options[CompareOptions::LOCK_FILE]);
if ($options[CompareOptions::NO_LOCK_VERIFICATION]) {
return $this->getAllFilesRecursive($expectedMax, $edge);
}
try {
$this->command->info('- CDN fetching files and directories from cache...');
return $this->edgeStorage->getStorageCache()->parse($edge);
} catch (FileNotFoundException $exception) {
$this->command->warn(sprintf(
'⚠ Cannot fetch %s from storage due "%s". Using recursive fallback...',
$options[CompareOptions::LOCK_FILE],
$exception->getMessage()
));
return $this->getAllFilesRecursive($expectedMax, $edge);
} catch (Exception $e) {
throw $e;
}
}
private function getAllFilesRecursive(int $expectedMax, string $edge): array
{
$this->command->info('- CDN fetching files and directories (progress is approximately)...');
$bar = $this->command->getOutput()->createProgressBar($expectedMax);
$result = $this->edgeStorage->allFiles($edge, fn() => $bar->advance());
$bar->finish();
$this->command->newLine();
return $result;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Bunny\Filesystem;
interface FileSerialized
{
public function toArray(): array;
public static function fromArray(array $array): self;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Bunny\Filesystem;
interface FileStreamable
{
/**
* @return resource|string
*/
public function getResource();
}

View File

@@ -4,15 +4,17 @@ namespace App\Bunny\Filesystem;
use Illuminate\Support\Str;
class LocalFile implements File
class LocalFile implements File, FileStreamable, FileSerialized
{
private string $filename;
private ?string $checksum;
private ?string $contents;
public function __construct(string $filename, ?string $checksum)
public function __construct(string $filename, ?string $checksum, string $contents = null)
{
$this->filename = $filename;
$this->checksum = $checksum;
$this->contents = $contents;
}
public function getFilename($search = '', $replace = ''): string
@@ -20,13 +22,35 @@ class LocalFile implements File
return Str::replaceFirst($search, $replace, $this->filename);
}
public function getChecksum(): string
public function getChecksum(): ?string
{
return $this->checksum;
}
public function isDirectory(): bool
{
return $this->checksum == null;
return $this->getChecksum() === null;
}
public function getResource()
{
if ($this->contents) {
return $this->contents;
}
return fopen($this->getFilename(), 'r');
}
public function toArray(string $search = '', string $replace = ''): array
{
return [
'sha256' => $this->getChecksum(),
'filename' => $this->getFilename($search, $replace),
];
}
public static function fromArray(array $array): self
{
return new self($array['filename'], $array['sha256']);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Bunny\Filesystem;
use Illuminate\Support\Str;
class Sort
{
public static function unique(&$directories): array
{
$relevant = [];
// sort all requested files
uksort($directories, function (string $a, string $b) {
$a = count(explode('/', $a));
$b = count(explode('/', $b));
if ($a == $b) {
return 0;
}
return ($a < $b) ? -1 : 1;
});
// filter all child files and directories
foreach ($directories as $path => $request) {
if (!Str::startsWith($path, array_keys($relevant))) {
$relevant[$path] = $request;
}
}
return $relevant;
}
private static function isParentDeleted(array $parents, string $file): bool
{
return Str::startsWith($file, $parents);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Bunny\Lock\Exceptions;
use Exception;
class LockException extends Exception
{
public static function fromInvalidVersion($version): self
{
return new self(sprintf('Your lock file version %s is not supported.', $version));
}
}

57
app/Bunny/Lock/Lock.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
namespace App\Bunny\Lock;
use App\Bunny\Filesystem\Exceptions\FileNotFoundException;
use App\Bunny\Lock\Exceptions\LockException;
class Lock
{
public const DEFAULT_FILENAME = '.well-known/bunny-cli.lock';
private array $contents;
private function __construct(array $contents)
{
$this->contents['version'] = 1;
$this->contents['_readme'] = [
'This file locks the files of your project to a known state',
'Read more about it at https://github.com/own3d/bunny-cli/wiki',
'This file is @generated automatically'
];
$this->contents['files'] = $contents['files'] ?? [];
}
public static function parse(string $contents, string $filename = self::DEFAULT_FILENAME): self
{
if (!$array = json_decode($contents, true)) {
throw new FileNotFoundException(sprintf('Cannot decode %s file.', $filename));
}
if (!isset($array['version']) || $array['version'] !== 1) {
throw LockException::fromInvalidVersion($array['version'] ?? 'undefined');
}
return new self($array);
}
public static function fromFiles(array $files): self
{
return new self(['files' => $files]);
}
public function getFiles(): array
{
return $this->contents['files'];
}
public function toArray(): array
{
return $this->contents;
}
public function toString(): string
{
return json_encode($this->toArray(), JSON_PRETTY_PRINT);
}
}

View File

@@ -2,10 +2,11 @@
namespace App\Commands;
use App\Bunny\Filesystem\CompareOptions;
use App\Bunny\Filesystem\EdgeStorage;
use App\Bunny\Filesystem\Exceptions\FilesystemException;
use App\Bunny\Filesystem\FileCompare;
use App\Bunny\Filesystem\LocalStorage;
use Illuminate\Console\Scheduling\Schedule;
use LaravelZero\Framework\Commands\Command;
class DeployCommand extends Command
@@ -16,14 +17,18 @@ class DeployCommand extends Command
* @var string
*/
protected $signature = 'deploy
{--dir=dist : Root directory to upload}';
{--dir=dist : Root directory to upload}
{--no-lock-verification : Skips checksum verification from bunny-cli.lock and polls the storage api recursively instead}
{--no-lock-generation : Skips generation of .well-known/bunny-cli.lock}
{--lock-file=.well-known/bunny-cli.lock : Changes the location and filename of .well-known/bunny-cli.lock}
{--dry-run : Outputs the operations but will not execute anything}';
/**
* The description of the command.
*
* @var string
*/
protected $description = 'Deploy dist folder to edge storage';
protected $description = 'Deploy a dist folder to edge storage';
/**
* Execute the console command.
@@ -32,9 +37,13 @@ class DeployCommand extends Command
*/
public function handle(): int
{
$edgeStorage = new EdgeStorage();
$localStorage = new LocalStorage();
$fileCompare = new FileCompare($localStorage, $edgeStorage, $this);
$start = microtime(true);
$fileCompare = new FileCompare(
app(LocalStorage::class),
app(EdgeStorage::class),
$this
);
$localPath = realpath($path = $this->option('dir') ?? 'dist');
@@ -45,20 +54,24 @@ class DeployCommand extends Command
$edgePath = sprintf('/%s', config('bunny.storage.username'));
$fileCompare->compare($localPath, $edgePath);
if ($this->option('dry-run')) {
$this->warn('⚠ Dry run is activated. The operations are displayed but not executed.');
}
try {
$fileCompare->compare($localPath, $edgePath, [
CompareOptions::START => $start,
CompareOptions::NO_LOCK_VERIFICATION => $this->option('no-lock-verification'),
CompareOptions::NO_LOCK_GENERATION => $this->option('no-lock-generation'),
CompareOptions::LOCK_FILE => $this->option('lock-file'),
CompareOptions::DRY_RUN => $this->option('dry-run'),
]);
} catch (FilesystemException $exception) {
$this->error($exception->getMessage());
return 2;
}
return 0;
}
/**
* Define the command's schedule.
*
* @param Schedule $schedule
* @return void
*/
public function schedule(Schedule $schedule): void
{
// $schedule->command(static::class)->everyMinute();
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Commands\Env;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
use LaravelZero\Framework\Commands\Command;
class EnvBackupCommand extends Command
{
/**
* The signature of the command.
*
* @var string
*/
protected $signature = 'env:backup {file : Location of the backup file}';
/**
* The description of the command.
*
* @var string
*/
protected $description = 'Backup .env file into a given file';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->info(sprintf("The following environment file is used: '%s'", App::environmentFilePath()));
Storage::put(sprintf('backups/%s', $this->argument('file')), Storage::get('.env'));
$this->info('The environment file was successfully backed up.');
return 0;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Commands\Env;
use Dotenv\Dotenv;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
use LaravelZero\Framework\Commands\Command;
class EnvListCommand extends Command
{
/**
* The signature of the command.
*
* @var string
*/
protected $signature = 'env:list';
/**
* The description of the command.
*
* @var string
*/
protected $description = 'List all current environment variables';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->info(sprintf("The following environment file is used: '%s'", App::environmentFilePath()));
if (Storage::exists('.env')) {
$env = Dotenv::parse(Storage::get('.env'));
} else {
$this->warn('The environment file does not exist.');
return 1;
}
if (empty($env)) {
$this->warn('The environment file is empty.');
return 2;
}
$this->table(['Key', 'Value'], array_map(fn($k, $v) => [$k, $v], array_keys($env), $env));
return 0;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Commands\Env;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
use LaravelZero\Framework\Commands\Command;
class EnvRestoreCommand extends Command
{
/**
* The signature of the command.
*
* @var string
*/
protected $signature = 'env:restore {file : Location of the backup file}';
/**
* The description of the command.
*
* @var string
*/
protected $description = 'Restore .env file from a given file';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->info(sprintf("The following environment file is used: '%s'", App::environmentFilePath()));
Storage::put('.env', Storage::get(sprintf('backups/%s', $this->argument('file'))));
$this->info('The environment file was successfully restored.');
return 0;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Commands\Env;
use Dotenv\Dotenv;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
use LaravelZero\Framework\Commands\Command;
class EnvSetCommand extends Command
{
/**
* The signature of the command.
*
* @var string
*/
protected $signature = 'env:set
{key : Key of the environment}
{value : Value of the environment}';
/**
* The description of the command.
*
* @var string
*/
protected $description = 'Set and save an environment variable in the .env file';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->info(sprintf("The following environment file is used: '%s'", App::environmentFilePath()));
if (Storage::exists('.env')) {
$env = Dotenv::parse(Storage::get('.env'));
} else {
$this->warn('The environment file does not exist. Creating a new one...');
$env = [];
}
$env[strtoupper($this->argument('key'))] = $this->argument('value');
Storage::put('.env', self::updateEnv($env));
$this->info('The environment file was successfully updated.');
return 0;
}
public static function updateEnv($data = []): string
{
if (!count($data)) {
return PHP_EOL;
}
$lines = [];
foreach ($data as $key => $value) {
if (preg_match('/\s/', $value) || strpos($value, '=') !== false) {
$value = '"' . $value . '"';
}
$lines[] = sprintf('%s=%s', $key, $value);
}
return implode(PHP_EOL, $lines);
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Commands;
use App\Bunny\Client;
use App\Commands\Env\EnvSetCommand;
use Dotenv\Dotenv;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
use LaravelZero\Framework\Commands\Command;
class InitCommand extends Command
{
/**
* The signature of the command.
*
* @var string
*/
protected $signature = 'init
{--api-key= : API key of the Bunny account}
{--storage-zone= : Name of the storage zone}
{--pull-zone= : Name of the pull zone zone}';
/**
* The description of the command.
*
* @var string
*/
protected $description = 'Initialize a new .env file';
/**
* Execute the console command.
*
* @param Client $client
* @return int
*/
public function handle(Client $client): int
{
$this->info(sprintf("The following environment file is used: '%s'", App::environmentFilePath()));
if (Storage::exists('.env')) {
$env = Dotenv::parse(Storage::get('.env'));
} else {
$this->warn('The environment file does not exist. Creating a new one...');
$env = [];
}
$this->newLine();
$this->info('In order for the Bunny CLI to work properly you need to store your Bunny CDN API token.');
$this->info('You can find your API Token in your Account Settings (https://panel.bunny.net/account).');
do {
$env['BUNNY_API_ACCESS_KEY'] = $this->ask(
'What is your API Token?',
$this->option('api-key') ?? $env['BUNNY_API_ACCESS_KEY'] ?? null
);
config()->set('bunny.api.access_key', $env['BUNNY_API_ACCESS_KEY']);
$result = $client->getStorageZones();
if (!$result->success()) {
$this->warn('Your API Token is invalid. Please try again.');
}
$storageZones = new Collection($result->getData());
} while (!$result->success() || $storageZones->isEmpty());
if (!$this->option('no-interaction')) {
$this->info('Please select your default storage zone below. This is used for the deploy command.');
$this->newLine();
$storageZones->each(fn($item) => $this->info(sprintf(' - %s', $item->Name)));
}
do {
$storageZoneName = $this->anticipate(
'Which storage zone do you want to use?',
function ($input) use ($storageZones) {
return $storageZones->filter(function ($item) use ($input) {
// replace stristr with your choice of matching function
return false !== stristr($item->Name, $input);
})->pluck('Name')->toArray();
},
$this->option('storage-zone') ?? $env['BUNNY_STORAGE_USERNAME'] ?? null
);
$storageZone = $storageZones->where('Name', '===', $storageZoneName)->first();
if (!$storageZone) {
$this->warn(sprintf('Cannot find storage zone by `%s`. Please check your spelling.', $storageZoneName));
} else {
$env['BUNNY_STORAGE_USERNAME'] = $storageZone->Name;
$env['BUNNY_STORAGE_PASSWORD'] = $storageZone->Password;
}
} while ($storageZone === null);
$pullZones = new Collection($storageZone->PullZones);
if (!$this->option('no-interaction')) {
$this->info('Now select your pull zone whose cache you want to flush when the deploy is complete.');
$this->newLine();
$pullZones->each(fn($item) => $this->info(sprintf(' - %s', $item->Name)));
}
$firstPullZone = $pullZones->count() > 0 ? $pullZones->first()->Name : null;
$pullZoneName = $this->anticipate(
'Which pull zone do you want to use?',
function ($input) use ($storageZones) {
return $storageZones->filter(function ($item) use ($input) {
// replace stristr with your choice of matching function
return false !== stristr($item->Name, $input);
})->pluck('Name')->toArray();
},
$this->option('api-key') ?? $firstPullZone
);
$pullZone = $pullZones->where('Name', '===', $pullZoneName)->first();
$env['BUNNY_PULL_ZONE_ID'] = $pullZone->Id ?? null;
if (!$pullZone) {
$this->warn('No pull zone was specified, therefore no pull zone is flushed during deployment.');
}
Storage::put('.env', EnvSetCommand::updateEnv($env));
$this->info('The environment file was successfully updated!');
$this->info('You can view these environment variables at any other time using the <comment>bunny env:list</comment> command.');
$this->info('If you need help, please check out our documentation: <comment>https://github.com/own3d/bunny-cli</comment>');
$this->newLine();
$this->info('Thanks for using Bunny CLI!');
return 0;
}
}

View File

@@ -16,9 +16,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot()
{
Storage::extend('ftp', function ($app, $config) {
return new Filesystem(new FtpAdapter($config));
});
//
}
/**

14
app/helpers.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
if (!function_exists('bunny_cli_path')) {
function bunny_cli_path(): string
{
if (strncasecmp(PHP_OS, 'WIN', 3) === 0) {
$result = exec("echo %appdata%");
} else {
$result = getenv('HOME');
}
return $result . DIRECTORY_SEPARATOR . '.bunny-cli';
}
}

View File

@@ -15,6 +15,21 @@ $app = new LaravelZero\Framework\Application(
dirname(__DIR__)
);
/*
|--------------------------------------------------------------------------
| Set the correct path for the environment file
|--------------------------------------------------------------------------
|
| If the current directory has a .env file then we will use that instead
| of the global one, otherwise the one under the user homepage.
|
*/
if (!file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . '.env')) {
$app->useEnvironmentPath(bunny_cli_path());
}
/*
|--------------------------------------------------------------------------
| Bind Important Interfaces

72
build.sh Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Accepts a version string and prints it incremented by one.
# Usage: increment_version <version> [<position>] [<leftmost>]
increment_version() {
local usage=" USAGE: $FUNCNAME [-l] [-t] <version> [<position>] [<leftmost>]
-l : remove leading zeros
-t : drop trailing zeros
<version> : The version string.
<position> : Optional. The position (starting with one) of the number
within <version> to increment. If the position does not
exist, it will be created. Defaults to last position.
<leftmost> : The leftmost position that can be incremented. If does not
exist, position will be created. This right-padding will
occur even to right of <position>, unless passed the -t flag."
# Get flags.
local flag_remove_leading_zeros=0
local flag_drop_trailing_zeros=0
while [ "${1:0:1}" == "-" ]; do
if [ "$1" == "--" ]; then shift; break
elif [ "$1" == "-l" ]; then flag_remove_leading_zeros=1
elif [ "$1" == "-t" ]; then flag_drop_trailing_zeros=1
else echo -e "Invalid flag: ${1}\n$usage"; return 1; fi
shift; done
# Get arguments.
if [ ${#@} -lt 1 ]; then echo "$usage"; return 1; fi
local v="${1}" # version string
local targetPos=${2-last} # target position
local minPos=${3-${2-0}} # minimum position
# Split version string into array using its periods.
local IFSbak; IFSbak=IFS; IFS='.' # IFS restored at end of func to
read -ra v <<< "$v" # avoid breaking other scripts.
# Determine target position.
if [ "${targetPos}" == "last" ]; then
if [ "${minPos}" == "last" ]; then minPos=0; fi
targetPos=$((${#v[@]}>${minPos}?${#v[@]}:$minPos)); fi
if [[ ! ${targetPos} -gt 0 ]]; then
echo -e "Invalid position: '$targetPos'\n$usage"; return 1; fi
(( targetPos-- )) || true # offset to match array index
# Make sure minPosition exists.
while [ ${#v[@]} -lt ${minPos} ]; do v+=("0"); done;
# Increment target position.
v[$targetPos]=`printf %0${#v[$targetPos]}d $((10#${v[$targetPos]}+1))`;
# Remove leading zeros, if -l flag passed.
if [ $flag_remove_leading_zeros == 1 ]; then
for (( pos=0; $pos<${#v[@]}; pos++ )); do
v[$pos]=$((${v[$pos]}*1)); done; fi
# If targetPosition was not at end of array, reset following positions to
# zero (or remove them if -t flag was passed).
if [[ ${flag_drop_trailing_zeros} -eq "1" ]]; then
for (( p=$((${#v[@]}-1)); $p>$targetPos; p-- )); do unset v[$p]; done
else for (( p=$((${#v[@]}-1)); $p>$targetPos; p-- )); do v[$p]=0; done; fi
echo "${v[*]}"
IFS=IFSbak
return 0
}
RELEASED_BUILD_VERSION=$(git describe --abbrev=0 --tags)
BUILD_VERSION=$(increment_version $RELEASED_BUILD_VERSION)
echo "Building bunny-cli for version $BUILD_VERSION (previous version was $RELEASED_BUILD_VERSION)..."
$PWD/bunny app:build --build-version "$BUILD_VERSION"

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{
"name": "ghostzero/bunny",
"description": "The Laravel Zero Framework.",
"name": "own3d/bunny-cli",
"description": "Replicate and store your files to the edge!",
"keywords": ["bunny", "cdn", "console", "cli"],
"homepage": "https://github.com/ghostzero/bunny-cli",
"type": "project",
@@ -23,14 +23,16 @@
"guzzlehttp/guzzle": "^7.0",
"laminas/laminas-text": "^2.8",
"laravel-zero/framework": "^8.8",
"laravel-zero/phar-updater": "^1.0.6",
"mockery/mockery": "^1.4.3",
"pestphp/pest": "^1.3"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"files": [
"app/helpers.php"
]
},
"autoload-dev": {
"psr-4": {

4661
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ return [
|
*/
'name' => 'Bunny.net',
'name' => 'bunny.net',
/*
|--------------------------------------------------------------------------

View File

@@ -2,7 +2,7 @@
return [
'storage' => [
'hostname' => env('BUNNY_STORAGE_HOSTNAME'),
'hostname' => env('BUNNY_STORAGE_HOSTNAME', 'storage.bunnycdn.com'),
'username' => env('BUNNY_STORAGE_USERNAME'),
'password' => env('BUNNY_STORAGE_PASSWORD'),
],

11
config/filesystems.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
return [
'default' => 'local',
'disks' => [
'local' => [
'driver' => 'local',
'root' => bunny_cli_path(),
],
],
];

View File

@@ -1,20 +0,0 @@
<?php
use LaravelZero\Framework\Components\Updater\Strategy\GithubStrategy;
return [
/*
|--------------------------------------------------------------------------
| Self-updater Strategy
|--------------------------------------------------------------------------
|
| Here you may specify which update strategy class you wish to use when
| updating your application via the "self-update" command. This must
| be a class that implements the StrategyInterface from Humbug.
|
*/
'strategy' => GithubStrategy::class,
];