Files
ladill-transfer/app/Services/Transfer/TransferService.php
T
isaaccladandCursor f9b16f6b61
Deploy Ladill Transfer / deploy (push) Successful in 44s
Add Files page upload flow with manual folders and duplicate handling.
Users can create folders, upload files or folders, and choose replace or keep both on name conflicts instead of auto-grouping by transfer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:27:39 +00:00

280 lines
9.6 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
{
return $this->storeFileWithName(
$user,
$transfer,
$file,
$file->getClientOriginalName() ?: ('file.'.($file->getClientOriginalExtension() ?: 'bin')),
);
}
public function addStorageFile(User $user, Transfer $transfer, UploadedFile $file, string $displayName): TransferFile
{
$stored = $this->storeFileWithName($user, $transfer, $file, $displayName);
$transfer->increment('storage_bytes', $stored->size_bytes);
return $stored;
}
private function storeFileWithName(User $user, Transfer $transfer, UploadedFile $file, string $displayName): 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' => $displayName,
'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]);
});
}
public function deleteFile(TransferFile $file): void
{
DB::transaction(function () use ($file) {
$transfer = $file->transfer()->lockForUpdate()->first();
Storage::disk($file->disk)->delete($file->path);
$size = $file->size_bytes;
$file->delete();
$transfer->decrement('storage_bytes', $size);
if ($transfer->files()->count() === 0 && ! $transfer->shouldPersistWhenEmpty()) {
$this->delete($transfer->fresh(['files', 'qrCode']));
}
});
}
/** @param list<TransferFile> $files */
public function deleteFiles(array $files): void
{
foreach ($files as $file) {
$this->deleteFile($file);
}
}
/** @param list<TransferFile> $files */
public function moveFiles(Transfer $destination, array $files): void
{
if ($files === []) {
return;
}
DB::transaction(function () use ($destination, $files) {
$destination = Transfer::query()->lockForUpdate()->findOrFail($destination->id);
$movedBytes = 0;
$sourceAdjustments = [];
foreach ($files as $file) {
if ($file->transfer_id === $destination->id) {
continue;
}
$sourceId = $file->transfer_id;
$movedBytes += $file->size_bytes;
$sourceAdjustments[$sourceId] = ($sourceAdjustments[$sourceId] ?? 0) + $file->size_bytes;
$file->update(['transfer_id' => $destination->id]);
}
foreach ($sourceAdjustments as $sourceId => $bytes) {
Transfer::query()->whereKey($sourceId)->decrement('storage_bytes', $bytes);
$source = Transfer::query()->with('files')->find($sourceId);
if ($source && $source->files->isEmpty() && $source->status !== Transfer::STATUS_DELETED && ! $source->shouldPersistWhenEmpty()) {
$this->delete($source->fresh(['files', 'qrCode']));
}
}
if ($movedBytes > 0) {
$destination->increment('storage_bytes', $movedBytes);
}
});
}
}