Skip to content
Open
42 changes: 42 additions & 0 deletions app/Actions/Backup/DownloadBackupFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Actions\Backup;

use App\Enums\BackupFileStatus;
use App\Models\BackupFile;
use Symfony\Component\HttpFoundation\StreamedResponse;

class DownloadBackupFile
{
public function run(BackupFile $backupFile): StreamedResponse
{
abort_unless($backupFile->isLocal(), 404, 'Downloading from this storage provider is not supported.');

abort_unless(in_array($backupFile->status, [
BackupFileStatus::CREATED,
BackupFileStatus::RESTORED,
BackupFileStatus::RESTORE_FAILED,
], true), 409, 'This backup file is not available for download.');

$path = $backupFile->path();
Comment thread
mstfkhazaal marked this conversation as resolved.
Resolved
$ssh = $backupFile->backup->server->ssh();

abort_unless($ssh->isRegularFile($path), 404, 'The backup file does not exist on the server.');

return response()->streamDownload(
static function () use ($ssh, $path): void {
$ssh->streamDownload($path, static function (string $chunk): void {
echo $chunk;
flush();
});
Comment thread
mstfkhazaal marked this conversation as resolved.
Resolved
},
basename($path),
[
'Content-Type' => 'application/octet-stream',
'Cache-Control' => 'private, no-store, no-cache, must-revalidate',
'X-Content-Type-Options' => 'nosniff',
'X-Accel-Buffering' => 'no',
],
);
}
}
27 changes: 27 additions & 0 deletions app/Helpers/SSH.php
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,33 @@ public function download(string $local, string $remote, ?string $log = null, ?in
$sftp->get($remote, $local);
}

/**
* Check that the requested remote path is a regular file before streaming a response.
*
* @throws SSHConnectionError
*/
public function isRegularFile(string $remote): bool
{
return $this->ensureSftp()->is_file($remote);
}

/**
* Stream an SFTP file in chunks without creating a temporary copy or
* holding the complete archive in PHP memory.
*
* @param callable(string): void $onChunk
*
* @throws SSHError
*/
public function streamDownload(string $remote, callable $onChunk): void
{
$downloaded = $this->ensureSftp()->get($remote, $onChunk);

if ($downloaded === false) {
throw new SSHError('Failed to stream file from the server.');
Comment thread
mstfkhazaal marked this conversation as resolved.
Resolved
}
}

/**
* @throws SSHError
*/
Expand Down
12 changes: 12 additions & 0 deletions app/Http/Controllers/BackupFileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Controllers;

use App\Actions\Backup\DownloadBackupFile;
use App\Actions\Backup\RestoreBackup;
use App\Http\Resources\BackupFileResource;
use App\Http\Resources\BackupResource;
Expand All @@ -17,6 +18,7 @@
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
use Symfony\Component\HttpFoundation\StreamedResponse;

#[Prefix('servers/{server}/backups/{backup}/files')]
#[Middleware(['auth', 'has-project'])]
Expand All @@ -37,6 +39,16 @@ public function index(Server $server, Backup $backup): Response
]);
}

#[Get('/{backupFile}/download', name: 'backup-files.download')]
public function download(Server $server, Backup $backup, BackupFile $backupFile): StreamedResponse
{
abort_unless($backup->server_id === $server->id && $backupFile->backup_id === $backup->id, 404);

$this->authorize('view', $backupFile);

return app(DownloadBackupFile::class)->run($backupFile);
}

