mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
6b76c11192
A file's history was only reachable from the library list, through the details panel's Activity tab — so anyone who arrived at the file from a link, a search or a notification had to go back and find the row they came from to ask what had happened to it. The file's own page now carries an Activity tab of its own, next to General and Sharing: the twenty most recent entries, fetched only if the tab is opened, and a link to the full history. It is behind the same view_actions_log permission as everywhere else. That full history is now filterable, which is the point of sending somebody to it. The action list is built from the file's own log rather than from the eighty-odd actions the software can record — all but a handful of which can never apply to a file — and each option carries its count. Downloads are three separate actions on purpose (a signed-in recipient, a public link, the public group listing), so "All downloads" asks that question once instead of three times; the group only appears when the file's log actually holds more than one of its members. Narrowing by who acted and by date range works the same as it does on the main activity log, the reader's own calendar day included.
514 lines
21 KiB
PHP
514 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\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\ActivityPresenter;
|
|
use App\Modules\Audit\DownloadPresenter;
|
|
use App\Modules\Comments\CommentingRules;
|
|
use App\Modules\Files\Access\DownloadAllowance;
|
|
use App\Modules\Files\Access\ShareTargets;
|
|
use App\Modules\Files\DownloadLimitScope;
|
|
use App\Modules\Files\Models\Category;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\Folder;
|
|
use App\Modules\Files\Models\ShareLink;
|
|
use App\Modules\Files\Versions\FileVersionLinks;
|
|
use App\Modules\Platform\Localization\LocalDay;
|
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* JSON feeds for the details slide-over (Details / Sharing / Activity),
|
|
* so a file's panel opens over the list without navigating away.
|
|
*/
|
|
class FileDetailsController extends Controller
|
|
{
|
|
/** Raw rows considered when grouping downloads() by actor — see that method's docblock. */
|
|
private const DOWNLOADS_SUMMARY_LIMIT = 500;
|
|
|
|
/**
|
|
* Filters that stand for a question rather than for one logged action.
|
|
*
|
|
* "Who downloaded this file?" is not one action: a signed-in recipient,
|
|
* somebody following a public link and a visitor to a public group
|
|
* listing are recorded separately, on purpose, because *how* a file
|
|
* left matters. But nobody reading a file's history wants to ask the
|
|
* question three times, so these offer it once — and are still only
|
|
* shown when the file's own log has more than one of the members.
|
|
*
|
|
* @var array<string, array{label: string, actions: list<Action>}>
|
|
*/
|
|
private const ACTION_GROUPS = [
|
|
'downloads' => [
|
|
'label' => 'All downloads',
|
|
'actions' => [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded],
|
|
],
|
|
'previews' => [
|
|
'label' => 'All previews',
|
|
'actions' => [Action::FilePreviewed, Action::PublicFilePreviewed],
|
|
],
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly ActivityPresenter $presenter,
|
|
private readonly DownloadPresenter $downloadPresenter,
|
|
private readonly ShareTargets $shareTargets,
|
|
private readonly CommentingRules $commenting,
|
|
private readonly FileVersionLinks $versionLinks,
|
|
private readonly DownloadAllowance $allowance,
|
|
private readonly TimezoneRegistry $timezones,
|
|
) {}
|
|
|
|
public function show(Request $request, File $file): JsonResponse
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
Gate::forUser($viewer)->authorize('view', $file);
|
|
|
|
return response()->json([
|
|
'type' => 'file',
|
|
'id' => $file->id,
|
|
'name' => $file->name,
|
|
'description' => $file->description,
|
|
'original_name' => $file->original_name,
|
|
'size' => $file->size,
|
|
'mime_type' => $file->mime_type,
|
|
'checksum' => $file->checksum,
|
|
'uploader' => $file->uploader?->name,
|
|
'folder' => $file->folder?->only('id', 'name'),
|
|
'categories' => $file->categories()->orderBy('name')->get()
|
|
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])
|
|
->values(),
|
|
'created_at' => $file->created_at?->toIso8601String(),
|
|
|
|
// The two rules that decide whether this file can still be
|
|
// taken. The panel is where someone looks to find out why a
|
|
// client says they cannot download something, so it has to
|
|
// answer that without sending them to the edit page.
|
|
'expires_at' => $file->expires_at?->toIso8601String(),
|
|
'expired' => $file->isExpired(),
|
|
'download_limit' => $file->download_limit,
|
|
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
|
// The file's total downloads, whatever the scope. Under a
|
|
// per-user limit no single figure can stand for "used", so
|
|
// the panel presents this as the file's own count rather
|
|
// than as a share of anyone's allowance.
|
|
'downloads_used' => $file->downloads()->count(),
|
|
// What *this* viewer has left, which is what the panel's own
|
|
// download button obeys. Not the same question as the row
|
|
// above: staff who did not upload the file are subject to
|
|
// its limit like anybody else.
|
|
'download_allowance' => $this->allowance->summaryFor($file, $viewer),
|
|
|
|
'version' => $this->versionLinks->for($file, $viewer, fn (File $other): string => route('files.edit', $other, false)),
|
|
'download_url' => route('files.download', $file, false),
|
|
'edit_url' => route('files.edit', $file, false),
|
|
'can_update' => Gate::forUser($viewer)->allows('update', $file),
|
|
'can_view_activity' => $viewer->can('view_actions_log'),
|
|
// Whether the panel offers a Comments tab at all. False only
|
|
// when the install has commenting off, or this file falls
|
|
// outside the configured scope — existing comments on a file
|
|
// that has left the scope stay readable, so this stays true
|
|
// while there is anything to read.
|
|
'comments_enabled' => $this->commenting->enabled(),
|
|
// Resolved from the chain root for a revision (ShareTargets
|
|
// does that), so this names who really has the file. The panel
|
|
// says where those recipients are set.
|
|
'shares' => $this->shareTargets->assigned($file),
|
|
'sharing_root' => $file->isRevision()
|
|
? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
|
|
: null,
|
|
// Read-only here: creating/revoking a public link is edited
|
|
// from the file's own edit page, not this info panel.
|
|
'share_links' => $file->shareLinks()->orderByDesc('created_at')->get()
|
|
->map(fn (ShareLink $link): array => [
|
|
'id' => $link->id,
|
|
'url' => route('share.show', $link->token),
|
|
'expires_at' => $link->expires_at?->toIso8601String(),
|
|
'max_downloads' => $link->max_downloads,
|
|
'downloads_count' => $link->downloads_count,
|
|
])->values(),
|
|
]);
|
|
}
|
|
|
|
public function activity(Request $request, File $file): JsonResponse
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
Gate::forUser($viewer)->authorize('view', $file);
|
|
abort_unless($viewer->can('view_actions_log'), 403);
|
|
|
|
$query = ActivityLog::query()
|
|
->where('subject_type', $file->getMorphClass())
|
|
->where('subject_id', $file->id);
|
|
|
|
$total = (clone $query)->count();
|
|
|
|
$entries = $query
|
|
->orderByDesc('created_at')->orderByDesc('id')
|
|
->limit(20)->get()
|
|
->map(fn (ActivityLog $entry): array => $this->presenter->present($entry));
|
|
|
|
return response()->json(['entries' => $entries, 'total' => $total]);
|
|
}
|
|
|
|
/**
|
|
* Full, paginated activity history for a file — the "View full
|
|
* history" destination linked from the details panel's Activity tab,
|
|
* which only shows the most recent 20 entries.
|
|
*/
|
|
public function activityHistory(Request $request, File $file): Response
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
Gate::forUser($viewer)->authorize('view', $file);
|
|
abort_unless($viewer->can('view_actions_log'), 403);
|
|
|
|
return $this->renderHistory(
|
|
$request,
|
|
$file->getMorphClass(),
|
|
$file->id,
|
|
$file->name,
|
|
route('files.edit', $file, false).'?tab=activity',
|
|
'files.activity.history',
|
|
['file' => $file->id],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Who downloaded this file and how many times, grouped by actor,
|
|
* with each individual download's timestamp and IP address so the
|
|
* list can be expanded per person.
|
|
*
|
|
* Grouping happens in PHP (the group key mixes actor and link/public
|
|
* cases, not a single column), so it runs over the most recent
|
|
* DOWNLOADS_SUMMARY_LIMIT raw rows rather than the whole table —
|
|
* accurate for typical files, but a heavy downloader's count could
|
|
* undercount past that window. `total` is a true, unbounded count;
|
|
* the full unbounded per-row list lives at downloadsHistory().
|
|
*/
|
|
public function downloads(Request $request, File $file): JsonResponse
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
Gate::forUser($viewer)->authorize('view', $file);
|
|
abort_unless($viewer->can('view_actions_log'), 403);
|
|
|
|
$query = ActivityLog::query()
|
|
->where('subject_type', $file->getMorphClass())
|
|
->where('subject_id', $file->id)
|
|
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]);
|
|
|
|
$total = (clone $query)->count();
|
|
|
|
$entries = $query
|
|
->orderByDesc('created_at')
|
|
->orderByDesc('id')
|
|
->limit(self::DOWNLOADS_SUMMARY_LIMIT)
|
|
->get();
|
|
|
|
$downloaders = $entries
|
|
->groupBy(function (ActivityLog $entry): string {
|
|
return match ($entry->action) {
|
|
Action::ShareLinkDownloaded => 'share_link',
|
|
Action::PublicFileDownloaded => 'public_listing',
|
|
default => $entry->actor_id !== null ? 'user:'.$entry->actor_id : 'deleted:'.$entry->actor_name,
|
|
};
|
|
})
|
|
->map(function (Collection $group): array {
|
|
/** @var ActivityLog $first */
|
|
$first = $group->first();
|
|
$entry = $this->downloadPresenter->present($first);
|
|
|
|
return [
|
|
'actor_id' => $first->actor_id,
|
|
'actor_name' => $entry['actor_name'],
|
|
'actor_type' => $entry['actor_type'],
|
|
'count' => $group->count(),
|
|
'downloads' => $group->map(fn (ActivityLog $entry): array => [
|
|
'created_at' => $entry->created_at->toIso8601String(),
|
|
'ip_address' => $entry->ip_address,
|
|
])->values(),
|
|
];
|
|
})
|
|
->sortByDesc('count')
|
|
->values();
|
|
|
|
return response()->json(['downloaders' => $downloaders, 'total' => $total]);
|
|
}
|
|
|
|
/**
|
|
* Full, paginated download history for a file — the flat, one-row-
|
|
* per-download counterpart to downloads() above, which only groups
|
|
* and caps for the details panel's Downloads tab.
|
|
*/
|
|
public function downloadsHistory(Request $request, File $file): Response
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
Gate::forUser($viewer)->authorize('view', $file);
|
|
abort_unless($viewer->can('view_actions_log'), 403);
|
|
|
|
$entries = ActivityLog::query()
|
|
->where('subject_type', $file->getMorphClass())
|
|
->where('subject_id', $file->id)
|
|
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
|
|
->orderByDesc('created_at')->orderByDesc('id')
|
|
->paginate(25)
|
|
->withQueryString();
|
|
|
|
return Inertia::render('activity/downloads', [
|
|
'entries' => $entries->getCollection()->map(fn (ActivityLog $entry): array => $this->downloadPresenter->present($entry))->all(),
|
|
'pagination' => [
|
|
'page' => $entries->currentPage(),
|
|
'last_page' => $entries->lastPage(),
|
|
'prev' => $entries->previousPageUrl(),
|
|
'next' => $entries->nextPageUrl(),
|
|
'total' => $entries->total(),
|
|
],
|
|
'subject_name' => $file->name,
|
|
'back_url' => route('files.edit', $file, false),
|
|
]);
|
|
}
|
|
|
|
public function showFolder(Request $request, Folder $folder): JsonResponse
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
Gate::forUser($viewer)->authorize('view', $folder);
|
|
|
|
return response()->json([
|
|
'type' => 'folder',
|
|
'id' => $folder->id,
|
|
'name' => $folder->name,
|
|
'files_count' => $folder->files()->count(),
|
|
'children_count' => $folder->children()->count(),
|
|
'creator' => $folder->creator?->name,
|
|
'created_at' => $folder->created_at?->toIso8601String(),
|
|
'open_url' => route('files.index', ['folder' => $folder->id], false),
|
|
// Read-only here, same as a file's shares — sharing (and every
|
|
// other editable field) is changed from the folder's own edit
|
|
// page, not this info panel.
|
|
'edit_url' => route('folders.share', $folder, false),
|
|
'can_update' => Gate::forUser($viewer)->allows('update', $folder),
|
|
'can_view_activity' => $viewer->can('view_actions_log'),
|
|
'shares' => $this->shareTargets->assigned($folder),
|
|
]);
|
|
}
|
|
|
|
public function folderActivity(Request $request, Folder $folder): JsonResponse
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
Gate::forUser($viewer)->authorize('view', $folder);
|
|
abort_unless($viewer->can('view_actions_log'), 403);
|
|
|
|
$query = ActivityLog::query()
|
|
->where('subject_type', $folder->getMorphClass())
|
|
->where('subject_id', $folder->id);
|
|
|
|
$total = (clone $query)->count();
|
|
|
|
$entries = $query
|
|
->orderByDesc('created_at')->orderByDesc('id')
|
|
->limit(20)->get()
|
|
->map(fn (ActivityLog $entry): array => $this->presenter->present($entry));
|
|
|
|
return response()->json(['entries' => $entries, 'total' => $total]);
|
|
}
|
|
|
|
/**
|
|
* Full, paginated activity history for a folder — same idea as
|
|
* activityHistory(), for the folder details panel.
|
|
*/
|
|
public function folderActivityHistory(Request $request, Folder $folder): Response
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
Gate::forUser($viewer)->authorize('view', $folder);
|
|
abort_unless($viewer->can('view_actions_log'), 403);
|
|
|
|
return $this->renderHistory(
|
|
$request,
|
|
$folder->getMorphClass(),
|
|
$folder->id,
|
|
$folder->name,
|
|
route('files.index', ['folder' => $folder->id], false),
|
|
'folders.activity.history',
|
|
['folder' => $folder->id],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $routeParams
|
|
*/
|
|
private function renderHistory(
|
|
Request $request,
|
|
string $morphClass,
|
|
int $subjectId,
|
|
string $subjectName,
|
|
string $backUrl,
|
|
string $routeName,
|
|
array $routeParams,
|
|
): Response {
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
|
|
$filters = $this->validatedHistoryFilters($request);
|
|
|
|
$entries = $this->historyQuery($morphClass, $subjectId, $filters, $viewer)
|
|
->paginate(25)
|
|
->withQueryString();
|
|
|
|
return Inertia::render('activity/subject', [
|
|
'entries' => $entries->getCollection()
|
|
->map(fn (ActivityLog $entry): array => $this->presenter->present($entry))
|
|
->all(),
|
|
'pagination' => [
|
|
'page' => $entries->currentPage(),
|
|
'last_page' => $entries->lastPage(),
|
|
'prev' => $entries->previousPageUrl(),
|
|
'next' => $entries->nextPageUrl(),
|
|
'total' => $entries->total(),
|
|
],
|
|
'filters' => $filters,
|
|
'action_options' => $this->actionOptions($morphClass, $subjectId),
|
|
'subject_name' => $subjectName,
|
|
'back_url' => $backUrl,
|
|
'route_name' => $routeName,
|
|
'route_params' => $routeParams,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The actions this subject's history actually contains, with how many
|
|
* times each happened.
|
|
*
|
|
* Built from the log rather than from `Action::cases()`: the enum has
|
|
* over eighty members and all but a handful can never appear against a
|
|
* file, so offering them all would be a dropdown you scroll past the
|
|
* answer in. What is here is what happened.
|
|
*
|
|
* @return list<array{key: string, label: string, count: int}>
|
|
*/
|
|
private function actionOptions(string $morphClass, int $subjectId): array
|
|
{
|
|
/** @var array<string, int> $counts */
|
|
$counts = ActivityLog::query()
|
|
->where('subject_type', $morphClass)
|
|
->where('subject_id', $subjectId)
|
|
->selectRaw('action, count(*) as total')
|
|
->groupBy('action')
|
|
->pluck('total', 'action')
|
|
->map(fn ($total): int => (int) $total)
|
|
->all();
|
|
|
|
$options = [];
|
|
|
|
foreach (self::ACTION_GROUPS as $key => $group) {
|
|
$present = array_filter($group['actions'], fn (Action $action): bool => isset($counts[$action->value]));
|
|
|
|
// One member present means the group would filter to exactly
|
|
// what its member already offers, under a vaguer name.
|
|
if (count($present) < 2) {
|
|
continue;
|
|
}
|
|
|
|
$options[] = [
|
|
'key' => $key,
|
|
'label' => $group['label'],
|
|
'count' => array_sum(array_map(fn (Action $action): int => $counts[$action->value], $present)),
|
|
];
|
|
}
|
|
|
|
// Enum order, not count order, so the list does not rearrange
|
|
// itself under the reader every time the file is downloaded.
|
|
foreach (Action::cases() as $action) {
|
|
if (! isset($counts[$action->value])) {
|
|
continue;
|
|
}
|
|
|
|
$options[] = [
|
|
'key' => $action->value,
|
|
'label' => $action->description(),
|
|
'count' => $counts[$action->value],
|
|
];
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* @return array{action: ?string, actor: ?string, from: ?string, to: ?string}
|
|
*/
|
|
private function validatedHistoryFilters(Request $request): array
|
|
{
|
|
$validated = $request->validate([
|
|
'action' => ['nullable', Rule::in([
|
|
...array_keys(self::ACTION_GROUPS),
|
|
...array_column(Action::cases(), 'value'),
|
|
])],
|
|
'actor' => ['nullable', 'string', 'max:255'],
|
|
'from' => ['nullable', 'date'],
|
|
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
|
]);
|
|
|
|
return [
|
|
'action' => $validated['action'] ?? null,
|
|
'actor' => $validated['actor'] ?? null,
|
|
'from' => $validated['from'] ?? null,
|
|
'to' => $validated['to'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{action: ?string, actor: ?string, from: ?string, to: ?string} $filters
|
|
* @return Builder<ActivityLog>
|
|
*/
|
|
private function historyQuery(string $morphClass, int $subjectId, array $filters, User $viewer): Builder
|
|
{
|
|
$timezone = $this->timezones->resolve($viewer);
|
|
|
|
return ActivityLog::query()
|
|
->where('subject_type', $morphClass)
|
|
->where('subject_id', $subjectId)
|
|
->when($filters['action'], function (Builder $query, string $action): void {
|
|
$group = self::ACTION_GROUPS[$action] ?? null;
|
|
|
|
$group === null
|
|
? $query->where('action', $action)
|
|
: $query->whereIn('action', array_map(fn (Action $member): string => $member->value, $group['actions']));
|
|
})
|
|
// Matched on the name snapshotted onto the entry, the same as
|
|
// the main log: an account deleted since is still findable by
|
|
// the name it acted under, which is the whole point of the
|
|
// snapshot.
|
|
->when($filters['actor'], fn (Builder $query, string $actor) => $query->where('actor_name', 'like', "%{$actor}%"))
|
|
// The reader'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');
|
|
}
|
|
}
|