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