Files
ladill-transfer/app/Http/Controllers/Transfer/FilesController.php
T
isaaccladandCursor 1a95915e21
Deploy Ladill Transfer / deploy (push) Successful in 28s
Separate share transfers from Files storage and add folder upload to New transfer.
Transfers lists only intentional shares while Files shows all storage, and New transfer accepts folder picks with flattened file uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 22:31:45 +00:00

551 lines
19 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(function ($query) {
$query->where('is_root_storage', true)
->orWhere(function ($nested) {
$nested->where('is_folder', false)->where('is_root_storage', 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)
->with('qrCode')
->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', []));
$folders = $this->authorizedFolders($request->input('folders', []));
if ($files->isEmpty() && $folders->isEmpty()) {
return back()->with('error', 'Select at least one file or folder to download.');
}
if ($files->count() === 1 && $folders->isEmpty()) {
$file = $files->first();
abort_unless($file->existsOnDisk(), 404);
return Storage::disk($file->disk)->response(
$file->path,
$file->original_name,
['Content-Type' => $file->mime_type],
);
}
if ($folders->count() === 1 && $files->isEmpty()) {
$folder = $folders->first();
return $this->streamFolderZip($folder, $folder->title.'.zip');
}
$entries = $this->zipEntriesForFiles($files);
foreach ($folders as $folder) {
$prefix = $folders->count() > 1 ? $folder->title.'/' : '';
foreach ($folder->files as $file) {
$entries[] = [
'zipPath' => $prefix.$file->original_name,
'file' => $file,
];
}
}
return $this->streamZipEntries(
$entries,
'ladill-files-'.now()->format('Y-m-d-His').'.zip',
);
}
public function downloadFolder(Transfer $folder): StreamedResponse|RedirectResponse
{
$this->authorizeFolder($folder);
if ($folder->files()->count() === 0) {
return back()->with('error', 'This folder is empty.');
}
return $this->streamFolderZip($folder, $folder->title.'.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', []));
$folders = $this->authorizedFolders($request->input('folders', []));
if ($files->isEmpty() && $folders->isEmpty()) {
return response()->json(['message' => 'Select at least one file or folder to share.'], 422);
}
$links = $files
->map(fn (TransferFile $file) => $file->transfer->qrCode?->publicUrl())
->merge($folders->map(fn (Transfer $folder) => $folder->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',
'upload_folder_name' => 'nullable|string|max:120',
]);
$account = ladill_account();
if (! empty($data['upload_folder_name'])) {
return response()->json(['conflicts' => []]);
}
$folder = $this->resolveUploadFolder($account, $data['folder'] ?? null);
$conflictLocation = $folder ?? $this->storage->rootStorage($account);
$conflicts = $this->storage->findConflicts(
$account,
$conflictLocation,
$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',
'upload_folder_name' => 'nullable|string|max:120',
'resolutions' => 'nullable|array',
'resolutions.*' => 'in:replace,keep_both',
]);
$account = ladill_account();
$files = array_values($request->file('files', []) ?? []);
$createdFolder = null;
try {
if (! empty($data['upload_folder_name'])) {
$createdFolder = $this->storage->createFolderForUpload($account, $data['upload_folder_name']);
$folder = $createdFolder;
} else {
$folder = $this->resolveUploadFolder($account, $data['folder'] ?? null);
}
$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 ($createdFolder) {
$message = $count.' file'.($count === 1 ? '' : 's').' uploaded to '.$createdFolder->title.'.';
}
if ($request->expectsJson()) {
return response()->json([
'message' => $message,
'count' => $count,
'folder_id' => $createdFolder?->id,
]);
}
if ($createdFolder) {
return redirect()
->route('transfer.files.index', ['folder' => $createdFolder->id])
->with('success', $message);
}
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,
);
}
private function authorizeFolder(Transfer $folder): void
{
abort_unless(
$folder->user_id === ladill_account()->id
&& $folder->is_folder
&& $folder->status !== Transfer::STATUS_DELETED,
403,
);
}
/** @param array<int|string>|null $ids */
private function authorizedFolders(?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 Transfer::query()
->whereIn('id', $ids)
->where('user_id', ladill_account()->id)
->where('is_folder', true)
->where('status', '!=', Transfer::STATUS_DELETED)
->with(['files', 'qrCode'])
->get();
}
private function streamFolderZip(Transfer $folder, string $filename): StreamedResponse
{
$folder->loadMissing('files');
return $this->streamZipEntries(
$this->zipEntriesForFiles($folder->files),
$filename,
);
}
/** @param Collection<int, TransferFile>|iterable<int, TransferFile> $files
* @return list<array{zipPath: string, file: TransferFile}>
*/
private function zipEntriesForFiles(iterable $files): array
{
$entries = [];
foreach ($files as $file) {
$entries[] = [
'zipPath' => $file->original_name,
'file' => $file,
];
}
return $entries;
}
/** @param list<array{zipPath: string, file: TransferFile}> $entries */
private function streamZipEntries(array $entries, string $filename): StreamedResponse
{
return response()->streamDownload(function () use ($entries) {
$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 ($entries as $entry) {
$file = $entry['file'];
if (! $file->existsOnDisk()) {
continue;
}
$name = $entry['zipPath'];
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']);
}
}