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

86
src/App.php Executable file
View File

@@ -0,0 +1,86 @@
<?php /** @noinspection PhpMultipleClassDeclarationsInspection */
declare(strict_types=1);
namespace Din9xtrCloud;
use Din9xtrCloud\Container\Container;
use Nyholm\Psr7\Stream;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Relay\Relay;
final class App
{
private Container $container;
/**
* @var array<int, string|callable|MiddlewareInterface>
*/
private array $middlewares = [];
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* @param string|callable|MiddlewareInterface ...$middleware
* @return self
*/
public function middleware(...$middleware): self
{
foreach ($middleware as $m) {
$this->middlewares[] = $m;
}
return $this;
}
public function router(Router $router): self
{
$this->middlewares[] = fn(ServerRequestInterface $request, callable $handler) => $router->dispatch($request);
return $this;
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function dispatch(): void
{
$request = $this->container->get(ServerRequestInterface::class);
$resolver = fn($entry) => is_string($entry)
? $this->container->get($entry)
: $entry;
$pipeline = new Relay($this->middlewares, $resolver);
$response = $pipeline->handle($request);
http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
foreach ($values as $value) {
header("$name: $value", false);
}
}
$body = $response->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
while (!$body->eof()) {
echo $body->read(8192);
flush();
}
if ($body instanceof Stream) {
$meta = $body->getMetadata();
if (!empty($meta['uri']) && str_ends_with($meta['uri'], '.zip')) {
@unlink($meta['uri']);
}
}
}
}

173
src/Container/Container.php Executable file
View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Container;
use Closure;
use Din9xtrCloud\Container\Exceptions\ContainerException;
use Din9xtrCloud\Container\Exceptions\NotFoundException;
use Psr\Container\ContainerInterface;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionUnionType;
use Throwable;
final class Container implements ContainerInterface
{
/**
* @var array<string, Definition>
*/
private array $definitions = [];
/**
* @var array<string, mixed>
*/
private array $shared = [];
/**
* @var array<string, mixed>
*/
private array $request = [];
public function has(string $id): bool
{
return isset($this->definitions[$id]) || class_exists($id);
}
/**
* @param string $id
* @param callable|object $concrete
* @return void
*/
public function singleton(string $id, callable|object $concrete): void
{
$this->define($id, $concrete, Scope::Shared);
}
/**
* @param string $id
* @param callable $factory
* @return void
*/
public function request(string $id, callable $factory): void
{
$this->define($id, $factory, Scope::Request);
}
/**
* @param string $id
* @param callable $factory
* @return void
*/
public function factory(string $id, callable $factory): void
{
$this->define($id, $factory, Scope::Factory);
}
/**
* @param string $id
* @param callable|object $concrete
* @param Scope $scope
* @return void
*/
private function define(string $id, callable|object $concrete, Scope $scope): void
{
$factory = $concrete instanceof Closure
? $concrete
: (is_callable($concrete) ? $concrete(...) : fn() => $concrete);
$this->definitions[$id] = new Definition($factory, $scope);
}
public function get(string $id)
{
if ($def = $this->definitions[$id] ?? null) {
return match ($def->scope) {
Scope::Shared => $this->shared[$id]
??= ($def->factory)($this),
Scope::Request => $this->request[$id]
??= ($def->factory)($this),
Scope::Factory => ($def->factory)($this),
};
}
if (class_exists($id)) {
return $this->shared[$id] ??= $this->autowire($id);
}
throw new NotFoundException("Service $id not found");
}
/**
* @template T
* @param class-string<T> $class
* @return mixed
* @throws ContainerException
*/
private function autowire(string $class): mixed
{
try {
$ref = new ReflectionClass($class);
if (!$ref->isInstantiable()) {
throw new ContainerException("Class $class is not instantiable");
}
$ctor = $ref->getConstructor();
if ($ctor === null) {
return new $class;
}
$deps = [];
foreach ($ctor->getParameters() as $param) {
$type = $param->getType();
if ($type === null) {
throw new ContainerException(
"Cannot resolve parameter \${$param->getName()} of $class: no type specified"
);
}
if ($type instanceof ReflectionUnionType) {
throw new ContainerException(
"Cannot resolve parameter \${$param->getName()} of $class: union types not supported"
);
}
if (!$type instanceof ReflectionNamedType) {
throw new ContainerException(
"Cannot resolve parameter \${$param->getName()} of $class: intersection types not supported"
);
}
if ($type->isBuiltin()) {
throw new ContainerException(
"Cannot resolve parameter \${$param->getName()} of $class: built-in type '{$type->getName()}' not supported"
);
}
$typeName = $type->getName();
if (!class_exists($typeName) && !interface_exists($typeName)) {
throw new ContainerException(
"Cannot resolve parameter \${$param->getName()} of $class: type '$typeName' not found"
);
}
$deps[] = $this->get($type->getName());
}
return $ref->newInstanceArgs($deps);
} catch (Throwable $e) {
throw new ContainerException("Reflection failed for $class", 0, $e);
}
}
public function beginRequest(): void
{
$this->request = [];
}
}

