Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/Backuper.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Pipeline;
use Itiden\Backup\Contracts\Repositories\BackupRepository;
use Itiden\Backup\DataTransferObjects\BackupDto;
Expand All @@ -15,6 +16,7 @@
use Itiden\Backup\Events\BackupFailed;
use Itiden\Backup\Models\Metadata;
use Itiden\Backup\Support\Zipper;
use RuntimeException;
use Throwable;

use function Illuminate\Filesystem\join_paths;
Expand All @@ -33,12 +35,55 @@ public function __construct(
*/
public function backup(?Authenticatable $user = null): BackupDto
{
if (function_exists('set_time_limit')) {
set_time_limit(0);
}

ignore_user_abort(true);

$lock = $this->stateManager->getLock();

$temp_zip_path = null;

try {
$this->stateManager->setState(State::BackupInProgress);

$temp_zip_path = join_paths(Config::string('backup.temp_path'), 'temp.zip');
$completed = false;

register_shutdown_function(static function () use (&$completed, $temp_zip_path): void {
if ($completed) {
return;
}

$error = error_get_last();

// Only treat true fatal errors as a "killed mid-backup" scenario.
if (
$error === null
|| !in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)
) {
return;
}

Log::error('backup: fatal error mid-backup', [
'error' => $error,
'temp_zip_exists' => File::exists($temp_zip_path),
]);

if (File::exists($temp_zip_path)) {
File::delete($temp_zip_path);
}

// Ensure the lock doesn't remain held indefinitely after a fatal error.
\Illuminate\Support\Facades\Cache::lock(StateManager::LOCK)->forceRelease();

app(StateManager::class)->setState(State::BackupFailed);
});

Log::info('backup: started', [
'user' => $user?->getAuthIdentifier(),
]);

$zipper = Zipper::write($temp_zip_path);

Expand All @@ -57,6 +102,18 @@ public function backup(?Authenticatable $user = null): BackupDto

$zipper->close();

Log::info('backup: zip closed', [
'size' => File::size($temp_zip_path),
]);

if (!Zipper::verify($temp_zip_path)) {
File::delete($temp_zip_path);

throw new RuntimeException('Zip verification failed — the backup archive is invalid.');
}

Log::info('backup: zip verified');

$backup = $this->repository->add($temp_zip_path);

$metadata = static::addMetaFromZipToBackupMeta($temp_zip_path, $backup);
Expand All @@ -73,8 +130,18 @@ public function backup(?Authenticatable $user = null): BackupDto

$this->stateManager->setState(State::BackupCompleted);

Log::info('backup: completed', ['path' => $backup->path]);

$completed = true;

return $backup;
} catch (Throwable $e) {
if ($temp_zip_path !== null && File::exists($temp_zip_path)) {
File::delete($temp_zip_path);
}

Log::error('backup: failed', ['error' => $e->getMessage()]);

$exception = new Exceptions\BackupFailed(previous: $e);

event(new BackupFailed($exception));
Expand Down
6 changes: 3 additions & 3 deletions src/Exceptions/BackupFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ final class BackupFailed extends Exception
{
public function __construct(Throwable $previous)
{
parent::__construct(__('statamic-backup::backup.failed', ['date' => Carbon::now()->format(
'Ymd',
)]), previous: $previous);
parent::__construct(__('statamic-backup::backup.failed', [
'date' => Carbon::now()->format('Ymd'),
]), previous: $previous);
}
}
42 changes: 22 additions & 20 deletions src/Http/Controllers/Api/BackupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,29 @@ public function __invoke(BackupRepository $repo): AnonymousResourceCollection
{
$backups = $repo->all();

return BackupResource::collection($backups)->additional(['meta' => [
// Required by statamic to render the table
'columns' => [
[
'label' => 'Name',
'field' => 'name',
'visible' => true,
],
[
'label' => 'Created at',
'field' => 'created_at',
'visible' => true,
'sortable' => true,
],
[
'label' => 'Size',
'field' => 'size',
'visible' => true,
'sortable' => true,
return BackupResource::collection($backups)->additional([
'meta' => [
// Required by statamic to render the table
'columns' => [
[
'label' => 'Name',
'field' => 'name',
'visible' => true,
],
[
'label' => 'Created at',
'field' => 'created_at',
'visible' => true,
'sortable' => true,
],
[
'label' => 'Size',
'field' => 'size',
'visible' => true,
'sortable' => true,
],
],
],
]]);
]);
}
}
22 changes: 19 additions & 3 deletions src/Http/Controllers/DownloadBackupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Itiden\Backup\Contracts\Repositories\BackupRepository;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\Response;

final readonly class DownloadBackupController
{
public function __invoke(Request $request, string $id, BackupRepository $repo): StreamedResponse
public function __invoke(Request $request, string $id, BackupRepository $repo): Response
{
$backup = $repo->find($id);

Expand All @@ -26,6 +26,22 @@ public function __invoke(Request $request, string $id, BackupRepository $repo):

$backup->getMetadata()->addDownload($user);

return Storage::disk(Config::string('backup.destination.disk'))->download($backup->path);
if (function_exists('set_time_limit')) {
set_time_limit(0);
}

// Clean and close all active output buffers to allow streaming without running out of memory
while (ob_get_level() > 0) {
ob_end_clean();
}

$disk = Storage::disk(Config::string('backup.destination.disk'));

try {
$path = $disk->path($backup->path);
return response()->download($path);
} catch (\Throwable) {
return $disk->download($backup->path);
}
}
}
122 changes: 113 additions & 9 deletions src/Support/Zipper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,66 @@

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use SensitiveParameter;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\Finder\Finder;
use ZipArchive;

// @mago-expect lint:too-many-methods
final class Zipper
{
/**
* File extensions that are already compressed and should be stored
* without re-compression to save CPU cycles and I/O bandwidth.
*/
private const STORED_EXTENSIONS = [
'zip',
'mp4',
'webm',
'png',
'jpg',
'jpeg',
'webp',
'gif',
'pdf',
'mp3',
'wav',
'mov',
'avi',
'ogg',
'gz',
'tar',
'tgz',
'woff',
'woff2',
'ttf',
'otf',
'ico',
'avif',
'heic',
'bz2',
'xz',
'7z',
'rar',
];

private ZipArchive $zip;
private array $meta = [];
private string $path;

public function __construct(string $path, int $flags = ZipArchive::CREATE | ZipArchive::OVERWRITE)
{
File::ensureDirectoryExists(dirname($path));

$this->path = $path;
$this->zip = new ZipArchive();

$this->zip->open($path, $flags);
$result = $this->zip->open($path, $flags);

if ($result !== true) {
throw new RuntimeException("Failed to open zip [{$path}] (error code: {$result})");
}
}

/**
Expand All @@ -38,12 +81,36 @@ public static function read(string $path): self
return new static($path, ZipArchive::RDONLY);
}

/**
* Verify that a zip file at the given path is a valid archive.
*/
public static function verify(string $path): bool
{
$zip = new ZipArchive();

if ($zip->open($path, ZipArchive::RDONLY) !== true) {
return false;
}

$valid = $zip->numFiles > 0;

$zip->close();

return $valid;
Comment thread
andreasbergqvist marked this conversation as resolved.
}

/**
* Close the Zipper and write the archive to the filesystem.
*/
public function close(): void
{
$this->zip->close();
if (!$this->zip->close()) {
Log::error('zipper: close failed', ['path' => $this->path]);

throw new RuntimeException(
"Failed to write zip archive [{$this->path}] — check disk space and memory limits.",
);
}
}

/**
Expand All @@ -53,18 +120,34 @@ public function encrypt(#[SensitiveParameter] string $password): self
{
$this->zip->setPassword($password);

collect(range(0, $this->zip->numFiles - 1))
->each(fn(int $file): bool => $this->zip->setEncryptionIndex($file, ZipArchive::EM_AES_256));
for ($i = 0; $i < $this->zip->numFiles; $i++) {
if (!$this->zip->setEncryptionIndex($i, ZipArchive::EM_AES_256)) {
throw new RuntimeException("Failed to set encryption for file at index {$i}");
}
Comment thread
andreasbergqvist marked this conversation as resolved.
}

return $this;
}

/**
* Add a file to the archive.
*
* Pre-compressed file types (images, videos, archives) are stored
* without re-compression using CM_STORE for maximum I/O performance.
* Text-based files use CM_DEFLATE for size reduction.
*/
public function addFile(string $path, ?string $name = null): self
{
$this->zip->addFile($path, $name ?? basename($path));
$entryName = $name ?? basename($path);

if (!$this->zip->addFile($path, $entryName)) {
throw new RuntimeException("Failed to add file to zip: {$path}");
}

$extension = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
$method = in_array($extension, self::STORED_EXTENSIONS, true) ? ZipArchive::CM_STORE : ZipArchive::CM_DEFLATE;

$this->zip->setCompressionName($entryName, $method);

return $this;
}
Expand All @@ -74,7 +157,9 @@ public function addFile(string $path, ?string $name = null): self
*/
public function addFromString(string $name, string $content): self
{
$this->zip->addFromString($name, $content);
if (!$this->zip->addFromString($name, $content)) {
throw new RuntimeException("Failed to add content to zip: {$name}");
}

return $this;
}
Expand All @@ -84,9 +169,28 @@ public function addFromString(string $name, string $content): self
*/
public function addDirectory(string $path, ?string $prefix = null): self
{
collect(File::allFiles($path))->each(function (SplFileInfo $file) use ($prefix): void {
$finder = new Finder();
$finder->files()->ignoreDotFiles(false)->in($path);

$count = 0;

foreach ($finder as $file) {
$this->addFile($file->getPathname(), $prefix . '/' . $file->getRelativePathname());
});

$count++;

if (($count % 500) === 0) {
Log::info('zipper: addDirectory progress', [
'directory' => $path,
'files_added' => $count,
]);
}
}

Log::info('zipper: addDirectory complete', [
'directory' => $path,
'total_files' => $count,
]);

return $this;
}
Expand Down
Loading
Loading