Initial commit: Cloud Control Panel

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

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
STORAGE_PATH=storage
STORAGE_USER_LIMIT_GB=70
USER=admin
PASSWORD=admin

60
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

72
db/migrate.php Executable file
View 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;
}
}

View 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(),
]);
};

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

View 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
View 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
View File

50
php/php.ini Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

144
public/index.php Executable file
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

193
resources/views/dashboard.php Executable file
View 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
View 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
View 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>

View 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>

View 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
View 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>&copy; <?= 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
View 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
View 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
View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

238
src/Router.php Executable file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

68
src/View.php Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

0
storage/.gitkeep Normal file
View File