Files
ladill-transfer/app/Http/Controllers/Transfer/FilesController.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

417 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers\Transfer;
use App\Http\Controllers\Controller;
use App\Models\Transfer;
use App\Models\TransferFile;
use App\Models\User;
use App\Services\Transfer\FileStorageService;
use App\Services\Transfer\TransferRecipientMailService;
use App\Services\Transfer\TransferService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
use ZipArchive;
class FilesController extends Controller
{
public function __construct(
private TransferService $transfers,
private FileStorageService $storage,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('q', ''));
$folderId = $request->integer('folder') ?: null;
$sort = (string) $request->query('sort', 'newest');
$viewMode = (string) $request->query('view', $folderId ? 'files' : 'all');
$folderTransfer = null;
if ($folderId) {
$folderTransfer = Transfer::query()
->where('user_id', $account->id)
->where('is_folder', true)
->where('status', '!=', Transfer::STATUS_DELETED)
->findOrFail($folderId);
}
$filesQuery = TransferFile::query()
->whereHas('transfer', fn ($q) => $q
->where('user_id', $account->id)
->where('status', '!=', Transfer::STATUS_DELETED)
->when($folderTransfer, fn ($inner) => $inner->whereKey($folderTransfer->id),
fn ($inner) => $inner->where('is_folder', false)))
->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%'))
->with(['transfer.qrCode'])
->when($sort === 'oldest', fn ($q) => $q->oldest())
->when($sort === 'name', fn ($q) => $q->orderBy('original_name'))
->when($sort === 'size', fn ($q) => $q->orderByDesc('size_bytes'))
->when($sort === 'downloads', fn ($q) => $q->orderByDesc('downloads_total'))
->when($sort === 'newest', fn ($q) => $q->latest());
$files = $filesQuery->paginate(30)->withQueryString();
$folders = Transfer::query()
->where('user_id', $account->id)
->where('is_folder', true)
->where('status', '!=', Transfer::STATUS_DELETED)
->withCount('files')
->when($search !== '', fn ($q) => $q->where('title', 'like', '%'.$search.'%'))
->orderBy('title')
->get();
$moveTargets = Transfer::query()
->where('user_id', $account->id)
->where('status', '!=', Transfer::STATUS_DELETED)
->where(function ($query) {
$query->where('is_folder', true)->orWhere('is_root_storage', true);
})
->orderBy('title')
->get(['id', 'title', 'is_root_storage']);
$storageBytes = Transfer::query()
->where('user_id', $account->id)
->where('status', '!=', Transfer::STATUS_DELETED)
->sum('storage_bytes');
return view('transfer.files.index', [
'files' => $files,
'folders' => $folders,
'folderTransfer' => $folderTransfer,
'moveTargets' => $moveTargets,
'search' => $search,
'sort' => $sort,
'viewMode' => $viewMode,
'storageBytes' => (int) $storageBytes,
'storageGb' => round($storageBytes / (1024 * 1024 * 1024), 2),
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
]);
}
public function download(TransferFile $file): StreamedResponse
{
$this->authorizeFile($file);
abort_unless($file->existsOnDisk(), 404);
return Storage::disk($file->disk)->response(
$file->path,
$file->original_name,
['Content-Type' => $file->mime_type],
);
}
public function bulkDownload(Request $request): StreamedResponse|RedirectResponse
{
$files = $this->authorizedFiles($request->input('files', []));
if ($files->isEmpty()) {
return back()->with('error', 'Select at least one file to download.');
}
if ($files->count() === 1) {
$file = $files->first();
abort_unless($file->existsOnDisk(), 404);
return Storage::disk($file->disk)->response(
$file->path,
$file->original_name,
['Content-Type' => $file->mime_type],
);
}
$filename = 'ladill-files-'.now()->format('Y-m-d-His').'.zip';
return response()->streamDownload(function () use ($files) {
$zip = new ZipArchive;
$tmp = tempnam(sys_get_temp_dir(), 'ladill_zip_');
if ($tmp === false || $zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Could not prepare download archive.');
}
$usedNames = [];
foreach ($files as $file) {
if (! $file->existsOnDisk()) {
continue;
}
$name = $file->original_name;
if (isset($usedNames[$name])) {
$usedNames[$name]++;
$ext = pathinfo($name, PATHINFO_EXTENSION);
$base = pathinfo($name, PATHINFO_FILENAME);
$name = $base.' ('.$usedNames[$name].')'.($ext !== '' ? '.'.$ext : '');
} else {
$usedNames[$name] = 0;
}
$zip->addFromString($name, Storage::disk($file->disk)->get($file->path));
}
$zip->close();
readfile($tmp);
@unlink($tmp);
}, $filename, ['Content-Type' => 'application/zip']);
}
public function destroy(TransferFile $file): RedirectResponse
{
$this->authorizeFile($file);
$this->transfers->deleteFile($file);
return back()->with('success', 'File deleted.');
}
public function bulkDestroy(Request $request): RedirectResponse
{
$files = $this->authorizedFiles($request->input('files', []));
if ($files->isEmpty()) {
return back()->with('error', 'Select at least one file to delete.');
}
$this->transfers->deleteFiles($files->all());
return back()->with('success', $files->count().' file'.($files->count() === 1 ? '' : 's').' deleted.');
}
public function move(Request $request): RedirectResponse
{
$data = $request->validate([
'destination' => 'required|integer|exists:transfers,id',
'files' => 'required|array|min:1',
'files.*' => 'integer|exists:transfer_files,id',
]);
$destination = Transfer::query()
->where('user_id', ladill_account()->id)
->where('status', '!=', Transfer::STATUS_DELETED)
->where(function ($query) {
$query->where('is_folder', true)->orWhere('is_root_storage', true);
})
->findOrFail($data['destination']);
$files = $this->authorizedFiles($data['files']);
$this->transfers->moveFiles($destination, $files->all());
return redirect()
->route('transfer.files.index', $destination->is_folder ? ['folder' => $destination->id] : [])
->with('success', $files->count().' file'.($files->count() === 1 ? '' : 's').' moved to '.$destination->title.'.');
}
public function share(Request $request): JsonResponse
{
$files = $this->authorizedFiles($request->input('files', []));
if ($files->isEmpty()) {
return response()->json(['message' => 'Select at least one file to share.'], 422);
}
$links = $files
->map(fn (TransferFile $file) => $file->transfer->qrCode?->publicUrl())
->filter()
->unique()
->values()
->all();
return response()->json([
'links' => $links,
'message' => count($links).' share link'.(count($links) === 1 ? '' : 's').' ready to copy.',
]);
}
public function reshareEmail(Request $request): RedirectResponse
{
$data = $request->validate([
'transfer' => 'required|integer|exists:transfers,id',
'email' => 'required|email|max:255',
]);
$transfer = Transfer::query()
->where('user_id', ladill_account()->id)
->where('status', '!=', Transfer::STATUS_DELETED)
->findOrFail($data['transfer']);
$transfer->forceFill([
'recipient_email' => $data['email'],
'recipient_email_milestones' => ['created'],
'recipient_milestones_sent' => [],
])->save();
app(TransferRecipientMailService::class)->notifyRecipient($transfer->fresh(['files', 'qrCode', 'user']), 'created');
return back()->with('success', 'Share link emailed to '.$data['email'].'.');
}
public function openInEmail(Request $request): RedirectResponse
{
$files = $this->authorizedFiles($request->input('files', []));
if ($files->isEmpty()) {
return back()->with('error', 'Select at least one file to open in email.');
}
$links = $files
->map(fn (TransferFile $file) => $file->transfer->qrCode?->publicUrl())
->filter()
->unique()
->values();
$names = $files->pluck('original_name')->unique()->take(3)->implode(', ');
if ($files->count() > 3) {
$names .= ' +'.($files->count() - 3).' more';
}
$body = "Hi,\n\nI'm sharing the following files with you via Ladill Transfer:\n\n";
foreach ($links as $link) {
$body .= $link."\n";
}
$body .= "\nDownload using the link".($links->count() === 1 ? '' : 's')." above.";
$url = ladill_webmail_url('').'?'.http_build_query([
'compose' => 1,
'subject' => 'Shared files: '.$names,
'body' => $body,
]);
return redirect()->away($url);
}
public function storeFolder(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:120',
'folder' => 'nullable|integer|exists:transfers,id',
]);
$account = ladill_account();
$parent = null;
if (! empty($data['folder'])) {
$parent = Transfer::query()
->where('user_id', $account->id)
->where('is_folder', true)
->findOrFail($data['folder']);
}
try {
$folder = $this->storage->createFolder($account, $data['name'], $parent);
} catch (RuntimeException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('transfer.files.index', ['folder' => $folder->id])
->with('success', 'Folder created.');
}
public function checkUpload(Request $request): JsonResponse
{
$data = $request->validate([
'filenames' => 'required|array|min:1',
'filenames.*' => 'required|string|max:255',
'folder' => 'nullable|integer|exists:transfers,id',
]);
$account = ladill_account();
$folder = $this->resolveUploadFolder($account, $data['folder'] ?? null);
$conflicts = $this->storage->findConflicts(
$account,
$folder,
$data['filenames'],
);
return response()->json(['conflicts' => $conflicts]);
}
public function storeUpload(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate([
'files' => 'required|array|min:1',
'files.*' => 'required|file',
'folder' => 'nullable|integer|exists:transfers,id',
'resolutions' => 'nullable|array',
'resolutions.*' => 'in:replace,keep_both',
]);
$account = ladill_account();
$folder = $this->resolveUploadFolder($account, $data['folder'] ?? null);
$files = array_values($request->file('files', []) ?? []);
try {
$uploaded = $this->storage->uploadFiles(
$account,
$folder,
$files,
$data['resolutions'] ?? [],
);
} catch (RuntimeException $e) {
if ($request->expectsJson()) {
return response()->json(['message' => $e->getMessage()], 422);
}
return back()->with('error', $e->getMessage());
}
$count = count($uploaded);
$message = $count.' file'.($count === 1 ? '' : 's').' uploaded.';
if ($request->expectsJson()) {
return response()->json(['message' => $message, 'count' => $count]);
}
return back()->with('success', $message);
}
private function resolveUploadFolder(User $user, ?int $folderId): ?Transfer
{
if ($folderId === null) {
return null;
}
return Transfer::query()
->where('user_id', $user->id)
->where('is_folder', true)
->where('status', '!=', Transfer::STATUS_DELETED)
->findOrFail($folderId);
}
/** @param array<int|string>|null $ids */
private function authorizedFiles(?array $ids): Collection
{
$ids = collect($ids ?? [])
->filter(fn ($id) => is_numeric($id))
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
if ($ids === []) {
return collect();
}
return TransferFile::query()
->whereIn('id', $ids)
->whereHas('transfer', fn ($q) => $q
->where('user_id', ladill_account()->id)
->where('status', '!=', Transfer::STATUS_DELETED))
->with(['transfer.qrCode'])
->get();
}
private function authorizeFile(TransferFile $file): void
{
abort_unless(
$file->transfer()->where('user_id', ladill_account()->id)->exists(),
403,
);
}
}