130
common/Files/Actions/CreateFileEntry.php
Executable file
130
common/Files/Actions/CreateFileEntry.php
Executable 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];
|
||||
}
|
||||
}
|
||||
40
common/Files/Actions/Deletion/DeleteEntries.php
Executable file
40
common/Files/Actions/Deletion/DeleteEntries.php
Executable 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();
|
||||
}
|
||||
}
|
||||
61
common/Files/Actions/Deletion/PermanentlyDeleteEntries.php
Executable file
61
common/Files/Actions/Deletion/PermanentlyDeleteEntries.php
Executable 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
22
common/Files/Actions/Deletion/RestoreEntries.php
Executable file
22
common/Files/Actions/Deletion/RestoreEntries.php
Executable 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()));
|
||||
}
|
||||
}
|
||||
40
common/Files/Actions/Deletion/SoftDeleteEntries.php
Executable file
40
common/Files/Actions/Deletion/SoftDeleteEntries.php
Executable 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));
|
||||
}
|
||||
}
|
||||
48
common/Files/Actions/GetServerMaxUploadSize.php
Executable file
48
common/Files/Actions/GetServerMaxUploadSize.php
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
common/Files/Actions/GetUserSpaceUsage.php
Executable file
69
common/Files/Actions/GetUserSpaceUsage.php
Executable 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;
|
||||
}
|
||||
}
|
||||
124
common/Files/Actions/StoreFile.php
Executable file
124
common/Files/Actions/StoreFile.php
Executable 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';
|
||||
}
|
||||
}
|
||||
138
common/Files/Actions/ValidateFileUpload.php
Executable file
138
common/Files/Actions/ValidateFileUpload.php
Executable 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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
171
common/Files/Commands/DeleteUploadArtifacts.php
Executable file
171
common/Files/Commands/DeleteUploadArtifacts.php
Executable file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Commands;
|
||||
|
||||
use Common\Files\Actions\Deletion\PermanentlyDeleteEntries;
|
||||
use Common\Files\FileEntry;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeleteUploadArtifacts extends Command
|
||||
{
|
||||
protected $signature = 'uploads:clean';
|
||||
protected $description = 'Delete uploaded files that are no longer used.';
|
||||
|
||||
protected array $map = [
|
||||
'branding_media' => [
|
||||
'type' => 'settings',
|
||||
'keys' => [
|
||||
'branding.logo_light',
|
||||
'branding.logo_dark',
|
||||
'branding.logo_light_mobile',
|
||||
'branding.logo_dark_mobile',
|
||||
],
|
||||
],
|
||||
'homepage_media' => [
|
||||
'type' => 'settings',
|
||||
'keys' => ['homepage.appearance'],
|
||||
],
|
||||
'page_media' => [
|
||||
'type' => 'model',
|
||||
'table' => 'custom_pages',
|
||||
'column' => 'body',
|
||||
],
|
||||
|
||||
// mtdb
|
||||
'person-posters' => [
|
||||
'type' => 'model',
|
||||
'table' => 'people',
|
||||
'column' => 'poster',
|
||||
],
|
||||
'episode-posters' => [
|
||||
'type' => 'model',
|
||||
'table' => 'episodes',
|
||||
'column' => 'poster',
|
||||
],
|
||||
'title-posters' => [
|
||||
'type' => 'model',
|
||||
'table' => 'titles',
|
||||
'column' => 'poster',
|
||||
],
|
||||
'title-backdrops' => [
|
||||
'type' => 'model',
|
||||
'table' => 'titles',
|
||||
'column' => 'backdrop',
|
||||
],
|
||||
'title-videos' => [
|
||||
'type' => 'model',
|
||||
'table' => 'videos',
|
||||
'column' => 'src',
|
||||
],
|
||||
'video-thumbnails' => [
|
||||
'type' => 'model',
|
||||
'table' => 'videos',
|
||||
'column' => 'thumbnail',
|
||||
],
|
||||
|
||||
// bemusic
|
||||
'track_image_media' => [
|
||||
'type' => 'model',
|
||||
'table' => 'tracks',
|
||||
'column' => 'image',
|
||||
],
|
||||
'album_media' => [
|
||||
'type' => 'model',
|
||||
'table' => 'albums',
|
||||
'column' => 'image',
|
||||
],
|
||||
'track_media' => [
|
||||
'type' => 'model',
|
||||
'table' => 'tracks',
|
||||
'column' => 'src',
|
||||
],
|
||||
'artist_media' => [
|
||||
'type' => 'model',
|
||||
'table' => 'artists',
|
||||
'column' => 'image_small',
|
||||
],
|
||||
'genre_media' => [
|
||||
'type' => 'model',
|
||||
'table' => 'genres',
|
||||
'column' => 'image',
|
||||
],
|
||||
'playlist_media' => [
|
||||
'type' => 'model',
|
||||
'table' => 'playlists',
|
||||
'column' => 'image',
|
||||
],
|
||||
|
||||
// bedesk
|
||||
'ticket_images' => [
|
||||
'type' => 'model',
|
||||
'table' => 'replies',
|
||||
'column' => 'body',
|
||||
],
|
||||
'category_images' => [
|
||||
'type' => 'model',
|
||||
'table' => 'categories',
|
||||
'column' => 'image',
|
||||
],
|
||||
'article_images' => [
|
||||
'type' => 'model',
|
||||
'table' => 'articles',
|
||||
'column' => 'body',
|
||||
],
|
||||
|
||||
// belink
|
||||
'link_overlay_images' => [
|
||||
'type' => 'model',
|
||||
'table' => 'link_overlays',
|
||||
'column' => 'colors',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$storage = Storage::disk('public');
|
||||
$count = 0;
|
||||
foreach ($this->map as $folder => $config) {
|
||||
if ($storage->exists($folder)) {
|
||||
$fileNames = collect($storage->allFiles($folder))
|
||||
->filter(fn($path) => $this->shouldDelete($path, $config))
|
||||
->map(fn($path) => basename($path));
|
||||
$count += $fileNames->count();
|
||||
$entryIds = FileEntry::whereIn('file_name', $fileNames)->pluck(
|
||||
'id',
|
||||
);
|
||||
app(PermanentlyDeleteEntries::class)->execute($entryIds);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Deleted $count unused files.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function shouldDelete(string $path, array $config): bool
|
||||
{
|
||||
if ($config['type'] === 'settings') {
|
||||
return collect($config['keys'])
|
||||
->map(fn($key) => settings($key))
|
||||
->filter(
|
||||
fn($configValue) => Str::contains(
|
||||
$configValue,
|
||||
basename($path),
|
||||
),
|
||||
)
|
||||
->isEmpty();
|
||||
} elseif ($config['type'] === 'model') {
|
||||
if (Schema::hasTable($config['table'])) {
|
||||
$fileName = basename($path);
|
||||
return DB::table($config['table'])
|
||||
->whereNotNull($config['column'])
|
||||
->where($config['column'], 'like', "%$fileName%")
|
||||
->count() === 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
common/Files/Controllers/AddPreviewTokenController.php
Executable file
43
common/Files/Controllers/AddPreviewTokenController.php
Executable file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Controllers;
|
||||
|
||||
use Common\Core\BaseController;
|
||||
use Common\Files\FileEntry;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AddPreviewTokenController extends BaseController
|
||||
{
|
||||
/**
|
||||
* @var Request
|
||||
*/
|
||||
private $request;
|
||||
|
||||
/**
|
||||
* @var FileEntry
|
||||
*/
|
||||
private $fileEntry;
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @param FileEntry $fileEntry
|
||||
*/
|
||||
public function __construct(Request $request, FileEntry $fileEntry)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->fileEntry = $fileEntry;
|
||||
}
|
||||
|
||||
public function store($id)
|
||||
{
|
||||
$entry = $this->fileEntry->findOrFail($id);
|
||||
|
||||
$this->authorize('show', $entry);
|
||||
|
||||
$token = Str::random(15);
|
||||
$entry->update(['preview_token' => $token]);
|
||||
|
||||
return $this->success(['preview_token' => $token]);
|
||||
}
|
||||
}
|
||||
37
common/Files/Controllers/DownloadFileController.php
Executable file
37
common/Files/Controllers/DownloadFileController.php
Executable file
@@ -0,0 +1,37 @@
|
||||
<?php namespace Common\Files\Controllers;
|
||||
|
||||
use Common\Core\BaseController;
|
||||
use Common\Files\FileEntry;
|
||||
use Common\Files\Response\DownloadFilesResponse;
|
||||
use Common\Files\Response\FileResponseFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class DownloadFileController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected FileEntry $fileEntry,
|
||||
protected FileResponseFactory $fileResponseFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
public function download(string $hashes)
|
||||
{
|
||||
$hashes = explode(',', $hashes);
|
||||
$ids = array_map(function ($hash) {
|
||||
return $this->fileEntry->decodeHash($hash);
|
||||
}, $hashes);
|
||||
$ids = array_filter($ids);
|
||||
|
||||
if (!$ids) {
|
||||
abort(404, 'No entry hashes provided.');
|
||||
}
|
||||
|
||||
$entries = $this->fileEntry->whereIn('id', $ids)->get();
|
||||
|
||||
$this->authorize('download', [FileEntry::class, $entries]);
|
||||
|
||||
return app(DownloadFilesResponse::class)->create($entries);
|
||||
}
|
||||
}
|
||||
154
common/Files/Controllers/FileEntriesController.php
Executable file
154
common/Files/Controllers/FileEntriesController.php
Executable file
@@ -0,0 +1,154 @@
|
||||
<?php namespace Common\Files\Controllers;
|
||||
|
||||
use Auth;
|
||||
use Common\Core\BaseController;
|
||||
use Common\Database\Datasource\Datasource;
|
||||
use Common\Files\Actions\CreateFileEntry;
|
||||
use Common\Files\Actions\Deletion\DeleteEntries;
|
||||
use Common\Files\Actions\StoreFile;
|
||||
use Common\Files\Actions\ValidateFileUpload;
|
||||
use Common\Files\Events\FileUploaded;
|
||||
use Common\Files\FileEntry;
|
||||
use Common\Files\FileEntryPayload;
|
||||
use Common\Files\Response\FileResponseFactory;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class FileEntriesController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected FileEntry $entry,
|
||||
) {
|
||||
$this->middleware('auth')->only(['index']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$params = $this->request->all();
|
||||
$params['userId'] = $this->request->get('userId');
|
||||
|
||||
// scope files to current user by default if it's an API request
|
||||
if (!requestIsFromFrontend() && !$params['userId']) {
|
||||
$params['userId'] = Auth::id();
|
||||
}
|
||||
|
||||
$this->authorize('index', FileEntry::class);
|
||||
|
||||
$dataSource = new Datasource($this->entry->with(['users']), $params);
|
||||
|
||||
$pagination = $dataSource->paginate();
|
||||
|
||||
return $this->success(['pagination' => $pagination]);
|
||||
}
|
||||
|
||||
public function show(FileEntry $fileEntry, FileResponseFactory $response)
|
||||
{
|
||||
$this->authorize('show', $fileEntry);
|
||||
|
||||
try {
|
||||
return $response->create($fileEntry);
|
||||
} catch (FileNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
public function showModel(FileEntry $fileEntry)
|
||||
{
|
||||
$this->authorize('show', $fileEntry);
|
||||
|
||||
return $this->success(['fileEntry' => $fileEntry]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$parentId = (int) request('parentId') ?: null;
|
||||
request()->merge(['parentId' => $parentId]);
|
||||
|
||||
$this->authorize('store', [FileEntry::class, request('parentId')]);
|
||||
|
||||
$this->validate($this->request, [
|
||||
'file' => [
|
||||
'required',
|
||||
'file',
|
||||
function ($attribute, UploadedFile $value, $fail) {
|
||||
$errors = app(ValidateFileUpload::class)->execute([
|
||||
'extension' => $value->guessExtension(),
|
||||
'size' => $value->getSize(),
|
||||
]);
|
||||
if ($errors) {
|
||||
$fail($errors->first());
|
||||
}
|
||||
},
|
||||
],
|
||||
'parentId' => 'nullable|exists:file_entries,id',
|
||||
'relativePath' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$file = $this->request->file('file');
|
||||
$payload = new FileEntryPayload($this->request->all());
|
||||
|
||||
app(StoreFile::class)->execute($payload, ['file' => $file]);
|
||||
|
||||
$fileEntry = app(CreateFileEntry::class)->execute($payload);
|
||||
|
||||
event(new FileUploaded($fileEntry));
|
||||
|
||||
return $this->success(['fileEntry' => $fileEntry->load('users')], 201);
|
||||
}
|
||||
|
||||
public function update(int $entryId)
|
||||
{
|
||||
$this->authorize('update', [FileEntry::class, [$entryId]]);
|
||||
|
||||
$this->validate($this->request, [
|
||||
'name' => 'string|min:3|max:200',
|
||||
'description' => 'nullable|string|min:3|max:200',
|
||||
]);
|
||||
|
||||
$params = $this->request->all();
|
||||
$entry = $this->entry->findOrFail($entryId);
|
||||
|
||||
$entry->fill($params)->update();
|
||||
|
||||
return $this->success(['fileEntry' => $entry->load('users')]);
|
||||
}
|
||||
|
||||
public function destroy(string $entryIds = null)
|
||||
{
|
||||
if ($entryIds) {
|
||||
$entryIds = explode(',', $entryIds);
|
||||
} else {
|
||||
$entryIds = $this->request->get('entryIds');
|
||||
}
|
||||
|
||||
$userId = Auth::id();
|
||||
|
||||
$this->validate($this->request, [
|
||||
'entryIds' => 'array|exists:file_entries,id',
|
||||
'paths' => 'array',
|
||||
'deleteForever' => 'boolean',
|
||||
'emptyTrash' => 'boolean',
|
||||
]);
|
||||
|
||||
// get all soft deleted entries for user, if we are emptying trash
|
||||
if ($this->request->get('emptyTrash')) {
|
||||
$entryIds = $this->entry
|
||||
->where('owner_id', $userId)
|
||||
->onlyTrashed()
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
app(DeleteEntries::class)->execute([
|
||||
'paths' => $this->request->get('paths'),
|
||||
'entryIds' => $entryIds,
|
||||
'soft' =>
|
||||
!$this->request->get('deleteForever', true) &&
|
||||
!$this->request->get('emptyTrash'),
|
||||
]);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
30
common/Files/Controllers/RestoreDeletedEntriesController.php
Executable file
30
common/Files/Controllers/RestoreDeletedEntriesController.php
Executable file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Controllers;
|
||||
|
||||
use Common\Core\BaseController;
|
||||
use Common\Files\Actions\Deletion\RestoreEntries;
|
||||
use Common\Files\FileEntry;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RestoreDeletedEntriesController extends BaseController
|
||||
{
|
||||
public function __construct(protected Request $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function restore(RestoreEntries $action)
|
||||
{
|
||||
$this->validate($this->request, [
|
||||
'entryIds' => 'required|array|exists:file_entries,id',
|
||||
]);
|
||||
|
||||
$entryIds = $this->request->get('entryIds');
|
||||
|
||||
$this->authorize('destroy', [FileEntry::class, $entryIds]);
|
||||
|
||||
$action->execute($entryIds);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
}
|
||||
23
common/Files/Controllers/ServerMaxUploadSizeController.php
Executable file
23
common/Files/Controllers/ServerMaxUploadSizeController.php
Executable file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Controllers;
|
||||
|
||||
use Common\Core\BaseController;
|
||||
use Common\Files\Actions\GetServerMaxUploadSize;
|
||||
|
||||
class ServerMaxUploadSizeController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return $this->success([
|
||||
'maxSize' => app(GetServerMaxUploadSize::class)->execute()[
|
||||
'original'
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
common/Files/Events/FileEntriesDeleted.php
Executable file
26
common/Files/Events/FileEntriesDeleted.php
Executable file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Events;
|
||||
|
||||
class FileEntriesDeleted
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $entryIds;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
public $permanently;
|
||||
|
||||
/**
|
||||
* @param array $entryIds
|
||||
* @param boolean $permanently
|
||||
*/
|
||||
public function __construct($entryIds, $permanently)
|
||||
{
|
||||
$this->entryIds = $entryIds;
|
||||
$this->permanently = $permanently;
|
||||
}
|
||||
}
|
||||
33
common/Files/Events/FileEntriesMoved.php
Executable file
33
common/Files/Events/FileEntriesMoved.php
Executable file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Events;
|
||||
|
||||
class FileEntriesMoved
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $entryIds;
|
||||
|
||||
/**
|
||||
* @var null|integer
|
||||
*/
|
||||
public $destination;
|
||||
|
||||
/**
|
||||
* @var int|null
|
||||
*/
|
||||
public $source;
|
||||
|
||||
/**
|
||||
* @param array $entryIds
|
||||
* @param null|int $destination
|
||||
* @param null|int $source
|
||||
*/
|
||||
public function __construct($entryIds, $destination, $source)
|
||||
{
|
||||
$this->entryIds = $entryIds;
|
||||
$this->destination = $destination;
|
||||
$this->source = $source;
|
||||
}
|
||||
}
|
||||
10
common/Files/Events/FileEntriesRestored.php
Executable file
10
common/Files/Events/FileEntriesRestored.php
Executable file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Events;
|
||||
|
||||
class FileEntriesRestored
|
||||
{
|
||||
public function __construct(public array $entryIds)
|
||||
{
|
||||
}
|
||||
}
|
||||
18
common/Files/Events/FileEntryCreated.php
Executable file
18
common/Files/Events/FileEntryCreated.php
Executable file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Events;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class FileEntryCreated
|
||||
{
|
||||
/**
|
||||
* @var FileEntry
|
||||
*/
|
||||
public $fileEntry;
|
||||
|
||||
public function __construct(FileEntry $fileEntry)
|
||||
{
|
||||
$this->fileEntry = $fileEntry;
|
||||
}
|
||||
}
|
||||
18
common/Files/Events/FileUploaded.php
Executable file
18
common/Files/Events/FileUploaded.php
Executable file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Events;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class FileUploaded
|
||||
{
|
||||
/**
|
||||
* @var FileEntry
|
||||
*/
|
||||
public $fileEntry;
|
||||
|
||||
public function __construct(FileEntry $fileEntry)
|
||||
{
|
||||
$this->fileEntry = $fileEntry;
|
||||
}
|
||||
}
|
||||
56
common/Files/FileDownloader.php
Executable file
56
common/Files/FileDownloader.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php namespace Common\Files;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
|
||||
class FileDownloader {
|
||||
|
||||
/**
|
||||
* Upload model.
|
||||
*
|
||||
* @var FileEntry
|
||||
*/
|
||||
private $upload;
|
||||
|
||||
/**
|
||||
* Http client instance.
|
||||
*
|
||||
* @var Client
|
||||
*/
|
||||
private $http;
|
||||
|
||||
/**
|
||||
* Laravel Storage service instance.
|
||||
*
|
||||
* @var FilesystemAdapter
|
||||
*/
|
||||
private $laravelStorage;
|
||||
|
||||
/**
|
||||
* Storage constructor.
|
||||
*
|
||||
* @param FileEntry $upload
|
||||
* @param Client $http
|
||||
* @param FilesystemManager $laravelStorage
|
||||
*/
|
||||
public function __construct(FileEntry $upload, Client $http, FilesystemManager $laravelStorage)
|
||||
{
|
||||
$this->upload = $upload;
|
||||
$this->http = $http;
|
||||
$this->laravelStorage = $laravelStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file from specified remote url.
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $params
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function downloadRemoteFile($url, $params = [])
|
||||
{
|
||||
return $this->http->request('GET', $url, $params)->getBody()->getContents();
|
||||
}
|
||||
}
|
||||
307
common/Files/FileEntry.php
Executable file
307
common/Files/FileEntry.php
Executable file
@@ -0,0 +1,307 @@
|
||||
<?php namespace Common\Files;
|
||||
|
||||
use App\Models\User;
|
||||
use Arr;
|
||||
use Auth;
|
||||
use Common\Core\BaseModel;
|
||||
use Common\Files\Traits\HandlesEntryPaths;
|
||||
use Common\Files\Traits\HashesId;
|
||||
use Common\Tags\HandlesTags;
|
||||
use Common\Tags\Tag;
|
||||
use Common\Workspaces\Traits\BelongsToWorkspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FileEntry extends BaseModel
|
||||
{
|
||||
use SoftDeletes,
|
||||
HashesId,
|
||||
HandlesEntryPaths,
|
||||
HandlesTags,
|
||||
BelongsToWorkspace;
|
||||
|
||||
public const MODEL_TYPE = 'fileEntry';
|
||||
protected $guarded = ['id'];
|
||||
protected $hidden = ['pivot', 'preview_token'];
|
||||
protected $appends = ['hash', 'url'];
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
'file_size' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'parent_id' => 'integer',
|
||||
'thumbnail' => 'boolean',
|
||||
'public' => 'boolean',
|
||||
'workspace_id' => 'integer',
|
||||
];
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->morphedByMany(
|
||||
FileEntryUser::class,
|
||||
'model',
|
||||
'file_entry_models',
|
||||
'file_entry_id',
|
||||
'model_id',
|
||||
)
|
||||
->using(FileEntryPivot::class)
|
||||
->select(
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'users.id',
|
||||
'avatar',
|
||||
'model_type',
|
||||
)
|
||||
->withPivot('owner', 'permissions')
|
||||
->withTimestamps()
|
||||
->orderBy('file_entry_models.created_at');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(static::class, 'parent_id')->withoutGlobalScope(
|
||||
'fsType',
|
||||
);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(static::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function tags(): BelongsToMany
|
||||
{
|
||||
return $this->morphToMany(Tag::class, 'taggable')->wherePivot(
|
||||
'user_id',
|
||||
Auth::id() ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public function getUrlAttribute(string $value = null): ?string
|
||||
{
|
||||
if ($value) {
|
||||
return $value;
|
||||
}
|
||||
if (
|
||||
!isset($this->attributes['type']) ||
|
||||
$this->attributes['type'] === 'folder'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$endpoint = config('common.site.file_preview_endpoint');
|
||||
|
||||
if (Arr::get($this->attributes, 'public')) {
|
||||
$publicPath = "$this->disk_prefix/$this->file_name";
|
||||
if ($endpoint) {
|
||||
return "$endpoint/storage/$publicPath";
|
||||
}
|
||||
return Storage::disk('public')->url($publicPath);
|
||||
} elseif ($endpoint) {
|
||||
return "$endpoint/uploads/{$this->file_name}/{$this->file_name}";
|
||||
} else {
|
||||
return "api/v1/file-entries/{$this->attributes['id']}";
|
||||
}
|
||||
}
|
||||
|
||||
public function getStoragePath(bool $useThumbnail = false): string
|
||||
{
|
||||
$fileName = $useThumbnail ? 'thumbnail.jpg' : $this->file_name;
|
||||
if ($this->public) {
|
||||
return "$this->disk_prefix/$fileName";
|
||||
} else {
|
||||
return "$this->file_name/$fileName";
|
||||
}
|
||||
}
|
||||
|
||||
public function getDisk()
|
||||
{
|
||||
if ($this->public) {
|
||||
return Storage::drive('public');
|
||||
} else {
|
||||
return Storage::drive('uploads');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder $query
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeWhereRootOrParentNotTrashed(Builder $query)
|
||||
{
|
||||
return $query
|
||||
->whereNull('parent_id')
|
||||
->orWhereHas('parent', function (Builder $query) {
|
||||
return $query->whereNull('deleted_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all entries user has access to.
|
||||
*/
|
||||
public function scopeWhereUser(
|
||||
Builder $builder,
|
||||
int $userId,
|
||||
bool|null $owner = null,
|
||||
): Builder {
|
||||
return $builder->whereIn($this->qualifyColumn('id'), function (
|
||||
$query,
|
||||
) use ($userId, $owner) {
|
||||
$query
|
||||
->select('file_entry_id')
|
||||
->from('file_entry_models')
|
||||
->where('model_id', $userId)
|
||||
->where('model_type', User::MODEL_TYPE);
|
||||
|
||||
// if $owner is not null, need to load either only
|
||||
// entries user owns or entries user does not own
|
||||
//if $owner is null, load all entries
|
||||
if (!is_null($owner)) {
|
||||
$query->where('owner', $owner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeWhereOwner(Builder $builder, int $userId): Builder
|
||||
{
|
||||
return $builder->where('owner_id', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select only entries that were not created by specified user.
|
||||
*/
|
||||
public function scopeWhereNotOwner(Builder $builder, int $userId): Builder
|
||||
{
|
||||
return $this->scopeWhereUser($builder, $userId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path of specified entry.
|
||||
*
|
||||
* @param int $id
|
||||
* @return string
|
||||
*/
|
||||
public function findPath($id)
|
||||
{
|
||||
$entry = $this->find($id, ['path']);
|
||||
return $entry ? $entry->getRawOriginal('path') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return file entry name with extension.
|
||||
* @return string
|
||||
*/
|
||||
public function getNameWithExtension()
|
||||
{
|
||||
if (!$this->exists) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$extension = pathinfo($this->name, PATHINFO_EXTENSION);
|
||||
|
||||
if (!$extension && $this->extension) {
|
||||
return $this->name . '.' . $this->extension;
|
||||
}
|
||||
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getTotalSize(): int
|
||||
{
|
||||
if ($this->type === 'folder') {
|
||||
return $this->allChildren()->sum('file_size');
|
||||
} else {
|
||||
return $this->file_size;
|
||||
}
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null): ?self
|
||||
{
|
||||
return $this->byIdOrHash($value)
|
||||
->withTrashed()
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* $value might be ID with extension: "4546.mp4" or hash: "ja4d5ad4" or ID int: 4546 or filename
|
||||
*/
|
||||
public function scopeByIdOrHash(Builder $builder, $value): Builder
|
||||
{
|
||||
if (str_contains($value, '.') && str_contains($value, '-')) {
|
||||
return $builder->where('file_name', $value);
|
||||
}
|
||||
|
||||
$id = (int) $value;
|
||||
if ($id === 0) {
|
||||
$id = $this->decodeHash($value);
|
||||
}
|
||||
return $builder->where('id', $id);
|
||||
}
|
||||
|
||||
protected function makeAllSearchableUsing($query)
|
||||
{
|
||||
return $query->with(['tags']);
|
||||
}
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'file_size' => $this->file_size,
|
||||
'mime' => $this->mime,
|
||||
'extension' => $this->extension,
|
||||
'owner_id' => $this->owner_id,
|
||||
'created_at' => $this->created_at->timestamp ?? '_null',
|
||||
'updated_at' => $this->updated_at->timestamp ?? '_null',
|
||||
'deleted_at' => $this->deleted_at->timestamp ?? '_null',
|
||||
'public' => $this->public,
|
||||
'description' => $this->description,
|
||||
'password' => $this->password,
|
||||
'type' => $this->type,
|
||||
'workspace_id' => $this->workspace_id ?? '_null',
|
||||
'tags' => $this->tags->pluck('name'),
|
||||
];
|
||||
}
|
||||
|
||||
public function toNormalizedArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->type,
|
||||
'image' => null,
|
||||
'model_type' => self::MODEL_TYPE,
|
||||
];
|
||||
}
|
||||
|
||||
public static function filterableFields(): array
|
||||
{
|
||||
return [
|
||||
'id',
|
||||
'owner_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'file_size',
|
||||
'public',
|
||||
'password',
|
||||
'type',
|
||||
'workspace_id',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getModelTypeAttribute(): string
|
||||
{
|
||||
return static::MODEL_TYPE;
|
||||
}
|
||||
}
|
||||
133
common/Files/FileEntryPayload.php
Executable file
133
common/Files/FileEntryPayload.php
Executable file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files;
|
||||
|
||||
use Arr;
|
||||
use Common\Files\Traits\GetsEntryTypeFromMime;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Str;
|
||||
|
||||
class FileEntryPayload
|
||||
{
|
||||
use GetsEntryTypeFromMime;
|
||||
|
||||
private array $data;
|
||||
public mixed $diskName;
|
||||
public string $clientName;
|
||||
public string $filename;
|
||||
public ?int $workspaceId;
|
||||
public string $clientMime;
|
||||
public string $type;
|
||||
public ?string $relativePath;
|
||||
public string $clientExtension;
|
||||
|
||||
public int $size;
|
||||
public ?int $parentId;
|
||||
|
||||
public string $diskPrefix;
|
||||
public bool $public;
|
||||
public string $visibility;
|
||||
public int|null $ownerId;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->prepareData($data);
|
||||
$this->diskName = Arr::get($data, 'disk', 'uploads');
|
||||
$this->public = $this->diskName === 'public';
|
||||
$this->prepareEntryPayload();
|
||||
}
|
||||
|
||||
protected function prepareData(array $data): void
|
||||
{
|
||||
$file = Arr::get($data, 'file');
|
||||
$this->data = Arr::except($data, 'file');
|
||||
if ($file instanceof UploadedFile) {
|
||||
$this->data['clientName'] = $file->getClientOriginalName();
|
||||
$this->data['clientMime'] = $file->getClientMimeType();
|
||||
$this->data['size'] = $file->getSize();
|
||||
$this->data[
|
||||
'clientExtension'
|
||||
] = $file->getClientOriginalExtension();
|
||||
}
|
||||
}
|
||||
|
||||
protected function prepareEntryPayload(): void
|
||||
{
|
||||
$this->clientName = $this->data['clientName'];
|
||||
$this->clientMime = $this->data['clientMime'];
|
||||
$this->clientExtension = $this->getExtension();
|
||||
$this->filename = $this->getFilename();
|
||||
$this->workspaceId = Arr::has($this->data, 'workspaceId')
|
||||
? (int) $this->data['workspaceId']
|
||||
: null;
|
||||
$this->relativePath = $this->getRelativePath();
|
||||
$this->diskPrefix = $this->getDiskPrefix();
|
||||
$this->parentId = (int) Arr::get($this->data, 'parentId') ?: null;
|
||||
$this->ownerId = (int) Arr::get($this->data, 'ownerId') ?: Auth::id();
|
||||
$this->size =
|
||||
$this->data['file_size'] ??
|
||||
($this->data['size'] ?? $this->data['clientSize']);
|
||||
$this->visibility = $this->public
|
||||
? 'public'
|
||||
: config('common.site.remote_file_visibility');
|
||||
$this->type = $this->getTypeFromMime(
|
||||
$this->clientMime,
|
||||
$this->clientExtension,
|
||||
);
|
||||
}
|
||||
|
||||
private function getDiskPrefix()
|
||||
{
|
||||
if ($this->public) {
|
||||
return Arr::get($this->data, 'diskPrefix');
|
||||
} else {
|
||||
return $this->filename;
|
||||
}
|
||||
}
|
||||
|
||||
private function getFilename()
|
||||
{
|
||||
$keepOriginalName = settings('uploads.keep_original_name');
|
||||
|
||||
if (isset($this->data['filename'])) {
|
||||
return $this->data['filename'];
|
||||
}
|
||||
|
||||
$uuid = Str::uuid();
|
||||
|
||||
// public files will be stored with extension
|
||||
if ($this->public) {
|
||||
return $keepOriginalName
|
||||
? $this->clientName
|
||||
: "{$uuid}.{$this->clientExtension}";
|
||||
} else {
|
||||
return $uuid;
|
||||
}
|
||||
}
|
||||
|
||||
private function getRelativePath(): string|null
|
||||
{
|
||||
// relative path might sometimes be "null" or "false" as string
|
||||
$relativePath = Arr::get($this->data, 'relativePath');
|
||||
if (!is_string($relativePath) || !Str::contains($relativePath, '/')) {
|
||||
$relativePath = null;
|
||||
}
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
private function getExtension(): string
|
||||
{
|
||||
if ($extension = Arr::get($this->data, 'clientExtension')) {
|
||||
return $extension;
|
||||
}
|
||||
|
||||
$pathinfo = pathinfo($this->clientName);
|
||||
|
||||
if (isset($pathinfo['extension'])) {
|
||||
return $pathinfo['extension'];
|
||||
}
|
||||
|
||||
return explode('/', $this->clientMime)[1];
|
||||
}
|
||||
}
|
||||
29
common/Files/FileEntryPivot.php
Executable file
29
common/Files/FileEntryPivot.php
Executable file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphPivot;
|
||||
|
||||
class FileEntryPivot extends MorphPivot
|
||||
{
|
||||
protected $table = 'file_entry_models';
|
||||
|
||||
protected $casts = ['owner' => 'boolean'];
|
||||
|
||||
/**
|
||||
* @param $value
|
||||
* @return array
|
||||
*/
|
||||
public function getPermissionsAttribute($value)
|
||||
{
|
||||
if (!$value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
55
common/Files/FileEntryUser.php
Executable file
55
common/Files/FileEntryUser.php
Executable file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files;
|
||||
|
||||
use App\Models\User;
|
||||
use Common\Auth\BaseUser;
|
||||
|
||||
class FileEntryUser extends BaseUser
|
||||
{
|
||||
protected $table = 'users';
|
||||
|
||||
protected bool $billingEnabled = false;
|
||||
|
||||
public function getMorphClass()
|
||||
{
|
||||
return User::MODEL_TYPE;
|
||||
}
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'has_password',
|
||||
'pivot',
|
||||
];
|
||||
|
||||
protected $appends = ['owns_entry', 'entry_permissions', 'display_name'];
|
||||
|
||||
public function getOwnsEntryAttribute()
|
||||
{
|
||||
return $this->pivot->owner;
|
||||
}
|
||||
|
||||
public function getEntryPermissionsAttribute()
|
||||
{
|
||||
if ($this->pivot->owner) {
|
||||
return [
|
||||
'edit' => true,
|
||||
'view' => true,
|
||||
'download' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->pivot->permissions;
|
||||
}
|
||||
|
||||
public function toArray(bool $showAll = false): array
|
||||
{
|
||||
return array_merge(
|
||||
$this->attributesToArray(),
|
||||
$this->relationsToArray(),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
common/Files/Listeners/CreateThumbnailForUploadedFile.php
Executable file
62
common/Files/Listeners/CreateThumbnailForUploadedFile.php
Executable file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Listeners;
|
||||
|
||||
use Common\Files\Events\FileUploaded;
|
||||
use Common\Files\FileEntry;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class CreateThumbnailForUploadedFile implements ShouldQueue
|
||||
{
|
||||
public function handle(FileUploaded $event): void
|
||||
{
|
||||
// only create thumbnail for images over 500KB in size
|
||||
if (
|
||||
!$event->fileEntry->public &&
|
||||
$event->fileEntry->type === 'image' &&
|
||||
$event->fileEntry->file_size > 500000 &&
|
||||
!config('common.site.disable_thumbnail_creation')
|
||||
) {
|
||||
try {
|
||||
$this->maybeCreateThumbnail($event->fileEntry);
|
||||
} catch (Exception $e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function maybeCreateThumbnail(FileEntry $entry): void
|
||||
{
|
||||
$this->setMemoryLimit();
|
||||
$file = $entry->getDisk()->readStream($entry->getStoragePath());
|
||||
|
||||
$manager = new ImageManager(new Driver());
|
||||
$img = $manager->read($file);
|
||||
|
||||
$img->cover(350, 250);
|
||||
|
||||
$encodedImg =
|
||||
$entry->extension === 'png' ? $img->toPng() : $img->toJpeg(60);
|
||||
|
||||
$entry
|
||||
->getDisk()
|
||||
->put("{$entry->file_name}/thumbnail.jpg", $encodedImg, [
|
||||
'mimetype' => $encodedImg->mimetype(),
|
||||
'visibility' => config('common.site.remote_file_visibility'),
|
||||
]);
|
||||
|
||||
$entry->fill(['thumbnail' => true])->save();
|
||||
}
|
||||
|
||||
private function setMemoryLimit(): void
|
||||
{
|
||||
$new = 512;
|
||||
$current = (int) ini_get('memory_limit');
|
||||
if ($current < $new) {
|
||||
@ini_set('memory_limit', "{$new}M");
|
||||
}
|
||||
}
|
||||
}
|
||||
36
common/Files/Providers/BackblazeServiceProvider.php
Executable file
36
common/Files/Providers/BackblazeServiceProvider.php
Executable file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Providers;
|
||||
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Storage;
|
||||
|
||||
class BackblazeServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Perform post-registration booting of services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
Storage::extend('backblaze_s3', function ($app, $config) {
|
||||
$config[
|
||||
'endpoint'
|
||||
] = "https://s3.{$config['region']}.backblazeb2.com";
|
||||
|
||||
return app(FilesystemManager::class)->createS3Driver($config);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register bindings in the container.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
31
common/Files/Providers/DigitalOceanServiceProvider.php
Executable file
31
common/Files/Providers/DigitalOceanServiceProvider.php
Executable file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Providers;
|
||||
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class DigitalOceanServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
Storage::extend('digitalocean_s3', function ($app, $config) {
|
||||
$config[
|
||||
'endpoint'
|
||||
] = "https://{$config['region']}.digitaloceanspaces.com";
|
||||
|
||||
return app(FilesystemManager::class)->createS3Driver($config);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register bindings in the container.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
81
common/Files/Providers/DropboxServiceProvider.php
Executable file
81
common/Files/Providers/DropboxServiceProvider.php
Executable file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Providers;
|
||||
|
||||
use Common\Settings\DotEnvEditor;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use League\Flysystem\Filesystem;
|
||||
use Spatie\Dropbox\Client as DropboxClient;
|
||||
use Spatie\Dropbox\RefreshableTokenProvider;
|
||||
use Spatie\FlysystemDropbox\DropboxAdapter;
|
||||
use Storage;
|
||||
|
||||
class DropboxServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Perform post-registration booting of services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
Storage::extend('dropbox', function ($app, $config) {
|
||||
$config = array_merge($config, ['case_sensitive' => false]);
|
||||
|
||||
$tokenProvider = new class ($config) implements
|
||||
RefreshableTokenProvider
|
||||
{
|
||||
protected string|null $token;
|
||||
|
||||
public function __construct(protected array $config)
|
||||
{
|
||||
$this->token = $this->config['access_token'];
|
||||
}
|
||||
|
||||
public function refresh(ClientException $exception): bool
|
||||
{
|
||||
$response = Http::asForm()->post(
|
||||
"https://{$this->config['app_key']}:{$this->config['app_secret']}@api.dropbox.com/oauth2/token",
|
||||
[
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $this->config['refresh_token'],
|
||||
],
|
||||
);
|
||||
$response->throw();
|
||||
|
||||
app(DotEnvEditor::class)->write([
|
||||
'STORAGE_DROPBOX_ACCESS_TOKEN' =>
|
||||
$response['access_token'],
|
||||
]);
|
||||
$this->token = $response['access_token'] ?? null;
|
||||
return $this->token ?: false;
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->token ?? '';
|
||||
}
|
||||
};
|
||||
|
||||
$adapter = new DropboxAdapter(new DropboxClient($tokenProvider));
|
||||
return new FilesystemAdapter(
|
||||
new Filesystem($adapter, $config),
|
||||
$adapter,
|
||||
$config,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register bindings in the container.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
68
common/Files/Providers/DynamicStorageDiskProvider.php
Executable file
68
common/Files/Providers/DynamicStorageDiskProvider.php
Executable file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Providers;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class DynamicStorageDiskProvider extends ServiceProvider
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
Storage::extend('dynamic-uploads', function (
|
||||
Application $app,
|
||||
$initialConfig,
|
||||
) {
|
||||
return $this->resolveDisk('uploads', $initialConfig);
|
||||
});
|
||||
|
||||
Storage::extend('dynamic-public', function (
|
||||
Application $app,
|
||||
$initialConfig,
|
||||
) {
|
||||
return $this->resolveDisk('public', $initialConfig);
|
||||
});
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
private function resolveDisk(string $type, array $initialConfig): Filesystem
|
||||
{
|
||||
$driverName = config("common.site.{$type}_disk_driver") ?? 'local';
|
||||
$config = array_merge(
|
||||
$initialConfig,
|
||||
config("services.$driverName") ?? [],
|
||||
);
|
||||
$config['driver'] = $driverName;
|
||||
|
||||
// set root based on drive type and name
|
||||
$config['root'] =
|
||||
$driverName === 'local'
|
||||
? $config['local_root']
|
||||
: $config['remote_root'];
|
||||
|
||||
// unset "storage" url from remote drives as "$disk->url()" will generate "storage/file_entry.jpg" url
|
||||
if (
|
||||
$driverName !== 'local' &&
|
||||
Arr::get($config, 'url') === $config['remote_root']
|
||||
) {
|
||||
unset($config['url']);
|
||||
}
|
||||
|
||||
if (isset($config['port'])) {
|
||||
$config['port'] = (int) $config['port'];
|
||||
}
|
||||
|
||||
$dynamicConfigKey = "{$type}_{$driverName}";
|
||||
Config::set("filesystems.disks.{$dynamicConfigKey}", $config);
|
||||
|
||||
return Storage::disk($dynamicConfigKey);
|
||||
}
|
||||
}
|
||||
175
common/Files/Response/DownloadFilesResponse.php
Executable file
175
common/Files/Response/DownloadFilesResponse.php
Executable file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Files\FileEntry;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipStream\ZipStream;
|
||||
|
||||
class DownloadFilesResponse
|
||||
{
|
||||
// basename with extension => count
|
||||
// for incrementing file names in zip for files that have duplicate name
|
||||
protected array $filesInZip = [];
|
||||
protected int $totalSize = 0;
|
||||
|
||||
public function __construct(
|
||||
protected FileResponseFactory $fileResponseFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection|FileEntry[] $entries
|
||||
* @return mixed
|
||||
*/
|
||||
public function create($entries)
|
||||
{
|
||||
if ($entries->count() === 1 && $entries->first()->type !== 'folder') {
|
||||
return $this->fileResponseFactory->create(
|
||||
$entries->first(),
|
||||
'attachment',
|
||||
);
|
||||
} else {
|
||||
return $this->streamZip($entries);
|
||||
}
|
||||
}
|
||||
|
||||
private function streamZip(Collection $entries): StreamedResponse
|
||||
{
|
||||
return new StreamedResponse(
|
||||
function () use ($entries) {
|
||||
$timestamp = Carbon::now()->getTimestamp();
|
||||
$zip = new ZipStream(
|
||||
// downloading multiple files from s3 will error out without this
|
||||
defaultEnableZeroHeader: true,
|
||||
contentType: 'application/octet-stream',
|
||||
sendHttpHeaders: true,
|
||||
outputName: "download-$timestamp.zip",
|
||||
);
|
||||
|
||||
$this->fillZip($zip, $entries);
|
||||
$zip->finish();
|
||||
},
|
||||
200,
|
||||
[
|
||||
'X-Accel-Buffering' => 'no',
|
||||
'Pragma' => 'public',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Content-Transfer-Encoding' => 'binary',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function fillZip(ZipStream $zip, Collection $entries): void
|
||||
{
|
||||
$entries->each(function (FileEntry $entry) use ($zip) {
|
||||
if ($entry->type === 'folder') {
|
||||
// this will load all children, nested at any level, so no need to do a recursive loop
|
||||
$entry
|
||||
->allChildren()
|
||||
->select([
|
||||
'id',
|
||||
'name',
|
||||
'extension',
|
||||
'path',
|
||||
'type',
|
||||
'file_name',
|
||||
'disk_prefix',
|
||||
])
|
||||
->orderBy('path', 'asc')
|
||||
->chunk(300, function (Collection $chunk) use (
|
||||
$zip,
|
||||
$entry,
|
||||
) {
|
||||
$chunk->each(function (FileEntry $childEntry) use (
|
||||
$zip,
|
||||
$entry,
|
||||
$chunk,
|
||||
) {
|
||||
$path = $this->transformPath(
|
||||
$childEntry,
|
||||
$entry,
|
||||
$chunk,
|
||||
);
|
||||
if ($childEntry->type === 'folder') {
|
||||
// add empty folder in case it has no children
|
||||
$zip->addFile("$path/", '');
|
||||
} else {
|
||||
$this->addFileToZip($childEntry, $zip, $path);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
$this->addFileToZip($entry, $zip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function addFileToZip(
|
||||
FileEntry $entry,
|
||||
ZipStream $zip,
|
||||
string|null $path = null,
|
||||
): void {
|
||||
if (!$path) {
|
||||
$path = $entry->getNameWithExtension();
|
||||
}
|
||||
|
||||
$parts = pathinfo($path);
|
||||
$basename = $parts['basename'];
|
||||
$filename = $parts['filename'];
|
||||
$extension = $parts['extension'];
|
||||
$dirname = $parts['dirname'] === '.' ? '' : $parts['dirname'];
|
||||
|
||||
// add number to duplicate file names (file(1).png, file(2).png etc)
|
||||
if (isset($this->filesInZip[$basename])) {
|
||||
$newCount = $this->filesInZip[$basename] + 1;
|
||||
$this->filesInZip[$basename] = $newCount;
|
||||
$path = "$dirname/$filename($newCount).$extension";
|
||||
} else {
|
||||
$this->filesInZip[$basename] = 0;
|
||||
}
|
||||
|
||||
$stream = $entry->getDisk()->readStream($entry->getStoragePath());
|
||||
if ($stream) {
|
||||
$zip->addFileFromStream($path, $stream);
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace entry IDs with names inside "path" property.
|
||||
*/
|
||||
private function transformPath(
|
||||
FileEntry $entry,
|
||||
FileEntry $parent,
|
||||
Collection $folders,
|
||||
): string {
|
||||
if (!$entry->path) {
|
||||
return $entry->getNameWithExtension();
|
||||
}
|
||||
|
||||
// '56/55/54 => [56,55,54]
|
||||
$path = array_filter(explode('/', $entry->path));
|
||||
$path = array_map(function ($id) {
|
||||
return (int) $id;
|
||||
}, $path);
|
||||
|
||||
//only generate path until specified parent and not root
|
||||
$path = array_slice($path, array_search($parent->id, $path));
|
||||
|
||||
// last value will be id of the file itself, remove it
|
||||
array_pop($path);
|
||||
|
||||
// map parent folder IDs to names
|
||||
$path = array_map(function ($id) use ($folders, $parent) {
|
||||
if ($id === $parent->id) {
|
||||
return $parent->name;
|
||||
}
|
||||
return $folders->find($id)->name;
|
||||
}, $path);
|
||||
|
||||
return implode('/', $path) . '/' . $entry->getNameWithExtension();
|
||||
}
|
||||
}
|
||||
15
common/Files/Response/FileResponse.php
Executable file
15
common/Files/Response/FileResponse.php
Executable file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
interface FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options);
|
||||
}
|
||||
74
common/Files/Response/FileResponseFactory.php
Executable file
74
common/Files/Response/FileResponseFactory.php
Executable file
@@ -0,0 +1,74 @@
|
||||
<?php namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
use League\Flysystem\Local\LocalFilesystemAdapter;
|
||||
use Request;
|
||||
|
||||
class FileResponseFactory
|
||||
{
|
||||
public function create(
|
||||
FileEntry $entry,
|
||||
string $disposition = 'inline',
|
||||
): mixed {
|
||||
$options = [
|
||||
'useThumbnail' => Request::get('thumbnail') && $entry->thumbnail,
|
||||
'disposition' => $disposition,
|
||||
];
|
||||
|
||||
return $this->resolveResponseClass($entry, $disposition)->make(
|
||||
$entry,
|
||||
$options,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveResponseClass(
|
||||
FileEntry $entry,
|
||||
string $disposition = 'inline',
|
||||
): FileResponse {
|
||||
$isLocalDrive =
|
||||
$entry->getDisk()->getAdapter() instanceof LocalFilesystemAdapter;
|
||||
$staticFileDelivery = config('common.site.static_file_delivery');
|
||||
|
||||
if ($this->shouldRedirectToRemoteUrl($entry)) {
|
||||
return new RemoteFileResponse();
|
||||
} elseif ($isLocalDrive && !$entry->public && $staticFileDelivery) {
|
||||
return $staticFileDelivery === 'xsendfile'
|
||||
? new XSendFileResponse()
|
||||
: new XAccelRedirectFileResponse();
|
||||
} elseif (
|
||||
!$isLocalDrive &&
|
||||
config('common.site.use_presigned_s3_urls')
|
||||
) {
|
||||
return new StreamedFileResponse();
|
||||
} elseif (
|
||||
$disposition === 'inline' &&
|
||||
$this->shouldReturnRangeResponse($entry)
|
||||
) {
|
||||
return new RangeFileResponse();
|
||||
} else {
|
||||
return new StreamedFileResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldReturnRangeResponse(FileEntry $entry): bool
|
||||
{
|
||||
return $entry->type === 'video' ||
|
||||
$entry->type === 'audio' ||
|
||||
$entry->mime === 'application/ogg';
|
||||
}
|
||||
|
||||
private function shouldRedirectToRemoteUrl(FileEntry $entry): bool
|
||||
{
|
||||
$adapter = $entry->getDisk()->getAdapter();
|
||||
$isS3 = $adapter instanceof AwsS3V3Adapter;
|
||||
$shouldUsePublicUrl =
|
||||
config('common.site.remote_file_visibility') === 'public' && $isS3;
|
||||
$shouldUsePresignedUrl =
|
||||
config('common.site.use_presigned_s3_urls') && $isS3;
|
||||
$hasCustomCdnUrl = config('common.site.file_preview_endpoint');
|
||||
return $shouldUsePresignedUrl ||
|
||||
$shouldUsePublicUrl ||
|
||||
$hasCustomCdnUrl;
|
||||
}
|
||||
}
|
||||
66
common/Files/Response/RangeFileResponse.php
Executable file
66
common/Files/Response/RangeFileResponse.php
Executable file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class RangeFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options)
|
||||
{
|
||||
$disk = $entry->getDisk();
|
||||
$size = $disk->size($entry->getStoragePath());
|
||||
$time = date('r', $entry->updated_at->timestamp);
|
||||
$fm = $disk->getDriver()->readStream($entry->getStoragePath());
|
||||
$begin = 0;
|
||||
$end = $size - 1;
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE']))
|
||||
{
|
||||
if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $_SERVER['HTTP_RANGE'], $matches))
|
||||
{
|
||||
$begin = intval($matches[1]);
|
||||
if (!empty($matches[2]))
|
||||
{
|
||||
$end = intval($matches[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE']))
|
||||
{
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
}
|
||||
else
|
||||
{
|
||||
header('HTTP/1.1 200 OK');
|
||||
}
|
||||
|
||||
header("Content-Type: $entry->mime");
|
||||
header('Cache-Control: public, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Content-Length:' . (($end - $begin) + 1));
|
||||
if (isset($_SERVER['HTTP_RANGE']))
|
||||
{
|
||||
header("Content-Range: bytes $begin-$end/$size");
|
||||
}
|
||||
header("Content-Disposition: inline; filename={$entry->getNameWithExtension()}");
|
||||
header("Content-Transfer-Encoding: binary");
|
||||
header("Last-Modified: $time");
|
||||
|
||||
$cur = $begin;
|
||||
fseek($fm, $begin, 0);
|
||||
|
||||
while(!feof($fm) && $cur <= $end && (connection_status() == 0))
|
||||
{
|
||||
print fread($fm, min(1024 * 16, ($end - $cur) + 1));
|
||||
$cur += 1024 * 16;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
common/Files/Response/RemoteFileResponse.php
Executable file
53
common/Files/Response/RemoteFileResponse.php
Executable file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class RemoteFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options): mixed
|
||||
{
|
||||
if ($options['disposition'] === 'attachment') {
|
||||
$fileName = rawurlencode($entry->name);
|
||||
return $this->getTemporaryUrl($entry, $options, [
|
||||
'ResponseContentType' => 'application/octet-stream',
|
||||
'ResponseContentDisposition' => "attachment;filename={$fileName}",
|
||||
]);
|
||||
} else {
|
||||
if (config('common.site.use_presigned_s3_urls')) {
|
||||
return $this->getTemporaryUrl($entry, $options, [
|
||||
'ResponseContentType' => $entry->mime,
|
||||
]);
|
||||
} else {
|
||||
return redirect(
|
||||
$entry
|
||||
->getDisk()
|
||||
->url($entry->getStoragePath($options['useThumbnail'])),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getTemporaryUrl(
|
||||
FileEntry $entry,
|
||||
array $entryOptions,
|
||||
array $urlOptions,
|
||||
) {
|
||||
return redirect(
|
||||
$entry
|
||||
->getDisk()
|
||||
->temporaryUrl(
|
||||
$entry->getStoragePath($entryOptions['useThumbnail']),
|
||||
Carbon::now()->addMinutes(30),
|
||||
$urlOptions,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
common/Files/Response/StreamedFileResponse.php
Executable file
56
common/Files/Response/StreamedFileResponse.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class StreamedFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options)
|
||||
{
|
||||
$downloadName = str_replace(
|
||||
['%', '/'],
|
||||
'',
|
||||
$entry->getNameWithExtension(),
|
||||
);
|
||||
|
||||
$path = $entry->getStoragePath($options['useThumbnail']);
|
||||
$response = new StreamedResponse();
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
$options['disposition'],
|
||||
$downloadName,
|
||||
Str::ascii($downloadName),
|
||||
);
|
||||
|
||||
$fileSize = $options['useThumbnail']
|
||||
? $entry->getDisk()->size($path)
|
||||
: $entry->file_size;
|
||||
|
||||
$response->headers->replace([
|
||||
'Content-Type' => $entry->mime,
|
||||
'Content-Length' => $fileSize,
|
||||
'Content-Disposition' => $disposition,
|
||||
'Cache-Control' => 'private, max-age=31536000, no-transform',
|
||||
//'X-Accel-Buffering' => 'no',
|
||||
]);
|
||||
$response->setCallback(function () use ($entry, $path) {
|
||||
$stream = $entry->getDisk()->readStream($path);
|
||||
if (!$stream) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
while (!feof($stream)) {
|
||||
echo fread($stream, 2048);
|
||||
}
|
||||
fclose($stream);
|
||||
});
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
23
common/Files/Response/XAccelRedirectFileResponse.php
Executable file
23
common/Files/Response/XAccelRedirectFileResponse.php
Executable file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class XAccelRedirectFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options)
|
||||
{
|
||||
$disposition = $options['disposition'];
|
||||
header('X-Media-Root: ' . storage_path('app/uploads'));
|
||||
header("X-Accel-Redirect: /uploads/{$entry->getStoragePath($options['useThumbnail'])}");
|
||||
header("Content-Type: {$entry->mime}");
|
||||
header("Content-Disposition: $disposition; filename=\"".$entry->getNameWithExtension().'"');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
23
common/Files/Response/XSendFileResponse.php
Executable file
23
common/Files/Response/XSendFileResponse.php
Executable file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class XSendFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options)
|
||||
{
|
||||
$path = storage_path('app/uploads').'/'.$entry->getStoragePath($options['useThumbnail']);
|
||||
$disposition = $options['disposition'];
|
||||
header("X-Sendfile: $path");
|
||||
header("Content-Type: {$entry->mime}");
|
||||
header("Content-Disposition: $disposition; filename=\"".$entry->getNameWithExtension().'"');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
61
common/Files/S3/AbortOldS3Uploads.php
Executable file
61
common/Files/S3/AbortOldS3Uploads.php
Executable file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\S3;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Error;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\AwsS3V3Adapter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AbortOldS3Uploads extends Command
|
||||
{
|
||||
use InteractsWithS3Api;
|
||||
|
||||
protected $signature = 's3:abort_expired';
|
||||
|
||||
protected $description = 'Abort and delete expired S3 file uploads';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
$client = $this->getClient();
|
||||
} catch (Error $e) {
|
||||
// if s3 is not configured or enabled, bail
|
||||
$this->error(
|
||||
'S3 is not configured or not selected as storage method in settings page.',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$data = $client->listMultipartUploads([
|
||||
'Bucket' => $this->getBucket(),
|
||||
]);
|
||||
|
||||
$uploads = $data['Uploads'] ?: [];
|
||||
|
||||
foreach ($uploads as $upload) {
|
||||
$createdAt = Carbon::parse($upload['Initiated']);
|
||||
|
||||
if ($createdAt->lessThanOrEqualTo(Carbon::now()->subDay())) {
|
||||
$client->abortMultipartUpload([
|
||||
'Bucket' => $this->getBucket(),
|
||||
'Key' => $upload['Key'],
|
||||
'UploadId' => $upload['UploadId'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Expired uploads deleted from S3');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function getDiskName(): string
|
||||
{
|
||||
if (Storage::disk('uploads') instanceof AwsS3V3Adapter) {
|
||||
return 'uploads';
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
}
|
||||
73
common/Files/S3/InteractsWithS3Api.php
Executable file
73
common/Files/S3/InteractsWithS3Api.php
Executable file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\S3;
|
||||
|
||||
use Aws\S3\S3Client;
|
||||
use Common\Settings\Settings;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait InteractsWithS3Api
|
||||
{
|
||||
protected function getDiskName(): string
|
||||
{
|
||||
return request()->input('disk') ?: 'uploads';
|
||||
}
|
||||
|
||||
protected function getDisk(): Filesystem
|
||||
{
|
||||
return Storage::disk($this->getDiskName());
|
||||
}
|
||||
|
||||
protected function getClient(): ?S3Client
|
||||
{
|
||||
return $this->getDisk()->getClient();
|
||||
}
|
||||
|
||||
protected function getBucket(): string
|
||||
{
|
||||
$credentialsKey = config(
|
||||
"common.site.{$this->getDiskName()}_disk_driver",
|
||||
);
|
||||
return config("services.{$credentialsKey}.bucket");
|
||||
}
|
||||
|
||||
protected function getAcl(): string
|
||||
{
|
||||
return $this->getDiskName() === 'public' ||
|
||||
config('common.site.remote_file_visibility') === 'public'
|
||||
? 'public-read'
|
||||
: 'private';
|
||||
}
|
||||
|
||||
protected function buildFileKey(): string
|
||||
{
|
||||
$uuid = Str::uuid();
|
||||
$filename = request('filename');
|
||||
$extension = request('extension');
|
||||
$keepOriginalName = app(Settings::class)->get(
|
||||
'uploads.keep_original_name',
|
||||
);
|
||||
|
||||
if ($this->getDiskName() === 'public') {
|
||||
$fileKey = $keepOriginalName ? $filename : "$uuid.$extension";
|
||||
$diskPrefix = request('diskPrefix');
|
||||
if ($diskPrefix) {
|
||||
$fileKey = "$diskPrefix/$fileKey";
|
||||
}
|
||||
} else {
|
||||
$diskPrefix = $uuid;
|
||||
$filename = $keepOriginalName ? $filename : $uuid;
|
||||
$fileKey = "$diskPrefix/$filename";
|
||||
}
|
||||
|
||||
$pathPrefix = $this->getDisk()->path('');
|
||||
|
||||
if ($pathPrefix) {
|
||||
$fileKey = "{$pathPrefix}{$fileKey}";
|
||||
}
|
||||
|
||||
return $fileKey;
|
||||
}
|
||||
}
|
||||
47
common/Files/S3/S3CorsController.php
Executable file
47
common/Files/S3/S3CorsController.php
Executable file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\S3;
|
||||
|
||||
use Common\Core\BaseController;
|
||||
use Illuminate\Filesystem\AwsS3V3Adapter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class S3CorsController extends BaseController
|
||||
{
|
||||
use InteractsWithS3Api;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('isAdmin');
|
||||
}
|
||||
|
||||
public function uploadCors()
|
||||
{
|
||||
$cors = [
|
||||
[
|
||||
'AllowedOrigins' => [config('app.url')],
|
||||
'AllowedMethods' => ['GET', 'HEAD', 'POST', 'PUT'],
|
||||
'MaxAgeSeconds' => 3000,
|
||||
'AllowedHeaders' => ['*'],
|
||||
'ExposeHeaders' => ['ETag'],
|
||||
],
|
||||
];
|
||||
|
||||
$this->getClient()->putBucketCors([
|
||||
'Bucket' => $this->getBucket(),
|
||||
'CORSConfiguration' => [
|
||||
'CORSRules' => $cors,
|
||||
],
|
||||
]);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
protected function getDiskName(): string
|
||||
{
|
||||
if (Storage::disk('uploads') instanceof AwsS3V3Adapter) {
|
||||
return 'uploads';
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
}
|
||||
38
common/Files/S3/S3FileEntryController.php
Executable file
38
common/Files/S3/S3FileEntryController.php
Executable file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\S3;
|
||||
|
||||
use Common\Core\BaseController;
|
||||
use Common\Files\Actions\CreateFileEntry;
|
||||
use Common\Files\Events\FileUploaded;
|
||||
use Common\Files\FileEntry;
|
||||
use Common\Files\FileEntryPayload;
|
||||
|
||||
class S3FileEntryController extends BaseController
|
||||
{
|
||||
public function store()
|
||||
{
|
||||
$validatedData = $this->validate(request(), [
|
||||
'clientExtension' => 'required|string',
|
||||
'clientMime' => 'nullable|string|max:255',
|
||||
'clientName' => 'required|string',
|
||||
'disk' => 'string',
|
||||
'diskPrefix' => 'string',
|
||||
'filename' => 'required|string',
|
||||
'parentId' => 'nullable|exists:file_entries,id',
|
||||
'relativePath' => 'nullable|string',
|
||||
'workspaceId' => 'nullable|int',
|
||||
'size' => 'required|int',
|
||||
]);
|
||||
|
||||
$payload = new FileEntryPayload($validatedData);
|
||||
|
||||
$this->authorize('store', [FileEntry::class, $payload->parentId]);
|
||||
|
||||
$fileEntry = app(CreateFileEntry::class)->execute($payload);
|
||||
|
||||
event(new FileUploaded($fileEntry));
|
||||
|
||||
return $this->success(['fileEntry' => $fileEntry]);
|
||||
}
|
||||
}
|
||||
118
common/Files/S3/S3MultipartUploadController.php
Executable file
118
common/Files/S3/S3MultipartUploadController.php
Executable file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\S3;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Core\BaseController;
|
||||
use Common\Files\Actions\ValidateFileUpload;
|
||||
|
||||
class S3MultipartUploadController extends BaseController
|
||||
{
|
||||
use InteractsWithS3Api;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$errors = app(ValidateFileUpload::class)->execute(request()->all());
|
||||
if ($errors) {
|
||||
abort(422, $errors->first());
|
||||
}
|
||||
|
||||
$result = $this->getClient()->createMultipartUpload([
|
||||
'Key' => $this->buildFileKey(),
|
||||
'Bucket' => $this->getBucket(),
|
||||
'ContentType' => request()->input('mime'),
|
||||
'ACL' => $this->getAcl(),
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'key' => $result['Key'],
|
||||
'uploadId' => $result['UploadId'],
|
||||
'acl' => $this->getAcl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getUploadedParts()
|
||||
{
|
||||
$data = $this->getClient()->listParts([
|
||||
'Bucket' => $this->getBucket(),
|
||||
'Key' => request('key'),
|
||||
'UploadId' => request('uploadId'),
|
||||
'PartNumberMarker' => 0,
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'parts' => $data['Parts'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function batchSignPartUrls()
|
||||
{
|
||||
$partNumbers = request()->input('partNumbers');
|
||||
|
||||
$urls = [];
|
||||
|
||||
foreach ($partNumbers as $partNumber) {
|
||||
$url = $this->getPartUrl(
|
||||
$partNumber,
|
||||
request('uploadId'),
|
||||
request('key'),
|
||||
);
|
||||
$urls[] = ['url' => $url, 'partNumber' => $partNumber];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'urls' => $urls,
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete()
|
||||
{
|
||||
$data = $this->getClient()->completeMultipartUpload([
|
||||
'Bucket' => $this->getBucket(),
|
||||
'Key' => request()->input('key'),
|
||||
'UploadId' => request()->input('uploadId'),
|
||||
'MultipartUpload' => [
|
||||
'Parts' => request()->input('parts'),
|
||||
],
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'location' => $data['Location'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function abort()
|
||||
{
|
||||
$this->getClient()->abortMultipartUpload([
|
||||
'Bucket' => $this->getBucket(),
|
||||
'Key' => request()->input('key'),
|
||||
'UploadId' => request()->input('uploadId'),
|
||||
]);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
protected function getPartUrl(
|
||||
string $partNumber,
|
||||
string $uploadId,
|
||||
string $key
|
||||
): string {
|
||||
$command = $this->getClient()->getCommand('UploadPart', [
|
||||
'Bucket' => $this->getBucket(),
|
||||
'Key' => $key,
|
||||
'UploadId' => $uploadId,
|
||||
'PartNumber' => $partNumber,
|
||||
]);
|
||||
$s3Request = $this->getClient()->createPresignedRequest(
|
||||
$command,
|
||||
Carbon::now()->addMinutes(30),
|
||||
);
|
||||
|
||||
return (string) $s3Request->getUri();
|
||||
}
|
||||
}
|
||||
44
common/Files/S3/S3SimpleUploadController.php
Executable file
44
common/Files/S3/S3SimpleUploadController.php
Executable file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\S3;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Core\BaseController;
|
||||
use Common\Files\Actions\ValidateFileUpload;
|
||||
|
||||
class S3SimpleUploadController extends BaseController
|
||||
{
|
||||
use InteractsWithS3Api;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function presignPost()
|
||||
{
|
||||
$fileKey = $this->buildFileKey();
|
||||
|
||||
$errors = app(ValidateFileUpload::class)->execute(request()->all());
|
||||
if ($errors) {
|
||||
abort(422, $errors->first());
|
||||
}
|
||||
|
||||
$command = $this->getClient()->getCommand('PutObject', [
|
||||
'Bucket' => $this->getBucket(),
|
||||
'ContentType' => request()->input('mime'),
|
||||
'Key' => $fileKey,
|
||||
'ACL' => $this->getAcl(),
|
||||
]);
|
||||
|
||||
$uri = $this->getClient()
|
||||
->createPresignedRequest($command, Carbon::now()->addHour())
|
||||
->getUri();
|
||||
|
||||
return $this->success([
|
||||
'url' => $uri,
|
||||
'key' => $fileKey,
|
||||
'acl' => $this->getAcl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
50
common/Files/Traits/GetsEntryTypeFromMime.php
Executable file
50
common/Files/Traits/GetsEntryTypeFromMime.php
Executable file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Traits;
|
||||
|
||||
use Str;
|
||||
|
||||
trait GetsEntryTypeFromMime
|
||||
{
|
||||
protected function getTypeFromMime(
|
||||
string $mime,
|
||||
string $extension = null,
|
||||
): string {
|
||||
$default = explode('/', $mime)[0];
|
||||
|
||||
if ($mime === 'text/plain' && $extension === 'csv') {
|
||||
return 'spreadsheet';
|
||||
}
|
||||
|
||||
switch ($mime) {
|
||||
case 'application/x-zip-compressed':
|
||||
case 'application/zip':
|
||||
return 'archive';
|
||||
case 'application/pdf':
|
||||
return 'pdf';
|
||||
case 'image/svg':
|
||||
return 'image/svg+xml';
|
||||
case 'image/vnd.dwg':
|
||||
return 'file';
|
||||
case 'vnd.android.package-archive':
|
||||
return 'android package';
|
||||
case Str::contains($mime, ['xls', 'excel', 'spreadsheetml', 'csv']):
|
||||
return 'spreadsheet';
|
||||
case Str::contains($mime, 'photoshop'):
|
||||
return 'photoshop';
|
||||
case Str::contains($mime, 'officedocument.presentation'):
|
||||
return 'powerPoint';
|
||||
case Str::contains($mime, [
|
||||
'application/msword',
|
||||
'wordprocessingml.document',
|
||||
]):
|
||||
return 'word';
|
||||
case Str::contains($mime, ['postscript', 'x-eps']):
|
||||
return 'postscript';
|
||||
case Str::startsWith($mime, 'message/rfc'):
|
||||
return 'text/plain';
|
||||
default:
|
||||
return $default === 'application' ? 'file' : $default;
|
||||
}
|
||||
}
|
||||
}
|
||||
113
common/Files/Traits/HandlesEntryPaths.php
Executable file
113
common/Files/Traits/HandlesEntryPaths.php
Executable file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Traits;
|
||||
|
||||
use DB;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait HandlesEntryPaths
|
||||
{
|
||||
public function getPathAttribute($value): string
|
||||
{
|
||||
if (!$value) {
|
||||
$value = '';
|
||||
}
|
||||
|
||||
$parts = explode('/', $value);
|
||||
|
||||
$parts = array_map(function ($part) {
|
||||
return $this->decodePathId($part);
|
||||
}, array_filter($parts));
|
||||
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
public function setPathAttribute($value)
|
||||
{
|
||||
if (!$value) {
|
||||
$value = '';
|
||||
}
|
||||
|
||||
$this->attributes['path'] = $this->encodePath($value);
|
||||
}
|
||||
|
||||
public function updatePaths(
|
||||
string $oldPath,
|
||||
string $newPath,
|
||||
$entryIds = null,
|
||||
): void {
|
||||
$oldPath = $this->encodePath($oldPath);
|
||||
$newPath = $this->encodePath($newPath);
|
||||
|
||||
$query = $this->newQuery();
|
||||
|
||||
if ($entryIds) {
|
||||
$query->whereIn('id', $entryIds);
|
||||
}
|
||||
|
||||
$query->where('path', 'LIKE', "$oldPath%")->update([
|
||||
'path' => DB::raw("REPLACE(path, '$oldPath', '$newPath')"),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads current model as well as all children.
|
||||
*/
|
||||
public function scopeAllChildren(Builder $builder): Builder
|
||||
{
|
||||
return $builder->where('path', 'like', $this->attributes['path'] . '%');
|
||||
}
|
||||
|
||||
public function getParentIds(): array
|
||||
{
|
||||
$folderIds = explode('/', $this->path);
|
||||
array_pop($folderIds);
|
||||
return $folderIds;
|
||||
}
|
||||
|
||||
public function scopeAllParents(Builder $builder): Builder
|
||||
{
|
||||
return $builder->whereIn('id', $this->getParentIds())->orderBy('path');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate full path for current entry, based on its parent.
|
||||
*/
|
||||
public function generatePath(): void
|
||||
{
|
||||
if (!$this->exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->path = $this->id;
|
||||
|
||||
if ($this->parent_id && ($parent = $this->find($this->parent_id))) {
|
||||
$this->path = "{$parent->path}/$this->path";
|
||||
}
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
private function encodePath($path): string
|
||||
{
|
||||
$parts = explode('/', (string) $path);
|
||||
|
||||
$parts = array_filter($parts);
|
||||
|
||||
$parts = array_map(function ($part) {
|
||||
return $this->encodePathId($part);
|
||||
}, $parts);
|
||||
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
private function encodePathId($id): string
|
||||
{
|
||||
return base_convert($id, 10, 36);
|
||||
}
|
||||
|
||||
private function decodePathId($id): string
|
||||
{
|
||||
return base_convert($id, 36, 10);
|
||||
}
|
||||
}
|
||||
22
common/Files/Traits/HashesId.php
Executable file
22
common/Files/Traits/HashesId.php
Executable file
@@ -0,0 +1,22 @@
|
||||
<?php namespace Common\Files\Traits;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait HashesId
|
||||
{
|
||||
public function getHashAttribute(): string
|
||||
{
|
||||
return trim(base64_encode(str_pad($this->getRawOriginal('id').'|', 10, 'padding')), '=');
|
||||
}
|
||||
|
||||
public function scopeWhereHash(Builder $query, $value)
|
||||
{
|
||||
$id = $this->decodeHash($value);
|
||||
return $query->where('id', $id);
|
||||
}
|
||||
|
||||
public function decodeHash($hash): int
|
||||
{
|
||||
return (int) explode('|', base64_decode($hash))[0];
|
||||
}
|
||||
}
|
||||
49
common/Files/Traits/LoadsAllChildEntries.php
Executable file
49
common/Files/Traits/LoadsAllChildEntries.php
Executable file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Traits;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
trait LoadsAllChildEntries
|
||||
{
|
||||
/**
|
||||
* Fetch all children of specified entries.
|
||||
*
|
||||
* @param array|Collection $entries
|
||||
* @param bool $withTrashed
|
||||
* @return Collection
|
||||
*/
|
||||
protected function loadChildEntries($entries, $withTrashed = false)
|
||||
{
|
||||
$builder = FileEntry::select(['id', 'file_name', 'type']);
|
||||
|
||||
if (is_array($entries)) {
|
||||
$entries = collect($entries);
|
||||
}
|
||||
|
||||
// load parent entries, if we got only IDs passed in
|
||||
if (is_numeric($entries->first())) {
|
||||
$entries = FileEntry::whereIn('id', $entries)->get();
|
||||
}
|
||||
|
||||
if ($withTrashed) {
|
||||
$builder->withTrashed();
|
||||
}
|
||||
|
||||
$entries->each(function (FileEntry $entry) use ($builder) {
|
||||
if ($entry->type === 'folder') {
|
||||
$path = $entry->getRawOriginal('path');
|
||||
$builder->orWhere('path', 'LIKE', "$path/%");
|
||||
}
|
||||
});
|
||||
|
||||
//only fetch children if any "where" constraints were applied
|
||||
if (count($builder->getQuery()->wheres)) {
|
||||
$children = $builder->get();
|
||||
$entries = $entries->merge($children);
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
}
|
||||
19
common/Files/Traits/SetsAvailableSpaceAttribute.php
Executable file
19
common/Files/Traits/SetsAvailableSpaceAttribute.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Traits;
|
||||
|
||||
trait SetsAvailableSpaceAttribute
|
||||
{
|
||||
/**
|
||||
* Large numbers are not stored in db on some servers properly without this.
|
||||
*
|
||||
* @param int $value
|
||||
*/
|
||||
public function setAvailableSpaceAttribute($value) {
|
||||
if ( ! config('database.mysql.strict') && ! is_null($value)) {
|
||||
$this->attributes['available_space'] = (string) $value;
|
||||
} else {
|
||||
$this->attributes['available_space'] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
common/Files/Tus/DeleteExpiredTusUploads.php
Executable file
40
common/Files/Tus/DeleteExpiredTusUploads.php
Executable file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class DeleteExpiredTusUploads extends Command
|
||||
{
|
||||
protected $signature = 'tus:abort_expired';
|
||||
|
||||
protected $description = 'Abort and delete expired TUS uploads.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$cache = app(TusCache::class);
|
||||
|
||||
$directory = storage_path('tus');
|
||||
|
||||
if (File::exists($directory)) {
|
||||
foreach (File::allFiles(storage_path('tus')) as $file) {
|
||||
$uploadKey = $file->getFilename();
|
||||
$tusData = $cache->get($uploadKey);
|
||||
if (
|
||||
!Arr::get($tusData, 'expires_at') ||
|
||||
Carbon::parse($tusData['expires_at'])->lt(Carbon::now())
|
||||
) {
|
||||
$cache->delete($uploadKey);
|
||||
File::delete($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Expired TUS uploads deleted.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
7
common/Files/Tus/Exceptions/ConnectionException.php
Executable file
7
common/Files/Tus/Exceptions/ConnectionException.php
Executable file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus\Exceptions;
|
||||
|
||||
class ConnectionException extends \Exception
|
||||
{
|
||||
}
|
||||
7
common/Files/Tus/Exceptions/FileException.php
Executable file
7
common/Files/Tus/Exceptions/FileException.php
Executable file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus\Exceptions;
|
||||
|
||||
class FileException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
7
common/Files/Tus/Exceptions/OutOfRangeException.php
Executable file
7
common/Files/Tus/Exceptions/OutOfRangeException.php
Executable file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus\Exceptions;
|
||||
|
||||
class OutOfRangeException extends \OutOfRangeException
|
||||
{
|
||||
}
|
||||
47
common/Files/Tus/TusCache.php
Executable file
47
common/Files/Tus/TusCache.php
Executable file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
class TusCache
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
if (
|
||||
config('cache.default') === 'array' ||
|
||||
config('cache.default') === 'null'
|
||||
) {
|
||||
config()->set('cache.default', 'file');
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $uploadKey)
|
||||
{
|
||||
return cache()->get("tus:$uploadKey");
|
||||
}
|
||||
|
||||
public function set(
|
||||
string $uploadKey,
|
||||
array $value,
|
||||
CarbonInterface $expiresAt,
|
||||
): bool {
|
||||
return cache()->set("tus:$uploadKey", $value, $expiresAt);
|
||||
}
|
||||
|
||||
public function merge(string $uploadKey, array $partialValue): bool
|
||||
{
|
||||
$value = $this->get($uploadKey);
|
||||
return cache()->set(
|
||||
"tus:$uploadKey",
|
||||
array_merge($value, $partialValue),
|
||||
Carbon::parse($value['expires_at']),
|
||||
);
|
||||
}
|
||||
|
||||
public function delete(string $uploadKey): bool
|
||||
{
|
||||
return cache()->delete("tus:$uploadKey");
|
||||
}
|
||||
}
|
||||
158
common/Files/Tus/TusFile.php
Executable file
158
common/Files/Tus/TusFile.php
Executable file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus;
|
||||
|
||||
use Common\Files\Tus\Exceptions\ConnectionException;
|
||||
use Common\Files\Tus\Exceptions\FileException;
|
||||
use Common\Files\Tus\Exceptions\OutOfRangeException;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class TusFile
|
||||
{
|
||||
protected const INPUT_STREAM = 'php://input';
|
||||
protected const READ_BINARY = 'rb';
|
||||
protected const APPEND_BINARY = 'ab';
|
||||
|
||||
protected int $offset = 0;
|
||||
protected int $totalBytes = 0;
|
||||
protected string $uploadKey;
|
||||
protected string $filePath;
|
||||
protected TusCache $cache;
|
||||
|
||||
public function __construct(array $params)
|
||||
{
|
||||
$this->cache = app(TusCache::class);
|
||||
$this->uploadKey = $params['upload_key'];
|
||||
$this->totalBytes = $params['total_bytes'];
|
||||
$this->filePath = $params['file_path'];
|
||||
$this->offset = $params['offset'];
|
||||
}
|
||||
|
||||
public function upload(): int
|
||||
{
|
||||
if ($this->offset === $this->totalBytes) {
|
||||
return $this->offset;
|
||||
}
|
||||
|
||||
$method = config('common.site.uploads_tus_method') ?: 'wait';
|
||||
|
||||
$input = $this->open(self::INPUT_STREAM, self::READ_BINARY);
|
||||
$output = $this->open($this->filePath, self::APPEND_BINARY);
|
||||
|
||||
try {
|
||||
if ($method === 'loop') {
|
||||
$this->uploadUsingLoop($input, $output);
|
||||
} else {
|
||||
$this->uploadUsingCopyToStream($input, $output);
|
||||
}
|
||||
} finally {
|
||||
$this->cache->merge($this->uploadKey, [
|
||||
'offset' => $this->offset,
|
||||
]);
|
||||
fclose($input);
|
||||
fclose($output);
|
||||
}
|
||||
|
||||
return $this->offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $input
|
||||
* @param resource $output
|
||||
*/
|
||||
protected function uploadUsingLoop($input, $output): void
|
||||
{
|
||||
$chunkSize = 2097152; // 2MB
|
||||
$this->seek($output, $this->offset);
|
||||
|
||||
while (!feof($input)) {
|
||||
if (CONNECTION_NORMAL !== connection_status()) {
|
||||
throw new ConnectionException('Connection aborted by user.');
|
||||
}
|
||||
|
||||
// read
|
||||
$data = fread($input, $chunkSize);
|
||||
if ($data === false) {
|
||||
throw new FileException('Cannot read file.');
|
||||
}
|
||||
|
||||
// write
|
||||
$bytesWritten = fwrite($output, $data, $chunkSize);
|
||||
if ($bytesWritten === false) {
|
||||
throw new FileException('Cannot write to a file.');
|
||||
}
|
||||
|
||||
$this->offset += $bytesWritten;
|
||||
|
||||
if ($this->offset > $this->totalBytes) {
|
||||
throw new OutOfRangeException('The uploaded file is corrupt.');
|
||||
}
|
||||
|
||||
if ($this->offset === $this->totalBytes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $input
|
||||
* @param resource $output
|
||||
*/
|
||||
protected function uploadUsingCopyToStream($input, $output): void
|
||||
{
|
||||
$this->seek($output, $this->offset);
|
||||
|
||||
$bytesWritten = stream_copy_to_stream($input, $output, null);
|
||||
|
||||
if ($bytesWritten === false) {
|
||||
throw new FileException('Cannot write to a file.');
|
||||
}
|
||||
|
||||
$this->offset += $bytesWritten;
|
||||
|
||||
if ($this->offset > $this->totalBytes) {
|
||||
throw new OutOfRangeException('The uploaded file is corrupt.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function open(string $filePath, string $mode)
|
||||
{
|
||||
$this->exists($filePath, $mode);
|
||||
|
||||
$resource = @fopen($filePath, $mode);
|
||||
|
||||
if ($resource === false) {
|
||||
throw new FileException("Unable to open $filePath.");
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
public function exists(
|
||||
string $filePath,
|
||||
string $mode = self::READ_BINARY,
|
||||
): bool {
|
||||
if (self::INPUT_STREAM === $filePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists(storage_path('tus'));
|
||||
|
||||
if (self::READ_BINARY === $mode && !file_exists($filePath)) {
|
||||
throw new FileException('File not found.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function seek($handle, int $offset): int
|
||||
{
|
||||
$position = fseek($handle, $offset);
|
||||
|
||||
if (-1 === $position) {
|
||||
throw new FileException('Cannot move pointer to desired position.');
|
||||
}
|
||||
|
||||
return $position;
|
||||
}
|
||||
}
|
||||
52
common/Files/Tus/TusFileEntryController.php
Executable file
52
common/Files/Tus/TusFileEntryController.php
Executable file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus;
|
||||
|
||||
use Common\Core\BaseController;
|
||||
use Common\Files\Actions\CreateFileEntry;
|
||||
use Common\Files\Actions\StoreFile;
|
||||
use Common\Files\Events\FileUploaded;
|
||||
use Common\Files\FileEntry;
|
||||
use Common\Files\FileEntryPayload;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class TusFileEntryController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$data = $this->validate(request(), [
|
||||
'uploadKey' => 'required|string',
|
||||
]);
|
||||
|
||||
$tusData = app(TusCache::class)->get($data['uploadKey']);
|
||||
|
||||
if (!$tusData) {
|
||||
return $this->error();
|
||||
}
|
||||
|
||||
$metadata = $tusData['metadata'];
|
||||
$tusFilePath = $tusData['file_path'];
|
||||
$metadata['size'] = $tusData['size'];
|
||||
// tus temp file fingerprint, not needed anymore
|
||||
unset($metadata['name']);
|
||||
|
||||
$payload = new FileEntryPayload($metadata);
|
||||
|
||||
$this->authorize('store', [FileEntry::class, $payload->parentId]);
|
||||
|
||||
app(StoreFile::class)->execute($payload, [
|
||||
'path' => $tusFilePath,
|
||||
'moveFile' => true,
|
||||
]);
|
||||
|
||||
$fileEntry = app(CreateFileEntry::class)->execute($payload);
|
||||
event(new FileUploaded($fileEntry));
|
||||
File::delete($tusFilePath);
|
||||
return $this->success(['fileEntry' => $fileEntry]);
|
||||
}
|
||||
}
|
||||
377
common/Files/Tus/TusServer.php
Executable file
377
common/Files/Tus/TusServer.php
Executable file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Files\Actions\ValidateFileUpload;
|
||||
use Common\Settings\Settings;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use TusPhp\Exception\ConnectionException;
|
||||
use TusPhp\Exception\FileException;
|
||||
use TusPhp\Exception\OutOfRangeException;
|
||||
|
||||
class TusServer
|
||||
{
|
||||
protected TusCache $cache;
|
||||
protected array $allowedHttpVerbs = [
|
||||
Request::METHOD_POST,
|
||||
Request::METHOD_PATCH,
|
||||
Request::METHOD_DELETE,
|
||||
Request::METHOD_HEAD,
|
||||
Request::METHOD_OPTIONS,
|
||||
];
|
||||
|
||||
protected const TUS_EXTENSIONS = [
|
||||
'creation',
|
||||
'termination',
|
||||
'checksum',
|
||||
'expiration',
|
||||
];
|
||||
|
||||
protected const TUS_PROTOCOL_VERSION = '1.0.0';
|
||||
protected const HEADER_CONTENT_TYPE = 'application/offset+octet-stream';
|
||||
protected const DEFAULT_CHECKSUM_ALGORITHM = 'sha256';
|
||||
protected const HTTP_CHECKSUM_MISMATCH = 460;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->cache = app(TusCache::class);
|
||||
}
|
||||
|
||||
public function serve(): Response
|
||||
{
|
||||
$requestMethod = request()->method();
|
||||
|
||||
if (!in_array($requestMethod, $this->allowedHttpVerbs, true)) {
|
||||
return $this->response(null, Response::HTTP_METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
$clientVersion = request()->header('Tus-Resumable');
|
||||
|
||||
if (
|
||||
Request::METHOD_OPTIONS !== $requestMethod &&
|
||||
$clientVersion &&
|
||||
self::TUS_PROTOCOL_VERSION !== $clientVersion
|
||||
) {
|
||||
return $this->response(null, Response::HTTP_PRECONDITION_FAILED, [
|
||||
'Tus-Version' => self::TUS_PROTOCOL_VERSION,
|
||||
]);
|
||||
}
|
||||
|
||||
return match (strtoupper($requestMethod)) {
|
||||
'OPTIONS' => $this->handleOptions(),
|
||||
'HEAD' => $this->handleHead(),
|
||||
'POST' => $this->handlePost(),
|
||||
'PATCH' => $this->handlePatch(),
|
||||
'DELETE' => $this->handleDelete(),
|
||||
};
|
||||
}
|
||||
|
||||
protected function handleOptions(): Response
|
||||
{
|
||||
$supportedAlgorithms = hash_algos();
|
||||
|
||||
$algorithms = [];
|
||||
foreach ($supportedAlgorithms as $hashAlgo) {
|
||||
if (str_contains($hashAlgo, ',')) {
|
||||
$algorithms[] = "'{$hashAlgo}'";
|
||||
} else {
|
||||
$algorithms[] = $hashAlgo;
|
||||
}
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Allow' => implode(',', $this->allowedHttpVerbs),
|
||||
'Tus-Version' => self::TUS_PROTOCOL_VERSION,
|
||||
'Tus-Extension' => implode(',', self::TUS_EXTENSIONS),
|
||||
'Tus-Checksum-Algorithm' => implode(',', $algorithms),
|
||||
];
|
||||
|
||||
$maxUploadSize = app(Settings::class)->get('uploads.max_size');
|
||||
if ($maxUploadSize > 0) {
|
||||
$headers['Tus-Max-Size'] = $maxUploadSize;
|
||||
}
|
||||
|
||||
return $this->response(null, Response::HTTP_OK, $headers);
|
||||
}
|
||||
|
||||
protected function handleHead(): Response
|
||||
{
|
||||
$uploadKey = $this->getUploadKeyFromUrl();
|
||||
|
||||
if (!($tusData = $this->cache->get($uploadKey))) {
|
||||
return $this->response(null, Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$offset = $tusData['offset'] ?? false;
|
||||
|
||||
if ($offset === false) {
|
||||
return $this->response(null, Response::HTTP_GONE);
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Upload-Length' => (int) $tusData['size'],
|
||||
'Upload-Offset' => (int) $tusData['offset'],
|
||||
'Cache-Control' => 'no-store',
|
||||
];
|
||||
|
||||
return $this->response(null, Response::HTTP_OK, $headers);
|
||||
}
|
||||
|
||||
protected function handlePost(): Response
|
||||
{
|
||||
$meta = $this->extractMeta();
|
||||
$errors = app(ValidateFileUpload::class)->execute([
|
||||
'size' => $meta['clientSize'],
|
||||
'extension' => $meta['clientExtension'],
|
||||
]);
|
||||
|
||||
if ($errors) {
|
||||
return $this->response(
|
||||
json_encode(['message' => $errors->first()]),
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
$uploadKey = $this->getOrCreateUploadKey();
|
||||
$filePath = storage_path("tus/$uploadKey");
|
||||
|
||||
$checksum = $this->getClientChecksum();
|
||||
$location = url("api/v1/tus/upload/$uploadKey");
|
||||
|
||||
$expiresAt = now()->addDay();
|
||||
$formattedExpiresAt = $expiresAt->format('D, d M Y H:i:s \G\M\T');
|
||||
$this->cache->set(
|
||||
$uploadKey,
|
||||
[
|
||||
'size' => (int) request()->header('Upload-Length'),
|
||||
'offset' => 0,
|
||||
'checksum' => $checksum,
|
||||
'location' => $location,
|
||||
'file_path' => $filePath,
|
||||
'metadata' => $this->extractMeta(),
|
||||
'created_at' => Carbon::now()->timestamp,
|
||||
'expires_at' => $formattedExpiresAt,
|
||||
],
|
||||
$expiresAt,
|
||||
);
|
||||
|
||||
return $this->response(null, Response::HTTP_CREATED, [
|
||||
'Location' => $location,
|
||||
'Upload-Expires' => $formattedExpiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function handlePatch(): Response
|
||||
{
|
||||
$uploadKey = $this->getUploadKeyFromUrl();
|
||||
|
||||
if (!($tusData = $this->cache->get($uploadKey))) {
|
||||
return $this->response(null, Response::HTTP_GONE);
|
||||
}
|
||||
|
||||
$status = $this->verifyPatchRequest($tusData);
|
||||
if (Response::HTTP_OK !== $status) {
|
||||
return $this->response(null, $status);
|
||||
}
|
||||
|
||||
$checksum = $tusData['checksum'];
|
||||
$fileSize = $tusData['size'];
|
||||
|
||||
try {
|
||||
$newOffset = (new TusFile([
|
||||
'upload_key' => $this->getUploadKeyFromUrl(),
|
||||
'total_bytes' => $tusData['size'],
|
||||
'file_path' => $tusData['file_path'],
|
||||
'offset' => $tusData['offset'],
|
||||
]))->upload();
|
||||
|
||||
if (
|
||||
$newOffset === $fileSize &&
|
||||
!$this->verifyChecksum($checksum, $tusData['file_path'])
|
||||
) {
|
||||
return $this->response(null, self::HTTP_CHECKSUM_MISMATCH);
|
||||
}
|
||||
} catch (FileException $e) {
|
||||
return $this->response(
|
||||
$e->getMessage(),
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
} catch (OutOfRangeException) {
|
||||
return $this->response(
|
||||
null,
|
||||
Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE,
|
||||
);
|
||||
} catch (ConnectionException) {
|
||||
return $this->response(null, Response::HTTP_CONTINUE);
|
||||
}
|
||||
|
||||
if (!($tusData = $this->cache->get($uploadKey))) {
|
||||
return $this->response(null, Response::HTTP_GONE);
|
||||
}
|
||||
|
||||
return $this->response(null, Response::HTTP_NO_CONTENT, [
|
||||
'Content-Type' => self::HEADER_CONTENT_TYPE,
|
||||
'Upload-Expires' => $tusData['expires_at'],
|
||||
'Upload-Offset' => $newOffset,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function verifyPatchRequest(array $meta): int
|
||||
{
|
||||
$uploadOffset = request()->header('upload-offset');
|
||||
|
||||
if ($uploadOffset && $uploadOffset !== (string) $meta['offset']) {
|
||||
return Response::HTTP_CONFLICT;
|
||||
}
|
||||
|
||||
$contentType = request()->header('Content-Type');
|
||||
|
||||
if ($contentType !== self::HEADER_CONTENT_TYPE) {
|
||||
return Response::HTTP_UNSUPPORTED_MEDIA_TYPE;
|
||||
}
|
||||
|
||||
return Response::HTTP_OK;
|
||||
}
|
||||
|
||||
protected function handleDelete(): Response
|
||||
{
|
||||
$uploadKey = $this->getUploadKeyFromUrl();
|
||||
$tusData = $this->cache->get($uploadKey);
|
||||
$resource = $tusData['file_path'] ?? null;
|
||||
|
||||
if (!$resource) {
|
||||
return $this->response(null, Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$isDeleted = $this->cache->delete($uploadKey);
|
||||
|
||||
if (!$isDeleted || !file_exists($resource)) {
|
||||
return $this->response(null, Response::HTTP_GONE);
|
||||
}
|
||||
|
||||
unlink($resource);
|
||||
|
||||
return $this->response(null, Response::HTTP_NO_CONTENT, [
|
||||
'Tus-Extension' => 'termination',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getClientChecksum(): string
|
||||
{
|
||||
$checksumHeader = request()->header('Upload-Checksum');
|
||||
|
||||
if (empty($checksumHeader)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
[$checksumAlgorithm, $checksum] = explode(' ', $checksumHeader);
|
||||
|
||||
$checksum = base64_decode($checksum);
|
||||
|
||||
if (
|
||||
$checksum === false ||
|
||||
!in_array($checksumAlgorithm, hash_algos(), true)
|
||||
) {
|
||||
abort(Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
return $checksum;
|
||||
}
|
||||
|
||||
protected function verifyChecksum(string $checksum, string $filePath): bool
|
||||
{
|
||||
if (empty($checksum)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $checksum ===
|
||||
hash_file($this->getChecksumAlgorithm(), $filePath);
|
||||
}
|
||||
|
||||
protected function getChecksumAlgorithm(): ?string
|
||||
{
|
||||
$checksumHeader = request()->header('Upload-Checksum');
|
||||
|
||||
if (empty($checksumHeader)) {
|
||||
return self::DEFAULT_CHECKSUM_ALGORITHM;
|
||||
}
|
||||
|
||||
[$checksumAlgorithm] = explode(' ', $checksumHeader);
|
||||
|
||||
return $checksumAlgorithm;
|
||||
}
|
||||
|
||||
protected function getOrCreateUploadKey(): string
|
||||
{
|
||||
if (!empty($this->uploadKey)) {
|
||||
return $this->uploadKey;
|
||||
}
|
||||
|
||||
$key = request()->header('Upload-Key') ?? Uuid::uuid4()->toString();
|
||||
|
||||
if (empty($key)) {
|
||||
abort(Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$this->uploadKey = $key;
|
||||
|
||||
return $this->uploadKey;
|
||||
}
|
||||
|
||||
protected function extractMeta(): array
|
||||
{
|
||||
$uploadMetaData = request()->header('Upload-Metadata');
|
||||
|
||||
if (empty($uploadMetaData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$uploadMetaDataChunks = explode(',', $uploadMetaData);
|
||||
|
||||
$result = [];
|
||||
foreach ($uploadMetaDataChunks as $chunk) {
|
||||
$pieces = explode(' ', trim($chunk));
|
||||
|
||||
$key = $pieces[0];
|
||||
$value = $pieces[1] ?? '';
|
||||
|
||||
$result[$key] = base64_decode($value);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function getUploadKeyFromUrl(): string
|
||||
{
|
||||
return basename(request()->getPathInfo());
|
||||
}
|
||||
|
||||
protected function response(
|
||||
string $content = null,
|
||||
int $status = 200,
|
||||
array $headers = [],
|
||||
): Response {
|
||||
$mergedHeaders = array_merge(
|
||||
[
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
|
||||
'Access-Control-Allow-Origin' => request()->header('Origin'),
|
||||
'Access-Control-Allow-Methods' => implode(
|
||||
',',
|
||||
$this->allowedHttpVerbs,
|
||||
),
|
||||
'Access-Control-Allow-Headers' =>
|
||||
'Origin, X-Requested-With, Content-Type, Content-Length, Upload-Key, Upload-Checksum, Upload-Length, Upload-Offset, Tus-Version, Tus-Resumable, Upload-Metadata',
|
||||
'Access-Control-Expose-Headers' =>
|
||||
'Upload-Key, Upload-Checksum, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Version, Tus-Resumable, Tus-Extension, Location',
|
||||
'Access-Control-Max-Age' => 86400, // 24 hours
|
||||
],
|
||||
$headers,
|
||||
);
|
||||
|
||||
return response($content, $status, $mergedHeaders);
|
||||
}
|
||||
}
|
||||
30
common/Files/Tus/TusServiceProvider.php
Executable file
30
common/Files/Tus/TusServiceProvider.php
Executable file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Tus;
|
||||
|
||||
use Common\Core\AppUrl;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use TusPhp\Tus\Server as TusServer;
|
||||
|
||||
class TusServiceProvider extends ServiceProvider
|
||||
{
|
||||
static function uploadDir(): string
|
||||
{
|
||||
return storage_path('tus');
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
$this->app->singleton('tus-server', function () {
|
||||
$server = new TusServer(config('cache.default'));
|
||||
|
||||
$baseUri = app(AppUrl::class)->htmlBaseUri;
|
||||
|
||||
$server
|
||||
->setApiPath("{$baseUri}api/v1/tus/upload")
|
||||
->setUploadDir(TusServiceProvider::uploadDir());
|
||||
|
||||
return $server;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user