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:
surtic86
2026-02-13 11:43:20 +01:00
co-authored by Claude Opus 4.6
parent 4790be8142
commit e37b322dde
120 changed files with 6346 additions and 1459 deletions
+62
View File
@@ -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(),
]);
}
}
+146
View File
@@ -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(),
]);
}
}
+192
View File
@@ -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),
]);
}
}
+65
View File
@@ -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');
}
}
+23
View File
@@ -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');
}
}
+75
View File
@@ -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'),
]);
}
}
+38
View File
@@ -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');
}
}