Store transfer files on Contabo Object Storage like Ladill Meet.
Deploy Ladill Transfer / deploy (push) Successful in 50s

Add transfer-files S3 disk, config-driven uploads, and an artisan command to migrate existing local blobs to object storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-04 20:01:49 +00:00
co-authored by Cursor
parent ae84295c12
commit aa2db7186a
9 changed files with 523 additions and 3 deletions
+9
View File
@@ -66,4 +66,13 @@ AFIA_API_KEY=
LINK_PUBLIC_DOMAIN=ladl.link LINK_PUBLIC_DOMAIN=ladl.link
# Contabo Object Storage (S3-compatible) — same bucket/account as Ladill Meet (`ladill`).
TRANSFER_FILES_DISK=transfer-files
TRANSFER_S3_BUCKET=ladill
TRANSFER_S3_REGION=default
TRANSFER_S3_ENDPOINT=https://eu2.contabostorage.com
TRANSFER_S3_USE_PATH_STYLE=true
TRANSFER_S3_ACCESS_KEY=
TRANSFER_S3_SECRET=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
@@ -0,0 +1,131 @@
<?php
namespace App\Console\Commands;
use App\Models\TransferFile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class MigrateTransferStorageCommand extends Command
{
protected $signature = 'transfer:migrate-storage
{--from=qr : Source disk for legacy transfer blobs}
{--to= : Target disk (defaults to transfer.files_disk config)}
{--dry-run : List files without copying or updating records}
{--delete-source : Remove local blobs after a successful upload}';
protected $description = 'Copy transfer file blobs from local disk to Contabo object storage and update transfer_files.disk';
public function handle(): int
{
$fromDisk = (string) $this->option('from');
$toDisk = (string) ($this->option('to') ?: config('transfer.files_disk', 'transfer-files'));
$dryRun = (bool) $this->option('dry-run');
$deleteSource = (bool) $this->option('delete-source');
if ($fromDisk === $toDisk) {
$this->error('Source and target disks must differ.');
return self::FAILURE;
}
if (! config("filesystems.disks.{$fromDisk}") || ! config("filesystems.disks.{$toDisk}")) {
$this->error('Unknown filesystem disk configured for migration.');
return self::FAILURE;
}
$files = TransferFile::query()
->where('disk', $fromDisk)
->orderBy('id')
->get();
if ($files->isEmpty()) {
$this->info("No transfer files on disk [{$fromDisk}].");
return self::SUCCESS;
}
$this->info(sprintf(
'%s migrating %d file(s) from [%s] to [%s].',
$dryRun ? 'Would migrate' : 'Migrating',
$files->count(),
$fromDisk,
$toDisk,
));
$migrated = 0;
$skipped = 0;
$failed = 0;
foreach ($files as $file) {
$label = "#{$file->id} {$file->path}";
if (! Storage::disk($fromDisk)->exists($file->path)) {
$this->warn("Missing source blob: {$label}");
$failed++;
continue;
}
if (Storage::disk($toDisk)->exists($file->path)) {
if (! $dryRun) {
$file->update(['disk' => $toDisk]);
if ($deleteSource) {
Storage::disk($fromDisk)->delete($file->path);
}
}
$this->line("Already on target, updated record: {$label}");
$skipped++;
continue;
}
if ($dryRun) {
$this->line("Would migrate: {$label}");
$migrated++;
continue;
}
try {
$stream = Storage::disk($fromDisk)->readStream($file->path);
if ($stream === false) {
throw new \RuntimeException('Could not open source stream.');
}
$written = Storage::disk($toDisk)->writeStream($file->path, $stream);
if (is_resource($stream)) {
fclose($stream);
}
if ($written === false) {
throw new \RuntimeException('Upload to target disk failed.');
}
if (! Storage::disk($toDisk)->exists($file->path)) {
throw new \RuntimeException('Uploaded blob not found on target disk.');
}
$file->update(['disk' => $toDisk]);
if ($deleteSource) {
Storage::disk($fromDisk)->delete($file->path);
}
$this->line("Migrated: {$label}");
$migrated++;
} catch (\Throwable $e) {
$this->error("Failed {$label}: {$e->getMessage()}");
$failed++;
}
}
$this->newLine();
$this->info("Done. migrated={$migrated} skipped={$skipped} failed={$failed}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
+8 -2
View File
@@ -144,19 +144,25 @@ class TransferService
$uuid = Str::uuid()->toString(); $uuid = Str::uuid()->toString();
$ext = $file->getClientOriginalExtension() ?: 'bin'; $ext = $file->getClientOriginalExtension() ?: 'bin';
$path = $user->id.'/transfers/'.$transfer->id.'/'.$uuid.'.'.$ext; $path = $user->id.'/transfers/'.$transfer->id.'/'.$uuid.'.'.$ext;
$disk = $this->filesDisk();
$file->storeAs('', $path, 'qr'); $file->storeAs('', $path, $disk);
return TransferFile::create([ return TransferFile::create([
'transfer_id' => $transfer->id, 'transfer_id' => $transfer->id,
'original_name' => $displayName, 'original_name' => $displayName,
'disk' => 'qr', 'disk' => $disk,
'path' => $path, 'path' => $path,
'mime_type' => $file->getMimeType() ?: 'application/octet-stream', 'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
'size_bytes' => (int) $file->getSize(), 'size_bytes' => (int) $file->getSize(),
]); ]);
} }
private function filesDisk(): string
{
return (string) config('transfer.files_disk', 'qr');
}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
*/ */
+1
View File
@@ -14,6 +14,7 @@
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"league/flysystem-aws-s3-v3": "^3.25",
"phpseclib/phpseclib": "^3.0" "phpseclib/phpseclib": "^3.0"
}, },
"require-dev": { "require-dev": {
Generated
+344 -1
View File
@@ -4,8 +4,159 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "529339feb28ae432869697327a78cb16", "content-hash": "b2d8ed250f623f28d0a370f6145673fb",
"packages": [ "packages": [
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.387.2",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "dac6a19ffc71e6d100f06098e7df58f2e27103f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/dac6a19ffc71e6d100f06098e7df58f2e27103f9",
"reference": "dac6a19ffc71e6d100f06098e7df58f2e27103f9",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/promises": "^2.0",
"guzzlehttp/psr7": "^2.4.5",
"mtdowling/jmespath.php": "^2.9.1",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.387.2"
},
"time": "2026-07-02T18:06:18+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@@ -1963,6 +2114,61 @@
}, },
"time": "2026-05-14T10:28:08+00:00" "time": "2026-05-14T10:28:08+00:00"
}, },
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.35.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94",
"reference": "3f93d3f4bd5c12a5dfb34a7267c40b1bc4587b94",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.1"
},
"time": "2026-06-25T06:51:08+00:00"
},
{ {
"name": "league/flysystem-local", "name": "league/flysystem-local",
"version": "3.31.0", "version": "3.31.0",
@@ -2353,6 +2559,72 @@
], ],
"time": "2026-01-02T08:56:05+00:00" "time": "2026-01-02T08:56:05+00:00"
}, },
{
"name": "mtdowling/jmespath.php",
"version": "2.9.1",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9c208ba27ae7d90853c288b3795d6702eb251d34",
"reference": "9c208ba27ae7d90853c288b3795d6702eb251d34",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.52"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.9-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.1"
},
"time": "2026-06-11T10:43:56+00:00"
},
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.11.4", "version": "3.11.4",
@@ -4317,6 +4589,77 @@
], ],
"time": "2026-01-05T13:30:16+00:00" "time": "2026-01-05T13:30:16+00:00"
}, },
{
"name": "symfony/filesystem",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
"reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{ {
"name": "symfony/finder", "name": "symfony/finder",
"version": "v7.4.8", "version": "v7.4.8",
+17
View File
@@ -67,6 +67,23 @@ return [
'report' => false, 'report' => false,
], ],
// Contabo Object Storage (S3-compatible) for transfer file blobs — same account as Ladill Meet.
'transfer-files' => [
'driver' => 's3',
'key' => env('TRANSFER_S3_ACCESS_KEY', env('MEET_EGRESS_S3_ACCESS_KEY', env('AWS_ACCESS_KEY_ID'))),
'secret' => env('TRANSFER_S3_SECRET', env('MEET_EGRESS_S3_SECRET', env('AWS_SECRET_ACCESS_KEY'))),
'region' => env('TRANSFER_S3_REGION', env('MEET_EGRESS_S3_REGION', env('AWS_DEFAULT_REGION', 'default'))),
'bucket' => env('TRANSFER_S3_BUCKET', env('MEET_EGRESS_S3_BUCKET', env('AWS_BUCKET'))),
'url' => env('TRANSFER_S3_URL', env('MEET_EGRESS_S3_URL', env('AWS_URL'))),
'endpoint' => env('TRANSFER_S3_ENDPOINT', env('MEET_EGRESS_S3_ENDPOINT', env('AWS_ENDPOINT'))),
'use_path_style_endpoint' => filter_var(
env('TRANSFER_S3_USE_PATH_STYLE', env('MEET_EGRESS_S3_USE_PATH_STYLE', env('AWS_USE_PATH_STYLE_ENDPOINT', true))),
FILTER_VALIDATE_BOOL,
),
'throw' => false,
'report' => false,
],
], ],
/* /*
+3
View File
@@ -39,4 +39,7 @@ return [
// Voice SMS uploads from sms.ladill.com (via platform API). // Voice SMS uploads from sms.ladill.com (via platform API).
'sms_voice_max_upload_kb' => (int) env('TRANSFER_SMS_VOICE_MAX_UPLOAD_KB', 5120), 'sms_voice_max_upload_kb' => (int) env('TRANSFER_SMS_VOICE_MAX_UPLOAD_KB', 5120),
// Filesystem disk for transfer blobs (Contabo S3 in production; local `qr` for dev/tests).
'files_disk' => env('TRANSFER_FILES_DISK', 'transfer-files'),
]; ];
+9
View File
@@ -64,3 +64,12 @@ TRANSFER_SMS_VOICE_MAX_UPLOAD_KB=5120
AFIA_ENABLED=true AFIA_ENABLED=true
AFIA_PRODUCT=transfer AFIA_PRODUCT=transfer
# Contabo Object Storage (S3-compatible) — same bucket/account as Ladill Meet.
TRANSFER_FILES_DISK=transfer-files
TRANSFER_S3_BUCKET=ladill
TRANSFER_S3_REGION=default
TRANSFER_S3_ENDPOINT=https://eu2.contabostorage.com
TRANSFER_S3_USE_PATH_STYLE=true
TRANSFER_S3_ACCESS_KEY=
TRANSFER_S3_SECRET=
+1
View File
@@ -33,5 +33,6 @@
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/> <env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="TRANSFER_FILES_DISK" value="qr"/>
</php> </php>
</phpunit> </phpunit>