#[Post('/{backupFile}/restore', name: 'backup-files.restore')]
public function restore(Request $request, Server $server, Backup $backup, BackupFile $backupFile): RedirectResponse
{
Expand Down
20 changes: 20 additions & 0 deletions app/Support/Testing/SSHFake.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ public function upload(string $local, string $remote, ?string $owner = null, ?st

public function download(string $local, string $remote, ?string $log = null, ?int $siteId = null): void {}

protected bool $remoteFileMissing = false;

public function remoteFileWillBeMissing(): void
{
$this->remoteFileMissing = true;
}

public function isRegularFile(string $remote): bool
{
$this->commands[] = 'sftp:is-file '.$remote;

return ! $this->remoteFileMissing;
}

public function streamDownload(string $remote, callable $onChunk): void
{
$this->commands[] = 'sftp:stream '.$remote;
$onChunk($this->output ?? 'fake output');
}

/**
* @param array<string>|string $commands
*/
Expand Down
12 changes: 12 additions & 0 deletions docs/4.x/servers/backups.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ You can restore a database backup to another database.
Database backups created in **v3.x are stored as `.zip` and cannot be restored in v4.x**. v4 stores database backups as compressed `.sql.gz` archives and can only restore backups created in v4. After upgrading, take a fresh database backup so you have a restorable v4 copy. See [Breaking Changes](../prologue/breaking-changes#database-backup-format) for details. (File/directory backups are unaffected.)
:::

## Download Backup Files

To keep an offline copy of a backup, open the backup's **Files** page and select **Download** from the actions menu of a completed backup file.

Downloading is currently supported for Vito's built-in **Local** storage provider. Database backups are downloaded as `.sql.gz` files, and file/directory backups as `.tar.gz` archives.

Vito streams the existing archive from the managed server directly to your browser over an authenticated request. No restore takes place, and the original backup is left unchanged. Only members authorized to view backup files can download them.

:::warning
Keep downloaded archives secure: database dumps and site file backups may contain personal data, credentials, and other sensitive information. Consider encrypting your off-server copies.
:::

## File/Directory Backups

Navigate to the `Backups` section in the server's menu and click on the `Create Backup` button. Select `File Backup` as backup type and then enter the path on your server that you want to backup and storage provider and how many backups you want vito to keep and then backup interval and start the backup.
Expand Down
10 changes: 10 additions & 0 deletions resources/js/pages/backups/components/file-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ function Restore({ backup, file }: { backup: Backup; file: BackupFile }) {
return <DropdownMenuItem onSelect={() => dialog.backupRestore.open({ backup, file })}>Restore</DropdownMenuItem>;
}

function Download({ file }: { file: BackupFile }) {
return (
<DropdownMenuItem asChild>
<a href={`/servers/${file.server_id}/backups/${file.backup_id}/files/${file.id}/download`}>Download</a>
</DropdownMenuItem>
);
}

function Delete({ file }: { file: BackupFile }) {
const dialog = useDialog();

Expand Down Expand Up @@ -131,6 +139,8 @@ export const columns: ColumnDef<BackupFile>[] = [
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{row.original.backup?.storage?.provider === 'local' &&
['created', 'restored', 'restore_failed'].includes(row.original.status) && <Download file={row.original} />}
<Restore backup={row.original.backup} file={row.original} />
<Delete file={row.original} />
</DropdownMenuContent>
Expand Down
148 changes: 148 additions & 0 deletions tests/Feature/BackupFileDownloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

use App\Enums\BackupFileStatus;
use App\Enums\BackupType;
use App\Facades\SSH;
use App\Models\Backup;
use App\Models\BackupFile;
use App\Models\Database;
use App\Models\Server;
use App\Models\StorageProvider;
use App\Models\User;
use App\StorageProviders\Local;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function () {
$storage = StorageProvider::factory()->create([
'user_id' => $this->user->id,
'provider' => Local::id(),
'credentials' => ['path' => '/home/vito/backups'],
]);

$this->backup = Backup::factory()->create([
'type' => BackupType::FILE,
'server_id' => $this->server->id,
'storage_id' => $storage->id,
'path' => '/home/vito/example.com',
]);

$this->file = BackupFile::factory()->create([
'backup_id' => $this->backup->id,
'name' => 'example-20261028065718',
'status' => BackupFileStatus::CREATED,
]);
});

function backupDownloadUrl(Server $server, Backup $backup, BackupFile $file): string
{
return route('backup-files.download', [
'server' => $server,
'backup' => $backup,
'backupFile' => $file,
]);
}

test('downloads a local file backup as a streamed attachment', function () {
SSH::fake('test archive bytes');

$response = $this->actingAs($this->user)
->get(backupDownloadUrl($this->server, $this->backup, $this->file))
->assertOk()
->assertHeader('content-type', 'application/octet-stream')
->assertHeader('x-content-type-options', 'nosniff');

expect($response->headers->get('content-disposition'))
->toContain('attachment')
->toContain('example-20261028065718.tar.gz');
expect($response->streamedContent())->toBe('test archive bytes');

SSH::assertExecutedContains('sftp:stream /home/vito/backups/example.com/example-20261028065718.tar.gz');
});

test('downloads a local database backup with sql.gz extension', function () {
SSH::fake('sql gzip bytes');

$database = Database::factory()->create(['server_id' => $this->server->id, 'name' => 'test_db']);
$this->backup->update([
'type' => BackupType::DATABASE,
'database_id' => $database->id,
'path' => null,
]);

$response = $this->actingAs($this->user)
->get(backupDownloadUrl($this->server, $this->backup, $this->file))
->assertOk();

expect($response->headers->get('content-disposition'))->toContain('.sql.gz');
expect($response->streamedContent())->toBe('sql gzip bytes');
SSH::assertExecutedContains('sftp:stream /home/vito/backups/test_db/example-20261028065718.sql.gz');
});

test('download fails when the remote backup archive is missing', function () {
SSH::fake()->remoteFileWillBeMissing();

$this->actingAs($this->user)
->get(backupDownloadUrl($this->server, $this->backup, $this->file))
->assertNotFound();
});

test('download rejects unavailable backup files', function (BackupFileStatus $status) {
SSH::fake();
$this->file->update(['status' => $status]);

$this->actingAs($this->user)
->get(backupDownloadUrl($this->server, $this->backup, $this->file))
->assertStatus(409);
})->with([
BackupFileStatus::CREATING,
BackupFileStatus::FAILED,
BackupFileStatus::RESTORING,
BackupFileStatus::DELETING,
BackupFileStatus::DELETE_FAILED,
]);

test('download is unavailable for unsupported storage providers', function () {
SSH::fake();

$storage = StorageProvider::factory()->dropbox()->create(['user_id' => $this->user->id]);
$this->backup->update(['storage_id' => $storage->id]);

$this->actingAs($this->user)
->get(backupDownloadUrl($this->server, $this->backup, $this->file))
->assertNotFound();
});

test('download checks the server and backup file relationship', function () {
SSH::fake();

$otherBackup = Backup::factory()->create([
'server_id' => $this->server->id,
'storage_id' => $this->backup->storage_id,
'type' => BackupType::FILE,
'path' => '/home/vito/other.com',
]);

$this->actingAs($this->user)
->get(backupDownloadUrl($this->server, $otherBackup, $this->file))
->assertNotFound();
});

test('download enforces project membership', function () {
SSH::fake();

$otherUser = User::factory()->create();
$otherUser->ensureHasDefaultProject();

$this->actingAs($otherUser)
->get(backupDownloadUrl($this->server, $this->backup, $this->file))
->assertForbidden();
});

test('guest cannot download a backup file', function () {
SSH::fake();

$this->get(backupDownloadUrl($this->server, $this->backup, $this->file))
->assertRedirect();
});