Deploy Ladill Transfer / deploy (push) Successful in 31s
Surface wallet balance and key stats at a glance, matching the layout used across other Ladill apps. Co-authored-by: Cursor <cursoragent@cursor.com>
568 lines
19 KiB
PHP
568 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\Billing\BillingClient;
|
|
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,
|
|
private BillingClient $billing,
|
|
) {}
|
|
|
|
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');
|
|
|
|
$fileCount = TransferFile::query()
|
|
->whereHas('transfer', fn ($q) => $q
|
|
->where('user_id', $account->id)
|
|
->where('status', '!=', Transfer::STATUS_DELETED))
|
|
->count();
|
|
|
|
$balanceMinor = 0;
|
|
try {
|
|
$balanceMinor = $this->billing->balanceMinor($account->public_id);
|
|
} catch (\Throwable) {
|
|
// Balance is informational on this page.
|
|
}
|
|
|
|
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),
|
|
'fileCount' => $fileCount,
|
|
'balanceMinor' => $balanceMinor,
|
|
]);
|
|
}
|
|
|
|
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']);
|
|
}
|
|
}
|