Files
projectsend/app/Modules/Files/Http/Controllers/OrphanFilesController.php
T
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

191 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\OrphanFileScanner;
use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Support\Pagination;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
/**
* v1-parity repair tool for the import_orphans permission: files sitting
* on disk with no matching File row (interrupted upload, restore,
* manual filesystem access) can be adopted in place or discarded.
* Scans the local disk, plus external storage once it's active — see
* OrphanFileScanner.
*/
class OrphanFilesController extends Controller
{
public function __construct(
private readonly OrphanFileScanner $scanner,
private readonly StoreUploadedFile $storeFile,
private readonly ActivityLogger $activity,
) {}
private const PER_PAGE = 25;
public function index(Request $request): Response|RedirectResponse
{
$user = $request->user();
assert($user !== null);
$validated = $request->validate(['search' => ['nullable', 'string', 'max:255']]);
$search = trim($validated['search'] ?? '');
// A full disk scan (potentially thousands of entries, across
// every scanned disk) happens once per request regardless of
// page — Storage::allFiles() has no server-side paging of its
// own — but only one page's worth of size()/lastModified()/
// isAllowed() stat calls and JSON payload ever reaches the response.
$matches = $this->scanner->scan($user, $search !== '' ? $search : null);
$page = Paginator::resolveCurrentPage();
$lastPage = (int) max(1, ceil(count($matches) / self::PER_PAGE));
// A stale/guessed ?page= beyond what actually exists (e.g. after
// importing/deleting enough rows to shrink the list, or just
// typed by hand) would otherwise silently render an empty page
// instead of the real last one.
if ($page > $lastPage) {
return redirect()->route('orphan-files.index', array_filter([
'search' => $search !== '' ? $search : null,
'page' => $lastPage > 1 ? $lastPage : null,
]));
}
$paginator = new LengthAwarePaginator(
array_slice($matches, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
count($matches),
self::PER_PAGE,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
return Inertia::render('files/orphans', [
'orphans' => $paginator->items(),
'pagination' => Pagination::meta($paginator),
'search' => $search,
'scanned_disks' => $this->scanner->scannedDisks(),
]);
}
public function import(Request $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
$validated = $this->validateItems($request);
$imported = 0;
$importedFile = null;
foreach ($validated['items'] as $item) {
$disk = Storage::disk($item['disk']);
// Re-validate against a fresh scan — never trust a
// client-supplied disk/path just because an earlier scan
// listed it.
if (! $this->scanner->isImportable($user, $item['disk'], $item['path'])) {
continue;
}
$importedFile = $this->storeFile->create(
uploader: $user,
originalName: basename($item['path']),
path: $item['path'],
mimeType: $disk->mimeType($item['path']) ?: 'application/octet-stream',
size: $disk->size($item['path']),
checksum: $this->checksumOf($disk, $item['path']),
folderId: null,
disk: $item['disk'],
action: Action::FileImported,
);
$imported++;
}
// A single-file import (the per-row "Import" button) goes straight
// to the editor, same as a plain upload would — a bulk import has
// no single file to land on, so it stays on the list.
if ($imported === 1 && $importedFile !== null) {
return redirect()->route('files.edit', $importedFile)->with('success', __('File imported.'));
}
return back()->with('success', trans_choice(
':count file imported.|:count files imported.',
$imported,
['count' => (string) $imported],
));
}
public function destroy(Request $request): RedirectResponse
{
$validated = $this->validateItems($request);
$deleted = 0;
foreach ($validated['items'] as $item) {
if (! $this->scanner->isOrphan($item['disk'], $item['path'])) {
continue;
}
Storage::disk($item['disk'])->delete($item['path']);
$this->activity->log(Action::OrphanFileDeleted, $request->user(), context: ['name' => basename($item['path'])]);
$deleted++;
}
return back()->with('success', trans_choice(
':count file deleted.|:count files deleted.',
$deleted,
['count' => (string) $deleted],
));
}
/**
* @return array{items: list<array{disk: string, path: string}>}
*/
private function validateItems(Request $request): array
{
return $request->validate([
'items' => ['required', 'array', 'min:1'],
'items.*.disk' => ['required', 'string', Rule::in(array_keys($this->scanner->scannedDisks()))],
'items.*.path' => ['required', 'string'],
]);
}
/**
* Streamed rather than hash_file() on a local path — the only way to
* checksum a file that might live on a non-local disk (S3 has no
* local filesystem path to hand hash_file()).
*/
private function checksumOf(Filesystem $disk, string $path): string
{
$stream = $disk->readStream($path);
if ($stream === null) {
return '';
}
$context = hash_init('sha256');
hash_update_stream($context, $stream);
fclose($stream);
return hash_final($context);
}
}