Files
ladill-transfer/app/Services/Transfer/TransferService.php
T
isaaccladandCursor 4b616a7f04
Deploy Ladill Transfer / deploy (push) Successful in 47s
Simplify recipient emails and add live upload progress on create.
Recipients always get the download link immediately when an email is set.
Large files upload in the background with per-file and overall progress bars.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:25:43 +00:00

201 lines
6.8 KiB
PHP

<?php
namespace App\Services\Transfer;
use App\Models\QrCode;
use App\Models\Transfer;
use App\Models\TransferDownloadEvent;
use App\Models\TransferFile;
use App\Models\User;
use App\Services\Qr\QrImageGeneratorService;
use App\Support\Qr\QrStyleDefaults;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class TransferService
{
public function __construct(
private QrImageGeneratorService $imageGenerator,
) {}
/**
* @param array<string, mixed> $data
* @param list<UploadedFile> $files
*/
public function create(User $user, array $data, array $files): Transfer
{
if ($files === []) {
throw new RuntimeException('Add at least one file to share.');
}
$maxFiles = (int) config('transfer.max_files_per_transfer', 20);
if (count($files) > $maxFiles) {
throw new RuntimeException("You can upload up to {$maxFiles} files per transfer.");
}
$maxBytes = (int) config('transfer.max_file_bytes', 0);
$billingPeriodDays = (int) config('transfer.billing_period_days', 30);
return DB::transaction(function () use ($user, $data, $files, $maxBytes, $billingPeriodDays) {
$passwordHash = null;
if (! empty($data['password'])) {
$passwordHash = Hash::make((string) $data['password']);
}
$recipientEmail = trim((string) ($data['recipient_email'] ?? ''));
$transfer = Transfer::create([
'user_id' => $user->id,
'title' => trim((string) $data['title']),
'message' => isset($data['message']) ? trim((string) $data['message']) : null,
'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null,
'recipient_email_milestones' => $recipientEmail !== '' ? ['created'] : null,
'recipient_milestones_sent' => [],
'owner_milestones_sent' => [],
'password_hash' => $passwordHash,
'retention_days' => $billingPeriodDays,
'status' => Transfer::STATUS_ACTIVE,
]);
$totalBytes = 0;
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
if ($maxBytes > 0 && $file->getSize() > $maxBytes) {
throw new RuntimeException($file->getClientOriginalName().' exceeds the maximum file size.');
}
$stored = $this->storeFile($user, $transfer, $file);
$totalBytes += $stored->size_bytes;
}
if ($totalBytes === 0) {
throw new RuntimeException('No valid files were uploaded.');
}
$qrCode = $this->createQrCode($user, $transfer, $data);
$transfer->update([
'qr_code_id' => $qrCode->id,
'storage_bytes' => $totalBytes,
]);
$transfer = $transfer->fresh(['files', 'qrCode']);
app(TransferBillingService::class)->chargeInitialPeriod($transfer, $user);
$transfer = $transfer->fresh(['files', 'qrCode']);
app(TransferRecipientMailService::class)->notifyRecipient($transfer, 'created');
return $transfer;
});
}
public function recordDownload(Transfer $transfer, ?TransferFile $file, Request $request): void
{
TransferDownloadEvent::create([
'transfer_id' => $transfer->id,
'transfer_file_id' => $file?->id,
'downloaded_at' => now(),
'ip_hash' => $request->ip() ? hash('sha256', $request->ip()) : null,
'user_agent' => Str::limit((string) $request->userAgent(), 500, ''),
]);
$transfer->increment('downloads_total');
if ($file) {
$file->increment('downloads_total');
}
}
public function verifyPassword(Transfer $transfer, ?string $password): bool
{
if (! $transfer->isPasswordProtected()) {
return true;
}
return $password !== null && Hash::check($password, $transfer->password_hash);
}
private function storeFile(User $user, Transfer $transfer, UploadedFile $file): TransferFile
{
$uuid = Str::uuid()->toString();
$ext = $file->getClientOriginalExtension() ?: 'bin';
$path = $user->id.'/transfers/'.$transfer->id.'/'.$uuid.'.'.$ext;
$file->storeAs('', $path, 'qr');
return TransferFile::create([
'transfer_id' => $transfer->id,
'original_name' => $file->getClientOriginalName() ?: 'file.'.$ext,
'disk' => 'qr',
'path' => $path,
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
'size_bytes' => (int) $file->getSize(),
]);
}
/**
* @param array<string, mixed> $data
*/
private function createQrCode(User $user, Transfer $transfer, array $data): QrCode
{
$shortCode = $this->generateUniqueShortCode();
$style = QrStyleDefaults::merge($data['style'] ?? null);
$qrCode = QrCode::create([
'user_id' => $user->id,
'short_code' => $shortCode,
'type' => QrCode::TYPE_TRANSFER,
'label' => $transfer->title,
'payload' => [
'content' => [
'transfer_id' => $transfer->id,
'message' => $transfer->message,
],
'style' => $style,
],
'is_active' => true,
'destination_updated_at' => now(),
]);
$this->imageGenerator->generateAndStore($qrCode);
return $qrCode;
}
private function generateUniqueShortCode(): string
{
do {
$code = Str::lower(Str::random(8));
} while (QrCode::query()->where('short_code', $code)->exists());
return $code;
}
public function delete(Transfer $transfer): void
{
DB::transaction(function () use ($transfer) {
foreach ($transfer->files as $file) {
Storage::disk($file->disk)->delete($file->path);
}
if ($transfer->qrCode) {
if ($transfer->qrCode->png_path) {
Storage::disk('qr')->delete($transfer->qrCode->png_path);
}
if ($transfer->qrCode->svg_path) {
Storage::disk('qr')->delete($transfer->qrCode->svg_path);
}
$transfer->qrCode->update(['is_active' => false]);
}
$transfer->update(['status' => Transfer::STATUS_DELETED]);
});
}
}