Add SealShare file sharing application with encryption, branding, auth, Docker, and Octane support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4790be8142
commit
e37b322dde
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanupExpiredShares extends Command
|
||||
{
|
||||
protected $signature = 'shares:cleanup';
|
||||
|
||||
protected $description = 'Delete expired shares and shares that have reached their download limit';
|
||||
|
||||
public function handle(ShareService $shareService): int
|
||||
{
|
||||
$expiredShares = Share::query()
|
||||
->where(function ($query): void {
|
||||
$query->where('expires_at', '<', now())
|
||||
->orWhereRaw('max_downloads IS NOT NULL AND download_count >= max_downloads');
|
||||
})
|
||||
->get();
|
||||
|
||||
$count = $expiredShares->count();
|
||||
|
||||
foreach ($expiredShares as $share) {
|
||||
$shareService->deleteShare($share);
|
||||
}
|
||||
|
||||
$this->info("Cleaned up {$count} expired share(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipStream\ZipStream;
|
||||
|
||||
class DownloadController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private FileEncryptionService $encryptionService,
|
||||
private ShareService $shareService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Download all files as a ZIP archive.
|
||||
*/
|
||||
public function download(Share $share): StreamedResponse
|
||||
{
|
||||
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
|
||||
$share->load('files');
|
||||
$key = $this->resolveDecryptionKey($share);
|
||||
|
||||
$this->shareService->recordDownload($share);
|
||||
|
||||
return new StreamedResponse(function () use ($share, $key): void {
|
||||
$zip = new ZipStream(
|
||||
outputName: 'share-'.$share->token.'.zip',
|
||||
sendHttpHeaders: false,
|
||||
);
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path));
|
||||
$callback = $this->encryptionService->decryptFileToCallback($encryptedPath, $key);
|
||||
|
||||
$filename = $file->relative_path ?: $file->original_name;
|
||||
$filename = str_replace('\\', '/', $filename);
|
||||
|
||||
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
|
||||
$filename = basename($filename);
|
||||
}
|
||||
|
||||
$zip->addFileFromCallback(fileName: $filename, callback: $callback, exactSize: $file->file_size);
|
||||
}
|
||||
|
||||
$zip->finish();
|
||||
}, 200, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Disposition' => 'attachment; filename="share-'.$share->token.'.zip"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single file.
|
||||
*/
|
||||
public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse
|
||||
{
|
||||
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
abort_if($shareFile->share_id !== $share->id, 404);
|
||||
|
||||
$key = $this->resolveDecryptionKey($share);
|
||||
|
||||
$this->shareService->recordDownload($share);
|
||||
|
||||
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path));
|
||||
|
||||
return $this->encryptionService->decryptFileStream(
|
||||
$encryptedPath,
|
||||
$key,
|
||||
$shareFile->original_name,
|
||||
$shareFile->mime_type ?? 'application/octet-stream',
|
||||
$shareFile->file_size,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the decryption key from session or share.
|
||||
*/
|
||||
private function resolveDecryptionKey(Share $share): string
|
||||
{
|
||||
if ($share->isPasswordProtected()) {
|
||||
$key = session('share_key_'.$share->token);
|
||||
|
||||
abort_if(! $key, 403, 'Password required');
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
return $this->shareService->getDecryptionKey($share);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->user()->is_admin) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSetupComplete
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! User::query()->where('is_admin', true)->exists() && ! $request->is('setup', 'setup/*', 'livewire*')) {
|
||||
return redirect()->route('setup');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SecurityHeaders
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('X-Frame-Options', 'DENY');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SystemPasswordGate
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$systemPassword = Setting::get('system_password');
|
||||
|
||||
if (! $systemPassword) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->session()->get('system_password_verified') === true) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect()->route('system-password');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class AdminDashboard extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
/** @var array<string, string> */
|
||||
public array $sortBy = ['column' => 'created_at', 'direction' => 'desc'];
|
||||
|
||||
public function deleteShare(int $shareId, ShareService $shareService): void
|
||||
{
|
||||
$share = Share::query()->findOrFail($shareId);
|
||||
$shareService->deleteShare($share);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string|bool>>
|
||||
*/
|
||||
public function headers(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'token', 'label' => __('Token')],
|
||||
['key' => 'files_count', 'label' => __('Files')],
|
||||
['key' => 'total_size', 'label' => __('Size')],
|
||||
['key' => 'download_count', 'label' => __('Downloads')],
|
||||
['key' => 'expires_at', 'label' => __('Expires')],
|
||||
['key' => 'created_at', 'label' => __('Created')],
|
||||
];
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
$shareService = app(ShareService::class);
|
||||
|
||||
$shares = Share::query()
|
||||
->withCount('files')
|
||||
->orderBy($this->sortBy['column'], $this->sortBy['direction'])
|
||||
->paginate(15);
|
||||
|
||||
return view('livewire.admin.admin-dashboard', [
|
||||
'shares' => $shares,
|
||||
'totalShares' => Share::query()->count(),
|
||||
'activeShares' => Share::query()->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})->count(),
|
||||
'totalFiles' => ShareFile::query()->count(),
|
||||
'usedSpace' => $shareService->getTotalUsedSpace(),
|
||||
'maxQuota' => $shareService->getMaxStorageQuota(),
|
||||
'headers' => $this->headers(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class AdminSettings extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $systemPassword = '';
|
||||
|
||||
public string $defaultExpiration = '';
|
||||
|
||||
public int $maxFileSize = 100;
|
||||
|
||||
public int $maxStorageQuota = 20;
|
||||
|
||||
public int $maxFilesPerShare = 50;
|
||||
|
||||
public int $maxSizePerShare = 2;
|
||||
|
||||
public bool $allowNeverExpire = false;
|
||||
|
||||
public string $siteTitle = '';
|
||||
|
||||
public string $siteDescription = '';
|
||||
|
||||
public $siteLogo;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||
$this->maxFileSize = min(
|
||||
(int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024),
|
||||
self::phpMaxUploadMb(),
|
||||
);
|
||||
$this->maxStorageQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
||||
$this->maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$this->maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
||||
$this->allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
|
||||
$this->siteTitle = Setting::get('site_title', '') ?? '';
|
||||
$this->siteDescription = Setting::get('site_description', '') ?? '';
|
||||
}
|
||||
|
||||
public static function phpMaxUploadMb(): int
|
||||
{
|
||||
$parse = function (string $value): int {
|
||||
$value = trim($value);
|
||||
$last = strtolower($value[strlen($value) - 1]);
|
||||
$num = (int) $value;
|
||||
|
||||
return match ($last) {
|
||||
'g' => $num * 1024,
|
||||
'm' => $num,
|
||||
'k' => max(1, (int) ($num / 1024)),
|
||||
default => max(1, (int) ($num / (1024 * 1024))),
|
||||
};
|
||||
};
|
||||
|
||||
$upload = $parse(ini_get('upload_max_filesize') ?: '2M');
|
||||
$post = $parse(ini_get('post_max_size') ?: '8M');
|
||||
|
||||
return min($upload, $post);
|
||||
}
|
||||
|
||||
public function saveSettings(): void
|
||||
{
|
||||
$phpMaxMb = self::phpMaxUploadMb();
|
||||
|
||||
$this->validate([
|
||||
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
|
||||
'maxStorageQuota' => ['required', 'integer', 'min:1'],
|
||||
'maxFilesPerShare' => ['required', 'integer', 'min:1'],
|
||||
'maxSizePerShare' => ['required', 'integer', 'min:1'],
|
||||
'siteTitle' => ['nullable', 'string', 'max:255'],
|
||||
'siteDescription' => ['nullable', 'string', 'max:1000'],
|
||||
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
|
||||
], [
|
||||
'maxFileSize.max' => __('Cannot exceed the PHP limit of :max MB. Increase upload_max_filesize and post_max_size in your PHP configuration.', ['max' => $phpMaxMb]),
|
||||
]);
|
||||
|
||||
if ($this->systemPassword) {
|
||||
Setting::set('system_password', Hash::make($this->systemPassword));
|
||||
}
|
||||
|
||||
Setting::set('default_expiration', $this->defaultExpiration ?: null);
|
||||
Setting::set('max_file_size', $this->maxFileSize * 1024 * 1024);
|
||||
Setting::set('max_storage_quota', $this->maxStorageQuota * 1024 * 1024 * 1024);
|
||||
Setting::set('max_files_per_share', $this->maxFilesPerShare);
|
||||
Setting::set('max_size_per_share', $this->maxSizePerShare * 1024 * 1024 * 1024);
|
||||
|
||||
Setting::set('allow_never_expire', $this->allowNeverExpire ? '1' : null);
|
||||
Setting::set('site_title', $this->siteTitle ?: null);
|
||||
Setting::set('site_description', $this->siteDescription ?: null);
|
||||
|
||||
if ($this->siteLogo && is_object($this->siteLogo)) {
|
||||
$existingLogo = Setting::get('site_logo');
|
||||
if ($existingLogo) {
|
||||
Storage::disk('public')->delete($existingLogo);
|
||||
}
|
||||
|
||||
$path = $this->siteLogo->store('branding', 'public');
|
||||
Setting::set('site_logo', $path);
|
||||
$this->siteLogo = null;
|
||||
}
|
||||
|
||||
$this->systemPassword = '';
|
||||
|
||||
session()->flash('message', __('Settings saved successfully.'));
|
||||
}
|
||||
|
||||
public function removeLogo(): void
|
||||
{
|
||||
$existingLogo = Setting::get('site_logo');
|
||||
|
||||
if ($existingLogo) {
|
||||
Storage::disk('public')->delete($existingLogo);
|
||||
Setting::set('site_logo', null);
|
||||
}
|
||||
|
||||
session()->flash('message', __('Logo removed.'));
|
||||
}
|
||||
|
||||
public function clearSystemPassword(): void
|
||||
{
|
||||
Setting::set('system_password', null);
|
||||
|
||||
session()->flash('message', __('System password cleared.'));
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.admin.admin-settings', [
|
||||
'hasSystemPassword' => (bool) Setting::get('system_password'),
|
||||
'currentLogo' => Setting::get('site_logo'),
|
||||
'phpMaxUploadMb' => self::phpMaxUploadMb(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class FileUploader extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<int, \Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $files = [];
|
||||
|
||||
/** @var array<int, string|null> */
|
||||
public array $relativePaths = [];
|
||||
|
||||
public bool $usePassword = false;
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
public string $expiration = '7d';
|
||||
|
||||
public ?int $maxDownloads = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->expiration = Setting::get('default_expiration', '7d') ?: '7d';
|
||||
}
|
||||
|
||||
public function _uploadErrored($name, $errorsInJson, $isMultiple): void
|
||||
{
|
||||
$this->dispatch('upload:errored', name: $name)->self();
|
||||
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$maxFileSizeMb = (int) ($maxFileSize / (1024 * 1024));
|
||||
|
||||
if (! is_null($errorsInJson)) {
|
||||
$errors = json_decode($errorsInJson, true)['errors'] ?? null;
|
||||
|
||||
if ($errors) {
|
||||
$messages = [];
|
||||
foreach ($errors as $messages_array) {
|
||||
foreach ((array) $messages_array as $msg) {
|
||||
$messages[] = $msg;
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: file exceeds the maximum size of :max MB.', ['max' => $maxFileSizeMb]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatedFiles(): void
|
||||
{
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$maxFileSizeMb = $maxFileSize / (1024 * 1024);
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
|
||||
$this->resetErrorBag('files');
|
||||
|
||||
if (count($this->files) > $maxFilesPerShare) {
|
||||
$this->addError('files', __('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->files as $file) {
|
||||
if ($file->getSize() > $maxFileSize) {
|
||||
$this->addError('files', __('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
||||
'name' => $file->getClientOriginalName(),
|
||||
'size' => round($file->getSize() / (1024 * 1024), 1),
|
||||
'max' => (int) $maxFileSizeMb,
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function removeFile(int $index): void
|
||||
{
|
||||
unset($this->files[$index], $this->relativePaths[$index]);
|
||||
$this->files = array_values($this->files);
|
||||
$this->relativePaths = array_values($this->relativePaths);
|
||||
}
|
||||
|
||||
public function createShare(ShareService $shareService): void
|
||||
{
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
|
||||
$rules = [
|
||||
'files' => ['required', 'array', 'min:1', 'max:'.$maxFilesPerShare],
|
||||
'files.*' => ['required', 'file', 'max:'.($maxFileSize / 1024)],
|
||||
];
|
||||
|
||||
$allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
|
||||
|
||||
if (! $allowNeverExpire) {
|
||||
$rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d'];
|
||||
}
|
||||
|
||||
if ($this->usePassword) {
|
||||
$rules['password'] = ['required', 'string', 'min:8'];
|
||||
}
|
||||
|
||||
$this->validate($rules, [
|
||||
'expiration.required' => __('An expiration time is required.'),
|
||||
'files.required' => __('Please select at least one file to upload.'),
|
||||
'files.max' => __('Too many files. Maximum :max files allowed per share.'),
|
||||
'files.*.max' => __('A file exceeds the maximum size of :max KB.'),
|
||||
]);
|
||||
|
||||
if ($shareService->isStorageFull()) {
|
||||
$this->addError('files', __('Storage is full. Please contact the administrator.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totalSize = collect($this->files)->sum(fn ($file) => $file->getSize());
|
||||
|
||||
if ($totalSize > $maxSizePerShare) {
|
||||
$this->addError('files', __('Total file size exceeds the maximum allowed per share.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$fileData = [];
|
||||
foreach ($this->files as $index => $file) {
|
||||
$relativePath = $this->relativePaths[$index] ?? null;
|
||||
|
||||
if ($relativePath !== null) {
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
|
||||
$relativePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
$fileData[] = [
|
||||
'file' => $file,
|
||||
'relativePath' => $relativePath,
|
||||
];
|
||||
}
|
||||
|
||||
$expiresAt = match ($this->expiration) {
|
||||
'1h' => now()->addHour(),
|
||||
'24h' => now()->addDay(),
|
||||
'48h' => now()->addDays(2),
|
||||
'7d' => now()->addWeek(),
|
||||
'14d' => now()->addDays(14),
|
||||
'30d' => now()->addMonth(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$share = $shareService->createShare($fileData, [
|
||||
'password' => $this->usePassword ? $this->password : null,
|
||||
'expires_at' => $expiresAt,
|
||||
'max_downloads' => $this->maxDownloads ?: null,
|
||||
]);
|
||||
|
||||
$this->redirect(route('share.created', $share), navigate: true);
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
$shareService = app(ShareService::class);
|
||||
|
||||
return view('livewire.file-uploader', [
|
||||
'isStorageFull' => $shareService->isStorageFull(),
|
||||
'siteTitle' => Setting::get('site_title'),
|
||||
'siteDescription' => Setting::get('site_description'),
|
||||
'siteLogo' => Setting::get('site_logo'),
|
||||
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
class SetupWizard extends Component
|
||||
{
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $name = '';
|
||||
|
||||
#[Validate('required|string|email|max:255|unique:users')]
|
||||
public string $email = '';
|
||||
|
||||
#[Validate('required|string|min:8|confirmed')]
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (User::query()->where('is_admin', true)->exists()) {
|
||||
$this->redirect(route('upload'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function createAdmin(): void
|
||||
{
|
||||
if (User::query()->where('is_admin', true)->exists()) {
|
||||
$this->redirect(route('upload'), navigate: true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
$user = User::query()->create([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'password' => Hash::make($this->password),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$user->is_admin = true;
|
||||
$user->save();
|
||||
|
||||
Setting::set('setup_complete', 'true');
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$this->redirect(route('admin.dashboard'), navigate: true);
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.setup-wizard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Share;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class ShareCreated extends Component
|
||||
{
|
||||
public Share $share;
|
||||
|
||||
public function mount(Share $share): void
|
||||
{
|
||||
$this->share = $share;
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.share-created');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class ShareDownload extends Component
|
||||
{
|
||||
public Share $share;
|
||||
|
||||
public bool $authenticated = false;
|
||||
|
||||
#[Validate('required|string')]
|
||||
public string $password = '';
|
||||
|
||||
public function mount(Share $share): void
|
||||
{
|
||||
$this->share = $share->load('files');
|
||||
|
||||
if ($share->isExpired() || $share->hasReachedDownloadLimit()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $share->isPasswordProtected()) {
|
||||
$this->authenticated = true;
|
||||
}
|
||||
|
||||
if ($share->isPasswordProtected() && session('share_key_'.$share->token)) {
|
||||
$this->authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function verifyPassword(ShareService $shareService): void
|
||||
{
|
||||
$rateLimitKey = 'share-password:'.$this->share->token.'|'.request()->ip();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) {
|
||||
$seconds = RateLimiter::availableIn($rateLimitKey);
|
||||
$this->addError('password', __('Too many attempts. Please try again in :seconds seconds.', ['seconds' => $seconds]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
if (! $shareService->verifyPassword($this->share, $this->password)) {
|
||||
RateLimiter::hit($rateLimitKey, 60);
|
||||
$this->addError('password', __('The password is incorrect.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimiter::clear($rateLimitKey);
|
||||
|
||||
$encryptionKey = $shareService->getDecryptionKey($this->share, $this->password);
|
||||
session(['share_key_'.$this->share->token => $encryptionKey]);
|
||||
$this->authenticated = true;
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.share-download', [
|
||||
'siteTitle' => Setting::get('site_title'),
|
||||
'siteDescription' => Setting::get('site_description'),
|
||||
'siteLogo' => Setting::get('site_logo'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.auth')]
|
||||
class SystemPasswordPrompt extends Component
|
||||
{
|
||||
#[Validate('required|string')]
|
||||
public string $password = '';
|
||||
|
||||
public function verify(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$systemPassword = Setting::get('system_password');
|
||||
|
||||
if (! $systemPassword || ! Hash::check($this->password, $systemPassword)) {
|
||||
$this->addError('password', __('The password is incorrect.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session(['system_password_verified' => true]);
|
||||
|
||||
$this->redirect(route('upload'), navigate: true);
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.system-password-prompt');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
];
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$setting = static::query()->where('key', $key)->first();
|
||||
|
||||
return $setting ? $setting->value : $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(
|
||||
['key' => $key],
|
||||
['value' => $value],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Share extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'token',
|
||||
'password',
|
||||
'encryption_key',
|
||||
'encryption_salt',
|
||||
'expires_at',
|
||||
'max_downloads',
|
||||
'download_count',
|
||||
'total_size',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'max_downloads' => 'integer',
|
||||
'download_count' => 'integer',
|
||||
'total_size' => 'integer',
|
||||
'encryption_key' => 'encrypted',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ShareFile, $this>
|
||||
*/
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShareFile::class);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isPasswordProtected(): bool
|
||||
{
|
||||
return ! is_null($this->password);
|
||||
}
|
||||
|
||||
public function hasReachedDownloadLimit(): bool
|
||||
{
|
||||
return $this->max_downloads && $this->download_count >= $this->max_downloads;
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'token';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ShareFile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'share_id',
|
||||
'original_name',
|
||||
'relative_path',
|
||||
'stored_path',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Share, $this>
|
||||
*/
|
||||
public function share(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Share::class);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Closure;
|
||||
use Generator;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FileEncryptionService
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
private const PBKDF2_ITERATIONS = 100000;
|
||||
|
||||
private const KEY_LENGTH = 32;
|
||||
|
||||
private const NONCE_LENGTH = 12;
|
||||
|
||||
private const TAG_LENGTH = 16;
|
||||
|
||||
private const MAGIC_HEADER = 'SEALCHK1';
|
||||
|
||||
private const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
|
||||
|
||||
/**
|
||||
* Derive an encryption key from a password and salt using PBKDF2-SHA256.
|
||||
*/
|
||||
public function deriveKey(string $password, string $salt): string
|
||||
{
|
||||
return hash_pbkdf2('sha256', $password, hex2bin($salt), self::PBKDF2_ITERATIONS, self::KEY_LENGTH, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random hex salt (32 bytes = 64 hex chars).
|
||||
*/
|
||||
public function generateSalt(): string
|
||||
{
|
||||
return bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random encryption key (32 bytes, returned as hex).
|
||||
*/
|
||||
public function generateRandomKey(): string
|
||||
{
|
||||
return bin2hex(random_bytes(self::KEY_LENGTH));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a file using chunked AES-256-GCM.
|
||||
*
|
||||
* Output format:
|
||||
* [8 bytes: "SEALCHK1" magic]
|
||||
* [4 bytes: chunk size, uint32 big-endian]
|
||||
* [12 bytes: base nonce]
|
||||
* Per chunk:
|
||||
* [16 bytes: GCM auth tag]
|
||||
* [N bytes: ciphertext (up to chunk_size)]
|
||||
*/
|
||||
public function encryptFile(string $sourcePath, string $destPath, string $key): void
|
||||
{
|
||||
$source = fopen($sourcePath, 'rb');
|
||||
|
||||
if ($source === false) {
|
||||
throw new RuntimeException("Cannot read source file: {$sourcePath}");
|
||||
}
|
||||
|
||||
$dest = fopen($destPath, 'wb');
|
||||
|
||||
if ($dest === false) {
|
||||
fclose($source);
|
||||
|
||||
throw new RuntimeException("Cannot write encrypted file: {$destPath}");
|
||||
}
|
||||
|
||||
try {
|
||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
||||
$baseNonce = random_bytes(self::NONCE_LENGTH);
|
||||
$chunkSize = self::DEFAULT_CHUNK_SIZE;
|
||||
|
||||
// Write header
|
||||
fwrite($dest, self::MAGIC_HEADER);
|
||||
fwrite($dest, pack('N', $chunkSize));
|
||||
fwrite($dest, $baseNonce);
|
||||
|
||||
$chunkIndex = 0;
|
||||
|
||||
while (! feof($source)) {
|
||||
$plaintext = fread($source, $chunkSize);
|
||||
|
||||
if ($plaintext === false || $plaintext === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LENGTH,
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('Encryption failed at chunk '.$chunkIndex);
|
||||
}
|
||||
|
||||
fwrite($dest, $tag);
|
||||
fwrite($dest, $ciphertext);
|
||||
$chunkIndex++;
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
fclose($source);
|
||||
fclose($dest);
|
||||
@unlink($destPath);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
fclose($source);
|
||||
fclose($dest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a file and return the plaintext content.
|
||||
*/
|
||||
public function decryptFile(string $encryptedPath, string $key): string
|
||||
{
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
$parts = [];
|
||||
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
$parts[] = $chunk;
|
||||
}
|
||||
|
||||
return implode('', $parts);
|
||||
}
|
||||
|
||||
return $this->decryptLegacy($encryptedPath, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a file and stream the response.
|
||||
*/
|
||||
public function decryptFileStream(string $encryptedPath, string $key, string $filename, string $mimeType, ?int $fileSize = null): StreamedResponse
|
||||
{
|
||||
$headers = [
|
||||
'Content-Type' => $mimeType ?: 'application/octet-stream',
|
||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, 'download'),
|
||||
];
|
||||
|
||||
if ($fileSize !== null) {
|
||||
$headers['Content-Length'] = $fileSize;
|
||||
}
|
||||
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
return new StreamedResponse(function () use ($encryptedPath, $key): void {
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
$content = $this->decryptLegacy($encryptedPath, $key);
|
||||
|
||||
if (! isset($headers['Content-Length'])) {
|
||||
$headers['Content-Length'] = strlen($content);
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($content): void {
|
||||
echo $content;
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a closure that decrypts a file into a temporary stream resource.
|
||||
* Suitable for ZipStream's addFileFromCallback.
|
||||
*/
|
||||
public function decryptFileToCallback(string $encryptedPath, string $key): Closure
|
||||
{
|
||||
return function () use ($encryptedPath, $key) {
|
||||
$tmp = tmpfile();
|
||||
|
||||
if ($tmp === false) {
|
||||
throw new RuntimeException('Cannot create temporary file');
|
||||
}
|
||||
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
fwrite($tmp, $chunk);
|
||||
}
|
||||
} else {
|
||||
fwrite($tmp, $this->decryptLegacy($encryptedPath, $key));
|
||||
}
|
||||
|
||||
rewind($tmp);
|
||||
|
||||
return $tmp;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a hex key to binary.
|
||||
*/
|
||||
private function normalizeToBinaryKey(string $key): string
|
||||
{
|
||||
return strlen($key) === 64 ? hex2bin($key) : $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a unique nonce for a chunk by XORing the chunk index into the last 4 bytes.
|
||||
*/
|
||||
private function deriveChunkNonce(string $baseNonce, int $chunkIndex): string
|
||||
{
|
||||
$nonce = $baseNonce;
|
||||
$indexBytes = pack('N', $chunkIndex);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
|
||||
}
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file uses the chunked encryption format.
|
||||
*/
|
||||
private function isChunkedFormat(string $path): bool
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$magic = fread($handle, 8);
|
||||
fclose($handle);
|
||||
|
||||
return $magic === self::MAGIC_HEADER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a legacy single-block encrypted file.
|
||||
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
|
||||
*/
|
||||
private function decryptLegacy(string $encryptedPath, string $key): string
|
||||
{
|
||||
$data = file_get_contents($encryptedPath);
|
||||
|
||||
if ($data === false) {
|
||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||
}
|
||||
|
||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
||||
$nonce = substr($data, 0, self::NONCE_LENGTH);
|
||||
$tag = substr($data, self::NONCE_LENGTH, self::TAG_LENGTH);
|
||||
$ciphertext = substr($data, self::NONCE_LENGTH + self::TAG_LENGTH);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator that yields decrypted plaintext chunks from a chunked encrypted file.
|
||||
*
|
||||
* @return Generator<int, string>
|
||||
*/
|
||||
private function decryptChunks(string $encryptedPath, string $key): Generator
|
||||
{
|
||||
$handle = fopen($encryptedPath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||
}
|
||||
|
||||
try {
|
||||
// Read header
|
||||
$magic = fread($handle, 8);
|
||||
|
||||
if ($magic !== self::MAGIC_HEADER) {
|
||||
throw new RuntimeException('Invalid chunked file format');
|
||||
}
|
||||
|
||||
$chunkSizeData = fread($handle, 4);
|
||||
$chunkSize = unpack('N', $chunkSizeData)[1];
|
||||
|
||||
$baseNonce = fread($handle, self::NONCE_LENGTH);
|
||||
|
||||
if (strlen($baseNonce) !== self::NONCE_LENGTH) {
|
||||
throw new RuntimeException('Invalid chunked file: truncated header');
|
||||
}
|
||||
|
||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
||||
$chunkIndex = 0;
|
||||
|
||||
while (! feof($handle)) {
|
||||
$tag = fread($handle, self::TAG_LENGTH);
|
||||
|
||||
if ($tag === false || strlen($tag) === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (strlen($tag) !== self::TAG_LENGTH) {
|
||||
throw new RuntimeException('Invalid chunked file: truncated tag at chunk '.$chunkIndex);
|
||||
}
|
||||
|
||||
$ciphertext = fread($handle, $chunkSize);
|
||||
|
||||
if ($ciphertext === false || $ciphertext === '') {
|
||||
throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex);
|
||||
}
|
||||
|
||||
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||
}
|
||||
|
||||
yield $plaintext;
|
||||
$chunkIndex++;
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class ShareService
|
||||
{
|
||||
public function __construct(
|
||||
private FileEncryptionService $encryptionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new share with encrypted files.
|
||||
*
|
||||
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
||||
* @param array{password?: string|null, expires_at?: string|null, max_downloads?: int|null} $options
|
||||
*/
|
||||
public function createShare(array $files, array $options = []): Share
|
||||
{
|
||||
$token = $this->generateUniqueToken();
|
||||
$salt = $this->encryptionService->generateSalt();
|
||||
$password = $options['password'] ?? null;
|
||||
|
||||
if ($password) {
|
||||
$encryptionKey = $this->encryptionService->deriveKey($password, $salt);
|
||||
$encryptionKeyHex = bin2hex($encryptionKey);
|
||||
$storedEncryptionKey = null;
|
||||
} else {
|
||||
$encryptionKeyHex = $this->encryptionService->generateRandomKey();
|
||||
$storedEncryptionKey = $encryptionKeyHex;
|
||||
}
|
||||
|
||||
$share = Share::query()->create([
|
||||
'token' => $token,
|
||||
'password' => $password ? Hash::make($password) : null,
|
||||
'encryption_key' => $storedEncryptionKey,
|
||||
'encryption_salt' => $salt,
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'max_downloads' => $options['max_downloads'] ?? null,
|
||||
'total_size' => 0,
|
||||
]);
|
||||
|
||||
$totalSize = 0;
|
||||
|
||||
foreach ($files as $fileData) {
|
||||
/** @var UploadedFile $file */
|
||||
$file = $fileData['file'];
|
||||
$relativePath = $fileData['relativePath'] ?? null;
|
||||
$storedName = Str::uuid().'.enc';
|
||||
$storedPath = 'shares/'.$share->token.'/'.$storedName;
|
||||
|
||||
$tempPath = $file->getRealPath();
|
||||
$destPath = Storage::disk('shares')->path($share->token.'/'.$storedName);
|
||||
|
||||
Storage::disk('shares')->makeDirectory($share->token);
|
||||
|
||||
$this->encryptionService->encryptFile($tempPath, $destPath, $encryptionKeyHex);
|
||||
|
||||
ShareFile::query()->create([
|
||||
'share_id' => $share->id,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'relative_path' => $relativePath,
|
||||
'stored_path' => $storedPath,
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
]);
|
||||
|
||||
$totalSize += $file->getSize();
|
||||
}
|
||||
|
||||
$share->update(['total_size' => $totalSize]);
|
||||
|
||||
return $share->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique share token with retry on collision.
|
||||
*/
|
||||
private function generateUniqueToken(): string
|
||||
{
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$token = Str::random(16);
|
||||
|
||||
if (! Share::query()->where('token', $token)->exists()) {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unable to generate a unique share token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a share and its files from disk.
|
||||
*/
|
||||
public function deleteShare(Share $share): void
|
||||
{
|
||||
Storage::disk('shares')->deleteDirectory($share->token);
|
||||
|
||||
$share->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decryption key for a share.
|
||||
*/
|
||||
public function getDecryptionKey(Share $share, ?string $password = null): string
|
||||
{
|
||||
if ($share->isPasswordProtected()) {
|
||||
if (! $password) {
|
||||
throw new \RuntimeException('Password required for this share');
|
||||
}
|
||||
|
||||
return bin2hex($this->encryptionService->deriveKey($password, $share->encryption_salt));
|
||||
}
|
||||
|
||||
return $share->encryption_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a share's stored hash.
|
||||
*/
|
||||
public function verifyPassword(Share $share, string $password): bool
|
||||
{
|
||||
if (! $share->isPasswordProtected()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Hash::check($password, $share->password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a download and auto-delete if limit reached.
|
||||
*/
|
||||
public function recordDownload(Share $share): void
|
||||
{
|
||||
$share->increment('download_count');
|
||||
|
||||
if ($share->hasReachedDownloadLimit()) {
|
||||
$this->deleteShare($share);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total used space in bytes.
|
||||
*/
|
||||
public function getTotalUsedSpace(): int
|
||||
{
|
||||
return (int) Share::query()->sum('total_size');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage is full based on admin-configured max quota.
|
||||
*/
|
||||
public function isStorageFull(): bool
|
||||
{
|
||||
$maxQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
||||
|
||||
return $this->getTotalUsedSpace() >= $maxQuota;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum storage quota in bytes.
|
||||
*/
|
||||
public function getMaxStorageQuota(): int
|
||||
{
|
||||
return (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user