176 lines
5.0 KiB
PHP
176 lines
5.0 KiB
PHP
<?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);
|
|
}
|
|
}
|