diff --git a/.env.example b/.env.example index 74e86a8..b835215 100644 --- a/.env.example +++ b/.env.example @@ -66,4 +66,13 @@ AFIA_API_KEY= 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}" diff --git a/app/Console/Commands/MigrateTransferStorageCommand.php b/app/Console/Commands/MigrateTransferStorageCommand.php new file mode 100644 index 0000000..10a076d --- /dev/null +++ b/app/Console/Commands/MigrateTransferStorageCommand.php @@ -0,0 +1,131 @@ +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; + } +} diff --git a/app/Services/Transfer/TransferService.php b/app/Services/Transfer/TransferService.php index ee8d979..94dd555 100644 --- a/app/Services/Transfer/TransferService.php +++ b/app/Services/Transfer/TransferService.php @@ -144,19 +144,25 @@ class TransferService $uuid = Str::uuid()->toString(); $ext = $file->getClientOriginalExtension() ?: 'bin'; $path = $user->id.'/transfers/'.$transfer->id.'/'.$uuid.'.'.$ext; + $disk = $this->filesDisk(); - $file->storeAs('', $path, 'qr'); + $file->storeAs('', $path, $disk); return TransferFile::create([ 'transfer_id' => $transfer->id, 'original_name' => $displayName, - 'disk' => 'qr', + 'disk' => $disk, 'path' => $path, 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', 'size_bytes' => (int) $file->getSize(), ]); } + private function filesDisk(): string + { + return (string) config('transfer.files_disk', 'qr'); + } + /** * @param array $data */ diff --git a/composer.json b/composer.json index 2cb6914..e3363ba 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/framework": "^12.0", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", + "league/flysystem-aws-s3-v3": "^3.25", "phpseclib/phpseclib": "^3.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 1f2b027..81d16fc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "529339feb28ae432869697327a78cb16", + "content-hash": "b2d8ed250f623f28d0a370f6145673fb", "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", "version": "0.14.8", @@ -1963,6 +2114,61 @@ }, "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", "version": "3.31.0", @@ -2353,6 +2559,72 @@ ], "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", "version": "3.11.4", @@ -4317,6 +4589,77 @@ ], "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", "version": "v7.4.8", diff --git a/config/filesystems.php b/config/filesystems.php index 4ebaa0b..b407722 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -67,6 +67,23 @@ return [ '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, + ], + ], /* diff --git a/config/transfer.php b/config/transfer.php index 2aa48f8..fa7f3b1 100644 --- a/config/transfer.php +++ b/config/transfer.php @@ -39,4 +39,7 @@ return [ // Voice SMS uploads from sms.ladill.com (via platform API). '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'), ]; diff --git a/deploy/shared.env.example b/deploy/shared.env.example index 00d588a..577db70 100644 --- a/deploy/shared.env.example +++ b/deploy/shared.env.example @@ -64,3 +64,12 @@ TRANSFER_SMS_VOICE_MAX_UPLOAD_KB=5120 AFIA_ENABLED=true 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= diff --git a/phpunit.xml b/phpunit.xml index 5d8de89..e9f1bc6 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,5 +33,6 @@ +