mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
6e47d76ba6
Client file sharing, rebuilt from the ground up: a private area per client, resumable uploads, folders, groups and categories, sharing with expiry dates and download limits, comments, file versions, an activity log, a REST API, and sixteen languages. This repository begins here. ProjectSend 2 was developed privately, and that development history is not published — the previous generation remains available, with its own history, at projectsend/legacy. Free software under the GNU General Public License v2, or (at your option) any later version.
636 lines
23 KiB
PHP
636 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Versions;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\Access\ViewableFileScope;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\FileAssignment;
|
|
use App\Modules\Files\Sharing\FileSharing;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Notifications\NotificationDigester;
|
|
use App\Modules\Notifications\Notifier;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/**
|
|
* Marking one file as a revision of another, and everything that follows
|
|
* from it.
|
|
*
|
|
* Its own class rather than part of FilesController for the reason
|
|
* FileSharing is: linking is not one UPDATE. It is an UPDATE plus an
|
|
* authorization pair, a cycle check, a move of assignment rows onto the
|
|
* original, a re-stamp of the chain's roots, an activity entry and a
|
|
* notification — and it has three callers (the file editor, the upload
|
|
* flow, the API) that must not be able to answer any of it differently.
|
|
*
|
|
* Two invariants this class is responsible for, both asserted by
|
|
* `versioning:check`:
|
|
*
|
|
* 1. previous_file_id IS NULL <=> version_root_id IS NULL.
|
|
* 2. A file with a non-null version_root_id owns no file_assignments rows.
|
|
* Its audience is the root's (see File::sharingOwnerId and
|
|
* Access\SharingIdentity). This is what makes "a revision is always
|
|
* shared with the same people as the original" a property of the
|
|
* schema rather than a convention some future write path can forget.
|
|
*
|
|
* Authorization is NOT the caller's job here, unlike FileSharing: the
|
|
* escalation this feature can produce is specific to versioning (inheriting
|
|
* a stranger's recipients), so the check lives at the seam every caller
|
|
* reaches. See FilePolicy::setVersion.
|
|
*/
|
|
class FileVersions
|
|
{
|
|
/**
|
|
* How deep a version history may go.
|
|
*
|
|
* Doubles as the termination guard for the chain walks below: if the
|
|
* table were ever corrupted into a cycle, every walk here still ends.
|
|
*/
|
|
public const MAX_CHAIN = 50;
|
|
|
|
public function __construct(
|
|
private readonly ViewableFileScope $viewable,
|
|
private readonly FileSharing $sharing,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly Notifier $notifier,
|
|
private readonly NotificationDigester $digester,
|
|
) {}
|
|
|
|
/**
|
|
* Mark $file as a revision of $previous. Idempotent: re-linking an
|
|
* existing pair is a no-op rather than an error, which matters for an
|
|
* API caller retrying a request.
|
|
*
|
|
* @throws ValidationException when a rule rejects the pair
|
|
* @throws AuthorizationException when $actor may not version either file
|
|
*/
|
|
public function link(File $file, File $previous, User $actor): void
|
|
{
|
|
$this->guard($file, $previous, $actor);
|
|
|
|
if ($file->previous_file_id === $previous->id) {
|
|
return;
|
|
}
|
|
|
|
$root = $previous->version_root_id ?? $previous->id;
|
|
|
|
// RESOLVED BEFORE THE MERGE, and the ordering is the whole dedupe:
|
|
// these are the people who could already see both files, so anyone
|
|
// the merge below is about to reach for the first time is excluded
|
|
// here and gets file_shared from FileSharing::assign() instead.
|
|
// Resolve it afterwards and every newly-added client receives two
|
|
// emails about one action.
|
|
$audience = $this->sharedAudience($file, $previous);
|
|
|
|
try {
|
|
DB::transaction(function () use ($file, $previous, $root, $actor): void {
|
|
// Move, never drop: a revision holds no recipients of its
|
|
// own, but the people who already had this file must not
|
|
// lose it. Through FileSharing so each target still gets
|
|
// its activity entry, notification and digest.
|
|
$this->moveAssignmentsToRoot($file, $root);
|
|
|
|
$file->update([
|
|
'previous_file_id' => $previous->id,
|
|
'version_root_id' => $root,
|
|
]);
|
|
|
|
// $file may itself have revisions; the whole subchain now
|
|
// belongs to $root.
|
|
$this->recomputeRoots($root);
|
|
|
|
$this->activity->log(
|
|
Action::FileVersionLinked,
|
|
actor: $actor,
|
|
subject: $file,
|
|
context: ['previous' => $previous->name],
|
|
);
|
|
});
|
|
} catch (UniqueConstraintViolationException) {
|
|
// Two staff picked the same original at once. The pre-check in
|
|
// guard() lost the race; the index is the authority. The
|
|
// message deliberately omits the winner's name — it may be a
|
|
// file this actor is not allowed to know about.
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('That file has just been revised by another file. Reload and try again.'),
|
|
]);
|
|
}
|
|
|
|
if ($audience->isNotEmpty()) {
|
|
$this->notifier->send('file_new_version', $audience, subject: $file, data: [
|
|
'itemName' => $file->name,
|
|
'previousName' => $previous->name,
|
|
]);
|
|
|
|
$this->digester->queue('file_new_version', $audience, $file->name, [
|
|
'previousName' => $previous->name,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The clients who could see BOTH files before this link was made.
|
|
*
|
|
* Notifier performs no authorization of its own — see its SECURITY
|
|
* CONTRACT docblock — so this is where the list has to be exact. It is
|
|
* an INTERSECTION, never a difference: a client who holds only the old
|
|
* file must be told nothing, because the new one was never shared with
|
|
* them and naming it would leak a file they were not granted. That is
|
|
* the same rule the badge follows, shaped as people.
|
|
*
|
|
* Candidates come from the previous file's own audience rather than a
|
|
* broad user query, then each is re-checked against both files with the
|
|
* authoritative visibility scope.
|
|
*
|
|
* @return Collection<int, User>
|
|
*/
|
|
private function sharedAudience(File $file, File $previous): Collection
|
|
{
|
|
$candidateIds = FileAssignment::query()
|
|
->where('file_id', $previous->sharingOwnerId())
|
|
->get()
|
|
->flatMap(function (FileAssignment $assignment): array {
|
|
$target = $assignment->assignable;
|
|
|
|
if ($target instanceof Group) {
|
|
return $target->members->pluck('id')->all();
|
|
}
|
|
|
|
return $target instanceof User ? [$target->id] : [];
|
|
})
|
|
->unique()
|
|
->values();
|
|
|
|
if ($candidateIds->isEmpty()) {
|
|
/** @var Collection<int, User> $empty */
|
|
$empty = new Collection;
|
|
|
|
return $empty;
|
|
}
|
|
|
|
return User::query()->whereIn('id', $candidateIds)->get()
|
|
->filter(fn (User $candidate): bool => File::query()->whereKey($file->id)->visibleToClient($candidate)->exists()
|
|
&& File::query()->whereKey($previous->id)->visibleToClient($candidate)->exists())
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* Detach $file from its original, making it a root again.
|
|
*
|
|
* The root's current recipients are copied onto it first: it is about
|
|
* to stop inheriting them, and silently revoking access people already
|
|
* have is never the right reading of "unlink".
|
|
*
|
|
* @return bool whether a link was actually removed
|
|
*/
|
|
public function unlink(File $file, User $actor): bool
|
|
{
|
|
if ($file->previous_file_id === null) {
|
|
return false;
|
|
}
|
|
|
|
Gate::forUser($actor)->authorize('setVersion', $file);
|
|
|
|
DB::transaction(function () use ($file): void {
|
|
$this->copyAssignmentsFrom($file->sharingOwnerId(), $file);
|
|
|
|
$file->update(['previous_file_id' => null, 'version_root_id' => null]);
|
|
|
|
// $file is the head of whatever followed it; that subchain
|
|
// roots at $file now.
|
|
$this->recomputeRoots($file->id);
|
|
});
|
|
|
|
$this->activity->log(Action::FileVersionUnlinked, actor: $actor, subject: $file);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* The full lineage $viewer may see, oldest first, including $file
|
|
* itself. Files in the chain the viewer cannot see are omitted rather
|
|
* than replaced with a placeholder — a gap is less informative than a
|
|
* hint that something exists.
|
|
*
|
|
* @return Collection<int, File>
|
|
*/
|
|
public function chain(File $file, ?User $viewer): Collection
|
|
{
|
|
$members = $this->walkBack($file)->reverse()->values();
|
|
|
|
foreach ($this->walkForward($file) as $successor) {
|
|
$members->push($successor);
|
|
}
|
|
|
|
if ($viewer === null) {
|
|
return $members->filter(
|
|
fn (File $member): bool => $member->isEffectivelyPublic() && ! $member->isExpired()
|
|
)->values();
|
|
}
|
|
|
|
$visibleIds = $this->viewable->for($viewer)
|
|
->whereIn('files.id', $members->pluck('id')->all())
|
|
->pluck('files.id')
|
|
->all();
|
|
|
|
return $members->filter(
|
|
fn (File $member): bool => in_array($member->id, $visibleIds, true)
|
|
)->values();
|
|
}
|
|
|
|
/**
|
|
* Files $actor may pick as $file's original.
|
|
*
|
|
* Staff get their viewable library. A CLIENT GETS THEIR OWN UPLOADS
|
|
* ONLY — deliberately not ViewableFileScope, which includes every file
|
|
* staff shared with them. Since a revision inherits the original's
|
|
* recipients, offering a shared file here would be offering a way to
|
|
* publish an upload to a list the client does not own. The picker
|
|
* agreeing with FilePolicy::setVersion is a courtesy; that policy is
|
|
* the control.
|
|
*
|
|
* $file is null in the upload flow, where the original is chosen
|
|
* before the row exists.
|
|
*
|
|
* @return Collection<int, File>
|
|
*/
|
|
public function candidates(?File $file, User $actor, string $search = '', int $limit = 25): Collection
|
|
{
|
|
return $this->candidateQuery($file, $actor)
|
|
->when($search !== '', fn (Builder $q) => $q->where(
|
|
fn (Builder $w) => $w->where('name', 'like', '%'.$search.'%')
|
|
->orWhere('original_name', 'like', '%'.$search.'%')
|
|
))
|
|
->orderByDesc('created_at')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* One candidate by id, or null — the same rule as candidates(), for a
|
|
* caller that already has an id (the upload flow, the API).
|
|
*
|
|
* Shares candidateQuery() rather than filtering a candidates() result,
|
|
* so resolving an id never depends on that id happening to fall inside
|
|
* the list's limit.
|
|
*/
|
|
public function resolveCandidate(?File $file, User $actor, int $previousFileId): ?File
|
|
{
|
|
return $this->candidateQuery($file, $actor)->whereKey($previousFileId)->first();
|
|
}
|
|
|
|
/**
|
|
* @return Builder<File>
|
|
*/
|
|
private function candidateQuery(?File $file, User $actor): Builder
|
|
{
|
|
$query = $actor->isStaff()
|
|
? $this->viewable->for($actor)
|
|
: File::query()->where('uploaded_by', $actor->id)->notExpired();
|
|
|
|
// Already revised by something: the unique index would refuse it.
|
|
//
|
|
// Except when that something is $file itself. Without this exemption
|
|
// a file's *current* original is not resolvable, so re-submitting an
|
|
// existing link 404s instead of being the no-op link() is careful to
|
|
// be — which an API client retrying a request will do.
|
|
$query->where(function (Builder $eligible) use ($file): void {
|
|
$eligible->whereDoesntHave('nextVersion');
|
|
|
|
if ($file !== null) {
|
|
$eligible->orWhereHas('nextVersion', fn (Builder $successor) => $successor->whereKey($file->id));
|
|
}
|
|
});
|
|
|
|
if ($file !== null) {
|
|
// Neither itself nor anything downstream of it — both would be
|
|
// rejected by guardAgainstCycle().
|
|
$query->whereKeyNot($file->id)
|
|
->whereNotIn('files.id', $this->walkForward($file)->pluck('id')->all());
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* The recipients link() would move from $file onto $previous's root.
|
|
*/
|
|
public function previewLink(File $file, File $previous): LinkPreview
|
|
{
|
|
$rootId = $previous->version_root_id ?? $previous->id;
|
|
|
|
$moving = $file->assignments()->get();
|
|
|
|
if ($moving->isEmpty()) {
|
|
return new LinkPreview;
|
|
}
|
|
|
|
$existing = FileAssignment::query()->where('file_id', $rootId)->get()
|
|
->map(fn (FileAssignment $a): string => $a->assignable_type.':'.$a->assignable_id)
|
|
->all();
|
|
|
|
$clients = [];
|
|
$groups = [];
|
|
|
|
foreach ($moving as $assignment) {
|
|
if (in_array($assignment->assignable_type.':'.$assignment->assignable_id, $existing, true)) {
|
|
continue;
|
|
}
|
|
|
|
$target = $assignment->assignable;
|
|
|
|
if ($target instanceof User) {
|
|
$clients[] = $target->name;
|
|
} elseif ($target instanceof Group) {
|
|
$groups[] = $target->name;
|
|
}
|
|
}
|
|
|
|
return new LinkPreview($clients, $groups);
|
|
}
|
|
|
|
/**
|
|
* Stamp version_root_id across the chain containing $anyFileIdInChain.
|
|
*
|
|
* Idempotent, and the repair path for `versioning:check --repair`:
|
|
* it derives every root from previous_file_id alone, so a table whose
|
|
* version_root_id column has drifted is fixed by running it.
|
|
*/
|
|
public function recomputeRoots(?int $anyFileIdInChain): void
|
|
{
|
|
if ($anyFileIdInChain === null) {
|
|
return;
|
|
}
|
|
|
|
$member = File::withTrashed()->find($anyFileIdInChain);
|
|
|
|
if ($member === null) {
|
|
return;
|
|
}
|
|
|
|
$head = $this->walkBack($member)->last();
|
|
assert($head instanceof File);
|
|
|
|
File::withTrashed()->whereKey($head->id)->update(['version_root_id' => null]);
|
|
|
|
foreach ($this->walkForward($head) as $successor) {
|
|
File::withTrashed()->whereKey($successor->id)->update(['version_root_id' => $head->id]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Repair the chain around a file being deleted. Called from
|
|
* File::booted(); not user-initiated, so it authorizes nothing.
|
|
*
|
|
* Heals rather than breaks: deleting v2 out of v1 <- v2 <- v3 leaves
|
|
* v1 <- v3, which is true — v3 does revise v1. The alternative (null
|
|
* out the successor's pointer) would silently amputate a history that
|
|
* is still accurate.
|
|
*
|
|
* Two things make this load-bearing rather than tidying:
|
|
*
|
|
* - A trashed row still occupies its original's unique
|
|
* previous_file_id slot. Without this, deleting a revision means the
|
|
* original can never be revised again, and the error message names a
|
|
* file the staffer can no longer see.
|
|
* - Deleting a ROOT would strand every revision below it with no
|
|
* recipients at all, since revisions hold none of their own. The
|
|
* promoted revision inherits the root's assignment rows here, inside
|
|
* the same transaction, before the row goes.
|
|
*/
|
|
public function detachOnDelete(File $file): void
|
|
{
|
|
$previousId = $file->previous_file_id;
|
|
|
|
$successor = File::withTrashed()->where('previous_file_id', $file->id)->first();
|
|
|
|
if ($previousId === null && $successor === null) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($file, $previousId, $successor): void {
|
|
// A root going means its successor becomes the new root and
|
|
// must take the recipients with it, or the whole chain below
|
|
// loses its audience. ($successor is non-null here: the
|
|
// both-null case already returned above.)
|
|
if ($previousId === null) {
|
|
$this->copyAssignmentsFrom($file->id, $successor);
|
|
}
|
|
|
|
// MUST come first: this frees $file's claim on $previousId's
|
|
// unique slot before $successor below tries to take it.
|
|
File::withTrashed()->whereKey($file->id)
|
|
->update(['previous_file_id' => null, 'version_root_id' => null]);
|
|
|
|
if ($successor !== null) {
|
|
// Literal ids, never a subquery over `files` — MySQL
|
|
// cannot reopen a table it is updating.
|
|
File::withTrashed()->whereKey($successor->id)
|
|
->update(['previous_file_id' => $previousId]);
|
|
|
|
$this->recomputeRoots($successor->id);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Every guard link() applies, in the order that produces the most
|
|
* useful message.
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
private function guard(File $file, File $previous, User $actor): void
|
|
{
|
|
if ($previous->is($file)) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('A file cannot be a revision of itself.'),
|
|
]);
|
|
}
|
|
|
|
// Both ends, always. The subject alone is not enough: linking
|
|
// widens the ORIGINAL's audience, so reaching one is reaching both.
|
|
Gate::forUser($actor)->authorize('setVersion', $file);
|
|
Gate::forUser($actor)->authorize('setVersion', $previous);
|
|
|
|
// A client links at upload time, when the file has no recipients.
|
|
// Allowing it later would let them push their own file's audience
|
|
// onto an original — the mirror of the escalation setVersion
|
|
// blocks, so it is closed here rather than left to the UI.
|
|
if (! $actor->isStaff() && $file->assignments()->exists()) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('This file is already shared with other people, so it can\'t be marked as a new version. Ask your contact to do it for you.'),
|
|
]);
|
|
}
|
|
|
|
// Queried rather than read off $previous->nextVersion: a model
|
|
// instance caches a relation once loaded, so a caller reusing one
|
|
// across two link() calls would see a stale null here and get the
|
|
// race-condition message from the unique index instead of this
|
|
// one, which names the file in the way.
|
|
$existing = File::query()->where('previous_file_id', $previous->id)->first();
|
|
|
|
if ($existing !== null && ! $existing->is($file)) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('That file has already been revised by ":name". Remove that link first.', ['name' => $existing->name]),
|
|
]);
|
|
}
|
|
|
|
$this->guardAgainstCycle($file, $previous);
|
|
}
|
|
|
|
/**
|
|
* previous_file_id is unique and single-valued, so in-degree and
|
|
* out-degree are both at most one and every component of the graph is
|
|
* a path or a cycle. Pointing $file at $previous therefore closes a
|
|
* cycle exactly when $file is already reachable by walking back from
|
|
* $previous — which makes this walk exact, not a heuristic.
|
|
*
|
|
* A PHP loop rather than a recursive CTE on purpose: chains are a
|
|
* handful of rows, and this stays identical on the sqlite test
|
|
* database and on MySQL/Postgres.
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
private function guardAgainstCycle(File $file, File $previous): void
|
|
{
|
|
$steps = 0;
|
|
|
|
foreach ($this->walkBack($previous) as $ancestor) {
|
|
if ($ancestor->is($file)) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('That file is already part of this file\'s version history.'),
|
|
]);
|
|
}
|
|
|
|
$steps++;
|
|
}
|
|
|
|
// $steps counts $previous and everything it revises, so the chain
|
|
// this link would produce is one longer.
|
|
if ($steps >= self::MAX_CHAIN) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('This version history is too long. Remove an older link first.'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* $file's own recipients become the root's, then stop being $file's.
|
|
*/
|
|
private function moveAssignmentsToRoot(File $file, int $rootId): void
|
|
{
|
|
$assignments = $file->assignments()->with('assignable')->get();
|
|
|
|
foreach ($assignments as $assignment) {
|
|
$target = $assignment->assignable;
|
|
|
|
if (! $target instanceof User && ! $target instanceof Group) {
|
|
continue;
|
|
}
|
|
|
|
$root = File::query()->find($rootId);
|
|
|
|
if ($root === null) {
|
|
continue;
|
|
}
|
|
|
|
// firstOrCreate inside, so a target the root already has is a
|
|
// no-op rather than a duplicate notification.
|
|
$this->sharing->assign($root, $target, $target->name);
|
|
}
|
|
|
|
$file->assignments()->delete();
|
|
}
|
|
|
|
/**
|
|
* Give $target its own copy of $sourceFileId's recipients — used when a
|
|
* file stops inheriting them (unlink) or when a promoted revision has
|
|
* to take over as root (detachOnDelete).
|
|
*
|
|
* A direct insert rather than FileSharing::assign(): nobody is gaining
|
|
* access here, the same people keep exactly what they had, so a
|
|
* "shared with you" notification would be a lie.
|
|
*/
|
|
private function copyAssignmentsFrom(int $sourceFileId, File $target): void
|
|
{
|
|
if ($sourceFileId === $target->id) {
|
|
return;
|
|
}
|
|
|
|
$rows = FileAssignment::query()->where('file_id', $sourceFileId)->get();
|
|
|
|
foreach ($rows as $row) {
|
|
FileAssignment::query()->firstOrCreate([
|
|
'file_id' => $target->id,
|
|
'assignable_type' => $row->assignable_type,
|
|
'assignable_id' => $row->assignable_id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* $file and every file it revises, nearest first.
|
|
*
|
|
* withTrashed() throughout: these walks maintain structure, and a
|
|
* trashed row still holds a pointer that has to be accounted for.
|
|
* Display paths use the relations instead, which hide trashed rows.
|
|
*
|
|
* @return Collection<int, File>
|
|
*/
|
|
private function walkBack(File $file): Collection
|
|
{
|
|
/** @var Collection<int, File> $chain */
|
|
$chain = new Collection([$file]);
|
|
$cursor = $file;
|
|
$steps = 0;
|
|
|
|
while ($cursor->previous_file_id !== null && ++$steps <= self::MAX_CHAIN) {
|
|
$cursor = File::withTrashed()->find($cursor->previous_file_id);
|
|
|
|
if ($cursor === null) {
|
|
break;
|
|
}
|
|
|
|
$chain->push($cursor);
|
|
}
|
|
|
|
return $chain;
|
|
}
|
|
|
|
/**
|
|
* Every file that revises $file, transitively, nearest first.
|
|
*
|
|
* @return Collection<int, File>
|
|
*/
|
|
private function walkForward(File $file): Collection
|
|
{
|
|
/** @var Collection<int, File> $chain */
|
|
$chain = new Collection;
|
|
$cursor = $file;
|
|
$steps = 0;
|
|
|
|
while (++$steps <= self::MAX_CHAIN) {
|
|
$next = File::withTrashed()->where('previous_file_id', $cursor->id)->first();
|
|
|
|
if ($next === null) {
|
|
break;
|
|
}
|
|
|
|
$chain->push($next);
|
|
$cursor = $next;
|
|
}
|
|
|
|
return $chain;
|
|
}
|
|
}
|