mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
5493955bea
Staff keep seeing every file they always saw — withholding is about recipients, not about the library — so the library now carries the state on the row: Checking, Quarantined, Released, or Not scanned with the reason behind it. Nothing at all for a clean file, which is the common case. The API says the same in a `scan` object on every file, with an `available` flag so a caller need not learn which of six states mean "you can have it", and `scan_status` is a filter, so an integration can wait for the file it just uploaded or collect what is in quarantine. The download endpoint answers 423 for a file that is not available, which it already did through the shared controller. Re-exported the OpenAPI document.
180 lines
8.6 KiB
PHP
180 lines
8.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Http\Resources\Api;
|
|
|
|
use App\Modules\Files\Access\ClientIdentityScope;
|
|
use App\Modules\Files\DownloadLimitScope;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\FileAssignment;
|
|
use App\Modules\Groups\Models\Group;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\JsonResource;
|
|
|
|
/**
|
|
* @mixin File
|
|
*
|
|
* Every field is listed explicitly. Never $file->toArray() here: that
|
|
* would publish whatever the next migration adds, and two of this model's
|
|
* columns must not leave the server at all —
|
|
*
|
|
* - `path` and `disk` describe where the bytes physically live. A caller
|
|
* has no use for them (downloads go through the download endpoint,
|
|
* which authorizes and then hands off), and publishing them leaks the
|
|
* storage layout, which is exactly the map you would want before
|
|
* attempting to reach the bytes another way.
|
|
* - `checksum` is included deliberately, since verifying an integration's
|
|
* own download is a real use case, and it reveals nothing about
|
|
* location.
|
|
*
|
|
* Two fields are narrowed to the caller: the uploader and the assignment
|
|
* list both name clients, and a client-scoped account may hold a file whose
|
|
* uploader or co-recipients are clients off their own roster — the file is
|
|
* theirs to read, those names are not theirs to see. ClientIdentityScope is
|
|
* the rule; a name dropped here is dropped to null or out of the list, and
|
|
* an unscoped account is unaffected.
|
|
*
|
|
* That narrowing happens here rather than in the controllers, which is the opposite of how the version counterparts are
|
|
* handled a few files over — and deliberately so. Whether a counterpart may
|
|
* be named is a set-shaped question with a query to express it, so it is
|
|
* asked once in the caller's eager load. Whether a client may be named is a
|
|
* per-row check against the viewer's roster with no query to fold it into,
|
|
* and this resource is built at eight call sites across four controllers,
|
|
* two of them re-loading `assignments.assignable` after a write. Asking at
|
|
* the point of serialisation is the only version of this rule that cannot
|
|
* be forgotten by the ninth caller.
|
|
*/
|
|
class FileResource extends JsonResource
|
|
{
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function toArray(Request $request): array
|
|
{
|
|
$viewer = $request->user();
|
|
$identity = app(ClientIdentityScope::class);
|
|
|
|
// The morph class rather than ::class, matching ShareTargets: with
|
|
// a morph map registered the two disagree, and this line now
|
|
// decides which roster an entry is checked against, so getting it
|
|
// wrong would mean checking a group id against the client list.
|
|
$groupMorph = (new Group)->getMorphClass();
|
|
|
|
return [
|
|
'id' => $this->id,
|
|
'name' => $this->name,
|
|
'slug' => $this->slug,
|
|
'description' => $this->description,
|
|
'original_name' => $this->original_name,
|
|
'mime_type' => $this->mime_type,
|
|
'size' => $this->size,
|
|
'checksum' => $this->checksum,
|
|
'public' => $this->public,
|
|
// Only consulted while the installation's comment setting is
|
|
// "only files marked as commentable"; published anyway, since a
|
|
// caller that sets it wants to read it back.
|
|
'commentable' => $this->commentable,
|
|
'expires_at' => $this->expires_at?->toIso8601String(),
|
|
'expired' => $this->isExpired(),
|
|
|
|
// What the virus scanner made of this file. `pending` and
|
|
// `infected` mean the bytes are not available: the download
|
|
// endpoint answers 423 for both, and a caller that has just
|
|
// uploaded should poll this rather than the download. `note`
|
|
// carries the threat name, or why a file was not scanned.
|
|
'scan' => [
|
|
'status' => $this->scan_status->value,
|
|
'available' => $this->scan_status->isAvailable(),
|
|
'note' => $this->scan_note,
|
|
'scanned_at' => $this->scanned_at?->toIso8601String(),
|
|
],
|
|
|
|
// Null when the file may be downloaded any number of times.
|
|
// `download_limit_scope` says what the number counts —
|
|
// "total" across everyone, or "per_user" for each person
|
|
// separately. It is always one of those two, and is
|
|
// meaningless while the limit is null.
|
|
'download_limit' => $this->download_limit,
|
|
'download_limit_scope' => ($this->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
|
// The file's total downloads. Under a per_user limit this is
|
|
// still the total, since "how much has this caller used" is a
|
|
// different number for every caller.
|
|
'downloads_used' => $this->downloads()->count(),
|
|
'created_at' => $this->created_at?->toIso8601String(),
|
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
|
|
|
// True when this file is a new version of an earlier one. A
|
|
// revision is always shared with the same people as the file it
|
|
// replaces, so it has no recipients of its own: assigning it
|
|
// returns 422, and `sharing_root_id` names the file to assign
|
|
// instead.
|
|
'is_revision' => $this->isRevision(),
|
|
|
|
// The oldest file in this version chain — the one whose
|
|
// recipients govern the whole chain. Null when this file is not
|
|
// a revision.
|
|
'sharing_root_id' => $this->version_root_id,
|
|
|
|
// The file this one replaces. Null when there is none, or when
|
|
// it is one you cannot see.
|
|
'previous_version' => $this->whenLoaded('previousVersion', fn (): ?array => $this->previousVersion === null ? null : [
|
|
'id' => $this->previousVersion->id,
|
|
'name' => $this->previousVersion->name,
|
|
]),
|
|
|
|
// The file that replaced this one, on the same terms.
|
|
'next_version' => $this->whenLoaded('nextVersion', fn (): ?array => $this->nextVersion === null ? null : [
|
|
'id' => $this->nextVersion->id,
|
|
'name' => $this->nextVersion->name,
|
|
]),
|
|
|
|
'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [
|
|
'id' => $this->folder->id,
|
|
'name' => $this->folder->name,
|
|
]),
|
|
|
|
// Name only. The uploader is a user record; their email address
|
|
// is not part of what "this file exists" needs to say. Null
|
|
// when the uploader is a client the token's owner is not
|
|
// scoped to; an unscoped account always gets the name.
|
|
'uploaded_by' => $this->whenLoaded(
|
|
'uploader',
|
|
fn (): ?array => $identity->permits($viewer, $this->uploader) && $this->uploader !== null ? [
|
|
'id' => $this->uploader->id,
|
|
'name' => $this->uploader->name,
|
|
] : null,
|
|
),
|
|
|
|
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
|
|
->map(fn ($category): array => [
|
|
'id' => $category->id,
|
|
'name' => $category->name,
|
|
])
|
|
->all()),
|
|
|
|
// Who the file is shared with, as far as this caller is
|
|
// concerned: a recipient the token's owner is not scoped to is
|
|
// left out rather than returned without a name.
|
|
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
|
|
->filter(fn (FileAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
|
|
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
|
|
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
|
|
->map(fn (FileAssignment $assignment): array => [
|
|
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
|
|
'id' => $assignment->assignable_id,
|
|
// getAttribute() rather than ->name: the relation is a
|
|
// MorphTo over User|Group, so the property is only
|
|
// knowable at runtime. Both targets carry a name.
|
|
'name' => $assignment->assignable?->getAttribute('name'),
|
|
])
|
|
->values()
|
|
->all()),
|
|
|
|
'links' => [
|
|
'download' => route('api.files.download', $this->resource),
|
|
],
|
|
];
|
|
}
|
|
}
|