16
src/Container/Definition.php Executable file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Container;
use Closure;
final readonly class Definition
{
public function __construct(
public Closure $factory,
public Scope $scope
)
{
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Container\Exceptions;
use Exception;
use Psr\Container\ContainerExceptionInterface;
class ContainerException extends Exception implements ContainerExceptionInterface
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Container\Exceptions;
use Exception;
use Psr\Container\NotFoundExceptionInterface;
class NotFoundException extends Exception implements NotFoundExceptionInterface
{
}

10
src/Container/Scope.php Executable file
View File

@@ -0,0 +1,10 @@
<?php
namespace Din9xtrCloud\Container;
enum Scope
{
case Shared;
case Request;
case Factory;
}

18
src/Contracts/ViewModel.php Executable file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Contracts;
interface ViewModel
{
/**
* @return array<string, mixed>
*/
public function toArray(): array;
public function template(): string;
public function layout(): ?string;
public function title(): ?string;
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Controllers;
use Din9xtrCloud\Middlewares\CsrfMiddleware;
use Din9xtrCloud\Services\LoginService;
use Din9xtrCloud\View;
use Din9xtrCloud\ViewModels\Login\LoginViewModel;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
final readonly class AuthController
{
public function __construct(
private LoginService $loginService,
private LoggerInterface $logger)
{
}
public function loginForm(): string
{
$this->logger->info("Login form started");
$error = $_SESSION['login_error'] ?? null;
unset($_SESSION['login_error']);
return View::display(new LoginViewModel(
title: 'Login',
error: $error,
csrf: CsrfMiddleware::generateToken()
));
}
public function loginSubmit(ServerRequestInterface $request): Response
{
$data = (array)($request->getParsedBody() ?? []);
$username = (string)($data['username'] ?? '');
$password = (string)($data['password'] ?? '');
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? null;
$ua = $request->getHeaderLine('User-Agent') ?: null;
$this->logger->info('Login submitted', [
'username' => $username,
'ip' => $ip,
]);
$authToken = $this->loginService->attemptLogin(
$username,
$password,
$ip,
$ua
);
if ($authToken !== null) {
session_regenerate_id(true);
return new Response(
302,
[
'Location' => '/',
'Set-Cookie' => sprintf(
'auth_token=%s; HttpOnly; SameSite=Strict; Path=/; Secure',
$authToken
),
]
);
}
$_SESSION['login_error'] = 'Invalid credentials';
return new Response(302, ['Location' => '/login']);
}
public function logout(ServerRequestInterface $request): Response
{
$token = $request->getCookieParams()['auth_token'] ?? null;
if ($token) {
$this->loginService->logout($token);
}
session_destroy();
return new Response(
302,
[
'Location' => '/login',
'Set-Cookie' =>
'auth_token=deleted; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; HttpOnly'
]
);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Controllers;
use Din9xtrCloud\Middlewares\CsrfMiddleware;
use Din9xtrCloud\Models\User;
use Din9xtrCloud\Storage\StorageService;
use Din9xtrCloud\Storage\UserStorageInitializer;
use Din9xtrCloud\View;
use Din9xtrCloud\ViewModels\Dashboard\DashboardViewModel;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Throwable;
final readonly class DashboardController
{
public function __construct(
private LoggerInterface $logger,
private StorageService $storageService,
private UserStorageInitializer $userStorageInitializer,
)
{
}
public function index(ServerRequestInterface $request): string
{
try {
/** @var User $user */
$user = $request->getAttribute('user');
$this->userStorageInitializer->init($user->id);
$storage = $this->storageService->getStats($user);
$folders = [];
foreach ($storage->byFolder as $name => $bytes) {
$percent = getStoragePercent(
$bytes,
$storage->totalBytes
);
$folders[] = [
'name' => $name,
'size' => formatBytes($bytes),
'percent' => $percent,
];
}
$this->logger->info('Dashboard loaded successfully');
} catch (Throwable $exception) {
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
return View::display(new DashboardViewModel(
title: 'Dashboard',
username: $user->username,
stats: [
'storage' => [
'total' => formatBytes(0),
'used' => formatBytes(0),
'free' => formatBytes(0),
'percent' => 0,
'folders' => [],
],
],
csrf: CsrfMiddleware::generateToken(),
));
}
return View::display(new DashboardViewModel(
title: 'Dashboard',
username: $user->username,
stats: [
'storage' => [
'total' => formatBytes($storage->totalBytes),
'used' => formatBytes($storage->usedBytes),
'free' => formatBytes($storage->freeBytes),
'percent' => $storage->percent,
'folders' => $folders,
],
],
csrf: CsrfMiddleware::generateToken(),
));
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Din9xtrCloud\Controllers;
use Din9xtrCloud\View;
use Din9xtrCloud\ViewModels\LicenseViewModel;
use Psr\Http\Message\ServerRequestInterface;
final readonly class LicenseController
{
public function license(ServerRequestInterface $request): string
{
return View::display(new LicenseViewModel('License'));
}
}

View File

@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Controllers;
use Din9xtrCloud\Middlewares\CsrfMiddleware;
use Din9xtrCloud\Models\User;
use Din9xtrCloud\Storage\StorageService;
use Din9xtrCloud\View;
use Din9xtrCloud\ViewModels\Folder\FolderViewModel;
use JsonException;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Log\LoggerInterface;
use Random\RandomException;
use Throwable;
final readonly class StorageController
{
public function __construct(
private StorageService $storageService,
private LoggerInterface $logger
)
{
}
public function createFolder(ServerRequestInterface $request): Response
{
try {
/** @var User $user */
$user = $request->getAttribute('user');
/** @var array<string, mixed>|null $data */
$data = $request->getParsedBody();
$name = trim($data['name'] ?? '');
if ($name === '' || !preg_match('/^[a-zA-Z\x{0400}-\x{04FF}0-9_\- ]+$/u', $name)) {
return new Response(302, ['Location' => '/']);
}
$this->storageService->createFolder($user, $name);
/** @phpstan-ignore catch.neverThrown */
} catch (Throwable $e) {
$this->logger->error('Failed to save folder: ' . $e->getMessage(), [
'user_id' => $user->id,
'exception' => $e
]);
return new Response(302, ['Location' => '/?error=save_failed']);
}
return new Response(302, ['Location' => '/']);
}
/**
* @throws JsonException
*/
public function uploadFile(ServerRequestInterface $request): Response
{
/** @var User $user */
$user = $request->getAttribute('user');
/** @var array<string, mixed>|null $data */
$data = $request->getParsedBody();
$folder = trim($data['folder'] ?? '');
$file = $request->getUploadedFiles()['file'] ?? null;
if (!$file instanceof UploadedFileInterface) {
return $this->jsonError('no_file', 400);
}
if ($file->getError() !== UPLOAD_ERR_OK) {
return $this->jsonError('upload_failed', 422);
}
try {
$this->storageService->uploadFile($user, $folder, $file);
$this->logger->info('File uploaded', [
'user_id' => $user->id,
'folder' => $folder,
'size' => $file->getSize(),
]);
return $this->jsonSuccess(['code' => 'file_uploaded']);
} catch (Throwable $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return $this->jsonError('save_failed', 500);
}
}
/**
* @param array<string, mixed> $payload
* @throws JsonException
*/
private function jsonSuccess(array $payload = []): Response
{
return new Response(
200,
['Content-Type' => 'application/json'],
json_encode(['success' => true] + $payload, JSON_THROW_ON_ERROR)
);
}
/**
* @throws JsonException
*/
private function jsonError(string $code, int $status): Response
{
return new Response(
$status,
['Content-Type' => 'application/json'],
json_encode([
'success' => false,
'error' => $code,
], JSON_THROW_ON_ERROR)
);
}
public function showFolder(ServerRequestInterface $request, string $folder): string
{
$user = $request->getAttribute('user');
$csrfToken = CsrfMiddleware::generateToken();
$folderPath = $this->storageService->getDriver()->getUserFolderPath($user->id, $folder);
$files = [];
$totalBytes = 0;
$lastModified = null;
if (is_dir($folderPath)) {
foreach (scandir($folderPath) as $entry) {
if (in_array($entry, ['.', '..'])) continue;
$fullPath = $folderPath . '/' . $entry;
if (!is_file($fullPath)) continue;
$size = filesize($fullPath);
$modifiedTime = filemtime($fullPath);
if ($size === false || $modifiedTime === false) {
continue;
}
$modified = date('Y-m-d H:i:s', $modifiedTime);
$files[] = [
'name' => $entry,
'size' => $this->humanFileSize($size),
'modified' => $modified,
];
$totalBytes += $size;
if ($lastModified === null || $modifiedTime > strtotime($lastModified)) {
$lastModified = $modified;
}
}
}
return View::display(new FolderViewModel(
title: $folder,
files: $files,
csrf: $csrfToken,
totalSize: $this->humanFileSize($totalBytes),
lastModified: $lastModified ?? '—'
));
}
private function humanFileSize(int $bytes): string
{
$sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if ($bytes === 0) return '0 B';
$factor = floor(log($bytes, 1024));
return sprintf("%.2f %s", $bytes / (1024 ** $factor), $sizes[(int)$factor]);
}
public function deleteFolder(ServerRequestInterface $request, string $folder): Response
{
/** @var User $user */
$user = $request->getAttribute('user');
$this->storageService->deleteFolder($user, $folder);
return new Response(302, ['Location' => '/']);
}
public function downloadFile(ServerRequestInterface $request): Response
{
/** @var User $user */
$user = $request->getAttribute('user');
$query = $request->getQueryParams();
$folder = $query['folder'] ?? '';
$file = $query['file'] ?? '';
$this->logger->info('Downloading file', ['user_id' => $user->id, 'folder' => $folder, 'file' => $file, 'query' => $query]);
try {
$path = $this->storageService->getFileForDownload($user, $folder, $file);
$this->logger->info('File downloaded', ['path' => $path]);
$filename = basename($path);
$mimeType = mime_content_type($path) ?: 'application/octet-stream';
$fileSize = filesize($path);
$fileStream = fopen($path, 'rb');
if ($fileStream === false) {
$this->logger->error('Cannot open file for streaming', ['path' => $path]);
return new Response(500);
}
$stream = Stream::create($fileStream);
return new Response(
200,
[
'Content-Type' => $mimeType,
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => (string)$fileSize,
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'Expires' => '0',
'Accept-Ranges' => 'bytes',
],
$stream
);
} catch (Throwable $e) {
$this->logger->warning('Download failed', [
'user_id' => $user->id,
'file' => $file,
'exception' => $e,
]);
return new Response(404);
}
}
public function deleteFile(ServerRequestInterface $request): Response
{
/** @var User $user */
$user = $request->getAttribute('user');
/** @var array<string, mixed> $data */
$data = $request->getParsedBody();
$folder = $data['folder'] ?? '';
$file = $data['file_name'] ?? '';
$this->storageService->deleteFile($user, $folder, $file);
$this->logger->info('File deleted', [
'user_id' => $user->id,
'folder' => $folder,
'file' => $file,
]);
return new Response(
302,
['Location' => '/folders/' . rawurlencode($folder)]
);
}
/**
* @throws JsonException
* @throws RandomException
*/
public function downloadMultiple(ServerRequestInterface $request): Response
{
/** @var User $user */
$user = $request->getAttribute('user');
$query = $request->getQueryParams();
$folder = $query['folder'] ?? '';
$raw = $query['file_names'] ?? '[]';
$fileNames = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (!$fileNames || !is_array($fileNames)) {
return new Response(400);
}
$zipPath = $this->storageService->buildZipForDownload(
user: $user,
folder: $folder,
files: $fileNames
);
if (!file_exists($zipPath)) {
return new Response(404);
}
$fileStream = @fopen($zipPath, 'rb');
if ($fileStream === false) {
$this->logger->error('Cannot open zip file for streaming', ['path' => $zipPath]);
return new Response(500);
}
$stream = Stream::create($fileStream);
return new Response(
200,
[
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="files.zip"',
'Content-Length' => (string)filesize($zipPath),
'Cache-Control' => 'no-store',
],
$stream
);
}
/**
* @throws JsonException
*/
public function deleteMultiple(ServerRequestInterface $request): Response
{
/** @var User $user */
$user = $request->getAttribute('user');
$data = $request->getParsedBody();
if (!is_array($data)) {
return new Response(400);
}
$folder = $data['folder'] ?? '';
$raw = $data['file_names'] ?? '[]';
$files = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
$this->storageService->deleteMultipleFiles($user, $folder, $files);
return new Response(
302,
['Location' => '/folders/' . rawurlencode($folder)]
);
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Controllers;
use Din9xtrCloud\Models\User;
use Din9xtrCloud\Storage\StorageService;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
final readonly class StorageTusController
{
public function __construct(
private StorageService $storage,
private LoggerInterface $logger
)
{
}
public function handle(ServerRequestInterface $request): Response
{
/** @var User|null $user */
$user = $request->getAttribute('user');
if (!$user) {
return new Response(401);
}
return match ($request->getMethod()) {
'POST' => $this->create($request, $user),
'OPTIONS' => $this->options(),
default => new Response(405),
};
}
/** @noinspection PhpUnhandledExceptionInspection */
private function create(ServerRequestInterface $request, User $user): Response
{
$length = (int)$request->getHeaderLine('Upload-Length');
if ($length <= 0) {
return new Response(400);
}
$metadata = $this->parseMetadata(
$request->getHeaderLine('Upload-Metadata')
);
$uploadId = bin2hex(random_bytes(16));
$this->logger->info('CREATE_METADATA', $metadata);
$this->storage->initTusUpload(
user: $user,
uploadId: $uploadId,
size: $length,
metadata: $metadata,
);
$this->logger->debug('ID: ' . $uploadId);
return new Response(
201,
[
'Tus-Resumable' => '1.0.0',
'Location' => '/storage/tus/' . $uploadId,
]
);
}
public function patch(
ServerRequestInterface $request,
?string $id
): Response
{
$user = $request->getAttribute('user');
if (!$id) {
return new Response(404);
}
$offset = (int)$request->getHeaderLine('Upload-Offset');
$body = $request->getBody();
$written = $this->storage->writeTusChunk(
user: $user,
uploadId: $id,
offset: $offset,
stream: $body
);
return new Response(
204,
[
'Tus-Resumable' => '1.0.0',
'Upload-Offset' => (string)($offset + $written),
]
);
}
public function head(
ServerRequestInterface $request,
?string $id
): Response
{
$user = $request->getAttribute('user');
if (!$id) {
return new Response(404);
}
$status = $this->storage->getTusStatus($user, $id);
return new Response(
200,
[
'Tus-Resumable' => '1.0.0',
'Upload-Offset' => (string)$status['offset'],
'Upload-Length' => (string)$status['size'],
]
);
}
/**
* @return array<string, string>
*/
private function parseMetadata(string $raw): array
{
$result = [];
foreach (explode(',', $raw) as $item) {
if (!str_contains($item, ' ')) continue;
[$k, $v] = explode(' ', $item, 2);
$result[$k] = base64_decode($v);
}
return $result;
}
private function options(): Response
{
return new Response(
204,
[
'Tus-Resumable' => '1.0.0',
'Tus-Version' => '1.0.0',
'Tus-Extension' => 'creation,creation-defer-length',
'Tus-Max-Size' => (string)(1024 ** 4),
'Access-Control-Allow-Methods' => 'POST, PATCH, HEAD, OPTIONS',
'Access-Control-Allow-Headers' =>
'Tus-Resumable, Upload-Length, Upload-Offset, Upload-Metadata, Content-Type',
]
);
}
}

23
src/Helpers/helpers.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
if (!function_exists('formatBytes')) {
function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 1) . ' ' . $units[$i];
}
}
if (!function_exists('getStoragePercent')) {
function getStoragePercent(int $categoryBytes, int $totalBytes): float
{
return $totalBytes > 0 ? ($categoryBytes / $totalBytes * 100) : 0;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Middlewares;
use Din9xtrCloud\Repositories\SessionRepository;
use Din9xtrCloud\Repositories\UserRepository;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class AuthMiddleware implements MiddlewareInterface
{
/**
* @var array<int, string> Path, no need to auth check
*/
private array $except = [
'/login',
'/logout',
'/license'
];
public function __construct(
private readonly SessionRepository $sessions,
private readonly UserRepository $users,
)
{
}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface
{
$path = $request->getUri()->getPath();
$token = $request->getCookieParams()['auth_token'] ?? null;
$session = null;
if ($token) {
$session = $this->sessions->findActiveByToken($token);
}
if ($path === '/login' && $session !== null) {
return new Response(302, ['Location' => '/']);
}
if (in_array($path, $this->except, true)) {
return $handler->handle($request);
}
if (!$token) {
if ($request->getMethod() !== 'GET') {
return new Response(401);
}
return new Response(302, ['Location' => '/login']);
}
if ($session === null) {
return new Response(
302,
[
'Location' => '/login',
'Set-Cookie' =>
'auth_token=deleted; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/'
]
);
}
$request = $request->withAttribute('user', $this->users->findById($session->userId));
$request = $request->withAttribute('session', $session);
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Middlewares;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Random\RandomException;
use RuntimeException;
final class CsrfMiddleware implements MiddlewareInterface
{
private const array UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$method = $request->getMethod();
$path = $request->getUri()->getPath();
if ($this->isExcludedPath($path)) {
return $handler->handle($request);
}
if (in_array($method, self::UNSAFE_METHODS, true)) {
$token = $_POST['_csrf'] ?? '';
if (!isset($_SESSION['_csrf']) || $token !== $_SESSION['_csrf']) {
return new Psr17Factory()->createResponse(403)
->withBody(new Psr17Factory()->createStream('CSRF validation failed'));
}
}
return $handler->handle($request);
}
private function isExcludedPath(string $path): bool
{
if (str_starts_with($path, '/storage/tus')) {
return true;
}
if (str_starts_with($path, '/api/')) {
return true;
}
if (str_starts_with($path, '/webhook/')) {
return true;
}
return false;
}
public static function generateToken(): string
{
if (empty($_SESSION['_csrf']) || $_SESSION['_csrf_expire'] < time()) {
try {
$_SESSION['_csrf'] = bin2hex(random_bytes(32));
$_SESSION['_csrf_expire'] = time() + 3600;
} catch (RandomException $e) {
throw new RuntimeException(
'Failed to generate CSRF token: ' . $e->getMessage(),
0,
$e
);
}
}
return $_SESSION['_csrf'];
}
}

View File

@@ -0,0 +1,66 @@
<?php /** @noinspection SqlDialectInspection */
namespace Din9xtrCloud\Middlewares;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use PDO;
final class ThrottleMiddleware implements MiddlewareInterface
{
private int $maxAttempts = 5;
private int $lockTime = 300; // seconds
public function __construct(private readonly PDO $db)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$ip = $request->getHeaderLine('X-Forwarded-For') ?: $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
$ip = explode(',', $ip)[0];
$stmt = $this->db->prepare("SELECT * FROM login_throttle WHERE ip = :ip ORDER BY id DESC LIMIT 1");
$stmt->execute(['ip' => $ip]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$now = time();
$blockedUntil = null;
if ($row) {
if ($row['attempts'] >= $this->maxAttempts && ($now - $row['last_attempt']) < $this->lockTime) {
$blockedUntil = $row['last_attempt'] + $this->lockTime;
}
}
if ($blockedUntil && $now < $blockedUntil) {
return new Response(429, [], 'Too Many Requests');
}
$response = $handler->handle($request);
if ($request->getUri()->getPath() === '/login') {
$attempts = ($row['attempts'] ?? 0);
if ($response->getStatusCode() === 302) {
$this->db->prepare("
INSERT INTO login_throttle (ip, attempts, last_attempt)
VALUES (:ip, 0, :last_attempt)
")->execute(['ip' => $ip, 'last_attempt' => $now]);
} else {
$attempts++;
$this->db->prepare("
INSERT INTO login_throttle (ip, attempts, last_attempt)
VALUES (:ip, :attempts, :last_attempt)
")->execute([
'ip' => $ip,
'attempts' => $attempts,
'last_attempt' => $now
]);
}
}
return $response;
}
}

19
src/Models/Session.php Executable file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Models;
final readonly class Session
{
public function __construct(
public string $id,
public int $userId,
public string $authToken,
public ?string $ip,
public ?string $userAgent,
public int $createdAt,
public int $lastActivityAt,
public ?int $revokedAt,
)
{
}
}

61
src/Models/User.php Executable file
View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Models;
final class User
{
public int $id {
get {
return $this->id;
}
set (int $id) {
$this->id = $id;
}
}
public string $username {
get {
return $this->username;
}
set (string $username) {
$this->username = $username;
}
}
public string $passwordHash {
get {
return $this->passwordHash;
}
set (string $passwordHash) {
$this->passwordHash = $passwordHash;
}
}
public int $createdAt {
get {
return $this->createdAt;
}
set (int $createdAt) {
$this->createdAt = $createdAt;
}
}
public function __construct(
int $id,
string $username,
string $passwordHash,
int $createdAt,
)
{
$this->id = $id;
$this->username = $username;
$this->passwordHash = $passwordHash;
$this->createdAt = $createdAt;
}
public function verifyPassword(string $password): bool
{
return password_verify($password, $this->passwordHash);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Repositories\Exceptions;
use RuntimeException;
class RepositoryException extends RuntimeException
{
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Repositories;
use Din9xtrCloud\Models\Session;
use Din9xtrCloud\Repositories\Exceptions\RepositoryException;
use PDO;
use PDOException;
use Psr\Log\LoggerInterface;
use Random\RandomException;
use Throwable;
final readonly class SessionRepository
{
public function __construct(
private PDO $db,
private LoggerInterface $logger
)
{
}
public function create(
int $userId,
?string $ip,
?string $userAgent
): Session
{
try {
$id = bin2hex(random_bytes(16));
$token = bin2hex(random_bytes(32));
$now = time();
$stmt = $this->db->prepare("
INSERT INTO sessions (
id, user_id, auth_token, ip, user_agent,
created_at, last_activity_at
) VALUES (
:id, :user_id, :auth_token, :ip, :user_agent,
:created_at, :last_activity_at
)
");
$stmt->execute([
'id' => $id,
'user_id' => $userId,
'auth_token' => $token,
'ip' => $ip,
'user_agent' => $userAgent,
'created_at' => $now,
'last_activity_at' => $now,
]);
return new Session(
$id,
$userId,
$token,
$ip,
$userAgent,
$now,
$now,
null
);
} catch (PDOException $e) {
$this->logger->critical('Failed to create session', [
'user_id' => $userId,
'ip' => $ip,
'exception' => $e,
]);
throw new RepositoryException(
'Failed to create session',
previous: $e
);
} catch (RandomException $e) {
$this->logger->critical('Failed to create session', [
'user_id' => $userId,
'ip' => $ip,
'exception' => $e,
]);
throw new RepositoryException(
'Failed to revoke session',
previous: $e
);
}
}
public function revokeByToken(string $token): void
{
try {
$stmt = $this->db->prepare("
UPDATE sessions
SET revoked_at = :revoked_at
WHERE auth_token = :token AND revoked_at IS NULL
");
$stmt->execute([
'token' => $token,
'revoked_at' => time(),
]);
} catch (PDOException $e) {
$this->logger->error('Failed to revoke session', [
'token' => $token,
'exception' => $e,
]);
throw new RepositoryException(
'Failed to revoke session',
previous: $e
);
}
}
public function findActiveByToken(string $token): ?Session
{
try {
$stmt = $this->db->prepare("
SELECT *
FROM sessions
WHERE auth_token = :token
AND revoked_at IS NULL
LIMIT 1
");
$stmt->execute(['token' => $token]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
return null;
}
return new Session(
$row['id'],
(int)$row['user_id'],
$row['auth_token'],
$row['ip'],
$row['user_agent'],
(int)$row['created_at'],
(int)$row['last_activity_at'],
$row['revoked_at'] !== null ? (int)$row['revoked_at'] : null
);
} catch (Throwable $e) {
$this->logger->error('Failed to fetch session by token', [
'exception' => $e,
]);
throw new RepositoryException(
'Failed to fetch session',
previous: $e
);
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Repositories;
use Din9xtrCloud\Models\User;
use Din9xtrCloud\Repositories\Exceptions\RepositoryException;
use PDO;
use PDOException;
use Psr\Log\LoggerInterface;
final readonly class UserRepository
{
public function __construct(
private PDO $db,
private LoggerInterface $logger
)
{
}
/**
* @param array<string, mixed> $criteria
* @return User|null
*/
public function findBy(array $criteria): ?User
{
try {
if (empty($criteria)) {
throw new \InvalidArgumentException('Criteria cannot be empty');
}
$whereParts = [];
$params = [];
foreach ($criteria as $field => $value) {
$whereParts[] = "$field = :$field";
$params[$field] = $value;
}
$whereClause = implode(' AND ', $whereParts);
$sql = "SELECT * FROM users WHERE $whereClause LIMIT 1";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
return null;
}
return new User(
(int)$row['id'],
$row['username'],
$row['password'],
(int)$row['created_at']
);
} catch (PDOException $e) {
$this->logger->error('Failed to fetch user by criteria', [
'criteria' => $criteria,
'exception' => $e,
]);
throw new RepositoryException(
'Failed to fetch user',
previous: $e
);
}
}
public function findByUsername(string $username): ?User
{
return $this->findBy(['username' => $username]);
}
public function findById(int $id): ?User
{
return $this->findBy(['id' => $id]);
}
}

238
src/Router.php Executable file
View File

@@ -0,0 +1,238 @@
<?php
/** @noinspection PhpMultipleClassDeclarationsInspection */
declare(strict_types=1);
namespace Din9xtrCloud;
use Din9xtrCloud\ViewModels\Errors\ErrorViewModel;
use FastRoute\Dispatcher;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Log\LoggerInterface;
use Relay\Relay;
use RuntimeException;
use Throwable;
use function FastRoute\simpleDispatcher;
final class Router
{
private Dispatcher $dispatcher;
/**
* @var array<string, list<string|callable|MiddlewareInterface>>
*/
private array $routeMiddlewares = [];
public function __construct(
callable $routes,
private readonly ContainerInterface $container
)
{
$this->dispatcher = simpleDispatcher($routes);
}
public function middlewareFor(string $path, string|callable|MiddlewareInterface ...$middlewares): self
{
/** @var list<string|callable|MiddlewareInterface> $middlewares */
$this->routeMiddlewares[$path] = $middlewares;
return $this;
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function dispatch(ServerRequestInterface $request): ResponseInterface
{
try {
$routeInfo = $this->dispatcher->dispatch(
$request->getMethod(),
rawurldecode($request->getUri()->getPath())
);
return match ($routeInfo[0]) {
Dispatcher::NOT_FOUND => $this->createErrorResponse(404, '404 Not Found'),
Dispatcher::METHOD_NOT_ALLOWED => $this->createErrorResponse(405, '405 Method Not Allowed'),
Dispatcher::FOUND => $this->handleFoundRoute($request, $routeInfo[1], $routeInfo[2]),
};
} catch (Throwable $e) {
if ($e instanceof NotFoundExceptionInterface) {
throw new $e;
}
if ($e instanceof ContainerExceptionInterface) {
throw new $e;
}
return $this->handleException($e);
}
}
/**
* @param string|callable|array{0: class-string, 1: string} $handler
* @param array<string, string> $routeParams
*/
private function handleFoundRoute(
ServerRequestInterface $request,
mixed $handler,
array $routeParams
): ResponseInterface
{
foreach ($routeParams as $key => $value) {
$request = $request->withAttribute($key, $value);
}
$middlewares = $this->getMiddlewaresFor($request);
$middlewares[] = fn(ServerRequestInterface $req, $next) => $this->ensureResponse($this->callHandler($req, $handler, $routeParams));
$resolver = fn($entry) => is_string($entry) ? $this->container->get($entry) : $entry;
$pipeline = new Relay($middlewares, $resolver);
return $pipeline->handle($request);
}
/**
* @return list<string|callable|MiddlewareInterface>
*/
private function getMiddlewaresFor(ServerRequestInterface $request): array
{
$path = $request->getUri()->getPath();
return $this->routeMiddlewares[$path] ?? [];
}
/**
* @param ServerRequestInterface $request
* @param mixed $handler
* @param array<string, string> $routeParams
* @return mixed
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws RuntimeException
*/
private function callHandler(ServerRequestInterface $request, mixed $handler, array $routeParams = []): mixed
{
if (is_callable($handler)) {
return $handler($request);
}
if (is_array($handler) && count($handler) === 2) {
[$controllerClass, $method] = $handler;
if (!is_string($controllerClass)) {
throw new RuntimeException('Controller must be class-string');
}
$controller = $this->container->get($controllerClass);
if (!method_exists($controller, $method)) {
throw new RuntimeException("Method $method not found in $controllerClass");
}
return $controller->$method($request, ...array_values($routeParams));
}
throw new RuntimeException('Invalid route handler');
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function ensureResponse(mixed $result): ResponseInterface
{
if ($result instanceof ResponseInterface) {
return $result;
}
if (is_string($result)) {
return $this->createHtmlResponse($result);
}
throw new RuntimeException('Handler must return string or ResponseInterface');
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function createHtmlResponse(string $html): ResponseInterface
{
$response = $this->psr17()->createResponse(200)
->withHeader('Content-Type', 'text/html; charset=utf-8');
$response->getBody()->write($html);
return $response;
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function handleException(Throwable $e): ResponseInterface
{
$this->container->get(LoggerInterface::class)->error('Unhandled exception', [
'exception' => $e,
]);
return $this->createErrorResponse(500, 'Internal Server Error');
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function createErrorResponse(
int $statusCode,
string $message,
): ResponseInterface
{
$errorMessages = [
404 => [
'title' => '404 - Page Not Found',
'message' => 'The page you are looking for might have been removed, or is temporarily unavailable.',
],
405 => [
'title' => '405 - Method Not Allowed',
'message' => 'The requested method is not allowed for this resource.',
],
500 => [
'title' => '500 - Internal Server Error',
'message' => 'Something went wrong on our server.',
]
];
$errorConfig = $errorMessages[$statusCode] ?? [
'title' => "$statusCode - Error",
'message' => $message,
];
$errorViewModel = new ErrorViewModel(
title: $errorConfig['title'],
errorCode: (string)$statusCode,
message: $errorConfig['message'],
);
$html = View::display($errorViewModel);
return $this->psr17()->createResponse($statusCode)
->withHeader('Content-Type', 'text/html; charset=utf-8')
->withBody($this->psr17()->createStream($html));
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function psr17(): Psr17Factory
{
return $this->container->get(Psr17Factory::class);
}
}

48
src/Services/LoginService.php Executable file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Services;
use Din9xtrCloud\Repositories\UserRepository;
use Din9xtrCloud\Repositories\SessionRepository;
final readonly class LoginService
{
public function __construct(
private UserRepository $users,
private SessionRepository $sessions
)
{
}
public function attemptLogin(
string $username,
string $password,
?string $ip = null,
?string $userAgent = null
): ?string
{
$user = $this->users->findByUsername($username);
if (!$user) {
return null;
}
if (!$user->verifyPassword($password)) {
return null;
}
$session = $this->sessions->create(
$user->id,
$ip,
$userAgent
);
return $session->authToken;
}
public function logout(string $token): void
{
$this->sessions->revokeByToken($token);
}
}

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

68
src/View.php Executable file
View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud;
use Din9xtrCloud\Contracts\ViewModel;
use Din9xtrCloud\ViewModels\BaseViewModel;
use Din9xtrCloud\ViewModels\LayoutViewModel;
use RuntimeException;
use Throwable;
final readonly class View
{
private string $basePath;
public function __construct(?string $basePath = null)
{
$this->basePath = $basePath ?? '/var/www/resources/views/';
}
public function render(ViewModel $viewModel): string
{
$html = $this->renderTemplate($viewModel);
if ($viewModel instanceof BaseViewModel && $viewModel->layout()) {
return $this->render(
new LayoutViewModel(
content: $html,
page: $viewModel
)
);
}
return $html;
}
private function renderTemplate(ViewModel $viewModel): string
{
$file = $this->basePath
. str_replace('.', '/', $viewModel->template())
. '.php';
if (!file_exists($file)) {
throw new RuntimeException("Template not found: $file");
}
ob_start();
try {
include $file;
} catch (Throwable $e) {
ob_end_clean();
throw new RuntimeException(
"Render template error $file: " . $e->getMessage(),
0,
$e
);
}
return (string)ob_get_clean();
}
public static function display(ViewModel $vm): string
{
static $instance;
return ($instance ??= new self())->render($vm);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\ViewModels;
use Din9xtrCloud\Contracts\ViewModel;
abstract readonly class BaseViewModel implements ViewModel
{
public function __construct(
public LayoutConfig $layoutConfig,
public string $title,
)
{
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'title' => $this->title,
'layoutConfig' => $this->layoutConfig,
...$this->data()
];
}
public function layout(): ?string
{
return $this->layoutConfig->layout;
}
/**
* @return array<string, mixed>
*/
protected function data(): array
{
return [];
}
abstract public function title(): string;
abstract public function template(): string;
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\ViewModels\Dashboard;
use Din9xtrCloud\ViewModels\BaseViewModel;
use Din9xtrCloud\ViewModels\LayoutConfig;
final readonly class DashboardViewModel extends BaseViewModel
{
/**
* @param string $title
* @param string $username
* @param array<string, mixed> $stats
* @param string|null $csrf
*/
public function __construct(
string $title,
public string $username,
public array $stats = [],
public ?string $csrf = null,
)
{
$layoutConfig = new LayoutConfig(
header: 'dashboard',
showFooter: true,
);
parent::__construct($layoutConfig, $title);
}
public function title(): string
{
return $this->title;
}
public function template(): string
{
return 'dashboard';
}
protected function data(): array
{
return [
'username' => $this->username,
'stats' => $this->stats,
'csrf' => $this->csrf,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\ViewModels\Errors;
use Din9xtrCloud\ViewModels\BaseViewModel;
use Din9xtrCloud\ViewModels\LayoutConfig;
final readonly class ErrorViewModel extends BaseViewModel
{
public function __construct(
string $title,
public string $errorCode,
public string $message,
)
{
$layoutConfig = new LayoutConfig(
header: null,
showFooter: false,
);
parent::__construct($layoutConfig, $title);
}
public function template(): string
{
return 'error';
}
protected function data(): array
{
return [
'errorCode' => $this->errorCode,
'message' => $this->message,
];
}
public function title(): string
{
return $this->title;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\ViewModels\Folder;
use Din9xtrCloud\ViewModels\BaseViewModel;
use Din9xtrCloud\ViewModels\LayoutConfig;
final readonly class FolderViewModel extends BaseViewModel
{
/**
* @param array<int, array{name: string, size: string, modified: string}> $files
*/
public function __construct(
string $title,
public array $files = [],
public ?string $csrf = null,
public ?string $totalSize = null,
public ?string $lastModified = null,
)
{
$layoutConfig = new LayoutConfig(
header: 'folder',
showFooter: true,
);
parent::__construct($layoutConfig, $title);
}
public function title(): string
{
return $this->title;
}
public function template(): string
{
return 'folder';
}
}

14
src/ViewModels/LayoutConfig.php Executable file
View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\ViewModels;
readonly class LayoutConfig
{
public function __construct(
public ?string $header = 'default',
public bool $showFooter = true,
public ?string $layout = 'layouts/app',
)
{
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\ViewModels;
use Din9xtrCloud\Contracts\ViewModel;
use RuntimeException;
final readonly class LayoutViewModel implements ViewModel
{
public function __construct(
public string $content,
public BaseViewModel $page,
)
{
}
public function template(): string
{
$layout = $this->page->layout();
if ($layout === null) {
throw new RuntimeException(
'LayoutViewModel requires page to have a layout, but layout() returned null'
);
}
return $layout;
}
public function layout(): ?string
{
return null;
}
public function toArray(): array
{
return [];
}
public function title(): ?string
{
return null;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Din9xtrCloud\ViewModels;
final readonly class LicenseViewModel extends BaseViewModel
{
/**
* @param string $title
*/
public function __construct(
string $title,
)
{
$layoutConfig = new LayoutConfig(
// header: null,
showFooter: true,
);
parent::__construct($layoutConfig, $title);
}
public function title(): string
{
return $this->title;
}
public function template(): string
{
return 'license';
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\ViewModels\Login;
use Din9xtrCloud\ViewModels\BaseViewModel;
use Din9xtrCloud\ViewModels\LayoutConfig;
final readonly class LoginViewModel extends BaseViewModel
{
public function __construct(
string $title,
public ?string $error = null,
public ?string $csrf = null,
)
{
$layoutConfig = new LayoutConfig(
header: null,
showFooter: true,
);
parent::__construct($layoutConfig, $title);
}
public function template(): string
{
return 'login';
}
protected function data(): array
{
return [
'error' => $this->error,
'csrf' => $this->csrf,
];
}
public function title(): string
{
return $this->title;
}
}