Initial commit: Cloud Control Panel
This commit is contained in:
86
src/App.php
Executable file
86
src/App.php
Executable 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
173
src/Container/Container.php
Executable 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
16
src/Container/Definition.php
Executable 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
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
11
src/Container/Exceptions/ContainerException.php
Executable file
11
src/Container/Exceptions/ContainerException.php
Executable 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
|
||||
{
|
||||
}
|
||||
11
src/Container/Exceptions/NotFoundException.php
Executable file
11
src/Container/Exceptions/NotFoundException.php
Executable 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
10
src/Container/Scope.php
Executable file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Din9xtrCloud\Container;
|
||||
|
||||
enum Scope
|
||||
{
|
||||
case Shared;
|
||||
case Request;
|
||||
case Factory;
|
||||
}
|
||||
18
src/Contracts/ViewModel.php
Executable file
18
src/Contracts/ViewModel.php
Executable 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;
|
||||
}
|
||||
97
src/Controllers/AuthController.php
Executable file
97
src/Controllers/AuthController.php
Executable 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'
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
86
src/Controllers/DashboardController.php
Executable file
86
src/Controllers/DashboardController.php
Executable 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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
15
src/Controllers/LicenseController.php
Normal file
15
src/Controllers/LicenseController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
344
src/Controllers/StorageController.php
Normal file
344
src/Controllers/StorageController.php
Normal 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)]
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
152
src/Controllers/StorageTusController.php
Normal file
152
src/Controllers/StorageTusController.php
Normal 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
23
src/Helpers/helpers.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
src/Middlewares/AuthMiddleware.php
Executable file
74
src/Middlewares/AuthMiddleware.php
Executable 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);
|
||||
}
|
||||
}
|
||||
73
src/Middlewares/CsrfMiddleware.php
Executable file
73
src/Middlewares/CsrfMiddleware.php
Executable 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'];
|
||||
}
|
||||
}
|
||||
66
src/Middlewares/ThrottleMiddleware.php
Executable file
66
src/Middlewares/ThrottleMiddleware.php
Executable 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
19
src/Models/Session.php
Executable 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
61
src/Models/User.php
Executable 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);
|
||||
}
|
||||
}
|
||||
10
src/Repositories/Exceptions/RepositoryException.php
Executable file
10
src/Repositories/Exceptions/RepositoryException.php
Executable file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Din9xtrCloud\Repositories\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class RepositoryException extends RuntimeException
|
||||
{
|
||||
}
|
||||
153
src/Repositories/SessionRepository.php
Executable file
153
src/Repositories/SessionRepository.php
Executable 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/Repositories/UserRepository.php
Executable file
80
src/Repositories/UserRepository.php
Executable 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
238
src/Router.php
Executable 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
48
src/Services/LoginService.php
Executable 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);
|
||||
}
|
||||
}
|
||||
356
src/Storage/Drivers/LocalStorageDriver.php
Normal file
356
src/Storage/Drivers/LocalStorageDriver.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/Storage/Drivers/StorageDriverInterface.php
Normal file
82
src/Storage/Drivers/StorageDriverInterface.php
Normal 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;
|
||||
}
|
||||
27
src/Storage/StorageGuard.php
Normal file
27
src/Storage/StorageGuard.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
332
src/Storage/StorageService.php
Normal file
332
src/Storage/StorageService.php
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
18
src/Storage/StorageStats.php
Normal file
18
src/Storage/StorageStats.php
Normal 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 = [],
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
40
src/Storage/UserStorageInitializer.php
Normal file
40
src/Storage/UserStorageInitializer.php
Normal 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
68
src/View.php
Executable 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);
|
||||
}
|
||||
}
|
||||
45
src/ViewModels/BaseViewModel.php
Executable file
45
src/ViewModels/BaseViewModel.php
Executable 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;
|
||||
}
|
||||
50
src/ViewModels/Dashboard/DashboardViewModel.php
Executable file
50
src/ViewModels/Dashboard/DashboardViewModel.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
42
src/ViewModels/Errors/ErrorViewModel.php
Executable file
42
src/ViewModels/Errors/ErrorViewModel.php
Executable 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;
|
||||
}
|
||||
}
|
||||
39
src/ViewModels/Folder/FolderViewModel.php
Normal file
39
src/ViewModels/Folder/FolderViewModel.php
Normal 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
14
src/ViewModels/LayoutConfig.php
Executable 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',
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
45
src/ViewModels/LayoutViewModel.php
Normal file
45
src/ViewModels/LayoutViewModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/ViewModels/LicenseViewModel.php
Normal file
31
src/ViewModels/LicenseViewModel.php
Normal 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';
|
||||
}
|
||||
}
|
||||
41
src/ViewModels/Login/LoginViewModel.php
Executable file
41
src/ViewModels/Login/LoginViewModel.php
Executable 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user