Initial commit: Cloud Control Panel

This commit is contained in:
2026-01-10 01:24:08 +07:00
commit 01d99c5054
69 changed files with 12697 additions and 0 deletions

View File

@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Storage\Drivers;
use JsonException;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use FilesystemIterator;
use RuntimeException;
final readonly class LocalStorageDriver implements StorageDriverInterface
{
public function __construct(
private string $basePath,
private int $defaultLimitBytes,
)
{
}
public function getTotalBytes(int|string $userId): int
{
return $this->defaultLimitBytes;
}
public function getUsedBytes(int|string $userId): int
{
return array_sum($this->getUsedBytesByCategory($userId));
}
public function getUsedBytesByCategory(int|string $userId): array
{
$userPath = $this->basePath . '/users/' . $userId;
if (!is_dir($userPath)) {
return [];
}
$result = [];
foreach (scandir($userPath) as $entry) {
if ($entry === '.' || $entry === '..' || $entry === '.tus') {
continue;
}
$fullPath = $userPath . '/' . $entry;
if (!is_dir($fullPath)) {
continue;
}
$result[$entry] = $this->getDirectorySize($fullPath);
}
return $result;
}
private function getDirectorySize(string $path): int
{
if (!is_dir($path)) {
return 0;
}
$size = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
$size += $file->getSize();
}
return $size;
}
public function createFolder(int|string $userId, string $name): void
{
$path = $this->basePath . '/users/' . $userId . '/' . $name;
if (is_dir($path)) {
return;
}
mkdir($path, 0755, true);
}
public function getUserFolderPath(int|string $userId, string $folder): string
{
return $this->basePath . '/users/' . $userId . '/' . $folder;
}
public function getUserPath(int|string $userId): string
{
return $this->basePath . '/users/' . $userId;
}
public function storeUploadedFile(
int|string $userId,
string $folder,
UploadedFileInterface $file,
string $targetName
): void
{
$dir = $this->getUserFolderPath($userId, $folder);
if (!is_dir($dir)) {
throw new RuntimeException('Folder not found');
}
$file->moveTo($dir . '/' . $targetName);
}
private function tusDir(int|string $userId): string
{
return $this->basePath . '/users/' . $userId . '/.tus';
}
private function tusMeta(int|string $userId, string $id): string
{
return $this->tusDir($userId) . "/$id.meta";
}
private function tusBin(int|string $userId, string $id): string
{
return $this->tusDir($userId) . "/$id.bin";
}
/**
* @throws JsonException
*/
public function tusInit(
int|string $userId,
string $uploadId,
int $size,
array $metadata
): void
{
$dir = $this->tusDir($userId);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents(
$this->tusMeta($userId, $uploadId),
json_encode([
'size' => $size,
'offset' => 0,
'metadata' => $metadata,
], JSON_THROW_ON_ERROR)
);
touch($this->tusBin($userId, $uploadId));
}
/**
* @throws JsonException
*/
public function tusWriteChunk(
int|string $userId,
string $uploadId,
int $offset,
StreamInterface $stream
): int
{
$metaFile = $this->tusMeta($userId, $uploadId);
$binFile = $this->tusBin($userId, $uploadId);
if (!is_file($metaFile) || !is_file($binFile)) {
throw new RuntimeException('Upload not found');
}
$metaContent = file_get_contents($metaFile);
if ($metaContent === false) {
throw new RuntimeException('Failed to read TUS metadata');
}
$meta = json_decode(
$metaContent,
true,
512,
JSON_THROW_ON_ERROR
);
if ($offset !== $meta['offset']) {
throw new RuntimeException('Invalid upload offset');
}
$fp = fopen($binFile, 'c+');
if ($fp === false) {
throw new RuntimeException('Failed to open binary file for writing');
}
if (fseek($fp, $offset) !== 0) {
fclose($fp);
throw new RuntimeException('Failed to seek to offset');
}
$detachedStream = $stream->detach();
if ($detachedStream === null) {
fclose($fp);
throw new RuntimeException('Stream is already detached');
}
$written = stream_copy_to_stream($detachedStream, $fp);
fclose($fp);
if ($written === false) {
throw new RuntimeException('Failed to write chunk');
}
$meta['offset'] += $written;
if ($meta['offset'] >= $meta['size']) {
$this->finalizeTusUpload($userId, $uploadId, $meta);
} else {
$updatedMeta = json_encode($meta, JSON_THROW_ON_ERROR);
if (file_put_contents($metaFile, $updatedMeta) === false) {
throw new RuntimeException('Failed to update TUS metadata');
}
}
return $written;
}
/**
* Finalize TUS upload
*
* @param int|string $userId
* @param string $uploadId
* @param array{
* size: int,
* offset: int,
* metadata: array<string, string>,
* created_at: string,
* expires_at: string
* } $meta
* @throws RuntimeException
*/
private function finalizeTusUpload(
int|string $userId,
string $uploadId,
array $meta
): void
{
$folder = $meta['metadata']['folder'] ?? 'default';
$filename = $meta['metadata']['filename'] ?? $uploadId;
$targetDir = $this->getUserFolderPath($userId, $folder);
if (!is_dir($targetDir)) {
if (!mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
throw new RuntimeException("Failed to create target directory: $targetDir");
}
}
$targetFile = $targetDir . '/' . $filename;
if (file_exists($targetFile)) {
$filename = $this->addSuffixToFilename($filename, $targetDir);
$targetFile = $targetDir . '/' . $filename;
}
$sourceFile = $this->tusBin($userId, $uploadId);
if (!rename($sourceFile, $targetFile)) {
throw new RuntimeException("Failed to move file from $sourceFile to $targetFile");
}
$metaFile = $this->tusMeta($userId, $uploadId);
if (!unlink($metaFile)) {
throw new RuntimeException("Failed to delete metadata file: $metaFile");
}
}
private function addSuffixToFilename(string $filename, string $targetDir): string
{
$pathInfo = pathinfo($filename);
$name = $pathInfo['filename'];
$ext = isset($pathInfo['extension']) ? '.' . $pathInfo['extension'] : '';
$counter = 1;
do {
$newFilename = $name . '_' . $counter . $ext;
$newPath = $targetDir . '/' . $newFilename;
$counter++;
} while (file_exists($newPath) && $counter < 100);
return $newFilename;
}
/**
* @throws JsonException
*/
public function tusGetStatus(
int|string $userId,
string $uploadId
): array
{
$metaFile = $this->tusMeta($userId, $uploadId);
if (!is_file($metaFile)) {
throw new RuntimeException('Upload not found');
}
$metaContent = file_get_contents($metaFile);
if ($metaContent === false) {
throw new RuntimeException('Failed to read TUS metadata');
}
$meta = json_decode(
$metaContent,
true,
512,
JSON_THROW_ON_ERROR
);
return [
'size' => (int)($meta['size'] ?? 0),
'offset' => (int)($meta['offset'] ?? 0),
'metadata' => $meta['metadata'] ?? [],
'created_at' => $meta['created_at'] ?? date('c'),
'expires_at' => $meta['expires_at'] ?? null,
];
}
public function getFilePath(
int|string $userId,
string $folder,
string $filename
): string
{
$path = $this->getUserFolderPath($userId, $folder) . '/' . $filename;
if (!is_file($path)) {
throw new RuntimeException('File not found');
}
return $path;
}
public function deleteFile(
int|string $userId,
string $folder,
string $filename
): void
{
$path = $this->getFilePath($userId, $folder, $filename);
if (!unlink($path)) {
throw new RuntimeException('Failed to delete file');
}
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Storage\Drivers;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
interface StorageDriverInterface
{
public function getTotalBytes(int|string $userId): int;
public function getUsedBytes(int|string $userId): int;
/** @return array<string, int> */
public function getUsedBytesByCategory(int|string $userId): array;
public function createFolder(int|string $userId, string $name): void;
public function storeUploadedFile(
int|string $userId,
string $folder,
UploadedFileInterface $file,
string $targetName
): void;
public function getUserFolderPath(int|string $userId, string $folder): string;
/**
* Initialize TUS upload
*
* @param int|string $userId
* @param string $uploadId
* @param int $size
* @param array<string, string> $metadata folder
*/
public function tusInit(
int|string $userId,
string $uploadId,
int $size,
array $metadata
): void;
public function tusWriteChunk(
int|string $userId,
string $uploadId,
int $offset,
StreamInterface $stream
): int;
/**
* Get TUS upload status
*
* @param int|string $userId
* @param string $uploadId
* @return array{
* size: int,
* offset: int,
* metadata: array<string, string>,
* created_at: string,
* expires_at: string|null
* }
*/
public function tusGetStatus(
int|string $userId,
string $uploadId
): array;
public function getUserPath(int|string $userId): string;
public function getFilePath(
int|string $userId,
string $folder,
string $filename
): string;
public function deleteFile(
int|string $userId,
string $folder,
string $filename
): void;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Storage;
use Psr\Log\LoggerInterface;
use RuntimeException;
final readonly class StorageGuard
{
public function __construct(private LoggerInterface $logger)
{
}
public function assertEnoughSpace(string $path, int $bytes): void
{
$free = disk_free_space($path);
if ($free !== false && $free < $bytes) {
$this->logger->warning("Physical disk is full", ['path' => $path]);
throw new RuntimeException('Physical disk is full');
}
}
}

View File

@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Storage;
use Din9xtrCloud\Models\User;
use Din9xtrCloud\Storage\Drivers\StorageDriverInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use Random\RandomException;
use RuntimeException;
use ZipArchive;
final readonly class StorageService
{
public function __construct(
private StorageDriverInterface $driver,
private StorageGuard $guard,
)
{
}
public function getDriver(): StorageDriverInterface
{
return $this->driver;
}
public function getStats(User $user): StorageStats
{
$total = $this->driver->getTotalBytes($user->id);
$used = $this->driver->getUsedBytes($user->id);
$free = max(0, $total - $used);
$percent = $total > 0
? (int)round(($used / $total) * 100)
: 0;
return new StorageStats(
totalBytes: $total,
usedBytes: $used,
freeBytes: $free,
percent: min(100, $percent),
byFolder: $this->driver->getUsedBytesByCategory($user->id)
);
}
public function createFolder(User $user, string $name): void
{
$this->driver->createFolder($user->id, $name);
}
public function uploadFile(
User $user,
string $folder,
UploadedFileInterface $file
): void
{
$this->assertValidFolderName($folder);
$stats = $this->getStats($user);
$fileSize = (int)$file->getSize();
if ($stats->freeBytes < $fileSize) {
throw new RuntimeException('Storage limit exceeded');
}
$folderPath = $this->driver->getUserPath($user->id);
$this->guard->assertEnoughSpace($folderPath, $fileSize);
$safeName = $this->generateSafeFilename(
$file->getClientFilename() ?? 'file',
$folderPath . '/' . $folder
);
$this->driver->storeUploadedFile(
$user->id,
$folder,
$file,
$safeName
);
}
private function generateSafeFilename(string $original, string $folderPath): string
{
$original = $this->fixFileNameEncoding($original);
$name = pathinfo($original, PATHINFO_FILENAME);
$ext = pathinfo($original, PATHINFO_EXTENSION);
$safe = preg_replace('/[\/:*?"<>|\\\\]/', '_', $name);
if (empty($safe)) {
$safe = 'file';
}
$baseName = $safe . ($ext ? '.' . $ext : '');
$basePath = $folderPath . '/' . $baseName;
if (!file_exists($basePath)) {
error_log("Returning: " . $baseName);
return $baseName;
}
$counter = 1;
$maxAttempts = 100;
do {
$newFilename = $safe . '_' . $counter . ($ext ? '.' . $ext : '');
$newPath = $folderPath . '/' . $newFilename;
$counter++;
} while (file_exists($newPath) && $counter <= $maxAttempts);
return $newFilename;
}
private function assertValidFolderName(string $folder): void
{
if ($folder === '' || !preg_match('/^[a-zA-Z\x{0400}-\x{04FF}0-9_\- ]+$/u', $folder)) {
throw new RuntimeException('Invalid folder');
}
}
private function fixFileNameEncoding(string $filename): string
{
$detected = mb_detect_encoding($filename, ['UTF-8', 'Windows-1251', 'ISO-8859-5', 'KOI8-R'], true);
if ($detected && $detected !== 'UTF-8') {
$converted = mb_convert_encoding($filename, 'UTF-8', $detected);
$filename = $converted !== false ? $converted : "unknown_file";
}
return $filename;
}
/**
* Initialize TUS upload
*
* @param User $user
* @param string $uploadId
* @param int $size
* @param array<string, string> $metadata folder
* @throws RuntimeException
*/
public function initTusUpload(
User $user,
string $uploadId,
int $size,
array $metadata
): void
{
$stats = $this->getStats($user);
$userFolderPath = $this->driver->getUserPath($user->id);
$this->guard->assertEnoughSpace($userFolderPath, $size);
if ($stats->freeBytes < $size) {
throw new RuntimeException('Storage limit exceeded');
}
$this->driver->tusInit(
userId: $user->id,
uploadId: $uploadId,
size: $size,
metadata: $metadata
);
}
/**
* Write TUS chunk
*
* @param User $user
* @param string $uploadId
* @param int $offset
* @param StreamInterface $stream
* @return int Number of bytes written
*/
public function writeTusChunk(
User $user,
string $uploadId,
int $offset,
StreamInterface $stream
): int
{
return $this->driver->tusWriteChunk(
userId: $user->id,
uploadId: $uploadId,
offset: $offset,
stream: $stream
);
}
/**
* Get TUS upload status
*
* @param User $user
* @param string $uploadId
* @return array{
* size: int,
* offset: int,
* metadata: array<string, string>,
* created_at: string,
* expires_at: string|null
* }
*/
public function getTusStatus(User $user, string $uploadId): array
{
return $this->driver->tusGetStatus($user->id, $uploadId);
}
public function deleteFile(
User $user,
string $folder,
string $filename
): void
{
$this->assertValidFolderName($folder);
if ($filename === '' || str_contains($filename, '..')) {
throw new RuntimeException('Invalid filename');
}
$this->driver->deleteFile($user->id, $folder, $filename);
}
public function getFileForDownload(
User $user,
string $folder,
string $filename
): string
{
$this->assertValidFolderName($folder);
if ($filename === '' || str_contains($filename, '..')) {
throw new RuntimeException('Invalid filename');
}
return $this->driver->getFilePath($user->id, $folder, $filename);
}
/**
* @param array<string> $files
* @throws RandomException
*/
public function buildZipForDownload(
User $user,
string $folder,
array $files
): string
{
$this->assertValidFolderName($folder);
$tmpDir = sys_get_temp_dir() . '/cloud-' . $user->id;
if (!is_dir($tmpDir)) {
mkdir($tmpDir, 0700, true);
}
$zipPath = $tmpDir . '/download-' . bin2hex(random_bytes(8)) . '.zip';
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
throw new RuntimeException('Cannot create zip');
}
foreach ($files as $file) {
if ($file === '' || str_contains($file, '..')) {
continue;
}
$path = $this->driver->getFilePath($user->id, $folder, $file);
$zip->addFile($path, $file);
}
$zip->close();
return $zipPath;
}
/**
* @param array<string> $files
*/
public function deleteMultipleFiles(
User $user,
string $folder,
array $files
): void
{
$this->assertValidFolderName($folder);
foreach ($files as $file) {
if ($file === '' || str_contains($file, '..')) {
continue;
}
$path = $this->driver->getFilePath($user->id, $folder, $file);
@unlink($path);
}
}
public function deleteFolder(User $user, string $folder): void
{
$this->assertValidFolderName($folder);
$path = $this->driver->getUserFolderPath($user->id, $folder);
if (!is_dir($path)) {
throw new RuntimeException("Folder not found: $folder");
}
$this->deleteDirectoryRecursive($path);
}
private function deleteDirectoryRecursive(string $dir): void
{
$items = scandir($dir);
if ($items === false) {
throw new RuntimeException("Failed to read directory: $dir");
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
if (is_dir($path)) {
$this->deleteDirectoryRecursive($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Storage;
final readonly class StorageStats
{
public function __construct(
public int $totalBytes,
public int $usedBytes,
public int $freeBytes,
public int $percent,
/** @var array<string, int> */
public array $byFolder = [],
)
{
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Storage;
use RuntimeException;
final class UserStorageInitializer
{
private const array CATEGORIES = [
'documents',
'media',
];
public function __construct(
private readonly string $basePath
)
{
}
/**
* @throws RuntimeException
*/
public function init(int|string $userId): void
{
$userPath = $this->basePath . '/users/' . $userId;
foreach (self::CATEGORIES as $dir) {
$path = $userPath . '/' . $dir;
if (!is_dir($path)) {
if (!mkdir($path, 0775, true)) {
throw new RuntimeException(
sprintf('can`t crate dir: %s', $path)
);
}
}
}
}
}