Files
ignacionelson 92bb807849 Show a client the public link to their own file
A client's portal lists two kinds of file side by side: what they
uploaded, and what somebody shared with them. Where a link exists on one
of their own, they can now copy it from the row — which is what makes the
hosted free plan a product rather than a place to put files, since a
customer there has no staff screen on which to make one.

The rule is narrow, and both halves are load-bearing: a link this client
created, on a file this client uploaded.

Not "a link on a file shared with them" — that link is the sharer's
decision about who may reach the file, and handing the recipient the URL
would quietly turn "you may download this" into "you may pass this on to
anyone".

And not "any link on their own file" either — a link staff minted on a
file a client uploaded exists for a reason the client may be no part of,
and on the shared instance it would sit beside the one link they were
promised. Ordering is by id, so an unfiltered lookup would hand them
whichever was minted first.

Both halves have a test that fails when only that half is removed. The
first draft did not: every case was carried by the ownership filter
alone, so the creator check was green for the wrong reason.

Links that no longer work are left out rather than shown greyed. The only
thing a client can do here is copy it, and a URL that answers "this link
has expired" is worse than no URL at all.

One query per listing, not one per row, and none at all for a client with
no files of their own.
2026-09-08 16:47:57 -03:00

79 lines
2.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Sharing;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use Illuminate\Support\Collection;
/**
* The public URLs a client may be shown for their own files.
*
* A client's portal lists two kinds of file side by side: what they
* uploaded, and what somebody shared with them. Both can carry share
* links, and only one kind of link is theirs to see — a link a staff
* member minted for a file they were given is that staff member's
* decision about who may reach it, and handing the recipient the URL
* would turn "you may download this" into "you may pass this on to
* anyone".
*
* So the rule is narrow and stated once: **a link this client created, on
* a file this client uploaded.** Both halves, not either. Neither is
* redundant — a client-created link on a file they no longer own would
* outlive a reassignment, and a staff link on their own upload is still
* not theirs to hand out.
*
* Inactive links are left out rather than shown greyed: the only thing a
* client can do with this is copy it, and a URL that answers "this link
* has expired" is worse than no URL at all.
*
* Resolved for a whole page at a time. One query for the listing, not one
* per row.
*/
class ClientShareLinks
{
/**
* @param Collection<int, File> $files
* @return array<int, string> file id => URL, for the files that have one
*/
public function forMany(Collection $files, User $client): array
{
$own = $files
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
if ($own === []) {
return [];
}
$links = ShareLink::query()
->where('shareable_type', (new File)->getMorphClass())
->whereIn('shareable_id', $own)
->where('created_by', $client->id)
// Oldest first, so a file that somehow carries two is
// described by the one the client has already been given
// rather than by whichever the database returned today.
->orderBy('id')
->get();
$urls = [];
foreach ($links as $link) {
$fileId = (int) $link->shareable_id;
if (isset($urls[$fileId]) || ! $link->isActive()) {
continue;
}
$urls[$fileId] = route('share.show', $link->token);
}
return $urls;
}
}