mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +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.
435 lines
15 KiB
PHP
435 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Models;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Files\Access\SharingIdentity;
|
|
use App\Modules\Files\DownloadLimitScope;
|
|
use App\Modules\Files\FileDiskCleanup;
|
|
use App\Modules\Files\Versions\FileVersions;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Support\Concerns\HasUniqueSlug;
|
|
use Database\Factories\FileFactory;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* @property int $id
|
|
* @property int|null $uploaded_by
|
|
* @property int|null $folder_id
|
|
* @property int|null $previous_file_id
|
|
* @property int|null $version_root_id
|
|
* @property string $name
|
|
* @property string $slug
|
|
* @property string|null $description
|
|
* @property string $original_name
|
|
* @property string $path
|
|
* @property string $disk
|
|
* @property string $mime_type
|
|
* @property int $size
|
|
* @property string $checksum
|
|
* @property bool $public
|
|
* @property Carbon|null $expires_at
|
|
* @property int|null $download_limit
|
|
* @property DownloadLimitScope|null $download_limit_scope
|
|
* @property-read int|null $downloads_count
|
|
* @property-read int|null $own_downloads_count
|
|
* @property-read User|null $uploader
|
|
* @property-read File|null $previousVersion
|
|
* @property-read File|null $nextVersion
|
|
*/
|
|
class File extends Model
|
|
{
|
|
/** @use HasFactory<FileFactory> */
|
|
use HasFactory;
|
|
|
|
use HasUniqueSlug;
|
|
use SoftDeletes;
|
|
|
|
protected $guarded = [];
|
|
|
|
/**
|
|
* Laravel derives a factory's name by stripping the App\Models prefix,
|
|
* which this module-namespaced model does not have — so name it here
|
|
* rather than have the lookup miss.
|
|
*/
|
|
protected static function newFactory(): FileFactory
|
|
{
|
|
return FileFactory::new();
|
|
}
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'public' => 'boolean',
|
|
'commentable' => 'boolean',
|
|
'expires_at' => 'datetime',
|
|
'download_limit' => 'integer',
|
|
// Nullable in PHP but not in the database: the column has a
|
|
// default, which Eloquent does not apply to a model it has
|
|
// not read back. Anything presenting the scope falls back to
|
|
// Total rather than publishing a null for a field that is
|
|
// only ever one of two strings.
|
|
'download_limit_scope' => DownloadLimitScope::class,
|
|
];
|
|
}
|
|
|
|
protected static function slugFallback(): string
|
|
{
|
|
return 'file';
|
|
}
|
|
|
|
protected static function booted(): void
|
|
{
|
|
// Soft-deleting a file is the only user-facing "delete" there is —
|
|
// nothing serves a trashed file's bytes (route-model binding
|
|
// already 404s every route for it) and there's no restore
|
|
// feature, so there's no reason to keep them. Fires on any future
|
|
// forceDelete() too; harmless either way.
|
|
static::deleted(function (File $file): void {
|
|
// Before the bytes go: a trashed row still occupies its
|
|
// predecessor's unique previous_file_id slot, so a chain that
|
|
// is not repaired here can never be re-linked. Runs first
|
|
// because it needs the row's own pointers intact.
|
|
app(FileVersions::class)->detachOnDelete($file);
|
|
|
|
app(FileDiskCleanup::class)->delete($file);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<User, $this>
|
|
*/
|
|
public function uploader(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'uploaded_by');
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<Folder, $this>
|
|
*/
|
|
public function folder(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Folder::class);
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<FileAssignment, $this>
|
|
*/
|
|
public function assignments(): HasMany
|
|
{
|
|
return $this->hasMany(FileAssignment::class);
|
|
}
|
|
|
|
/**
|
|
* The file this one revises, if any.
|
|
*
|
|
* Branch on this relation, never on the previous_file_id column: the
|
|
* column stays populated after its target is trashed, the relation
|
|
* correctly resolves to null (SoftDeletes' global scope applies here).
|
|
* Every display path must go through it or through
|
|
* Versions\FileVersionLinks, which does.
|
|
*
|
|
* @return BelongsTo<File, $this>
|
|
*/
|
|
public function previousVersion(): BelongsTo
|
|
{
|
|
return $this->belongsTo(File::class, 'previous_file_id');
|
|
}
|
|
|
|
/**
|
|
* The file that revises this one. At most one, enforced by the unique
|
|
* index on previous_file_id — that is what keeps a version history a
|
|
* straight line instead of a tree.
|
|
*
|
|
* @return HasOne<File, $this>
|
|
*/
|
|
public function nextVersion(): HasOne
|
|
{
|
|
return $this->hasOne(File::class, 'previous_file_id');
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<File, $this>
|
|
*/
|
|
public function versionRoot(): BelongsTo
|
|
{
|
|
return $this->belongsTo(File::class, 'version_root_id');
|
|
}
|
|
|
|
/**
|
|
* Whether this file is a revision of an earlier one — and therefore
|
|
* owns no recipients of its own (see sharingOwnerId).
|
|
*
|
|
* Deliberately reads the column rather than the relation: a revision
|
|
* whose root was trashed is not thereby promoted to a root, and
|
|
* version_root_id is maintained eagerly on delete (detachOnDelete)
|
|
* precisely so no query has to chase a trashed row to find out.
|
|
*/
|
|
public function isRevision(): bool
|
|
{
|
|
return $this->version_root_id !== null;
|
|
}
|
|
|
|
/**
|
|
* The file whose file_assignments rows decide who may see this one —
|
|
* itself if it is a root, otherwise the oldest file in its chain.
|
|
*
|
|
* A revision inherits its recipients rather than holding copies, so
|
|
* the two can never disagree about who has access. This is the single
|
|
* definition of that rule; SharingIdentity is its SQL twin.
|
|
*/
|
|
public function sharingOwnerId(): int
|
|
{
|
|
return $this->version_root_id ?? $this->id;
|
|
}
|
|
|
|
/**
|
|
* @return BelongsToMany<Category, $this>
|
|
*/
|
|
public function categories(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Category::class)->withTimestamps();
|
|
}
|
|
|
|
/**
|
|
* @return MorphMany<ShareLink, $this>
|
|
*/
|
|
public function shareLinks(): MorphMany
|
|
{
|
|
return $this->morphMany(ShareLink::class, 'shareable');
|
|
}
|
|
|
|
/**
|
|
* Every logged download of this file — direct, via a public share
|
|
* link, or via the public group listing — matching the "Downloads"
|
|
* tab in the details panel (FileDetailsController::downloads()) —
|
|
* for use with withCount().
|
|
*
|
|
* @return MorphMany<ActivityLog, $this>
|
|
*/
|
|
public function downloads(): MorphMany
|
|
{
|
|
return $this->morphMany(ActivityLog::class, 'subject')
|
|
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]);
|
|
}
|
|
|
|
public function isOwnedBy(User $user): bool
|
|
{
|
|
return $this->uploaded_by === $user->id;
|
|
}
|
|
|
|
/**
|
|
* A file's own expiration date — independent of any share link's.
|
|
* Null means never expires. Once past, the file is hidden from
|
|
* clients and the public site (see scopeNotExpired) but staff keep
|
|
* full access to view, download, and manage it.
|
|
*/
|
|
public function isExpired(): bool
|
|
{
|
|
return $this->expires_at !== null && $this->expires_at->isPast();
|
|
}
|
|
|
|
/**
|
|
* @param Builder<File> $query
|
|
*/
|
|
public function scopeNotExpired(Builder $query): void
|
|
{
|
|
$query->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
|
|
}
|
|
|
|
/**
|
|
* Whether a cap has been set on how many times this may be
|
|
* downloaded. Unlike expiry, reaching it does not hide the file:
|
|
* it stays listed and stops being downloadable, so the recipient can
|
|
* see it existed and ask for more. The counting and the "may this
|
|
* person take it" question both live in DownloadAllowance, because
|
|
* the answer depends on who is asking.
|
|
*/
|
|
public function hasDownloadLimit(): bool
|
|
{
|
|
return $this->download_limit !== null && $this->download_limit > 0;
|
|
}
|
|
|
|
/**
|
|
* The inverse of scopeNotExpired() above — files currently past their
|
|
* own expiry, regardless of any retention grace period (see
|
|
* PurgeExpiredFilesCommand, which applies the grace period on top of
|
|
* this same expires_at column separately).
|
|
*
|
|
* @param Builder<File> $query
|
|
*/
|
|
public function scopeExpired(Builder $query): void
|
|
{
|
|
$query->whereNotNull('expires_at')->where('expires_at', '<=', now());
|
|
}
|
|
|
|
/**
|
|
* True when this file's own `public` flag is set, or it sits
|
|
* anywhere in a public folder's live subtree — the single check
|
|
* everywhere a file's real public status matters (public downloads,
|
|
* the file editor's inherited/grayed-out checkbox state).
|
|
*/
|
|
public function isEffectivelyPublic(): bool
|
|
{
|
|
return $this->public || ($this->folder?->isEffectivelyPublic() ?? false);
|
|
}
|
|
|
|
/**
|
|
* A client can access a file that is assigned to them directly or
|
|
* via a group, that sits in a folder shared with them (self or
|
|
* ancestor), or that they uploaded themselves via the portal — the
|
|
* single source of truth for client file access.
|
|
*
|
|
* The assignment half matches on SharingIdentity::column() rather than
|
|
* on files.id, because a revision owns no assignment rows: it inherits
|
|
* the recipients of its version chain's root. Folder placement, expiry
|
|
* and the public flag stay per-file, so only this one branch changes.
|
|
*
|
|
* @param Builder<File> $query
|
|
*/
|
|
public function scopeVisibleToClient(Builder $query, User $client): void
|
|
{
|
|
/** @var list<int> $groupIds */
|
|
$groupIds = $client->memberOfGroups()->pluck('groups.id')->all();
|
|
|
|
$query->where(function (Builder $outer) use ($client, $groupIds): void {
|
|
// Directly assigned, or via a group — on this file or on the
|
|
// original it revises.
|
|
$outer->whereIn(
|
|
SharingIdentity::column(),
|
|
SharingIdentity::assignedToClient($client, $groupIds),
|
|
);
|
|
|
|
// Or inside a shared folder subtree.
|
|
$visibleFolders = Folder::query()->select('id')->tap(
|
|
fn (Builder $folders) => $folders->getModel()->scopeVisibleToClient($folders, $client)
|
|
);
|
|
$outer->orWhereIn('folder_id', $visibleFolders);
|
|
|
|
// Or uploaded by the client themselves via the portal.
|
|
$outer->orWhere('uploaded_by', $client->id);
|
|
});
|
|
|
|
$query->notExpired();
|
|
}
|
|
|
|
/**
|
|
* Files publicly reachable through a specific (public) group: the
|
|
* file's own `public` flag AND (directly assigned to the group, or
|
|
* inside the subtree of a folder assigned to the group). A
|
|
* group-keyed sibling of scopeVisibleToClient() above — kept
|
|
* separate since that one is client-keyed and already used
|
|
* elsewhere. See PublicGroupsController.
|
|
*
|
|
* Same substitution as scopeVisibleToClient for the assignment branch:
|
|
* a revision inherits the group its original was shared with.
|
|
*
|
|
* @param Builder<File> $query
|
|
*/
|
|
public function scopePubliclyVisibleForGroup(Builder $query, Group $group): void
|
|
{
|
|
$groupMorph = (new Group)->getMorphClass();
|
|
|
|
$query->where('public', true)->where(function (Builder $outer) use ($group, $groupMorph): void {
|
|
$outer->whereIn(
|
|
SharingIdentity::column(),
|
|
SharingIdentity::assignedToGroups([$group->id]),
|
|
);
|
|
|
|
$assignedFolderIds = FolderAssignment::query()
|
|
->where('assignable_type', $groupMorph)
|
|
->where('assignable_id', $group->id)
|
|
->pluck('folder_id');
|
|
|
|
$subtreeFolderIds = Folder::query()
|
|
->whereIn('id', $assignedFolderIds)
|
|
->get()
|
|
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
|
|
->unique()
|
|
->values();
|
|
|
|
$outer->orWhereIn('folder_id', $subtreeFolderIds);
|
|
});
|
|
|
|
$query->notExpired();
|
|
}
|
|
|
|
/**
|
|
* Files publicly reachable through a specific public folder: every
|
|
* file in its live subtree, regardless of the file's own `public`
|
|
* flag — the whole point of a public folder is that it makes
|
|
* everything inside it public. A folder-keyed sibling of
|
|
* scopePubliclyVisibleForGroup() above. See PublicFoldersController.
|
|
*
|
|
* @param Builder<File> $query
|
|
*/
|
|
public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void
|
|
{
|
|
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired();
|
|
}
|
|
|
|
/**
|
|
* Public files not reachable through any public group or public
|
|
* folder — the public listing's "front page" entries, so a file is
|
|
* never shown twice (once standalone, once via its group's or
|
|
* folder's own page). A file assigned only to a private group still
|
|
* appears here: the file's own `public` flag is independent of any
|
|
* group's or folder's.
|
|
*
|
|
* @param Builder<File> $query
|
|
*/
|
|
public function scopeStandalonePublic(Builder $query): void
|
|
{
|
|
$groupMorph = (new Group)->getMorphClass();
|
|
$publicGroupIds = Group::query()->where('public', true)->pluck('id');
|
|
|
|
$publicGroupFolderIds = FolderAssignment::query()
|
|
->where('assignable_type', $groupMorph)
|
|
->whereIn('assignable_id', $publicGroupIds)
|
|
->pluck('folder_id');
|
|
|
|
$publicGroupSubtreeFolderIds = Folder::query()
|
|
->whereIn('id', $publicGroupFolderIds)
|
|
->get()
|
|
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
|
|
->unique()
|
|
->values();
|
|
|
|
$publicFolderSubtreeIds = Folder::query()
|
|
->where('public', true)
|
|
->get()
|
|
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
|
|
->unique()
|
|
->values();
|
|
|
|
$query->where('public', true)
|
|
// Reached through the chain root, like the two scopes above: a
|
|
// revision of a file that belongs to a public group is reachable
|
|
// via that group's own page, so it is not standalone either.
|
|
->whereNotIn(
|
|
SharingIdentity::column(),
|
|
SharingIdentity::assignedToGroups($publicGroupIds),
|
|
)
|
|
->where(function (Builder $folder) use ($publicGroupSubtreeFolderIds): void {
|
|
$folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicGroupSubtreeFolderIds);
|
|
})
|
|
->where(function (Builder $folder) use ($publicFolderSubtreeIds): void {
|
|
$folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicFolderSubtreeIds);
|
|
})
|
|
->notExpired();
|
|
}
|
|
}
|