mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
7d1903f9db
The installation-wide download history listed every download newest first and offered nothing else, so "did that client ever actually download the contract?" meant paging through everything that had happened since. It now filters by file name, by who downloaded it, and by date range, in the same toolbar every other list uses: the query string carries the filters, so a narrowed view is a link somebody can be sent. Both names are matched against what the entry snapshotted rather than through a join, so a file or an account deleted since is still findable by the name it went out under — often exactly what this page is being asked. The filters narrow the viewer's already-scoped query rather than replacing it, so a client-scoped staffer cannot search their way to a download of a file outside their library.
128 lines
5.3 KiB
PHP
128 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Audit\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Audit\ActivityLogScope;
|
|
use App\Modules\Audit\DownloadPresenter;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Platform\Localization\LocalDay;
|
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
|
use App\Support\Pagination;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Http\Request;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* Installation-wide download history — every FileDownloaded /
|
|
* ShareLinkDownloaded / PublicFileDownloaded entry across every file,
|
|
* newest first. The per-file downloads tab/history (FileDetailsController)
|
|
* covers a single file; this is the "all of them" view linked from the
|
|
* sidebar.
|
|
*/
|
|
class DownloadsController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly DownloadPresenter $presenter,
|
|
private readonly ActivityLogScope $scope,
|
|
private readonly TimezoneRegistry $timezones,
|
|
) {}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
|
|
$filters = $this->validatedFilters($request);
|
|
|
|
$entries = $this->filteredQuery($filters, $viewer)
|
|
->paginate(25)
|
|
->withQueryString();
|
|
|
|
$canOpenFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
|
|
|
|
// Openable, not merely existing — the scope decides, so a row never
|
|
// links to a file the viewer would be refused.
|
|
$openableFileIds = $this->scope->openableFileIds($viewer, $entries->getCollection()->pluck('subject_id'));
|
|
|
|
return Inertia::render('activity/downloads', [
|
|
'entries' => $entries->getCollection()->map(function (ActivityLog $entry) use ($openableFileIds, $canOpenFiles): array {
|
|
$openable = $entry->subject_id !== null && isset($openableFileIds[$entry->subject_id]);
|
|
|
|
return [
|
|
...$this->presenter->present($entry),
|
|
'file_name' => $entry->subject_name ?? __('(deleted file)'),
|
|
'file_url' => $openable && $canOpenFiles ? route('files.edit', $entry->subject_id, false) : null,
|
|
];
|
|
})->all(),
|
|
'pagination' => Pagination::meta($entries),
|
|
'filters' => $filters,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{file: ?string, user: ?string, from: ?string, to: ?string}
|
|
*/
|
|
private function validatedFilters(Request $request): array
|
|
{
|
|
$validated = $request->validate([
|
|
'file' => ['nullable', 'string', 'max:255'],
|
|
'user' => ['nullable', 'string', 'max:255'],
|
|
'from' => ['nullable', 'date'],
|
|
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
|
]);
|
|
|
|
return [
|
|
'file' => $validated['file'] ?? null,
|
|
'user' => $validated['user'] ?? null,
|
|
'from' => $validated['from'] ?? null,
|
|
'to' => $validated['to'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{file: ?string, user: ?string, from: ?string, to: ?string} $filters
|
|
* @return Builder<ActivityLog>
|
|
*/
|
|
private function filteredQuery(array $filters, User $viewer): Builder
|
|
{
|
|
$timezone = $this->timezones->resolve($viewer);
|
|
|
|
// A download row names the file and says who fetched it from which
|
|
// IP, so it needs the viewer's library scope applied — not just
|
|
// `view_actions_log`. See ActivityLogScope for the full reasoning.
|
|
return $this->scope
|
|
->apply(ActivityLog::query(), $viewer)
|
|
->where('subject_type', (new File)->getMorphClass())
|
|
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
|
|
// Both names are matched on what the entry snapshotted, not on
|
|
// a join: a file or an account deleted since is still findable
|
|
// by the name it went out under, which is often exactly what
|
|
// this page is being asked.
|
|
->when($filters['file'], fn (Builder $query, string $file) => $query->where('subject_name', 'like', "%{$file}%"))
|
|
// Only rows with a real account can match a name. The two
|
|
// anonymous flavours ("Public link", "Public listing") are
|
|
// labels this page prints, not stored values, so a search for
|
|
// them finds nothing rather than something arbitrary.
|
|
->when($filters['user'], fn (Builder $query, string $user) => $query->where('actor_name', 'like', "%{$user}%"))
|
|
// The viewer's own calendar day, not the UTC one — see LocalDay.
|
|
->when(
|
|
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
|
|
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
|
|
)
|
|
->when(
|
|
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
|
|
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
|
|
)
|
|
->orderByDesc('created_at')
|
|
->orderByDesc('id');
|
|
}
|
|
}
|