first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
<?php
namespace Common\Files\Actions;
use App\Models\User;
use Common\Files\Events\FileEntryCreated;
use Common\Files\FileEntry;
use Common\Files\FileEntryPayload;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
class CreateFileEntry
{
public function execute(FileEntryPayload $payload): FileEntry
{
$data = [
'name' => $payload->clientName,
'file_name' => $payload->filename,
'mime' => $payload->clientMime,
'file_size' => $payload->size,
'parent_id' => $payload->parentId,
'disk_prefix' => $payload->diskPrefix,
'type' => $payload->type,
'extension' => $payload->clientExtension,
'public' => $payload->public,
'workspace_id' => $payload->workspaceId,
'owner_id' => $payload->ownerId,
];
$entries = new Collection();
// uploading a folder
if ($payload->relativePath && !$payload->public) {
$path = $this->createPath($payload);
$parent = $path['allParents']->last();
if ($path['allParents']->isNotEmpty()) {
$entries = $entries->merge($path['allParents']);
$data['parent_id'] = $parent->id;
}
}
$fileEntry = FileEntry::create($data);
if (!$payload->public) {
$fileEntry->generatePath();
}
$entries = $entries->push($fileEntry);
$entryIds = $entries
->mapWithKeys(function ($entry) {
return [$entry->id => ['owner' => 1]];
})
->toArray();
User::find($payload->ownerId)
->entries()
->syncWithoutDetaching($entryIds);
if (isset($path['newlyCreated'])) {
$path['newlyCreated']->each(function (FileEntry $entry) use (
$payload,
) {
// make sure new folder gets attached to all
// users who have access to the parent folder
event(new FileEntryCreated($entry));
});
}
if (isset($parent) && $parent) {
$fileEntry->setRelation('parent', $parent);
} else {
$fileEntry->load('parent');
}
$entries->load('users');
event(new FileEntryCreated($fileEntry));
return $fileEntry;
}
private function createPath(FileEntryPayload $payload): array
{
$newlyCreated = collect();
$dirname = dirname($payload->relativePath);
// remove file name from path and split into folder names
$path = collect(
explode('/', $dirname === '.' ? $payload->relativePath : $dirname),
)->filter();
if ($path->isEmpty()) {
return $path->toArray();
}
$allParents = $path->reduce(function ($parents, $name) use (
$newlyCreated,
$payload,
) {
if (!$parents) {
$parents = collect();
}
$parent = $parents->last();
$values = [
'type' => 'folder',
'name' => $name,
// file name is limited to 36 chars in database, make sure we match that if we get very long file names
'file_name' => Str::limit($name, 36, ''),
'parent_id' => $parent ? $parent->id : $payload->parentId,
'workspace_id' => $payload->workspaceId ?? 0,
];
// check if user already has a folder with that name and parent
$folder = FileEntry::where($values)
->whereUser($payload->ownerId)
->first();
if (!$folder) {
$values['owner_id'] = $payload->ownerId;
$folder = FileEntry::create($values);
$folder->generatePath();
$newlyCreated->push($folder);
}
return $parents->push($folder);
});
return ['allParents' => $allParents, 'newlyCreated' => $newlyCreated];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Common\Files\Actions\Deletion;
use Arr;
use Common\Files\FileEntry;
use Gate;
class DeleteEntries
{
public function execute(array $params, $authorize = true): void
{
$entryIds =
$params['entryIds'] ?? $this->idsFromPaths($params['paths']);
if (count($entryIds)) {
if ($authorize) {
Gate::authorize('destroy', [FileEntry::class, $entryIds]);
}
if (Arr::get($params, 'soft')) {
app(SoftDeleteEntries::class)->execute($entryIds);
} else {
app(PermanentlyDeleteEntries::class)->execute($entryIds);
}
}
}
private function idsFromPaths(array $paths): array
{
$filenames = array_map(function ($path) {
return basename($path);
}, $paths);
return app(FileEntry::class)
->whereIn('file_name', $filenames)
->pluck('id')
->toArray();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Common\Files\Actions\Deletion;
use Common\Files\Events\FileEntriesDeleted;
use Common\Files\FileEntry;
use DB;
use Illuminate\Support\Collection;
class PermanentlyDeleteEntries extends SoftDeleteEntries
{
/**
* Permanently delete file entries, related records and files from disk.
*/
protected function delete(Collection|array $entries): void
{
$entries = $this->loadChildEntries($entries, true);
$this->deleteFiles($entries);
$this->deleteEntries($entries);
event(new FileEntriesDeleted($entries->pluck('id')->toArray(), true));
}
/**
* Delete file entries from database.
*/
private function deleteEntries(Collection $entries): void
{
$entryIds = $entries->pluck('id');
// detach users
DB::table('file_entry_models')
->whereIn('file_entry_id', $entryIds)
->delete();
// detach tags
DB::table('taggables')
->where('taggable_type', FileEntry::MODEL_TYPE)
->whereIn('taggable_id', $entryIds)
->delete();
$this->entry->whereIn('id', $entries->pluck('id'))->forceDelete();
}
/**
* Delete files from disk.
*/
private function deleteFiles(Collection $entries): void
{
$entries
->filter(function (FileEntry $entry) {
return $entry->type !== 'folder';
})
->each(function (FileEntry $entry) {
if ($entry->public) {
$entry->getDisk()->delete($entry->getStoragePath());
} else {
$entry->getDisk()->deleteDirectory($entry->file_name);
}
});
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Common\Files\Actions\Deletion;
use Common\Files\Events\FileEntriesRestored;
use Illuminate\Support\Collection;
class RestoreEntries extends SoftDeleteEntries
{
public function execute(Collection|array $entryIds): void
{
$entries = $this->entry
->onlyTrashed()
->whereIn('id', $entryIds)
->get();
$entries = $this->loadChildEntries($entries, true);
$this->entry->whereIn('id', $entries->pluck('id'))->restore();
event(new FileEntriesRestored($entries->pluck('id')->toArray()));
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Common\Files\Actions\Deletion;
use Common\Files\Events\FileEntriesDeleted;
use Common\Files\FileEntry;
use Common\Files\Traits\LoadsAllChildEntries;
use Illuminate\Support\Collection;
class SoftDeleteEntries
{
use LoadsAllChildEntries;
public function __construct(protected FileEntry $entry)
{
}
public function execute(Collection|array $entryIds): void
{
collect($entryIds)
->chunk(50)
->each(function ($ids) {
$entries = $this->entry
->withTrashed()
->whereIn('id', $ids)
->get();
$this->delete($entries);
});
}
/**
* Move specified entries to "trash".
*/
protected function delete(Collection|array $entries): void
{
$entries = $this->loadChildEntries($entries);
$this->entry->whereIn('id', $entries->pluck('id'))->delete();
event(new FileEntriesDeleted($entries->pluck('id')->toArray(), false));
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Common\Files\Actions;
class GetServerMaxUploadSize
{
protected $configKeys = ['post_max_size', 'upload_max_filesize', 'memory_limit'];
/**
* @return array
*/
public function execute()
{
$configValues = collect($this->configKeys)
->map(function($key) {
$value = ini_get($key);
return ['original' => $value, 'bytes' => $this->getBytes($value)];
})->filter(function($value) {
return $value['bytes'] > 0;
});
return $configValues->where('bytes', $configValues->min('bytes'))->first();
}
/**
* @param int|string $value
* @return int
*/
protected function getBytes($value)
{
if (is_numeric($value)) {
return (int) $value;
}
$metric = strtoupper(substr($value, -1));
switch ($metric) {
case 'K':
return (int) $value * 1024;
case 'M':
return (int) $value * 1048576;
case 'G':
return (int) $value * 1073741824;
default:
return (int) $value;
}
}
}

View File

@@ -0,0 +1,69 @@
<?php namespace Common\Files\Actions;
use App\Models\User;
use Common\Billing\Models\Product;
use Common\Settings\Settings;
use Illuminate\Support\Facades\Auth;
class GetUserSpaceUsage
{
protected User $user;
public function __construct(protected Settings $settings)
{
$this->user = Auth::user();
}
public function execute(User $user = null): array
{
$this->user = $user ?? Auth::user();
return [
'used' => $this->getSpaceUsed(),
'available' => $this->getAvailableSpace(),
];
}
private function getSpaceUsed(): int|float
{
return (int) $this->user
->entries(['owner' => true])
->where('type', '!=', 'folder')
->withTrashed()
->sum('file_size');
}
public function getAvailableSpace(): int|float|null
{
$space = null;
if (!is_null($this->user->available_space)) {
$space = $this->user->available_space;
} elseif (app(Settings::class)->get('billing.enable')) {
if ($this->user->subscribed()) {
$space = $this->user->subscriptions->first()->product
->available_space;
} elseif ($freePlan = Product::where('free', true)->first()) {
$space = $freePlan->available_space;
}
}
// space is not set at all on user or billing plans
if (is_null($space)) {
$defaultSpace = $this->settings->get('uploads.available_space');
return is_numeric($defaultSpace) ? abs($defaultSpace) : null;
} else {
return abs($space);
}
}
public function hasEnoughSpaceToUpload(int $bytes): bool
{
$availableSpace = $this->getAvailableSpace();
// unlimited space
if (is_null($availableSpace)) {
return true;
}
return $this->getSpaceUsed() + $bytes <= $availableSpace;
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Common\Files\Actions;
use Common\Files\FileEntryPayload;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File as FileFacade;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\Flysystem\Local\LocalFilesystemAdapter;
use Symfony\Component\Mime\MimeTypes;
class StoreFile
{
protected Filesystem $disk;
protected array $diskOptions;
protected FileEntryPayload $payload;
public function execute(
FileEntryPayload $payload,
array $fileOptions,
): string|false {
$this->disk = $payload->public
? Storage::disk('public')
: Storage::disk('uploads');
$this->diskOptions = [
'mimetype' => $payload->clientMime,
'visibility' => $payload->visibility,
];
$this->payload = $payload;
if (
// prevent uploading .htaccess files
$payload->filename === '.htaccess' ||
// dont store php files in public disk
($payload->public && $this->isPhpFile($payload, $fileOptions)) ||
// prevent path traversal in user specified folder
($payload->diskPrefix && Str::contains($payload->diskPrefix, '..'))
) {
abort(403);
}
if (isset($fileOptions['file'])) {
return $this->storeUploadedFile($fileOptions['file']);
} elseif (isset($fileOptions['contents'])) {
return $this->storeStringContents($fileOptions['contents']);
} elseif (isset($fileOptions['path'])) {
// if source and destination is local (and not temp dir) move file
// instead of copying or using streams, this will be a lot faster
if (
Arr::get($fileOptions, 'moveFile') === true &&
$this->disk->getAdapter() instanceof LocalFilesystemAdapter
) {
return $this->storeLocalFile($fileOptions['path']);
} else {
return $this->storeUploadedFile(new File($fileOptions['path']));
}
}
return false;
}
protected function storeUploadedFile(File|UploadedFile $file): string|false
{
return $this->disk->putFileAs(
$this->payload->diskPrefix,
$file,
$this->payload->filename,
$this->diskOptions,
);
}
protected function storeStringContents(string $contents): string|false
{
return $this->disk->put(
"{$this->payload->diskPrefix}/{$this->payload->filename}",
$contents,
$this->diskOptions,
);
}
protected function storeLocalFile(string $sourcePath): string|false
{
$dirPath = $this->disk->path($this->payload->diskPrefix);
FileFacade::ensureDirectoryExists($dirPath);
$stored = @rename($sourcePath, "$dirPath/{$this->payload->filename}");
if ($stored) {
return "{$this->payload->diskPrefix}/{$this->payload->filename}";
}
return false;
}
protected function isPhpFile(
FileEntryPayload $payload,
array $fileOptions,
): bool {
if (
Str::of($payload->clientExtension)
->lower()
->startsWith(['php', 'phtml'])
) {
return true;
}
$mimeType = null;
if (isset($fileOptions['file'])) {
$mimeType = $fileOptions['file']->getMimeType();
} elseif (isset($fileOptions['path'])) {
$mimeType = MimeTypes::getDefault()->guessMimeType(
$fileOptions['path'],
);
}
return $mimeType === 'application/x-php';
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace Common\Files\Actions;
use Common\Settings\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
class ValidateFileUpload
{
protected array $fileData;
public function execute(array $fileData): Collection|null
{
$this->fileData = $fileData;
$errors = collect([
'size' => $this->validateMaximumFileSize(),
'spaceUsage' => $this->validateAllowedStorageSpace(),
'allowedExtensions' => $this->validateAllowedExtensions(),
'blockedExtensions' => $this->validateBlockedExtensions(),
])->filter(fn($msg) => !is_null($msg));
if (!$errors->isEmpty()) {
return $errors;
}
return null;
}
protected function validateAllowedExtensions(): string|null
{
$allowedExtensions = settings('uploads.allowed_extensions');
if (
!empty($allowedExtensions) &&
!$this->extensionMatches($allowedExtensions)
) {
return __('Files of this type are not allowed');
}
return null;
}
protected function validateBlockedExtensions(): string|null
{
$blockedExtensions = settings('uploads.blocked_extensions');
if (
!empty($blockedExtensions) &&
$this->extensionMatches($blockedExtensions)
) {
return __('Files of this type are not allowed');
}
return null;
}
protected function extensionMatches(array $extensions): bool
{
if (empty($extensions) || !isset($this->fileData['extension'])) {
return false;
}
$extensions = array_map(
fn($ext) => str_replace('.', '', $ext),
$extensions,
);
return in_array(
str_replace('.', '', $this->fileData['extension']),
$extensions,
);
}
protected function validateMaximumFileSize(): ?string
{
$maxSize = app(Settings::class)->get('uploads.max_size');
if (is_null($maxSize) || !isset($this->fileData['size'])) {
return null;
}
if ((int) $this->fileData['size'] > (int) $maxSize) {
return __('The file size may not be greater than :size', [
'size' => self::formatBytes((int) $maxSize),
]);
}
return null;
}
protected function validateAllowedStorageSpace(): string|null
{
if (!isset($this->fileData['size']) || !Auth::check()) {
return null;
}
$enoughSpace = app(GetUserSpaceUsage::class)->hasEnoughSpaceToUpload(
$this->fileData['size'],
);
if (!$enoughSpace) {
return self::notEnoughSpaceMessage();
}
return null;
}
public static function formatBytes(?int $bytes, $unit = 'MB'): string
{
if (is_null($bytes)) {
return '0 bytes';
}
if ((!$unit && $bytes >= 1 << 30) || $unit == 'GB') {
return number_format($bytes / (1 << 30), 1) . 'GB';
}
if ((!$unit && $bytes >= 1 << 20) || $unit == 'MB') {
return number_format($bytes / (1 << 20), 1) . 'MB';
}
if ((!$unit && $bytes >= 1 << 10) || $unit == 'KB') {
return number_format($bytes / (1 << 10), 1) . 'KB';
}
return number_format($bytes) . ' bytes';
}
public static function notEnoughSpaceMessage(): string
{
return __(
'You have exhausted your allowed space of :space. Delete some files or upgrade your plan.',
[
'space' => self::formatBytes(
app(GetUserSpaceUsage::class)->getAvailableSpace(),
),
],
);
}
}