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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}
}
}

View 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]);
}
}

View 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);
}
}

View 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();
}
}

View 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();
}
}

View 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'
],
]);
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Common\Files\Events;
class FileEntriesRestored
{
public function __construct(public array $entryIds)
{
}
}

View 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;
}
}

View 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
View 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
View 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
View 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
View 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
View 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(),
);
}
}

View 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");
}
}
}

View 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()
{
//
}
}

View 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()
{
//
}
}

View 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()
{
//
}
}

View 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);
}
}

View 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();
}
}

View 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);
}

View 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;
}
}

View 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;
}
}
}

View 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,
),
);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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';
}
}

View 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;
}
}

View 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';
}
}

View 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]);
}
}

View 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();
}
}

View 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(),
]);
}
}

View 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;
}
}
}

View 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);
}
}

View 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];
}
}

View 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;
}
}

View 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;
}
}
}

View 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;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Common\Files\Tus\Exceptions;
class ConnectionException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Common\Files\Tus\Exceptions;
class FileException extends \RuntimeException
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Common\Files\Tus\Exceptions;
class OutOfRangeException extends \OutOfRangeException
{
}

47
common/Files/Tus/TusCache.php Executable file
View 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
View 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;
}
}

View 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
View 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);
}
}

View 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;
});
}
}