Initial commit: Cloud Control Panel
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
STORAGE_PATH=storage
|
||||
STORAGE_USER_LIMIT_GB=70
|
||||
USER=admin
|
||||
PASSWORD=admin
|
||||
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
*.com
|
||||
*.class
|
||||
*.dll
|
||||
*.exe
|
||||
*.o
|
||||
*.so
|
||||
|
||||
node_modules/
|
||||
vendor/
|
||||
|
||||
/logs/*
|
||||
/logs/**/*
|
||||
*.log
|
||||
!logs/.gitkeep
|
||||
|
||||
/storage/*
|
||||
/storage/**/*
|
||||
!storage/.gitkeep
|
||||
|
||||
/db/database.sqlite
|
||||
/db/database.sqlite-journal
|
||||
/db/database.sqlite-wal
|
||||
/db/database.sqlite-shm
|
||||
/db/*.sqlite*
|
||||
|
||||
!/db/.gitkeep
|
||||
!/db/migration/
|
||||
!/db/migrate.php
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.cache/
|
||||
.phpunit.result.cache
|
||||
.phpstan/cache/
|
||||
.phpstan/resultCache.php
|
||||
.phpstan
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
/uploads/
|
||||
|
||||
.dockerignore
|
||||
docker-compose.override.yml
|
||||
|
||||
coverage/
|
||||
|
||||
composer.phar
|
||||
composer.lock
|
||||
*.cache
|
||||
.sass-cache/
|
||||
7
Dockerfile
Executable file
7
Dockerfile
Executable file
@@ -0,0 +1,7 @@
|
||||
FROM php:8.5-cli-alpine
|
||||
RUN apk add --no-cache libzip-dev zip \
|
||||
&& docker-php-ext-install zip
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
WORKDIR /var/www
|
||||
COPY . .
|
||||
CMD ["php", "-S", "0.0.0.0:8001", "-t", "public"]
|
||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Grudinin Andrew (din9xtr)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
25
Makefile
Executable file
25
Makefile
Executable file
@@ -0,0 +1,25 @@
|
||||
.PHONY: build up down install logs bash migrate
|
||||
|
||||
|
||||
build:
|
||||
docker compose build --no-cache
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
install:
|
||||
docker compose run --rm \
|
||||
-v $(PWD):/var/www \
|
||||
app composer install
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
bash:
|
||||
docker compose exec app sh
|
||||
|
||||
migrate:
|
||||
docker compose exec app php /var/www/db/migrate.php
|
||||
66
README.md
Normal file
66
README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Cloud control panel
|
||||
|
||||
A lightweight, self-hosted cloud management panel designed for simplicity and performance. Built with modern PHP and
|
||||
containerized for easy deployment, it provides an intuitive interface for managing your personal cloud storage with
|
||||
minimal resource overhead.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
1. Minimal footprint - Low memory and CPU usage
|
||||
|
||||
2. Docker-first - Easy deployment with containerization
|
||||
3. Modern stack - Built with PHP 8+ and clean architecture
|
||||
|
||||
4. File management - Upload, organize, and share files
|
||||
|
||||
5. Responsive UI - Pure CSS and vanilla JavaScript, no framework dependencies
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites:
|
||||
|
||||
Docker and Docker Compose, Make utility
|
||||
|
||||
Configure Environment Variables
|
||||
|
||||
```bash
|
||||
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Build and Deploy
|
||||
|
||||
```bash
|
||||
|
||||
make build
|
||||
make install
|
||||
make up
|
||||
make migrate
|
||||
```
|
||||
|
||||
## 🌐 Access
|
||||
|
||||
Web Interface: http://localhost:8001 (or any configured port)
|
||||
|
||||
### ⚙️ Additional Commands
|
||||
|
||||
```bash
|
||||
|
||||
make bash
|
||||
# in docker environment
|
||||
composer analyse
|
||||
composer test
|
||||
```
|
||||
|
||||
### 📄 Note: For production use, ensure to:
|
||||
|
||||
1. Change default credentials
|
||||
|
||||
2. Configure SSL/TLS
|
||||
|
||||
3. Set up regular backups
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
42
composer.json
Executable file
42
composer.json
Executable file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"require": {
|
||||
"php": "^8.5",
|
||||
"nikic/fast-route": "^1.3",
|
||||
"monolog/monolog": "^3",
|
||||
"nyholm/psr7": "^1.3.2",
|
||||
"nyholm/psr7-server": "^1.0",
|
||||
"psr/container": "^2.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"relay/relay": "^3.0",
|
||||
"ext-pdo": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-zip": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/extension-installer": "^1.4",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"friendsofphp/php-cs-fixer": "^3.92"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Din9xtrCloud\\": "src/"
|
||||
},
|
||||
"files": [
|
||||
"src/Helpers/helpers.php"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit",
|
||||
"analyse": "phpstan analyse src --level=8",
|
||||
"analyse-strict": "phpstan analyse src --level=max",
|
||||
"coverage": "phpunit --coverage-html coverage",
|
||||
"cs-check": "php-cs-fixer fix --dry-run --diff",
|
||||
"cs-fix": "php-cs-fixer fix"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"phpstan/extension-installer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
0
db/.gitkeep
Normal file
0
db/.gitkeep
Normal file
72
db/migrate.php
Executable file
72
db/migrate.php
Executable file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$username = $_ENV['USER'] ?? null;
|
||||
$password = $_ENV['PASSWORD'] ?? null;
|
||||
|
||||
if (!$username || !$password) {
|
||||
throw new RuntimeException('Environment variable "USER" or "PASSWORD" is missing');
|
||||
}
|
||||
|
||||
$databasePath = __DIR__ . '/database.sqlite';
|
||||
$migrationsPath = __DIR__ . '/migration';
|
||||
|
||||
if (!is_dir(__DIR__)) {
|
||||
mkdir(__DIR__, 0777, true);
|
||||
}
|
||||
|
||||
$db = new PDO('sqlite:' . $databasePath);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
migration TEXT NOT NULL UNIQUE,
|
||||
ran_at INTEGER NOT NULL
|
||||
);
|
||||
");
|
||||
|
||||
$migrations = glob($migrationsPath . '/*.php');
|
||||
sort($migrations);
|
||||
|
||||
foreach ($migrations as $file) {
|
||||
$name = basename($file);
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"SELECT COUNT(*) FROM migrations WHERE migration = :migration"
|
||||
);
|
||||
$stmt->execute(['migration' => $name]);
|
||||
|
||||
if ((int)$stmt->fetchColumn() > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "Running: $name\n";
|
||||
|
||||
/** @var callable $migration */
|
||||
$migration = require $file;
|
||||
|
||||
if (!is_callable($migration)) {
|
||||
throw new RuntimeException("Migration $name must return callable");
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$migration($db);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO migrations (migration, ran_at)
|
||||
VALUES (:migration, :ran_at)
|
||||
");
|
||||
$stmt->execute([
|
||||
'migration' => $name,
|
||||
'ran_at' => time(),
|
||||
]);
|
||||
|
||||
$db->commit();
|
||||
echo "✔ Migrated: $name\n";
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
echo "✖ Failed: $name\n";
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
37
db/migration/001_create_users.php
Executable file
37
db/migration/001_create_users.php
Executable file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return function (PDO $db): void {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
");
|
||||
$stmt = $db->prepare(
|
||||
"SELECT COUNT(*) FROM users WHERE username = :username"
|
||||
);
|
||||
$stmt->execute(['username' => getenv('USER')]);
|
||||
|
||||
if ((int)$stmt->fetchColumn() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$passwordHash = password_hash(
|
||||
getenv('PASSWORD'),
|
||||
PASSWORD_DEFAULT
|
||||
);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO users (username, password, created_at)
|
||||
VALUES (:username, :password, :created_at)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
'username' => $_ENV['USER'],
|
||||
'password' => $passwordHash,
|
||||
'created_at' => time(),
|
||||
]);
|
||||
};
|
||||
18
db/migration/002_create_sessions.php
Executable file
18
db/migration/002_create_sessions.php
Executable file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return function (PDO $db): void {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
auth_token TEXT NOT NULL UNIQUE,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_activity_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
");
|
||||
};
|
||||
13
db/migration/003_create_login_throttle.php
Executable file
13
db/migration/003_create_login_throttle.php
Executable file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return function (PDO $db): void {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS login_throttle (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 1,
|
||||
last_attempt INTEGER NOT NULL
|
||||
);
|
||||
");
|
||||
};
|
||||
14
docker-compose.yml
Executable file
14
docker-compose.yml
Executable file
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: cloud
|
||||
ports:
|
||||
- "8001:8001"
|
||||
volumes:
|
||||
- .:/var/www
|
||||
- ./db:/var/db
|
||||
- ./php/php.ini:/usr/local/etc/php/conf.d/uploads.ini
|
||||
env_file:
|
||||
- .env
|
||||
0
logs/.gitkeep
Normal file
0
logs/.gitkeep
Normal file
50
php/php.ini
Normal file
50
php/php.ini
Normal file
@@ -0,0 +1,50 @@
|
||||
; ---------------------------------------------------------------------
|
||||
; PHP Configuration for File Uploads (< 512M)
|
||||
; ---------------------------------------------------------------------
|
||||
|
||||
; Error Reporting (for development only!)
|
||||
display_errors = 0
|
||||
error_reporting = E_ALL
|
||||
log_errors = On
|
||||
error_log = /var/log/php_errors.log
|
||||
|
||||
; File Upload Settings
|
||||
upload_max_filesize = 512M
|
||||
post_max_size = 565M
|
||||
max_file_uploads = 10
|
||||
max_execution_time = 300
|
||||
max_input_time = 300
|
||||
memory_limit = 256M
|
||||
file_uploads = On
|
||||
fastcgi_request_buffering off;
|
||||
|
||||
; Session Settings
|
||||
session.gc_maxlifetime = 14400
|
||||
session.cookie_lifetime = 14400
|
||||
session.use_strict_mode = 1
|
||||
session.cookie_httponly = 1
|
||||
session.cookie_secure = 1
|
||||
session.cookie_samesite = Strict
|
||||
|
||||
; Security Settings
|
||||
expose_php = Off
|
||||
allow_url_include = Off
|
||||
allow_url_fopen = Off
|
||||
|
||||
; Character Encoding
|
||||
default_charset = UTF-8
|
||||
internal_encoding = UTF-8
|
||||
output_encoding = UTF-8
|
||||
|
||||
; Output Buffering
|
||||
output_buffering = 4096
|
||||
|
||||
; Realpath Cache
|
||||
realpath_cache_size = 4096K
|
||||
realpath_cache_ttl = 120
|
||||
|
||||
; OpCache (for production)
|
||||
; opcache.enable=1
|
||||
; opcache.memory_consumption=128
|
||||
; opcache.max_accelerated_files=10000
|
||||
; opcache.revalidate_freq=2
|
||||
78
phpstan.neon
Executable file
78
phpstan.neon
Executable file
@@ -0,0 +1,78 @@
|
||||
parameters:
|
||||
level: 8
|
||||
paths:
|
||||
- src/
|
||||
excludePaths:
|
||||
- tests/*
|
||||
- vendor/*
|
||||
fileExtensions:
|
||||
- php
|
||||
phpVersion: 80500
|
||||
treatPhpDocTypesAsCertain: true
|
||||
parallel:
|
||||
maximumNumberOfProcesses: 4
|
||||
processTimeout: 300.0
|
||||
jobSize: 20
|
||||
ignoreErrors:
|
||||
- '#Method .*::.*\(\) has no return typehint specified.#'
|
||||
- '#Property .* has no typehint specified.#'
|
||||
- '#Parameter .* has no typehint specified.#'
|
||||
- '#Access to an undefined property.*#'
|
||||
- '#Class .* not found.#'
|
||||
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
|
||||
universalObjectCratesClasses:
|
||||
- stdClass
|
||||
- SimpleXMLElement
|
||||
|
||||
bootstrapFiles:
|
||||
- vendor/autoload.php
|
||||
|
||||
scanFiles:
|
||||
- vendor/autoload.php
|
||||
|
||||
tmpDir: .phpstan/cache
|
||||
|
||||
checkExplicitMixedMissingReturn: false
|
||||
checkFunctionNameCase: false
|
||||
checkInternalClassCaseSensitivity: false
|
||||
reportMaybesInMethodSignatures: false
|
||||
reportMaybesInPropertyPhpDocTypes: false
|
||||
reportStaticMethodSignatures: false
|
||||
checkTooWideReturnTypesInProtectedAndPublicMethods: false
|
||||
checkUninitializedProperties: true
|
||||
checkDynamicProperties: false
|
||||
rememberPossiblyImpureFunctionValues: true
|
||||
checkBenevolentUnionTypes: false
|
||||
reportPossiblyNonexistentGeneralArrayOffset: false
|
||||
reportPossiblyNonexistentConstantArrayOffset: false
|
||||
reportAlwaysTrueInLastCondition: false
|
||||
reportWrongPhpDocTypeInVarTag: false
|
||||
reportAnyTypeWideningInVarTag: false
|
||||
checkMissingOverrideMethodAttribute: false
|
||||
checkStrictPrintfPlaceholderTypes: false
|
||||
checkMissingCallableSignature: false
|
||||
|
||||
exceptions:
|
||||
implicitThrows: false
|
||||
uncheckedExceptionRegexes:
|
||||
- '#^Exception$#'
|
||||
- '#^Error$#'
|
||||
- '#^RuntimeException$#'
|
||||
- '#^LogicException$#'
|
||||
checkedExceptionClasses: []
|
||||
|
||||
tips:
|
||||
treatPhpDocTypesAsCertain: false
|
||||
tipsOfTheDay: true
|
||||
|
||||
additionalConstructors: []
|
||||
|
||||
polluteScopeWithLoopInitialAssignments: true
|
||||
polluteScopeWithAlwaysIterableForeach: true
|
||||
polluteScopeWithBlock: true
|
||||
|
||||
inferPrivatePropertyTypeFromConstructor: false
|
||||
|
||||
includes:
|
||||
2304
public/assets/cloud.css
Executable file
2304
public/assets/cloud.css
Executable file
File diff suppressed because it is too large
Load Diff
144
public/index.php
Executable file
144
public/index.php
Executable file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use Din9xtrCloud\App;
|
||||
use Din9xtrCloud\Container\Container;
|
||||
use Din9xtrCloud\Controllers\AuthController;
|
||||
use Din9xtrCloud\Controllers\DashboardController;
|
||||
use Din9xtrCloud\Controllers\LicenseController;
|
||||
use Din9xtrCloud\Controllers\StorageController;
|
||||
use Din9xtrCloud\Controllers\StorageTusController;
|
||||
use Din9xtrCloud\Middlewares\AuthMiddleware;
|
||||
use Din9xtrCloud\Middlewares\CsrfMiddleware;
|
||||
use Din9xtrCloud\Router;
|
||||
use Din9xtrCloud\Storage\Drivers\LocalStorageDriver;
|
||||
use Din9xtrCloud\Storage\Drivers\StorageDriverInterface;
|
||||
use Din9xtrCloud\Storage\UserStorageInitializer;
|
||||
use Din9xtrCloud\ViewModels\BaseViewModel;
|
||||
use Din9xtrCloud\ViewModels\LayoutConfig;
|
||||
use FastRoute\RouteCollector;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Monolog\Level;
|
||||
use Nyholm\Psr7Server\ServerRequestCreator;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PHP runtime
|
||||
// ---------------------------------------------------------------------
|
||||
error_reporting(E_ALL);
|
||||
|
||||
session_start();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ENV
|
||||
// ---------------------------------------------------------------------
|
||||
$storageBasePath = dirname(__DIR__) . '/' . ($_ENV['STORAGE_PATH'] ?? 'storage');
|
||||
$userLimitBytes = (int)($_ENV['STORAGE_USER_LIMIT_GB'] ?? 70) * 1024 * 1024 * 1024;
|
||||
// ---------------------------------------------------------------------
|
||||
// Container
|
||||
// ---------------------------------------------------------------------
|
||||
$container = new Container();
|
||||
|
||||
$logPath = dirname(__DIR__) . '/logs/cloud.log';
|
||||
if (!is_dir(dirname($logPath))) mkdir(dirname($logPath), 0755, true);
|
||||
|
||||
$container->singleton(StorageDriverInterface::class, function () use ($storageBasePath, $userLimitBytes) {
|
||||
return new LocalStorageDriver(
|
||||
basePath: $storageBasePath,
|
||||
defaultLimitBytes: $userLimitBytes
|
||||
);
|
||||
});
|
||||
$container->singleton(UserStorageInitializer::class, function () use ($storageBasePath) {
|
||||
return new UserStorageInitializer($storageBasePath);
|
||||
});
|
||||
$container->singleton(LoggerInterface::class, function () use ($logPath) {
|
||||
$logger = new Logger('cloud');
|
||||
$logger->pushHandler(new StreamHandler($logPath, Level::Debug));
|
||||
$logger->pushProcessor(new PsrLogMessageProcessor());
|
||||
return $logger;
|
||||
});
|
||||
$container->singleton(PDO::class, function () {
|
||||
return new PDO(
|
||||
'sqlite:/var/db/database.sqlite',
|
||||
null,
|
||||
null,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
});
|
||||
$container->singleton(Psr17Factory::class, fn() => new Psr17Factory());
|
||||
|
||||
$container->request(
|
||||
ServerRequestInterface::class,
|
||||
fn(Container $c) => new ServerRequestCreator(
|
||||
$c->get(Psr17Factory::class),
|
||||
$c->get(Psr17Factory::class),
|
||||
$c->get(Psr17Factory::class),
|
||||
$c->get(Psr17Factory::class),
|
||||
)->fromGlobals()
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------
|
||||
$routes = static function (RouteCollector $r): void {
|
||||
|
||||
$r->get('/', [DashboardController::class, 'index']);
|
||||
$r->get('/license', [LicenseController::class, 'license']);
|
||||
|
||||
$r->get('/login', [AuthController::class, 'loginForm']);
|
||||
$r->post('/login', [AuthController::class, 'loginSubmit']);
|
||||
$r->post('/logout', [AuthController::class, 'logout']);
|
||||
|
||||
$r->post('/storage/folders', [StorageController::class, 'createFolder']);
|
||||
$r->post('/storage/files', [StorageController::class, 'uploadFile']);
|
||||
|
||||
$r->get('/folders/{folder}', [StorageController::class, 'showFolder']);
|
||||
|
||||
$r->post('/storage/folders/{folder}/delete', [StorageController::class, 'deleteFolder']);
|
||||
|
||||
$r->addRoute(['POST', 'OPTIONS'], '/storage/tus', [
|
||||
StorageTusController::class,
|
||||
'handle',
|
||||
]);
|
||||
$r->patch('/storage/tus/{id:[a-f0-9]+}', [StorageTusController::class, 'patch']);
|
||||
$r->head('/storage/tus/{id:[a-f0-9]+}', [StorageTusController::class, 'head']);
|
||||
|
||||
$r->get('/storage/files/download', [StorageController::class, 'downloadFile']);
|
||||
$r->post('/storage/files/delete', [StorageController::class, 'deleteFile']);
|
||||
|
||||
$r->get('/storage/files/download/multiple', [StorageController::class, 'downloadMultiple']);
|
||||
$r->post('/storage/files/delete/multiple', [StorageController::class, 'deleteMultiple']);
|
||||
};
|
||||
|
||||
|
||||
// route,middlewares
|
||||
$router = new Router($routes, $container);
|
||||
//$router->middlewareFor('/', AuthMiddleware::class);
|
||||
//$router->middlewareFor('/login', AuthMiddleware::class);
|
||||
|
||||
|
||||
$app = new App($container);
|
||||
|
||||
//global,middlewares
|
||||
$app->middleware(
|
||||
CsrfMiddleware::class,
|
||||
AuthMiddleware::class
|
||||
);
|
||||
$app->router($router);
|
||||
|
||||
try {
|
||||
$container->beginRequest();
|
||||
$app->dispatch();
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Internal Server Error';
|
||||
}
|
||||
60
public/js/dashboard.js
Normal file
60
public/js/dashboard.js
Normal file
@@ -0,0 +1,60 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const {modalManager, FileUploader, showNotification} = window.sharedUtils;
|
||||
|
||||
/* =======================
|
||||
* ELEMENTS
|
||||
* ======================= */
|
||||
const folderModal = document.getElementById('create-folder-modal');
|
||||
const uploadModal = document.getElementById('upload-file-modal');
|
||||
|
||||
const folderOpenBtn = document.getElementById('create-folder-btn');
|
||||
const uploadOpenBtn = document.getElementById('upload-file-btn');
|
||||
|
||||
const folderCloseBtn = document.getElementById('cancel-create-folder');
|
||||
const uploadCloseBtn = document.getElementById('cancel-upload-file');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (folderModal) folderModal.style.display = 'none';
|
||||
if (uploadModal) uploadModal.style.display = 'none';
|
||||
|
||||
if (uploadModal) {
|
||||
new FileUploader({
|
||||
formSelector: '#upload-file-modal form',
|
||||
fileInputSelector: '#upload-file-modal input[type="file"]',
|
||||
folderSelectSelector: '#upload-file-modal select[name="folder"]',
|
||||
progressFillSelector: '#upload-file-modal .progress-fill',
|
||||
progressTextSelector: '#upload-file-modal .progress-text',
|
||||
uploadProgressSelector: '#upload-file-modal .upload-progress',
|
||||
submitBtnSelector: '#upload-file-modal #submit-upload',
|
||||
cancelBtnSelector: '#upload-file-modal #cancel-upload-file'
|
||||
});
|
||||
}
|
||||
|
||||
if (folderOpenBtn) {
|
||||
folderOpenBtn.addEventListener('click', () => modalManager.open(folderModal));
|
||||
}
|
||||
|
||||
if (uploadOpenBtn) {
|
||||
uploadOpenBtn.addEventListener('click', () => modalManager.open(uploadModal));
|
||||
}
|
||||
|
||||
if (folderCloseBtn) {
|
||||
folderCloseBtn.addEventListener('click', () => modalManager.close());
|
||||
}
|
||||
|
||||
if (uploadCloseBtn) {
|
||||
uploadCloseBtn.addEventListener('click', () => modalManager.close());
|
||||
}
|
||||
|
||||
[folderModal, uploadModal].forEach(modal => {
|
||||
if (modal) {
|
||||
modal.addEventListener('click', e => {
|
||||
if (e.target === modal) modalManager.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
229
public/js/folder.js
Normal file
229
public/js/folder.js
Normal file
@@ -0,0 +1,229 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const {modalManager, FileUploader, showNotification} = window.sharedUtils;
|
||||
|
||||
/* =======================
|
||||
* SELECT/DELETE FILES LOGIC
|
||||
* ======================= */
|
||||
class FileSelectionManager {
|
||||
constructor() {
|
||||
this.selectAllCheckbox = document.getElementById('select-all-checkbox');
|
||||
this.fileCheckboxes = document.querySelectorAll('.file-select-checkbox');
|
||||
this.fileCards = document.querySelectorAll('.file-card');
|
||||
this.deleteMultipleForm = document.querySelector('.multiple-delete-form');
|
||||
this.downloadMultipleForm = document.querySelector('.multiple-download-form');
|
||||
this.deleteMultipleBtn = document.getElementById('delete-multiple-btn');
|
||||
this.downloadMultipleBtn = document.getElementById('download-multiple-btn');
|
||||
this.multipleFileNamesInput = document.getElementById('multiple-file-names');
|
||||
this.multipleDownloadNamesInput = document.getElementById('multiple-download-names');
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.selectAllCheckbox) {
|
||||
this.selectAllCheckbox.addEventListener('change', () => this.toggleSelectAll());
|
||||
}
|
||||
|
||||
this.fileCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', () => this.updateSelectionState());
|
||||
});
|
||||
|
||||
this.fileCards.forEach(card => {
|
||||
card.addEventListener('click', (e) => this.handleCardClick(e, card));
|
||||
});
|
||||
|
||||
if (this.deleteMultipleForm) {
|
||||
this.deleteMultipleForm.addEventListener('submit', (e) => this.handleMultipleDelete(e));
|
||||
}
|
||||
|
||||
if (this.downloadMultipleForm) {
|
||||
this.downloadMultipleForm.addEventListener('submit', (e) => this.handleMultipleDownload(e));
|
||||
}
|
||||
}
|
||||
|
||||
handleCardClick(e, card) {
|
||||
if (e.target.closest('.file-card-actions') ||
|
||||
e.target.closest('.file-action-btn') ||
|
||||
e.target.closest('.delete-form') ||
|
||||
e.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkbox = card.querySelector('.file-select-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
const isChecked = this.selectAllCheckbox.checked;
|
||||
this.fileCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = isChecked;
|
||||
checkbox.dispatchEvent(new Event('change'));
|
||||
});
|
||||
this.updateSelectionState();
|
||||
}
|
||||
|
||||
updateSelectionState() {
|
||||
const selectedFiles = Array.from(this.fileCheckboxes)
|
||||
.filter(cb => cb.checked)
|
||||
.map(cb => cb.value);
|
||||
|
||||
// flash selected
|
||||
this.fileCards.forEach(card => {
|
||||
const fileName = card.dataset.fileName;
|
||||
if (selectedFiles.includes(fileName)) {
|
||||
card.classList.add('selected');
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
|
||||
// show/hide action-buttons
|
||||
if (selectedFiles.length > 0) {
|
||||
this.deleteMultipleForm.style.display = 'block';
|
||||
this.downloadMultipleForm.style.display = 'inline-block';
|
||||
this.multipleFileNamesInput.value = JSON.stringify(selectedFiles);
|
||||
this.multipleDownloadNamesInput.value = JSON.stringify(selectedFiles);
|
||||
|
||||
this.deleteMultipleBtn.innerHTML = `
|
||||
<span class="btn-icon">🗑️</span>
|
||||
Delete Selected (${selectedFiles.length})
|
||||
`;
|
||||
this.downloadMultipleBtn.innerHTML = `
|
||||
<span class="btn-icon">⬇️</span>
|
||||
Download Selected (${selectedFiles.length})
|
||||
`;
|
||||
} else {
|
||||
this.deleteMultipleForm.style.display = 'none';
|
||||
this.downloadMultipleForm.style.display = 'none';
|
||||
}
|
||||
|
||||
// refresh select all
|
||||
if (this.selectAllCheckbox) {
|
||||
this.selectAllCheckbox.checked = selectedFiles.length === this.fileCheckboxes.length && this.fileCheckboxes.length > 0;
|
||||
this.selectAllCheckbox.indeterminate = selectedFiles.length > 0 && selectedFiles.length < this.fileCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
handleMultipleDelete(e) {
|
||||
if (!confirm('Are you sure you want to delete selected files?')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
handleMultipleDownload(e) {
|
||||
console.log('Downloading selected files...');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* FILE PREVIEW
|
||||
* ======================= */
|
||||
class FilePreviewManager {
|
||||
constructor(fileInput, previewContainer) {
|
||||
this.fileInput = fileInput;
|
||||
this.previewContainer = previewContainer;
|
||||
this.img = previewContainer.querySelector('img');
|
||||
this.info = previewContainer.querySelector('.file-info');
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.fileInput.addEventListener('change', () => this.updatePreview());
|
||||
}
|
||||
|
||||
updatePreview() {
|
||||
const file = this.fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
this.previewContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this.previewContainer.style.display = 'block';
|
||||
this.info.innerHTML = `
|
||||
<div><strong>Name:</strong> ${file.name}</div>
|
||||
<div><strong>Size:</strong> ${formatBytes(file.size)}</div>
|
||||
<div><strong>Type:</strong> ${file.type || 'Unknown'}</div>
|
||||
`;
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.img.src = e.target.result;
|
||||
this.img.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
this.img.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.previewContainer.style.display = 'none';
|
||||
this.img.src = '';
|
||||
this.img.style.display = 'none';
|
||||
this.info.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* Load init
|
||||
* ======================= */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const uploadModal = document.getElementById('upload-file-modal-folder');
|
||||
if (uploadModal) {
|
||||
uploadModal.style.display = 'none';
|
||||
|
||||
new FileUploader({
|
||||
formSelector: '#upload-form-folder',
|
||||
fileInputSelector: '#file-input-folder',
|
||||
progressFillSelector: '#upload-file-modal-folder .progress-fill',
|
||||
progressTextSelector: '#upload-file-modal-folder .progress-text',
|
||||
uploadProgressSelector: '#upload-file-modal-folder .upload-progress',
|
||||
submitBtnSelector: '#upload-file-modal-folder #submit-upload-folder',
|
||||
cancelBtnSelector: '#upload-file-modal-folder #cancel-upload-file-folder',
|
||||
onSuccess: () => {
|
||||
showNotification('File uploaded successfully', 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
});
|
||||
|
||||
const fileInput = document.getElementById('file-input-folder');
|
||||
const previewContainer = uploadModal.querySelector('.file-preview');
|
||||
if (fileInput && previewContainer) {
|
||||
new FilePreviewManager(fileInput, previewContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.querySelector('.file-select-checkbox')) {
|
||||
new FileSelectionManager();
|
||||
}
|
||||
|
||||
const uploadBtn = document.getElementById('upload-file-folder');
|
||||
const uploadFirstBtn = document.getElementById('upload-first-file');
|
||||
const cancelUploadBtn = document.getElementById('cancel-upload-file-folder');
|
||||
|
||||
const openUploadModal = () => modalManager.open(uploadModal);
|
||||
const closeUploadModal = () => modalManager.close();
|
||||
|
||||
if (uploadBtn) uploadBtn.addEventListener('click', openUploadModal);
|
||||
if (uploadFirstBtn) uploadFirstBtn.addEventListener('click', openUploadModal);
|
||||
if (cancelUploadBtn) cancelUploadBtn.addEventListener('click', closeUploadModal);
|
||||
|
||||
if (uploadModal) {
|
||||
uploadModal.addEventListener('click', e => {
|
||||
if (e.target === uploadModal) closeUploadModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
363
public/js/shared.js
Normal file
363
public/js/shared.js
Normal file
@@ -0,0 +1,363 @@
|
||||
// /js/shared.js
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* =======================
|
||||
* CONFIG
|
||||
* ======================= */
|
||||
const SMALL_FILE_LIMIT = 256 * 1024 * 1024; // 256MB
|
||||
const TUS_ENDPOINT = '/storage/tus';
|
||||
|
||||
/* =======================
|
||||
* STATE
|
||||
* ======================= */
|
||||
let currentUploadType = '';
|
||||
|
||||
/* =======================
|
||||
* UTILS
|
||||
* ======================= */
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 Bytes';
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function getErrorMessage(code) {
|
||||
const errors = {
|
||||
no_file: 'No file selected',
|
||||
upload_failed: 'Upload failed',
|
||||
storage_limit: 'Storage limit exceeded',
|
||||
invalid_folder: 'Invalid folder selected'
|
||||
};
|
||||
return errors[code] || 'Unknown error';
|
||||
}
|
||||
|
||||
function getSuccessMessage(code) {
|
||||
const success = {
|
||||
file_uploaded: 'File uploaded successfully',
|
||||
folder_created: 'Folder created successfully',
|
||||
files_deleted: 'Files deleted successfully'
|
||||
};
|
||||
return success[code] || 'Success';
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* NOTIFICATIONS
|
||||
* ======================= */
|
||||
window.showNotification = function (message, type = 'info') {
|
||||
const body = document.body;
|
||||
const notification = document.createElement('div');
|
||||
|
||||
const oldNotifications = document.querySelectorAll('.global-notification');
|
||||
oldNotifications.forEach(n => n.remove());
|
||||
|
||||
notification.className = 'global-notification';
|
||||
notification.textContent = message;
|
||||
|
||||
const bgColor = type === 'error' ? '#e53e3e' :
|
||||
type === 'success' ? '#38a169' :
|
||||
type === 'warning' ? '#d69e2e' : '#3182ce';
|
||||
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 16px;
|
||||
background: ${bgColor};
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideOut 0.3s ease-in';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
|
||||
if (!document.querySelector('#notification-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'notification-styles';
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
};
|
||||
|
||||
/* =======================
|
||||
* MODAL MANAGEMENT
|
||||
* ======================= */
|
||||
window.modalManager = {
|
||||
currentModal: null,
|
||||
|
||||
open(modal) {
|
||||
this.closeAll();
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
this.currentModal = modal;
|
||||
|
||||
const escHandler = (e) => {
|
||||
if (e.key === 'Escape') this.close();
|
||||
};
|
||||
modal._escHandler = escHandler;
|
||||
document.addEventListener('keydown', escHandler);
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.currentModal) {
|
||||
document.removeEventListener('keydown', this.currentModal._escHandler);
|
||||
this.currentModal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
this.currentModal = null;
|
||||
}
|
||||
},
|
||||
|
||||
closeAll() {
|
||||
document.querySelectorAll('.modal-overlay').forEach(modal => {
|
||||
modal.style.display = 'none';
|
||||
if (modal._escHandler) {
|
||||
document.removeEventListener('keydown', modal._escHandler);
|
||||
}
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
this.currentModal = null;
|
||||
}
|
||||
};
|
||||
|
||||
/* =======================
|
||||
* FILE UPLOAD
|
||||
* ======================= */
|
||||
class FileUploader {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
formSelector: '',
|
||||
fileInputSelector: '',
|
||||
folderSelectSelector: null,
|
||||
progressFillSelector: '.progress-fill',
|
||||
progressTextSelector: '.progress-text',
|
||||
uploadProgressSelector: '.upload-progress',
|
||||
submitBtnSelector: '',
|
||||
cancelBtnSelector: '',
|
||||
onSuccess: null,
|
||||
onError: null,
|
||||
...options
|
||||
};
|
||||
|
||||
this.form = document.querySelector(this.options.formSelector);
|
||||
this.fileInput = document.querySelector(this.options.fileInputSelector);
|
||||
this.folderSelect = this.options.folderSelectSelector ?
|
||||
document.querySelector(this.options.folderSelectSelector) : null;
|
||||
this.progressFill = document.querySelector(this.options.progressFillSelector);
|
||||
this.progressText = document.querySelector(this.options.progressTextSelector);
|
||||
this.uploadProgress = document.querySelector(this.options.uploadProgressSelector);
|
||||
this.submitBtn = document.querySelector(this.options.submitBtnSelector);
|
||||
this.cancelBtn = document.querySelector(this.options.cancelBtnSelector);
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.form) {
|
||||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
}
|
||||
|
||||
if (this.fileInput) {
|
||||
this.fileInput.addEventListener('change', () => this.handleFileSelect());
|
||||
}
|
||||
|
||||
if (this.cancelBtn) {
|
||||
this.cancelBtn.addEventListener('click', () => this.reset());
|
||||
}
|
||||
}
|
||||
|
||||
handleFileSelect() {
|
||||
const file = this.fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
console.log('File selected:', file.name, formatBytes(file.size));
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = this.fileInput.files[0];
|
||||
const folder = this.folderSelect ? this.folderSelect.value : this.form.querySelector('input[name="folder"]')?.value;
|
||||
|
||||
if (!file) {
|
||||
showNotification('Please select a file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.folderSelect && !folder) {
|
||||
showNotification('Please select a folder', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setUploadingState(true);
|
||||
|
||||
if (file.size <= SMALL_FILE_LIMIT) {
|
||||
await this.uploadSmallFile(file, folder);
|
||||
} else {
|
||||
this.uploadLargeFile(file, folder);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadSmallFile(file, folder) {
|
||||
try {
|
||||
this.updateProgress(0, 'Starting upload...');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (folder) formData.append('folder', folder);
|
||||
formData.append('_csrf', this.form.querySelector('input[name="_csrf"]')?.value || '');
|
||||
|
||||
let progress = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
if (progress < 90) {
|
||||
progress += 10;
|
||||
this.updateProgress(progress);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const response = await fetch('/storage/files', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
this.updateProgress(100, 'Upload complete!');
|
||||
this.onUploadSuccess();
|
||||
|
||||
} catch (error) {
|
||||
this.onUploadError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
uploadLargeFile(file, folder) {
|
||||
this.updateProgress(0, 'Starting large file upload...');
|
||||
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: TUS_ENDPOINT,
|
||||
chunkSize: 5 * 1024 * 1024,
|
||||
retryDelays: [0, 1000, 3000, 5000],
|
||||
metadata: {
|
||||
folder: folder || '',
|
||||
filename: file.name
|
||||
},
|
||||
withCredentials: true,
|
||||
|
||||
onProgress: (uploaded, total) => {
|
||||
const percent = Math.round((uploaded / total) * 100);
|
||||
this.updateProgress(percent, `${percent}%`);
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
this.updateProgress(100, 'Upload complete!');
|
||||
setTimeout(() => this.onUploadSuccess(), 500);
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
this.onUploadError(error.toString());
|
||||
}
|
||||
});
|
||||
|
||||
upload.start();
|
||||
}
|
||||
|
||||
updateProgress(percent, text = '') {
|
||||
if (this.progressFill) {
|
||||
this.progressFill.style.width = percent + '%';
|
||||
}
|
||||
if (this.progressText) {
|
||||
this.progressText.textContent = text || percent + '%';
|
||||
}
|
||||
}
|
||||
|
||||
onUploadSuccess() {
|
||||
showNotification('File uploaded successfully', 'success');
|
||||
this.setUploadingState(false);
|
||||
modalManager.close();
|
||||
this.reset();
|
||||
|
||||
if (this.options.onSuccess) {
|
||||
this.options.onSuccess();
|
||||
} else {
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
onUploadError(error) {
|
||||
showNotification(error, 'error');
|
||||
this.setUploadingState(false);
|
||||
this.reset();
|
||||
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadingState(uploading) {
|
||||
if (this.submitBtn) this.submitBtn.disabled = uploading;
|
||||
if (this.cancelBtn) this.cancelBtn.disabled = uploading;
|
||||
if (this.uploadProgress) {
|
||||
this.uploadProgress.style.display = uploading ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.form) this.form.reset();
|
||||
this.updateProgress(0);
|
||||
this.setUploadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* INIT URL PARAMS
|
||||
* ======================= */
|
||||
function initUrlParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('error')) {
|
||||
showNotification(getErrorMessage(params.get('error')), 'error');
|
||||
}
|
||||
if (params.get('success')) {
|
||||
showNotification(getSuccessMessage(params.get('success')), 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* EXPORTS
|
||||
* ======================= */
|
||||
window.sharedUtils = {
|
||||
formatBytes,
|
||||
getErrorMessage,
|
||||
getSuccessMessage,
|
||||
showNotification,
|
||||
modalManager,
|
||||
FileUploader,
|
||||
initUrlParams,
|
||||
SMALL_FILE_LIMIT,
|
||||
TUS_ENDPOINT
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initUrlParams);
|
||||
|
||||
})();
|
||||
4989
public/js/tus.js
Normal file
4989
public/js/tus.js
Normal file
File diff suppressed because one or more lines are too long
193
resources/views/dashboard.php
Executable file
193
resources/views/dashboard.php
Executable file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
/** @var Din9xtrCloud\ViewModels\Dashboard\DashboardViewModel $viewModel */
|
||||
?>
|
||||
<div class="dashboard-container">
|
||||
|
||||
|
||||
<!-- Основная статистика хранения -->
|
||||
<section class="storage-overview">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Storage Overview</h2>
|
||||
<div class="storage-summary">
|
||||
<span class="summary-text">Total: <strong><?= $viewModel->stats['storage']['total'] ?></strong></span>
|
||||
<span class="summary-text">Used: <strong><?= $viewModel->stats['storage']['used'] ?></strong></span>
|
||||
<span class="summary-text">Free: <strong><?= $viewModel->stats['storage']['free'] ?></strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Прогресс-бар общего использования -->
|
||||
<div class="main-progress-container">
|
||||
<div class="progress-header">
|
||||
<span class="progress-label">Overall Usage</span>
|
||||
<span class="progress-percent"><?= $viewModel->stats['storage']['percent'] ?>%</span>
|
||||
</div>
|
||||
<div class="main-progress-bar">
|
||||
<div class="main-progress-fill" style="width: <?= $viewModel->stats['storage']['percent'] ?>%"></div>
|
||||
</div>
|
||||
<div class="progress-details">
|
||||
<span class="detail-item">Used: <?= $viewModel->stats['storage']['used'] ?></span>
|
||||
<span class="detail-item">Available: <?= $viewModel->stats['storage']['free'] ?></span>
|
||||
<span class="detail-item">Total: <?= $viewModel->stats['storage']['total'] ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Детальная статистика по типам файлов -->
|
||||
<section class="stats-grid">
|
||||
<?php foreach ($viewModel->stats['storage']['folders'] as $folder): ?>
|
||||
<a href="/folders/<?= urlencode($folder['name']) ?>" class="stat-card">
|
||||
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<span>📁</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-content">
|
||||
<h3 class="stat-title"><?= htmlspecialchars($folder['name']) ?></h3>
|
||||
|
||||
<div class="stat-value"><?= $folder['size'] ?></div>
|
||||
|
||||
<div class="stat-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: <?= $folder['percent'] ?>%"></div>
|
||||
</div>
|
||||
<span class="progress-text"><?= $folder['percent'] ?>% of total</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Быстрые действия -->
|
||||
<section class="quick-actions">
|
||||
<h2 class="actions-title">Storage Management</h2>
|
||||
<div class="actions-grid">
|
||||
<button class="action-btn" id="upload-file-btn">
|
||||
<span class="action-icon">📤</span>
|
||||
<span class="action-text">Upload Files</span>
|
||||
</button>
|
||||
<button class="action-btn" id="create-folder-btn">
|
||||
<span class="action-icon">📁</span>
|
||||
<span class="action-text">Create Folder</span>
|
||||
</button>
|
||||
<!-- <button class="action-btn">-->
|
||||
<!-- <span class="action-icon">🧹</span>-->
|
||||
<!-- <span class="action-text">Clean Storage</span>-->
|
||||
<!-- </button>-->
|
||||
<!-- <button class="action-btn">-->
|
||||
<!-- <span class="action-icon">📊</span>-->
|
||||
<!-- <span class="action-text">Generate Report</span>-->
|
||||
<!-- </button>-->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Предупреждения и уведомления -->
|
||||
<!-- <section class="alerts-container">-->
|
||||
<!-- <div class="section-header">-->
|
||||
<!-- <h2 class="section-title">Storage Alerts</h2>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="alerts-list">-->
|
||||
<!-- <div class="alert-item warning">-->
|
||||
<!-- <div class="alert-icon">⚠️</div>-->
|
||||
<!-- <div class="alert-content">-->
|
||||
<!-- <h4>Storage nearing capacity</h4>-->
|
||||
<!-- <p>You've used 65% of your available storage. Consider upgrading your plan.</p>-->
|
||||
<!-- </div>-->
|
||||
<!-- <button class="alert-action">Review</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="alert-item info">-->
|
||||
<!-- <div class="alert-icon">ℹ️</div>-->
|
||||
<!-- <div class="alert-content">-->
|
||||
<!-- <h4>Backup scheduled</h4>-->
|
||||
<!-- <p>Next backup: Today at 2:00 AM</p>-->
|
||||
<!-- </div>-->
|
||||
<!-- <button class="alert-action">Settings</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- </section>-->
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Модальное окно - скрыто по умолчанию -->
|
||||
<div class="modal-overlay" id="create-folder-modal">
|
||||
<div class="modal">
|
||||
<h3>Create new folder</h3>
|
||||
|
||||
<form method="POST" action="/storage/folders">
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($viewModel->csrf) ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Folder name</label>
|
||||
<label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
pattern="[a-zA-Z\u0400-\u04FF0-9_\- ]+"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancel-create-folder">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="upload-file-modal">
|
||||
<div class="modal">
|
||||
<h3>Upload File</h3>
|
||||
|
||||
<form method="POST" action="/storage/files" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($viewModel->csrf) ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Select folder</label>
|
||||
<label>
|
||||
<select name="folder" required class="folder-select">
|
||||
<option value="" disabled selected>Choose folder...</option>
|
||||
<?php foreach ($viewModel->stats['storage']['folders'] as $folder): ?>
|
||||
<option value="<?= htmlspecialchars($folder['name']) ?>">
|
||||
<?= htmlspecialchars($folder['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Choose file</label>
|
||||
<input type="file" name="file" required accept="*/*" class="file-input">
|
||||
<div class="file-preview" style="display:none; margin-top:10px;">
|
||||
<img src="" alt="Preview" style="max-width:100px; max-height:100px; display:none;">
|
||||
<div class="file-info"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="upload-progress" style="display:none;">
|
||||
<div class="progress-bar" style="height:6px; background:#e2e8f0; border-radius:3px;">
|
||||
<div class="progress-fill"
|
||||
style="height:100%; background:#667eea; width:0; border-radius:3px;"></div>
|
||||
</div>
|
||||
<div class="progress-text" style="font-size:0.9rem; color:#718096; margin-top:5px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancel-upload-file">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="submit-upload">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/tus.js"></script>
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/dashboard.js"></script>
|
||||
21
resources/views/error.php
Executable file
21
resources/views/error.php
Executable file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/** @var Din9xtrCloud\ViewModels\Errors\ErrorViewModel $viewModel */
|
||||
?>
|
||||
|
||||
<div class="error-container">
|
||||
<div class="error-code"><?= htmlspecialchars($viewModel->errorCode) ?></div>
|
||||
|
||||
<p class="error-message">
|
||||
<?= nl2br(htmlspecialchars($viewModel->message)) ?>
|
||||
</p>
|
||||
|
||||
|
||||
<div class="action-buttons">
|
||||
<a href="javascript:history.back()" class="btn btn-secondary">
|
||||
Go Back
|
||||
</a>
|
||||
<a href="/" class="btn btn-primary">
|
||||
Go to Homepage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
163
resources/views/folder.php
Normal file
163
resources/views/folder.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/** @var Din9xtrCloud\ViewModels\Folder\FolderViewModel $viewModel */
|
||||
?>
|
||||
<div class="folder-container">
|
||||
|
||||
<!-- Действия с файлами -->
|
||||
<section class="file-actions-section">
|
||||
<h2 class="section-title">Files</h2>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" id="upload-file-folder">
|
||||
<span class="btn-icon">📤</span>Upload File
|
||||
</button>
|
||||
<form method="GET" action="/storage/files/download/multiple" class="multiple-download-form"
|
||||
style="display: none;">
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($viewModel->csrf) ?>">
|
||||
<input type="hidden" name="folder" value="<?= htmlspecialchars($viewModel->title) ?>">
|
||||
<input type="hidden" name="file_names" id="multiple-download-names" value="">
|
||||
<button type="submit" class="btn btn-success" id="download-multiple-btn">
|
||||
<span class="btn-icon">⬇️</span>Download Selected
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/storage/files/delete/multiple" class="multiple-delete-form"
|
||||
style="display: none;">
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($viewModel->csrf) ?>">
|
||||
<input type="hidden" name="folder" value="<?= htmlspecialchars($viewModel->title) ?>">
|
||||
<input type="hidden" name="file_names" id="multiple-file-names" value="">
|
||||
<button type="submit" class="btn btn-danger" id="delete-multiple-btn">
|
||||
<span class="btn-icon">🗑️</span>Delete Selected
|
||||
</button>
|
||||
</form>
|
||||
<div class="selection-controls">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="select-all-checkbox">
|
||||
<span class="checkmark"></span>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Список файлов -->
|
||||
<section class="files-section">
|
||||
<?php if (empty($viewModel->files)): ?>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h3>No files in this folder</h3>
|
||||
<p>Upload your first file to get started</p>
|
||||
<button class="btn btn-primary" id="upload-first-file">
|
||||
<span class="btn-icon">📤</span>Upload File
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="files-grid">
|
||||
<?php foreach ($viewModel->files as $file): ?>
|
||||
<div class="file-card" data-file-name="<?= htmlspecialchars($file['name']) ?>">
|
||||
<div class="file-card-header">
|
||||
<label class="file-checkbox">
|
||||
<input type="checkbox" class="file-select-checkbox"
|
||||
value="<?= htmlspecialchars($file['name']) ?>">
|
||||
</label>
|
||||
<div class="file-icon">
|
||||
<?php
|
||||
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||
$icon = match (strtolower($extension)) {
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg' => '🖼️',
|
||||
'pdf' => '📕',
|
||||
'doc', 'docx' => '📄',
|
||||
'xls', 'xlsx' => '📊',
|
||||
'zip', 'rar', '7z', 'tar', 'gz' => '📦',
|
||||
'mp3', 'wav', 'ogg' => '🎵',
|
||||
'mp4', 'avi', 'mkv', 'mov' => '🎬',
|
||||
default => '📄'
|
||||
};
|
||||
echo $icon;
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<h4 class="file-name" title="<?= htmlspecialchars($file['name']) ?>">
|
||||
<?= htmlspecialchars($file['name']) ?>
|
||||
</h4>
|
||||
|
||||
<div class="file-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Size:</span>
|
||||
<span class="detail-value"><?= $file['size'] ?></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Modified:</span>
|
||||
<span class="detail-value"><?= $file['modified'] ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-card-actions">
|
||||
<a href="/storage/files/download?file=<?= urlencode($file['name']) ?>&folder=<?= urlencode($viewModel->title) ?>"
|
||||
class="file-action-btn download-btn"
|
||||
title="Download">
|
||||
<span class="action-icon">🤏</span>
|
||||
</a>
|
||||
<form method="POST" action="/storage/files/delete"
|
||||
class="delete-form"
|
||||
onsubmit="return confirm('Are you sure you want to delete <?= htmlspecialchars($file['name']) ?>?')">
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($viewModel->csrf) ?>">
|
||||
<input type="hidden" name="file_name" value="<?= htmlspecialchars($file['name']) ?>">
|
||||
<input type="hidden" name="folder"
|
||||
value="<?= htmlspecialchars($viewModel->title) ?>">
|
||||
<button type="submit" class="file-action-btn delete-btn" title="Delete">
|
||||
<span class="action-icon">🗑️</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно загрузки файла -->
|
||||
<div class="modal-overlay" id="upload-file-modal-folder">
|
||||
<div class="modal">
|
||||
<h3>Upload File to <?= htmlspecialchars($viewModel->folderName) ?></h3>
|
||||
|
||||
<form method="POST" action="/storage/files" enctype="multipart/form-data" id="upload-form-folder">
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($viewModel->csrf) ?>">
|
||||
<input type="hidden" name="folder" value="<?= htmlspecialchars($viewModel->title) ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Choose file</label>
|
||||
<input type="file" name="file" required accept="*/*" class="file-input" id="file-input-folder">
|
||||
<div class="file-preview" style="display:none; margin-top:10px;">
|
||||
<img src="" alt="Preview" style="max-width:100px; max-height:100px; display:none;">
|
||||
<div class="file-info"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="upload-progress" style="display:none;">
|
||||
<div class="progress-bar" style="height:6px; background:#e2e8f0; border-radius:3px;">
|
||||
<div class="progress-fill"
|
||||
style="height:100%; background:#667eea; width:0; border-radius:3px;"></div>
|
||||
</div>
|
||||
<div class="progress-text" style="font-size:0.9rem; color:#718096; margin-top:5px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancel-upload-file-folder">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="submit-upload-folder">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/tus.js"></script>
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/folder.js"></script>
|
||||
26
resources/views/headers/dashboard.php
Normal file
26
resources/views/headers/dashboard.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/** @var Din9xtrCloud\ViewModels\Dashboard\DashboardViewModel $page */
|
||||
?>
|
||||
<header class="dashboard-header">
|
||||
<div class="welcome-section">
|
||||
<h1 class="welcome-title">Welcome to your cloud storage
|
||||
<?= htmlspecialchars($page->username) ?> 👋</h1>
|
||||
<p class="welcome-subtitle">Manage your cloud storage efficiently</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<form action="/" method="GET" style="display: inline;">
|
||||
|
||||
<button class="btn btn-primary" id="refresh-dashboard">
|
||||
<span class="btn-icon">💨</span>Refresh
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form action="/logout" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-secondary" id="logout-btn">
|
||||
<span class="btn-icon">🚪</span> Logout
|
||||
</button>
|
||||
<input type="hidden" name="_csrf"
|
||||
value="<?= htmlspecialchars($page->csrf) ?>">
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
32
resources/views/headers/folder.php
Normal file
32
resources/views/headers/folder.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php /** @var Din9xtrCloud\ViewModels\Folder\FolderViewModel $page */
|
||||
?>
|
||||
<header class="folder-header">
|
||||
<div class="folder-info">
|
||||
<h1 class="folder-title">
|
||||
<span class="folder-icon">📁</span>
|
||||
<?= htmlspecialchars($page->title) ?>
|
||||
</h1>
|
||||
<div class="folder-stats">
|
||||
<span class="stat-item"><?= count($page->files) ?> files</span>
|
||||
<span class="stat-separator">•</span>
|
||||
<span class="stat-item"><?= $page->totalSize ?></span>
|
||||
<span class="stat-separator">•</span>
|
||||
<span class="stat-item">Last updated: <?= $page->lastModified ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-actions">
|
||||
<?php if ($page->title !== 'documents' && $page->title !== 'media'): ?>
|
||||
<form method="POST" action="/storage/folders/<?= urlencode($page->title) ?>/delete" style="display:inline;">
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($page->csrf) ?>">
|
||||
<button type="submit" class="btn btn-danger"
|
||||
onclick="return confirm('Delete folder <?= htmlspecialchars($page->title) ?>?');">
|
||||
<span class="btn-icon">🗑️</span>Delete Folder
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<a href="/" class="btn btn-secondary">
|
||||
<span class="btn-icon">👈</span>Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
53
resources/views/layouts/app.php
Executable file
53
resources/views/layouts/app.php
Executable file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Din9xtrCloud\ViewModels\LayoutViewModel;
|
||||
|
||||
/** @var LayoutViewModel $viewModel */
|
||||
$page = $viewModel->page;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($page->title() ?? 'Cloud App') ?></title>
|
||||
<link rel="stylesheet" href=/assets/cloud.css>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
<?php if ($page->layoutConfig->header === 'default'): ?>
|
||||
<header>
|
||||
<nav class="navbar">
|
||||
<span class="navbar-brand">Cloud Control Panel</span>
|
||||
<a href="/" class="back-link">👈 Back</a>
|
||||
|
||||
</nav>
|
||||
</header>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$headerFile = __DIR__ . '/../headers/' . $page->layoutConfig->header . '.php';
|
||||
if (file_exists($headerFile)):
|
||||
include $headerFile;
|
||||
?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="container">
|
||||
<?= $viewModel->content ?>
|
||||
|
||||
</main>
|
||||
<?php if ($page->layoutConfig->showFooter): ?>
|
||||
|
||||
<footer>
|
||||
<p>© <?= date('Y') ?> Cloud Control Panel.
|
||||
<a href="/license" style="color: #667eea; text-decoration: none; transition: color 0.3s ease;"
|
||||
onmouseover="this.style.color='#764ba2'; this.style.textDecoration='underline'"
|
||||
onmouseout="this.style.color='#667eea'; this.style.textDecoration='none'">
|
||||
MIT License
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
498
resources/views/license.php
Normal file
498
resources/views/license.php
Normal file
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
/** @var Din9xtrCloud\ViewModels\LicenseViewModel $viewModel */
|
||||
?>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
|
||||
color: #2d3748;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
color: #4a5568;
|
||||
padding: 1rem 2rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
border-radius: 0 0 20px 20px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: transparent;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #764ba2;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 100px auto 60px;
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.license-container {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 24px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08),
|
||||
0 8px 24px rgba(0, 0, 0, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.license-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.license-title {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.license-subtitle {
|
||||
color: #718096;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quick-summary {
|
||||
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem;
|
||||
margin-bottom: 3rem;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
color: #2d3748;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-title::before {
|
||||
content: '📋';
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
color: #4a5568;
|
||||
line-height: 1.7;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.permissions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.permission-card {
|
||||
background: #f7fafc;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.permission-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.permission-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.permission-icon {
|
||||
font-size: 2rem;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.permission-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.permission-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #4a5568;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.permission-item::before {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.can-list .permission-item::before {
|
||||
content: '✅';
|
||||
color: #48bb78;
|
||||
}
|
||||
|
||||
.must-list .permission-item::before {
|
||||
content: '✍️';
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.cannot-list .permission-item::before {
|
||||
content: '❌';
|
||||
color: #fc8181;
|
||||
}
|
||||
|
||||
.full-license {
|
||||
background: #f7fafc;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.full-license-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.full-license-title {
|
||||
color: #2d3748;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.full-license-subtitle {
|
||||
color: #718096;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.license-text {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.8;
|
||||
color: #4a5568;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 0.9rem;
|
||||
color: #718096;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: #764ba2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(30, 41, 59, 0.95);
|
||||
color: #cbd5e1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.license-container {
|
||||
background: rgba(30, 41, 59, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.quick-summary {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
border-left-color: #818cf8;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.permission-card {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
}
|
||||
|
||||
.permission-title {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.full-license {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
}
|
||||
|
||||
.full-license-title {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.license-text {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: #cbd5e1;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #c084fc;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.license-container {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.license-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.permissions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-summary {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.license-text {
|
||||
padding: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.license-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.license-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.permission-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="license-container">
|
||||
<div class="license-header">
|
||||
<h1 class="license-title">MIT License</h1>
|
||||
<p class="license-subtitle">
|
||||
Behind every piece of software lies the dedication, expertise, and passion of talented individuals.
|
||||
We champion a license that embodies the spirit of freedom and openness.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="quick-summary">
|
||||
<h2 class="summary-title">Quick Summary</h2>
|
||||
<p class="summary-content">
|
||||
At our core, we value the vibrant community of individuals who make each line of code possible.
|
||||
With pride, we champion a license that is renowned as one of the most liberal in the industry,
|
||||
empowering users to unleash their creativity, explore new possibilities, and shape their digital
|
||||
experiences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="permissions-grid">
|
||||
<!-- Can -->
|
||||
<div class="permission-card">
|
||||
<div class="permission-header">
|
||||
<div class="permission-icon"
|
||||
style="background: linear-gradient(135deg, #48bb7815 0%, #38a16915 100%); color: #48bb78;">
|
||||
✅
|
||||
</div>
|
||||
<h3 class="permission-title">Can</h3>
|
||||
</div>
|
||||
<ul class="permission-list can-list">
|
||||
<li class="permission-item">You may use the work commercially.</li>
|
||||
<li class="permission-item">You may make changes to the work.</li>
|
||||
<li class="permission-item">You may distribute the compiled code and/or source.</li>
|
||||
<li class="permission-item">You may incorporate the work into something that has a more restrictive
|
||||
license.
|
||||
</li>
|
||||
<li class="permission-item">You may use the work for private use.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Must -->
|
||||
<div class="permission-card">
|
||||
<div class="permission-header">
|
||||
<div class="permission-icon"
|
||||
style="background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%); color: #667eea;">
|
||||
✍️
|
||||
</div>
|
||||
<h3 class="permission-title">Must</h3>
|
||||
</div>
|
||||
<ul class="permission-list must-list">
|
||||
<li class="permission-item">You must include the copyright notice in all copies or substantial uses
|
||||
of the work.
|
||||
</li>
|
||||
<li class="permission-item">You must include the license notice in all copies or substantial uses of
|
||||
the work.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Cannot -->
|
||||
<div class="permission-card">
|
||||
<div class="permission-header">
|
||||
<div class="permission-icon"
|
||||
style="background: linear-gradient(135deg, #fc818115 0%, #f5656515 100%); color: #fc8181;">
|
||||
❌
|
||||
</div>
|
||||
<h3 class="permission-title">Cannot</h3>
|
||||
</div>
|
||||
<ul class="permission-list cannot-list">
|
||||
<li class="permission-item">The work is provided "as is". You may not hold the author liable.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="full-license">
|
||||
<div class="full-license-header">
|
||||
<h2 class="full-license-title">MIT License</h2>
|
||||
<p class="full-license-subtitle">Full License Text</p>
|
||||
</div>
|
||||
<div class="license-text">
|
||||
Copyright © Grudinin Andrew (din9xtr)
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
||||
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
21
resources/views/login.php
Executable file
21
resources/views/login.php
Executable file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/** @var Din9xtrCloud\ViewModels\Login\LoginViewModel $viewModel */
|
||||
|
||||
?>
|
||||
<div class="login-card">
|
||||
<h1><?= htmlspecialchars($viewModel->title ?? 'Login') ?></h1>
|
||||
<?php if (!empty($viewModel->error)) : ?>
|
||||
<p class="error"><?= htmlspecialchars($viewModel->error) ?></p>
|
||||
<?php endif; ?>
|
||||
<form method="POST" action="/login" class="login-form">
|
||||
<label>
|
||||
<input type="text" name="username" required placeholder="Enter username">
|
||||
</label>
|
||||
<label>
|
||||
<input type="password" name="password" required placeholder="Enter password">
|
||||
</label>
|
||||
<button type="submit">Sign In</button>
|
||||
<input type="hidden" name="_csrf"
|
||||
value="<?= htmlspecialchars($viewModel->csrf) ?>">
|
||||
</form>
|
||||
</div>
|
||||
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;
|
||||
}
|
||||
}
|
||||
0
storage/.gitkeep
Normal file
0
storage/.gitkeep
Normal file
Reference in New Issue
Block a user