Compare commits

...
4 Commits
Author SHA1 Message Date
Michal Sedlák dc452059f3 Fix SFTP root navigation on Windows
Use portable POSIX parent paths instead of platform-dependent dirname() for remote navigation, previews, uploads, file operations and shared URLs.

Normalize browser paths, avoid doubled root separators and preserve top-level directory names in ZIP downloads.

Add regression coverage for root navigation, generated links, relative paths and ZIP members, and document the new test command. Verified with PHP syntax checks and all three regression suites.
2026-10-16 10:36:57 +02:00
Michal Sedlák 574662c1cc stuff 2026-10-16 10:31:29 +02:00
Michal Sedlák 295a9aca07 Document project setup and database installation
Expand README with application features, requirements, XAMPP setup, connection configuration, project structure and troubleshooting.

Add Czech database documentation covering SQL import, manual SQL and phpMyAdmin table setup, bcrypt administrator creation, database permissions and verification.

Provide the missing share table schema as a separate SQL supplement and explain its relationship to the original users dump.
2026-10-16 10:29:25 +02:00
Michal Sedlák 565fd18379 Redesign SFTP workspace and fix server monitoring
Replace Bootstrap dependencies with custom blue-accented light and dark themes and native dialogs.

Share the sidebar, top bar and responsive layout across file browsing, account settings, user management, server statistics and file previews. Add folder search, type filters, upload progress and asset cache versioning.

Fix SSH configuration usage, filesystem discovery, missing statistics and RAID health parsing. Adapt PDF sizing and fullscreen controls to the shared workspace.

Validation: PHP workspace and server statistics regression tests, JavaScript syntax checks, responsive browser checks and live XAMPP navigation.
2026-10-16 10:20:55 +02:00
39 changed files with 1593 additions and 2006 deletions
+4
View File
@@ -0,0 +1,4 @@
## Architecture
- Frontend: html + JavaScript
- Backend: PHP
- DB: MySQL with phpmyadmin
+281
View File
@@ -0,0 +1,281 @@
# Nastavení databáze
Tento návod popisuje databázi webové aplikace USB RAID Array pro MySQL/MariaDB. Lze použít phpMyAdmin v XAMPP nebo příkazový klient `mysql`.
Vyber jednu ze dvou cest: [import SQL](#varianta-a-import-sql) nebo [ruční vytvoření tabulek](#varianta-b-ruční-vytvoření-tabulek). Poté pokračuj [vytvořením prvního správce](#první-administrátor) a [nastavením připojení](#připojení-aplikace).
## Co aplikace ukládá
| Tabulka | Účel | Používají ji |
| --- | --- | --- |
| `users` | Webové účty, hashe hesel, role, vzdálené cesty a oprávnění. | `login.php`, `adminpanel.php`, `changepassword.php` |
| `share` | Tokeny sdílených médií, cesta k souboru, expirace a povolení stažení. | `ftp/view.php`, `ftp/shared.php` |
Obsah souborů zůstává na SFTP serveru. Databáze uchovává jen účty a metadata sdílení.
### Původní SQL soubor a doplnění
[usbraidlogin.sql](usbraidlogin.sql) je původní dump z phpMyAdmin. Obsahuje vytvoření tabulky `users` a vložení uživatelských dat. **Neobsahuje `CREATE DATABASE`, `USE` ani tabulku `share`.** Tabulka `users` v tomto dumpu nemá primární ani unikátní klíč.
[share.sql](share.sql) doplňuje strukturu, kterou aktuální kód potřebuje pro sdílené odkazy. Je odvozená z dotazů v `view.php` a `shared.php`, nikoli z původního dumpu. Neobsahuje testovací odkazy ani uživatelská data. `IF NOT EXISTS` při opakovaném spuštění nevytvoří druhou tabulku, ale také neopraví již existující nekompatibilní schéma.
Obě varianty níže používají databázi `usbraidlogin`, InnoDB a `utf8mb4_unicode_ci` podle původního SQL. Import původního dumpu prováděj do nové databáze; na existující tabulce `users` by `CREATE TABLE` skončilo chybou.
## Varianta A: import SQL
### Přes phpMyAdmin
1. V XAMPP spusť Apache a MySQL.
2. Otevři `http://localhost/phpmyadmin/` a přihlas se databázovým účtem s právem vytvářet databáze a tabulky.
3. Na kartě **SQL** vytvoř databázi:
```sql
CREATE DATABASE `usbraidlogin`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
```
4. V levém panelu vyber **usbraidlogin**. Tento krok je nutný, protože dump neobsahuje příkaz `USE`.
5. Otevři **Import**, vyber `Documentation/usbraidlogin.sql`, ponech formát SQL a spusť import.
6. Ve stejné databázi znovu otevři **Import** a importuj `Documentation/share.sql`.
7. Zkontroluj, že jsou přítomné tabulky `users` a `share`.
Původní dump vkládá i účty s hashi hesel. Z hashe nelze zjistit původní heslo. Pro vlastní instalaci vytvoř vlastní účet podle kapitoly [První administrátor](#první-administrátor). Importované účty před zpřístupněním instalace zkontroluj a ponech jen ty, které chceš používat.
### Přes příkazový řádek
V PowerShellu otevři klienta z XAMPP:
```powershell
& 'C:\xampp\mysql\bin\mysql.exe' -u root -p
```
Použij jméno svého správce databáze místo `root`, pokud se liší. Heslo se zadává až do výzvy klienta. Jestli má lokální účet prázdné heslo, potvrď výzvu Enterem.
Na Linuxu nebo pokud je klient v `PATH`:
```sh
mysql -u root -p
```
V klientovi spusť následující příkazy. Cesty uprav podle umístění repozitáře; v příkladu pro Windows jsou použité dopředné lomítka:
```sql
CREATE DATABASE `usbraidlogin`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `usbraidlogin`;
SOURCE C:/cesta/USB-RAID-Array/Documentation/usbraidlogin.sql;
SOURCE C:/cesta/USB-RAID-Array/Documentation/share.sql;
SHOW TABLES;
```
`SOURCE` je příkaz klienta `mysql`. V phpMyAdmin používej kartu Import. Na Linuxu nahraď cesty například `/cesta/USB-RAID-Array/Documentation/usbraidlogin.sql` a odpovídající cestou k `share.sql`.
## Varianta B: ruční vytvoření tabulek
Tato varianta vytvoří prázdné tabulky bez importovaných účtů. Následující SQL vlož do karty **SQL** v phpMyAdmin nebo do klienta `mysql`. Původní dump už v této variantě neimportuj.
### 1. Databáze
```sql
CREATE DATABASE `usbraidlogin`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `usbraidlogin`;
```
Pokud databáze už existuje, vyber ji příkazem `USE` a přeskoč `CREATE DATABASE`. Před vytvořením tabulek ověř jejich existenci pomocí `SHOW TABLES`; existující tabulky nemaž kvůli opakování návodu.
### 2. Tabulka `users`
Struktura přesně odpovídá tabulce v původním `usbraidlogin.sql`:
```sql
CREATE TABLE `users` (
`uname` varchar(255) NOT NULL,
`pswd` varchar(60) NOT NULL,
`admin` tinyint(1) NOT NULL DEFAULT '0',
`defPath` varchar(255) NOT NULL DEFAULT '/',
`delPer` tinyint(1) NOT NULL DEFAULT '0',
`downPer` tinyint(1) NOT NULL DEFAULT '0',
`upPer` tinyint(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
| Sloupec | Význam |
| --- | --- |
| `uname` | Přihlašovací jméno. Každý účet vytvářej s odlišným jménem. |
| `pswd` | Bcrypt hash hesla vytvořený PHP, dlouhý 60 znaků. |
| `admin` | `1` pro správce, `0` pro běžný účet; správce má přístup ke správě účtů a statistikám serveru. |
| `defPath` | Absolutní vzdálená cesta na SFTP serveru, například `/mnt/raid`. |
| `delPer` | `1` povoluje mazání souborů a adresářů. |
| `downPer` | `1` povoluje stahování v rozhraní. |
| `upPer` | `1` povoluje nahrávání a úpravy, například vytváření, přejmenování, editaci a rozbalování archivů. |
Hodnoty oprávnění zadávej jako `0` nebo `1`. Role správce a souborová oprávnění jsou samostatné sloupce; pro plný přístup nastav prvnímu správci všechny čtyři přepínače na `1`.
Věrná kopie původního schématu nemá unikátní index pro `uname`. Administrace duplicity ověřuje při přidávání účtu, ale při ručním SQL je musíš zkontrolovat sám.
### 3. Tabulka `share`
Stejnou tabulku vytváří doplňující soubor `share.sql`:
```sql
CREATE TABLE IF NOT EXISTS `share` (
`token` char(32) NOT NULL,
`file_path` text NOT NULL,
`expiration` datetime NOT NULL,
`download_allowed` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`token`),
KEY `idx_share_expiration` (`expiration`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
| Sloupec | Význam |
| --- | --- |
| `token` | Identifikátor odkazu v URL; aplikace generuje 32 hexadecimálních znaků pomocí `bin2hex(random_bytes(16))`. |
| `file_path` | Vzdálená cesta ke sdílenému souboru. |
| `expiration` | Datum a čas konce platnosti. Dotaz v `shared.php` vyžaduje `expiration > NOW()`. |
| `download_allowed` | `1` povoluje stažení přes sdílený odkaz, `0` pouze náhled. |
Primární klíč zajišťuje jedinečnost tokenu, index `idx_share_expiration` usnadňuje dotazy podle expirace. Mezi tabulkami není cizí klíč: současný kód u odkazu neukládá autora ani ID uživatele.
### Alternativa: naklikání tabulek v phpMyAdmin
Místo spuštění uvedených `CREATE TABLE` můžeš stejné tabulky vytvořit přes formulář:
1. Vyber databázi `usbraidlogin`, zvol vytvoření nové tabulky `users` a počet sloupců **7**.
2. Zadej názvy a datové typy podle SQL výše: `uname VARCHAR(255)`, `pswd VARCHAR(60)`, `admin TINYINT(1)`, `defPath VARCHAR(255)`, `delPer TINYINT(1)`, `downPer TINYINT(1)` a `upPer TINYINT(1)`.
3. U všech sloupců ponech **NULL vypnuté** a nezapínej `A_I` (AUTO_INCREMENT). Přepínačům nastav výchozí hodnotu `0`, sloupci `defPath` hodnotu `/`. `uname` a `pswd` nemají výchozí hodnotu. Pro shodu s původním dumpem nenastavuj žádný primární klíč.
4. Vyber engine **InnoDB**, porovnávání **utf8mb4_unicode_ci** a tabulku ulož.
5. Vytvoř tabulku `share` se **4** sloupci: `token CHAR(32)`, `file_path TEXT`, `expiration DATETIME` a `download_allowed TINYINT(1)`.
6. Opět vypni NULL a AUTO_INCREMENT. U `download_allowed` nastav výchozí hodnotu `0`; ostatní sloupce výchozí hodnotu nemají. Sloupci `token` nastav index **PRIMARY**.
7. Použij InnoDB a utf8mb4_unicode_ci. Po uložení na kartě **Struktura** přidej běžný index `idx_share_expiration` na sloupci `expiration`.
8. Výsledek porovnej pomocí `SHOW CREATE TABLE users;` a `SHOW CREATE TABLE share;` s uvedenými SQL definicemi.
## První administrátor
### 1. Vytvoření hashe hesla
Heslo do `pswd` neukládej jako prostý text ani jako MD5/SHA hash. Přihlášení používá PHP `password_verify()` a aplikace vytváří hesla pomocí `PASSWORD_BCRYPT`.
Pokud je PHP v `PATH`, spusť:
```sh
php -r "echo password_hash('ZVOLTE_VLASTNI_HESLO', PASSWORD_BCRYPT), PHP_EOL;"
```
V PowerShellu s XAMPP lze použít plnou cestu:
```powershell
& 'C:\xampp\php\php.exe' -r "echo password_hash('ZVOLTE_VLASTNI_HESLO', PASSWORD_BCRYPT), PHP_EOL;"
```
`ZVOLTE_VLASTNI_HESLO` před spuštěním nahraď vlastním heslem. Zvol alespoň 8 znaků, jednu číslici a jedno velké písmeno, aby odpovídalo pravidlům správy účtů. Výstup bude bcrypt hash začínající například `$2y$10$`. Každé spuštění může pro stejné heslo vytvořit jiný platný hash.
Pokud nechceš heslo uvést v historii příkazové řádky, spusť `php -a` a zavolej `password_hash()` v interaktivním PHP shellu. Příkaz s vlastním heslem ani jeho výstup neukládej do veřejných ukázek dokumentace.
### 2. Vložení účtu
Nejprve ověř, zda zvolené jméno už není použité:
```sql
USE `usbraidlogin`;
SELECT `uname`, `admin`, `defPath` FROM `users` WHERE `uname` = 'spravce';
```
Pokud dotaz nic nevrátí, vlož účet. Text `SEM_VLOZTE_BCRYPT_HASH` nahraď celým skutečným hashem z předchozího kroku a `/mnt/raid` skutečnou cestou na SFTP serveru:
```sql
INSERT INTO `users`
(`uname`, `pswd`, `admin`, `defPath`, `delPer`, `downPer`, `upPer`)
VALUES
('spravce', 'SEM_VLOZTE_BCRYPT_HASH', 1, '/mnt/raid', 1, 1, 1);
```
Tento SQL příkaz je šablona — placeholder není platný hash. Do přihlašovacího formuláře následně zadávej jméno `spravce` a původní heslo, ze kterého jsi hash vytvořil. Další účty už lze přidávat přes **User management**.
Pokud jméno existuje, další stejné nevkládej. Pro obnovení přístupu ke svému účtu lze v phpMyAdmin nahradit pouze jeho hash:
```sql
UPDATE `users`
SET `pswd` = 'SEM_VLOZTE_BCRYPT_HASH'
WHERE `uname` = 'spravce';
```
## Připojení aplikace
Pro aplikaci vytvoř samostatný databázový účet. Následující příklad předpokládá, že se PHP připojuje k databázi lokálně jako `usbraid_app` na hostiteli `localhost`:
```sql
CREATE USER 'usbraid_app'@'localhost' IDENTIFIED BY 'DOPLNTE_VLASTNI_HESLO_DB';
GRANT SELECT, INSERT, UPDATE, DELETE ON `usbraidlogin`.* TO 'usbraid_app'@'localhost';
```
DDL příkazy pro vytvoření tabulek spouštěj správcovským databázovým účtem. Aplikace pro běžný provoz nepotřebuje práva `CREATE` ani `DROP`. Pokud účet již existuje, `CREATE USER` neopakuj; nastav mu potřebná práva. Pro vzdálenou databázi použij hostitele odpovídajícího umístění PHP serveru místo `localhost`.
V existujícím `Web/config/config.php` nastav:
```php
$DB_HOST = 'localhost';
$DB_USER = 'usbraid_app';
$DB_PASS = 'DOPLNTE_VLASTNI_HESLO_DB';
$DB_NAME = 'usbraidlogin';
```
Účet MySQL, webový účet z tabulky `users` a SFTP/SSH účet jsou tři různé věci. Změna hesla ve webové aplikaci mění pouze hash v `users.pswd`.
### Časové pásmo sdílených odkazů
`view.php` počítá expiraci v `Europe/Prague`, zatímco databázový dotaz ji porovnává s `NOW()`. Databázový čas musí odpovídat tomuto pásmu, jinak může odkaz vypršet dříve nebo později. Ověř:
```sql
SELECT NOW() AS database_time,
@@global.time_zone AS global_time_zone,
@@session.time_zone AS session_time_zone,
@@system_time_zone AS system_time_zone;
```
Pokud databáze používá `SYSTEM`, ověř časové pásmo systému databázového serveru. U spravované instalace lze nakonfigurovat časové pásmo MySQL na `Europe/Prague`, pokud má server načtené tabulky časových pásem. Jednorázové `SET time_zone` v phpMyAdmin mění pouze jeho spojení a nevyřeší nastavení nových spojení aplikace. Pevný posun `+01:00` nepokrývá český letní čas.
## Ověření instalace
V databázi spusť:
```sql
USE `usbraidlogin`;
SHOW TABLES;
DESCRIBE `users`;
DESCRIBE `share`;
SELECT `uname`, `admin`, `defPath`, `delPer`, `downPer`, `upPer`,
CHAR_LENGTH(`pswd`) AS hash_length
FROM `users`;
```
Výsledek má obsahovat obě tabulky. U účtu vytvořeného podle návodu má mít `hash_length` hodnotu `60`. Délka sama o sobě ještě nepotvrzuje správnost hesla; tu ověří přihlášení.
V aplikaci pak ověř:
1. Přihlášení vlastním administrátorem a otevření správy účtů.
2. Správnou výchozí cestu a přístup k SFTP souborům.
3. Vytvoření sdíleného odkazu u testovacího média a jeho otevření v anonymním okně.
4. Platnost odkazu a chování volby stažení.
Změny role, cesty a oprávnění provedené přímo v databázi se do existujícího webového přihlášení nepromítnou okamžitě — hodnoty se načítají do session při přihlášení. Po takové změně se odhlas a znovu přihlas.
## Řešení chyb
| Chyba | Příčina a řešení |
| --- | --- |
| `Unknown database 'usbraidlogin'` | Vytvoř databázi a zkontroluj `$DB_NAME`. |
| `No database selected` při importu | Před importem vyber databázi v phpMyAdmin nebo spusť `USE usbraidlogin`. |
| `Table 'users' already exists` | Dump se importuje do již vytvořeného schématu. Zkontroluj existující tabulku a nepokračuj slepým mazáním dat. |
| `Table 'usbraidlogin.share' doesn't exist` | Importuj `share.sql` nebo tabulku vytvoř ručně podle návodu. |
| `Access denied for user` | Zkontroluj `$DB_USER`, `$DB_PASS`, hostitele a přidělená práva. |
| Přihlášení hlásí nesprávné údaje | Ověř jméno a bcrypt hash; do formuláře patří původní heslo. |
| Sdílený odkaz ihned expiruje | Zkontroluj časové pásmo a `NOW()` na databázovém serveru. |
| Čeština se ukládá nesprávně | Tabulky používají `utf8mb4`; současný helper v `config.php` nastavuje spojení na `utf8`. Pro plné čtyřbajtové znaky, například emoji, je potřeba sjednotit i spojení na `utf8mb4`. |
+12
View File
@@ -0,0 +1,12 @@
-- Doplneni puvodniho usbraidlogin.sql o tabulku pro sdilene odkazy.
-- Nejprve vyberte databazi usbraidlogin v phpMyAdmin nebo prikazem USE.
-- Struktura odpovida INSERT/SELECT v Web/content/ftp/view.php a shared.php.
CREATE TABLE IF NOT EXISTS `share` (
`token` char(32) NOT NULL,
`file_path` text NOT NULL,
`expiration` datetime NOT NULL,
`download_allowed` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`token`),
KEY `idx_share_expiration` (`expiration`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+155 -2
View File
@@ -1,2 +1,155 @@
# USB-RAID-pole # USB RAID Array
Cílem práce je nalézt využití zastaralých paměťových médií jako například slabopaměťové USB flash disky s malou paměťovou kapacitou. Technologii RAID (NAS) pole v praxi implementujte na vícero RAID technologiích a vzájemně komparujte řešení. Komparujte chování navrženého systému minimálně na technologiích RAID 0, RAID 1 a RAID 5. Vytvořte vzdálené připojení na toto RAID pole s možností nahrání a stažení souborů po přihlášení uživatele (web login). K tomuto účelu vytvořte webovou aplikaci. Zabezpečte vzdálené připojení. Použité technologie: RAID, NAS, FTP, USB, Flash, HTML, PHP, MySQL.
Webový SFTP klient pro vzdálenou práci se soubory na RAID/NAS úložišti. Projekt vznikl jako školní práce zaměřená na využití starších USB flash disků a porovnání RAID 0, RAID 1 a RAID 5.
Aplikace běží na PHP serveru, účty a sdílené odkazy ukládá do MySQL/MariaDB a k úložišti se připojuje přes SFTP. Statistiky vzdáleného Linux serveru získává přes SSH. Samotné vytvoření RAID pole se provádí na serveru mimo tuto webovou aplikaci.
## Funkce
- Přihlášení, změna hesla a správa uživatelských účtů.
- Oprávnění pro nahrávání, stahování a mazání souborů.
- Procházení adresářů, hledání v aktuální složce a filtrování souborů/složek.
- Nahrávání souborů i adresářů, drag & drop a zobrazení průběhu přenosu.
- Vytváření, přejmenování a mazání položek; stažení adresáře jako ZIP.
- Editor textových souborů, náhled obrázků, videa, audia a PDF.
- Sdílení médií odkazem s expirací a volitelným stažením.
- Přehled disků, Linux MD RAID polí, CPU, RAM a swapu.
- Společná postranní navigace, modré akcenty, světlý a tmavý režim bez závislosti na Bootstrapu.
## Technologie a požadavky
| Část | Požadavky |
| --- | --- |
| Webový server | Apache, například v XAMPP; pro lokální vývoj lze použít i vestavěný PHP server. |
| PHP | Projekt je lokálně ověřený na PHP 8.2.12. Používané závislosti jsou uvedené v `Web/composer.lock`. |
| Databáze | MySQL/MariaDB; původní SQL dump pochází z MySQL 8.0.41. |
| PHP moduly | `mysqli` s `mysqlnd` pro `get_result()`, `session`, `fileinfo`; `zip` pro ZIP operace. Testy vykreslení používají také `dom`. |
| Závislosti | Composer a `phpseclib/phpseclib` řady 3; v repozitáři je také adresář `Web/vendor`. |
| Úložiště | Dostupný SFTP server a účet s přístupem k požadované cestě. |
| Statistiky | SSH přístup k Linux serveru, příkazy `df`, `free`, `vmstat`, `awk`, `tail` a čitelný `/proc/mdstat` pro Linux MD RAID. |
| Prohlížeč | Moderní prohlížeč s podporou nativního `<dialog>`. PDF náhled načítá PDF.js z CDN. |
Rozbalování archivů kromě ZIP může vyžadovat další nástroje, například UnRAR, 7-Zip nebo tar podle zvoleného formátu a operačního systému. PHP musí mít zapisovatelný dočasný adresář pro práci s archivy.
## Rychlé zprovoznění
### 1. Stažení projektu a závislostí
```sh
git clone https://github.com/michalcz10/USB-RAID-Array.git
cd USB-RAID-Array/Web
composer install
```
Pokud už projekt máš stažený, spusť `composer install` v jeho adresáři `Web`. Pro instalaci podle existujícího lock souboru používej `install`.
### 2. Databáze a první správce
Postup je v [dokumentaci databáze](Documentation/Databaze.md). Obsahuje dvě varianty:
1. Import [původního SQL dumpu](Documentation/usbraidlogin.sql) a [doplnění tabulky pro sdílení](Documentation/share.sql).
2. Ruční vytvoření databáze a obou tabulek.
Součástí návodu je vytvoření prvního administrátora s bcrypt hashem hesla. Původní dump obsahuje také uživatelská data a sám nevytváří databázi ani tabulku `share`.
### 3. Konfigurace připojení
V souboru `Web/config/config.php` nastav vlastní připojení. Následující hodnoty jsou příklady; nahrazují odpovídající proměnné v existujícím souboru:
```php
$DB_HOST = 'localhost';
$DB_USER = 'usbraid_app';
$DB_PASS = 'DOPLNTE_HESLO_DB';
$DB_NAME = 'usbraidlogin';
$SFTP_HOST = 'ADRESA_SFTP_SERVERU';
$SFTP_USER = 'SFTP_UZIVATEL';
$SFTP_PASS = 'DOPLNTE_HESLO_SFTP';
$SSH_HOST = 'ADRESA_SSH_SERVERU';
$SSH_USER = 'SSH_UZIVATEL';
$SSH_PASS = 'DOPLNTE_HESLO_SSH';
```
SFTP a SSH mohou používat stejný server, ale mají samostatné konfigurační proměnné. Aktuální pomocné funkce používají výchozí port 22 a přihlášení heslem. Přihlašovací údaje do webu se ověřují proti tabulce `users`; jednotliví weboví uživatelé sdílejí nakonfigurované SFTP připojení.
Výchozí vzdálená cesta se načítá z `users.defPath` po přihlášení. Nastav například `/mnt/raid` podle skutečného připojení úložiště na vzdáleném serveru. Nejde o cestu na počítači s XAMPP. SFTP účet musí mít také odpovídající oprávnění v Linuxu.
### 4. Spuštění přes XAMPP
1. Spusť Apache a MySQL v XAMPP Control Panelu.
2. Zpřístupni adresář `Web` jako `C:\xampp\htdocs\Web` — zkopírováním nebo již nastaveným symlinkem.
3. Otevři `http://localhost/Web/` a přihlas se vytvořeným účtem.
4. Ve správě uživatelů nastav další účty, jejich vzdálené cesty a oprávnění.
Při úpravách projektu používej soubory, které Apache skutečně obsluhuje. U kopie v `htdocs` je potřeba změny překopírovat; symlink odkazuje přímo na pracovní adresář.
Alternativně lze z kořene repozitáře spustit lokální PHP server:
```sh
php -S 127.0.0.1:8080 -t Web
```
Pak otevři `http://127.0.0.1:8080/`. Databáze a vzdálený SFTP/SSH server musí být dostupné i v tomto režimu.
## Struktura projektu
```text
USB-RAID-Array/
├── README.md
├── LICENSE
├── Documentation/
│ ├── Databaze.md # import, ruční vytvoření tabulek a první správce
│ ├── usbraidlogin.sql # původní dump tabulky users včetně dat
│ ├── share.sql # doplnění schématu pro sdílené odkazy
│ └── ... # původní technická dokumentace a podklady
├── tests/
│ ├── workspace_test.php
│ ├── serverstats_test.php
│ └── remotepaths_test.php
└── Web/
├── index.php # přihlášení
├── config/config.php # databáze, SFTP a SSH
├── includes/workspace.php
├── content/
│ ├── adminpanel.php
│ ├── changepassword.php
│ └── ftp/ # správce souborů, náhledy a statistiky
├── css/ # společné styly a rozložení
├── js/ # téma a dialogy
├── composer.json
└── vendor/
```
## Kontroly
Z kořene repozitáře:
```sh
php tests/workspace_test.php
php tests/serverstats_test.php
php tests/remotepaths_test.php
node --check Web/js/theme.js
node --check Web/js/ui.js
node --check Web/content/ftp/js/index.js
```
PHP testy používají testovací data a nepotřebují připojení k databázi ani SSH. Kontrolují vykreslení stránek, navigaci podle role, zpracování statistik a stavy RAID polí. Node.js slouží u uvedených příkazů ke kontrole syntaxe; pro běh aplikace není potřeba.
## Časté problémy
| Problém | Co zkontrolovat |
| --- | --- |
| Chyba připojení k databázi | Běžící MySQL, název databáze, `$DB_*` a oprávnění databázového účtu. |
| Chybí tabulka `users` nebo `share` | Dokončení [nastavení databáze](Documentation/Databaze.md). |
| Přihlášení do webu nefunguje | `pswd` musí obsahovat bcrypt hash, nikoli samotné heslo. |
| `Login Failed` nebo `SSH login failed` | Nastavení SFTP/SSH, dostupnost serveru a vzdálená oprávnění. |
| Nefunguje upload větších souborů | `upload_max_filesize`, `post_max_size`, `max_file_uploads` a časové limity PHP/serveru. Po změně `php.ini` v XAMPP restartuj Apache. |
| Statistiky úložiště jsou stejné | `/` a uživatelská výchozí cesta mohou ležet na stejném souborovém systému. |
| RAID uvádí `Not configured` | Na připojeném serveru není v `/proc/mdstat` detekováno Linux MD pole; RAID na hostiteli virtuálního serveru se tímto způsobem nezobrazuje. |
| PDF se nenačte | Dostupnost PDF souboru, přístup přes SFTP a načtení PDF.js z CDN. |
| Zobrazuje se staré rozhraní | Obnov stránku včetně cache a ověř, že XAMPP načítá správnou kopii projektu. |
## Licence
Projekt je dostupný pod [licencí MIT](LICENSE). Autorem je Michal Sedlák.
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add *)"
]
}
}
+1 -13
View File
@@ -3,6 +3,7 @@
// Autoload dependencies (phpseclib) // Autoload dependencies (phpseclib)
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../includes/remotepaths.php';
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
use phpseclib3\Net\SSH2; use phpseclib3\Net\SSH2;
@@ -56,17 +57,4 @@ function getSSHConnection(): SSH2 {
return $ssh; return $ssh;
} }
/** Utility: Normalise a path */
function normalizePath(string $path): string {
$parts = array_filter(explode('/', $path), fn($part) => $part !== '' && $part !== '.');
$stack = [];
foreach ($parts as $part) {
if ($part === '..') {
array_pop($stack);
} else {
$stack[] = $part;
}
}
return '/' . implode('/', $stack);
}
?> ?>
+36 -54
View File
@@ -10,6 +10,7 @@ ini_set('display_errors', 1);
error_reporting(E_ALL); error_reporting(E_ALL);
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/workspace.php';
// $username = "UNAME"; // $username = "UNAME";
// $password = "PSWD"; // $password = "PSWD";
// $db = "usbraidlogin"; // $db = "usbraidlogin";
@@ -136,15 +137,15 @@ function CheckPassword($password) {
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Panel</title> <title>Admin Panel</title>
<link rel="icon" href="../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'content/css/adminpanel.css') ?>">
<link rel="stylesheet" href="css/adminpanel.css">
<script src="../js/bootstrap.bundle.js"></script>
<script> <script>
function confirmDelete(uname) { function confirmDelete(uname) {
if (confirm('Are you sure you want to delete this user?')) { if (confirm('Are you sure you want to delete this user?')) {
@@ -152,45 +153,39 @@ function CheckPassword($password) {
} }
} }
</script> </script>
<script src="<?= workspaceAsset('../', 'js/theme.js') ?>"></script>
<script src="<?= workspaceAsset('../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'users',
<i class="bi"></i> 'section' => 'User management',
<span id="themeText"></span> 'eyebrow' => 'Workspace administration',
</button> 'title' => 'User management',
</div> 'description' => 'Manage accounts, storage locations and access permissions.',
'class' => 'users-page',
]); ?>
<section class="users-layout">
<div class="custom-container dark-box">
<header class="row border-bottom m-5">
<h1>USB RAID Array</h1>
<div class="mb-3 p-3">
<a href="logout.php" class="btn btn-danger">Logout</a>
<a href="changepassword.php" class="btn btn-warning">Change Password</a>
<a href="ftp/index.php" class="btn btn-primary">SFTP</a>
<a href="ftp/serverstat.php" class="btn btn-primary">Server Status</a>
</div>
</header>
<section class="row">
<article class="col border border-2 border-primary rounded p-2 surface-panel">
<h2 class="mb-4">User Management</h2>
<?php if ($message): ?> <?php if ($message): ?>
<div class="alert alert-<?php echo $message_type === 'success' ? 'success' : 'danger'; ?>"> <div class="alert alert-<?php echo $message_type === 'success' ? 'success' : 'danger'; ?>">
<?php echo htmlspecialchars($message); ?> <?php echo htmlspecialchars($message); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<form method="POST" action="" class="mb-4 add-user-form"> <div class="content-panel user-create"><div class="panel-heading"><h2>Add a member</h2><p>Set up an account and choose its access.</p></div>
<form method="POST" action="" class="add-user-form">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label" for="memberUsername">Username</label>
<input type="text" name="uname" class="form-control" placeholder="Username" required> <input type="text" name="uname" id="memberUsername" class="form-control" placeholder="Username" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label" for="memberPassword">Password</label>
<input type="password" name="pswd" class="form-control" placeholder="Password" required> <input type="password" name="pswd" id="memberPassword" class="form-control" placeholder="Password" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Default Path</label> <label class="form-label" for="memberPath">Default path</label>
<input type="text" name="defPath" class="form-control" placeholder="Default Path (e.g., /mnt/raid)" required> <input type="text" name="defPath" id="memberPath" class="form-control" placeholder="Default Path (e.g., /mnt/raid)" required>
</div> </div>
<div class="d-flex flex-column align-items-center mb-3"> <div class="d-flex flex-column align-items-center mb-3">
<div class="form-check mb-2"> <div class="form-check mb-2">
@@ -211,9 +206,11 @@ function CheckPassword($password) {
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary">Add User</button> <button type="submit" class="btn btn-primary">Add User</button>
</form> </form></div>
<h3 class="mb-3">Users List</h3> <div class="content-panel user-list"><div class="panel-heading"><h2>Workspace members</h2><p>Accounts and permissions for your storage.</p></div>
<table class="table table-bordered">
<div class="table-scroll"><table class="table users-table">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th>Username</th> <th>Username</th>
@@ -240,25 +237,10 @@ function CheckPassword($password) {
</tr> </tr>
<?php } ?> <?php } ?>
</tbody> </tbody>
</table> </table></div>
</article>
</section>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3">
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div> </div>
</footer> </section>
<?php renderWorkspaceEnd(); ?>
<script src="../js/theme.js"></script>
</div>
</body> </body>
</html> </html>
<?php <?php
+28 -51
View File
@@ -7,6 +7,7 @@ if (!isset($_SESSION['uname'])) {
} }
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/workspace.php';
// $username = "UNAME"; // $username = "UNAME";
// $password = "PSWD"; // $password = "PSWD";
// $db = "usbraidlogin"; // $db = "usbraidlogin";
@@ -93,42 +94,32 @@ function CheckPassword($password) {
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta charset="UTF-8">
<title>Change Password</title> <title>Change Password</title>
<link rel="icon" href="../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../img/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'content/css/changepassword.css') ?>">
<link rel="stylesheet" href="css/changepassword.css"> <script src="<?= workspaceAsset('../', 'js/theme.js') ?>"></script>
<script src="../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'account',
<i class="bi"></i> 'section' => 'Account settings',
<span id="themeText"></span> 'eyebrow' => 'Your account',
</button> 'title' => 'Account settings',
</div> 'description' => 'Keep your workspace account secure and up to date.',
'class' => 'account-page',
]); ?>
<div class="custom-container dark-box">
<header class="row border-bottom my-3 my-md-5">
<h1>USB RAID Array</h1>
<div class="mb-3 p-3">
<div class="btn-group-responsive">
<a href="logout.php" class="btn btn-danger">Logout</a>
<a href="ftp/index.php" class="btn btn-primary">Back to Files</a>
<?php if (isset($_SESSION["admin"]) && $_SESSION["admin"] == true) { ?>
<a href="adminpanel.php" class="btn btn-primary">Admin Panel</a>
<?php } ?>
</div>
</div>
</header>
<section class="row content-section"> <section class="account-layout">
<article class="col-12 border border-2 border-primary rounded p-2 p-md-4 surface-panel"> <article class="content-panel">
<h2 class="mb-4">Change Password</h2> <div class="panel-heading"><h2>Change password</h2><p>Choose a strong password to protect your files.</p></div>
<?php if ($message): ?> <?php if ($message): ?>
<div class="alert alert-<?php echo $messageType; ?> mb-4"> <div class="alert alert-<?php echo $messageType; ?> mb-4">
@@ -138,37 +129,23 @@ function CheckPassword($password) {
<form method="POST" action="" class="mb-4 change-password-form"> <form method="POST" action="" class="mb-4 change-password-form">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Current Password</label> <label class="form-label" for="currentPassword">Current password</label>
<input type="password" name="current_password" class="form-control" required> <input type="password" id="currentPassword" autocomplete="current-password" name="current_password" class="form-control" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">New Password</label> <label class="form-label" for="newPassword">New password</label>
<input type="password" name="new_password" class="form-control" required> <input type="password" id="newPassword" autocomplete="new-password" name="new_password" class="form-control" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Confirm New Password</label> <label class="form-label" for="confirmPassword">Confirm new password</label>
<input type="password" name="confirm_password" class="form-control" required> <input type="password" id="confirmPassword" autocomplete="new-password" name="confirm_password" class="form-control" required>
</div> </div>
<button type="submit" class="btn btn-primary">Change Password</button> <button type="submit" class="btn btn-primary">Change Password</button>
</form> </form>
</article> </article>
<aside class="account-help"><span class="eyebrow">Password requirements</span><h3>A little extra protection</h3><p>Use at least 8 characters, one uppercase letter and one number.</p><p>You can return to your files at any time using the workspace navigation.</p></aside>
</section> </section>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3"> <?php renderWorkspaceEnd(); ?>
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3 flex-wrap justify-content-center">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
<script src="../js/theme.js"></script>
</div>
</body> </body>
</html> </html>
+13 -66
View File
@@ -1,66 +1,13 @@
html, body { .users-layout { display:grid; grid-template-columns:300px minmax(0,1fr); align-items:start; gap:22px; }
height: 100%; .users-layout > .alert { grid-column:1/-1; margin:0; }
margin: 0; .add-user-form { margin:0; text-align:left; }
} .add-user-form .form-check { width:100%; margin-bottom:12px; font-size:12px; }
.add-user-form .btn { width:100%; }
.custom-container { .users-table { min-width:650px; }
display: flex; .users-table th { font-size:10px; padding:12px 10px; white-space:nowrap; }
flex-direction: column; .users-table td { font-size:12px; padding:14px 10px; }
min-height: 100vh; .users-table td:first-child { font-weight:600; }
max-width: 60%; .users-table td:nth-child(3) { font-family:ui-monospace,monospace; white-space:nowrap; }
margin: 0 auto; .user-create .form-label { font-size:12px; }
} @media(max-width:1200px) { .users-layout { grid-template-columns:1fr; } .add-user-form { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; } .add-user-form > .d-flex { grid-column:1/-1; display:grid; grid-template-columns:1fr 1fr; gap:8px; } .add-user-form > .btn { width:auto; justify-self:start; } }
.custom-container.dark-box { @media(max-width:600px) { .add-user-form { display:block; } .add-user-form > .d-flex { display:flex; align-items:flex-start; gap:0; } }
min-height: calc(100vh - 4rem);
width: min(60%, 1200px);
}
section.row {
flex: 1;
}
@media (max-width: 1120px) {
.custom-container {
max-width: 90%;
}
.custom-container.dark-box {
width: 90%;
}
}
@media (max-width: 700px) {
.custom-container.dark-box {
width: calc(100% - 2rem);
min-height: calc(100vh - 2rem);
margin: 1rem auto;
}
body {
min-width: 0;
}
}
.add-user-form {
max-width: 400px;
margin: 0 auto;
}
.form-check {
display: flex;
align-items: center;
width: 100%;
justify-content: flex-start;
}
.form-check-input {
margin-right: 10px;
}
.form-check-label {
margin-left: 0.5rem;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
+6 -56
View File
@@ -1,56 +1,6 @@
.container-fluid { .account-layout { display:grid; grid-template-columns:minmax(0,560px) minmax(200px,1fr); align-items:start; gap:40px; }
width: 100%; .change-password-form { margin:0; }
padding-right: 15px; .change-password-form .form-label { font-size:12px; }
padding-left: 15px; .account-help { max-width:320px; padding:24px 0; color:var(--muted); font-size:13px; }
margin-right: auto; .account-help h3 { color:var(--text); margin-top:12px; font-size:18px; }
margin-left: auto; @media(max-width:1000px) { .account-layout { grid-template-columns:1fr; gap:16px; } .account-help { max-width:100%; padding:12px 4px; } }
}
.content-section {
max-width: 800px;
margin: 0 auto;
}
.change-password-form {
max-width: 100%;
margin: 0 auto;
}
@media (min-width: 768px) {
.change-password-form {
max-width: 400px;
}
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
.btn-group-responsive {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
}
h1 {
font-size: 1.8rem;
}
@media (min-width: 576px) {
h1 {
font-size: 2.5rem;
}
}
+15 -141
View File
@@ -1,142 +1,16 @@
html, body { .pathbar { display:flex; align-items:center; flex-wrap:wrap; gap:12px; padding:13px 16px; background:var(--surface); border:1px solid var(--line); border-radius:10px; margin-bottom:20px; font-size:12px; overflow-wrap:anywhere; }
height: 100%; .pathbar a { color:var(--text); } .pathbar a:first-child { color:var(--blue); } .path-separator { color:var(--muted); } .path-label { margin-left:auto; color:var(--muted); font:11px ui-monospace,monospace; }
margin: 0; .file-panel { border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:var(--shadow); overflow:hidden; }
overflow-x: hidden; .browser-toolbar { padding:20px; display:flex; align-items:center; gap:12px; border-bottom:1px solid var(--line); }
overflow-y: auto; .search-field { display:flex; align-items:center; gap:10px; margin:0; padding:0 12px; border:1px solid var(--line); border-radius:8px; width:360px; max-width:100%; background:var(--bg); color:var(--muted); font-weight:400; }
} .search-field > span { font-size:24px; } .search-field input { width:100%; min-width:0; padding:10px 0; border:0; background:transparent; color:var(--text); outline:none; } .search-field:focus-within { outline:2px solid var(--blue); } .search-field input::placeholder { color:var(--muted); } kbd { font:11px ui-monospace,monospace; padding:0 5px; border:1px solid var(--line); border-radius:3px; }
.browser-toolbar select { width:130px; margin-left:auto; font-size:12px; padding:10px; }
.action-buttons { display:flex; flex-wrap:wrap; gap:8px; padding:18px 20px 0; } .action-buttons .btn { font-size:12px; } .action-buttons .btn-primary { order:-1; }
.dropzone { margin:18px 20px 22px; border:1px dashed var(--line); background:var(--bg); border-radius:9px; display:flex; align-items:center; justify-content:center; gap:14px; padding:20px; text-align:left; }
.dropzone.dragover { border-color:var(--blue); background:var(--blue-soft); } .upload-symbol { display:grid; place-items:center; width:36px; height:36px; border:1px solid var(--line); border-radius:9px; background:var(--surface); font-size:22px; color:var(--blue); } .dropzone strong { font-weight:550; font-size:12px; } .dropzone small { display:block; color:var(--muted); font-size:11px; margin-top:3px; }
.file-table-scroll { overflow-x:auto; } .file-table { min-width:650px; } .file-table th { padding:12px 20px; } .file-table td { padding:14px 20px; font-size:12px; } .file-table th:first-child { width:48%; } .file-table th:nth-child(2) { width:13%; } .file-table th:last-child { width:39%; } .file-table td:first-child { font-weight:550; } .file-table td:first-child a { color:var(--text); } .file-table .btn { font-size:11px; padding:5px 8px; min-height:30px; background:transparent; border-color:transparent; } .file-table .btn:hover { background:var(--surface); border-color:var(--line); } .file-table td:last-child { white-space:nowrap; } .file-table td:nth-child(2) { color:var(--muted); font-family:ui-monospace,monospace; font-size:11px; }
.file-icon { display:inline-grid; place-items:center; width:30px; height:32px; margin-right:10px; color:var(--muted); background:var(--surface-alt); border-radius:7px; vertical-align:middle; font-size:18px; } .folder-icon { color:var(--blue); background:var(--blue-soft); } .file-table .badge { margin-left:5px; }
.browser-status { display:flex; justify-content:space-between; padding:13px 20px; color:var(--muted); font-size:11px; } .empty-state { text-align:center; padding:48px 20px; color:var(--muted); } .empty-state > span { font-size:38px; } .empty-state h3 { color:var(--text); margin:6px 0; } .empty-state p { margin:0; }
.file-content > .alert { margin:16px 20px; }
.custom-container { @media(max-width:760px) { .path-label { display:none; } .browser-toolbar { padding:14px; gap:8px; } .search-field { width:auto; flex:1; min-width:0; } .browser-toolbar select { width:100px; } .action-buttons { padding:14px 14px 0; } .dropzone { margin:14px; } }
display: flex;
flex-direction: column;
min-height: 100vh;
margin: 0;
padding: 0;
max-width: 100%;
overflow: visible;
}
section.row {
flex: 1;
margin: 0;
width: 100%;
justify-content: center;
}
article.col-8 {
max-width: 1600px;
}
@media (max-width: 1800px) {
article.col-8 {
flex: 0 0 auto;
width: 95%;
}
}
@media (max-width: 768px) {
.custom-container {
height: auto;
min-height: initial;
}
section.row {
flex: 0 0 auto;
}
footer {
margin-top: 20px;
position: relative;
}
body {
height: auto;
min-height: initial;
}
article {
padding-bottom: 20px;
}
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
table-layout: fixed;
}
th, td {
padding: 10px;
border: 1px solid #ddd;
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
}
.dropzone {
width: 100%;
padding: 20px;
border: 2px dashed #007bff;
border-radius: 10px;
text-align: center;
margin-bottom: 20px;
cursor: pointer;
box-sizing: border-box;
}
.dropzone.dragover {
background-color: #e0f7fa;
}
.action-buttons {
margin-bottom: 15px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.action-buttons button {
margin-right: 0;
}
body {
min-width: 500px;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
footer {
margin-top: auto;
padding: 20px;
border-top: 1px solid #ddd;
width: 100%;
}
/* Give the SFTP file browser its own surface, distinct from the outer page. */
article.col-8 {
background: linear-gradient(145deg, var(--sftp-bg-start), var(--sftp-bg-end));
border-color: rgba(77, 145, 255, 0.3) !important;
border-radius: 1rem !important;
box-shadow: 0 12px 32px rgba(3, 8, 18, 0.2);
}
html[data-bs-theme="light"] article.col-8 {
background: linear-gradient(145deg, #ffffff, #edf3fb);
border-color: rgba(59, 130, 246, 0.4) !important;
box-shadow: 0 18px 48px rgba(31, 58, 92, 0.1);
}
+6 -54
View File
@@ -1,54 +1,6 @@
.editor-container { .editor-container { position:relative; border:1px solid var(--line); border-radius:10px; overflow:hidden; margin-bottom:20px; }
border: 1px solid #ccc; #editor { width:100%; min-height:55vh; padding:16px; font:13px/1.65 ui-monospace,Consolas,monospace; white-space:pre; overflow:auto; resize:vertical; tab-size:4; border:0; border-radius:0; background:var(--bg); }
border-radius: 4px; .editor-container:has(.line-numbers) #editor { padding-left:64px; }
margin-bottom: 20px; .line-numbers { position:absolute; left:0; top:0; width:48px; text-align:right; padding:16px 10px 16px 0; border-right:1px solid var(--line); color:var(--muted); user-select:none; font:13px/1.65 ui-monospace,Consolas,monospace; overflow:hidden; z-index:1; background:var(--surface-alt); }
position: relative; .readonly-notice { color:var(--muted); font-size:12px; }
} .editor-page .d-flex { flex-wrap:wrap; gap:8px; }
#editor {
width: 100%;
min-height: 400px;
font-family: monospace;
padding: 10px;
white-space: pre;
overflow: auto;
resize: vertical;
tab-size: 4;
-moz-tab-size: 4;
padding-left: 55px; /* Make room for line numbers */
}
.line-numbers {
position: absolute;
left: 0;
top: 0;
width: 45px;
text-align: right;
padding: 10px 5px 10px 0;
border-right: 1px solid #ccc;
color: #999;
user-select: none;
font-family: monospace;
overflow: hidden;
z-index: 1;
background-color: #f8f9fa; /* Light mode default */
}
[data-bs-theme="dark"] .line-numbers {
background-color: #212529; /* Dark mode background */
border-right-color: #495057; /* Darker border for dark mode */
color: #6c757d; /* Lighter text for dark mode */
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.readonly-notice {
color: #dc3545;
font-weight: bold;
margin-left: 10px;
}
+15 -253
View File
@@ -1,253 +1,15 @@
body { .pdf-workspace { padding:16px; }
min-height: 100vh; #pdfContainer { position:relative; width:100%; min-height:380px; display:flex; flex-direction:column; align-items:center; padding:16px 0; overflow:auto; background:var(--bg); border:1px solid var(--line); border-radius:8px; }
margin: 0; #loadingMessage { position:absolute; inset:0; background:var(--bg); z-index:1; color:var(--muted); }
padding: 0; .page-container { background:white; box-shadow:0 4px 20px #0002; max-width:100%; overflow:hidden; }
} .page-container canvas { display:block; }
#pdfContainer { .controls { display:flex; justify-content:center; margin-top:16px; }
width: 100%; .controls .btn-group { flex-wrap:wrap; justify-content:center; gap:6px; }
min-height: calc(100vh - 80px); .controls .btn { font-size:12px; }
display: flex; .actionButton { display:flex; justify-content:center; margin-top:12px; }
flex-direction: column; .actionButton .btn { margin:0; }
align-items: center; .pdf-workspace.fullscreen-mode { position:fixed; inset:0; width:100%; height:100dvh; z-index:9999; background:var(--bg); padding:0; border:0; border-radius:0; display:flex; flex-direction:column; }
padding: 20px 0; .fullscreen-mode #pdfContainer { flex:1; height:100%; min-height:0; padding:0; border:0; border-radius:0; justify-content:center; }
} .fullscreen-mode .controls { position:absolute; bottom:12px; left:50%; transform:translateX(-50%); margin:0; padding:8px; border:1px solid var(--line); border-radius:10px; background:var(--surface); max-width:calc(100% - 24px); width:max-content; z-index:2; }
#loadingMessage { .fullscreen-mode .actionButton { display:none; }
position: absolute; @media(max-width:600px) { .pdf-workspace { padding:10px; } .controls .btn { padding:7px 10px; } }
top: 0;
left: 0;
z-index: 1000;
background-color: var(--bs-body-bg);
}
[data-bs-theme="dark"] #loadingMessage {
background-color: rgba(33, 37, 41, 0.9);
}
[data-bs-theme="light"] #loadingMessage {
background-color: rgba(248, 249, 250, 0.9);
}
.page-container {
background-color: white;
margin: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
border-radius: 4px;
overflow: hidden;
}
.controls {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
}
canvas {
display: block;
}
footer {
margin-top: auto;
padding: 20px;
border-top: 1px solid #ddd;
width: 100%;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
.file-info table {
table-layout: fixed;
word-wrap: break-word;
}
.actionButton {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
}
.fullscreen-mode {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-color: var(--bs-body-bg);
z-index: 9999;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.fullscreen-mode header,
.fullscreen-mode footer,
.fullscreen-mode .theme-toggle,
.fullscreen-mode .actionButton {
display: none !important;
}
.fullscreen-mode #pdfContainer {
flex: 1;
margin: 0;
padding: 0;
height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fullscreen-mode .controls {
position: fixed;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
margin: 0;
padding: 6px 12px;
background-color: rgba(var(--bs-body-bg-rgb), 0.3);
backdrop-filter: blur(5px);
transition: all 0.3s ease;
opacity: 0.3;
z-index: 1000;
border-radius: .375rem;
width: auto;
max-width: 95%;
}
.fullscreen-mode .controls:hover {
opacity: 1;
background-color: rgba(var(--bs-body-bg-rgb), 0.9);
box-shadow: 0 0 15px rgba(0,0,0,0.2);
}
.fullscreen-mode[data-bs-theme="light"] .btn-light {
background-color: rgba(233, 236, 239, 0.7);
border-color: rgba(222, 226, 230, 0.7);
}
.fullscreen-mode[data-bs-theme="dark"] .btn-light {
background-color: rgba(52, 58, 64, 0.7);
border-color: rgba(73, 80, 87, 0.7);
}
.fullscreen-mode .btn-light:hover {
transform: scale(1.05);
transition: transform 0.2s ease;
}
.fullscreen-mode .btn-light.disabled {
background-color: rgba(var(--bs-body-bg-rgb), 0.5);
}
.fullscreen-mode .page-container {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
height: 100vh;
width: 100%;
}
.fullscreen-mode canvas {
max-width: 100%;
max-height: 100vh;
width: 100% !important;
height: auto !important;
margin: 0;
object-fit: contain;
}
.fullscreen-mode .btn-group {
display: flex;
align-items: center;
gap: 4px;
}
.fullscreen-mode .btn-group .btn {
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.fullscreen-mode .btn-group .btn.disabled {
min-width: 120px;
padding: 6px 12px;
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.fullscreen-mode .controls {
bottom: 5px;
padding: 4px 8px;
width: auto;
max-width: calc(100% - 20px);
}
.fullscreen-mode .btn-group .btn {
min-width: 36px;
padding: 4px 6px;
}
.fullscreen-mode .btn-group .btn.disabled {
min-width: 110px;
padding: 4px 8px;
}
.fullscreen-mode #pageNum,
.fullscreen-mode #pageCount {
font-size: 0.875rem;
margin: 0 2px;
}
.fullscreen-mode .btn-group {
gap: 3px;
}
/* Add spacing between page numbers and "of" text */
.fullscreen-mode .btn-light.disabled span {
margin: 0 2px;
}
}
/* Theme-specific button styles */
[data-bs-theme="light"] .btn-light {
background-color: #e9ecef;
border-color: #dee2e6;
color: #212529;
}
[data-bs-theme="light"] .btn-light:hover {
background-color: #dde2e6;
border-color: #ced4da;
color: #000;
}
[data-bs-theme="dark"] .btn-light {
background-color: #343a40;
border-color: #495057;
color: #f8f9fa;
}
[data-bs-theme="dark"] .btn-light:hover {
background-color: #495057;
border-color: #6c757d;
color: #fff;
}
/* Button group specific styles */
.btn-group .btn-light {
margin: 0 1px;
}
.btn-group .btn-light.disabled {
opacity: 0.8;
}
+18 -124
View File
@@ -1,124 +1,18 @@
/* Layout */ .stats-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:22px; }
body { .stats-cell { min-width:0; }
min-height: 100vh; .monitor-card { padding:0; overflow:hidden; box-shadow:var(--shadow); }
margin: 0; .monitor-card .card-header { padding:18px 22px; background:var(--surface); border-bottom:1px solid var(--line); }
padding: 0; .monitor-card h3 { font-size:14px; }
display: flex; .monitor-card .card-body { padding:24px; min-height:235px; }
flex-direction: column; .monitor-card .card-body > .text-muted { overflow-wrap:anywhere; }
} .monitor-card .row { flex-wrap:nowrap; gap:10px; }
.monitor-card .col-4 { min-width:0; }
footer { .monitor-card .fw-bold { font-size:13px; overflow-wrap:anywhere; margin-top:6px; }
margin-top: auto; .monitor-card .small { font-size:11px; }
padding: 20px; .cpu-gauge { min-height:185px; display:grid; place-items:center; }
border-top: 1px solid var(--bs-border-color); .raid-summary { text-align:center; padding:20px 0; }
width: 100%; .raid-summary > .ui-icon { font-size:30px; margin-bottom:12px; }
} .monitor-card .progress { margin-top:8px; }
.monitor-card .resource-section + .resource-section { margin-top:24px; }
/* Monitor Cards */ @media(max-width:1000px) { .stats-grid { grid-template-columns:1fr; } }
.monitor-card { @media(max-width:600px) { .monitor-card .card-body { padding:18px; } .monitor-card .row { gap:6px; } .monitor-card .p-2 { padding:10px 6px; } }
transition: transform 0.2s ease-in-out;
height: 100%;
}
.monitor-card:hover {
transform: translateY(-5px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.monitor-card .card-body {
display: flex;
flex-direction: column;
justify-content: center;
padding: 1.5rem;
min-height: 320px;
}
.monitor-header {
background-color: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.progress {
background-color: var(--bs-tertiary-bg);
}
/* CPU Gauge */
.cpu-gauge {
position: relative;
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.cpu-gauge canvas {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.cpu-gauge .position-relative {
position: absolute !important;
top: 50%;
left: 50%;
transform: translate(-50%, -55%);
z-index: 2;
width: auto;
text-align: center;
line-height: 1.2;
}
.cpu-gauge .h3 {
font-size: 1.75rem;
line-height: 1;
margin: 0;
}
.cpu-gauge .small {
opacity: 0.75;
margin-top: 3px;
}
/* Theme & Logo */
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
/* Grid Spacing */
.row.g-4 {
--bs-gutter-y: 2rem;
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 0.5rem;
}
.h4 {
font-size: 1.25rem;
}
.monitor-card .card-body {
padding: 1rem;
min-height: 280px;
}
.row.g-4 > [class*="col-"] {
margin-bottom: 1rem;
}
}
+11 -86
View File
@@ -1,86 +1,11 @@
html, body { .media-container { display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:12px; padding:24px; background:var(--bg); border:1px solid var(--line); border-radius:10px; min-height:200px; }
height: 100%; .media-container img,.media-container video { display:block; max-width:100%; max-height:65vh; object-fit:contain; flex-basis:100%; }
margin: 0; .media-container audio { display:block; width:100%; max-width:680px; margin:28px auto; flex-basis:100%; }
overflow-x: hidden; .media-container .btn { margin:0; }
} .file-info { max-width:760px; }
.file-info h4 { font-size:16px; }
.custom-container { .file-info table { width:100%; table-layout:fixed; }
display: flex; .file-info th { width:160px; }
flex-direction: column; .file-info td { overflow-wrap:anywhere; font-size:12px; }
min-height: 100vh; .media-page .input-group { flex-wrap:wrap; } .media-page .input-group input { flex:1; min-width:160px; }
margin: 0; @media(max-width:600px) { .media-container { padding:12px; } .file-info th { width:110px; } }
padding: 0;
max-width: 100%;
}
.media-container {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
max-height: 80vh;
margin: 0 auto;
overflow: visible;
flex-direction: column;
}
.media-container img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.media-container video {
max-width: 100%;
max-height: 70vh;
}
.media-container audio {
width: 100%;
max-width: 600px;
margin: 20px 0;
}
.controls {
margin: 20px 0;
text-align: center;
}
footer {
margin-top: auto;
padding: 20px;
border-top: 1px solid #ddd;
width: 100%;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
.file-info table {
table-layout: fixed;
word-wrap: break-word;
}
@media (max-width: 576px) {
.file-info table {
width: 95% !important;
}
.file-info th {
width: 40%;
}
.file-info td {
width: 60%;
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ $sftp = initializeSFTP();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) {
$itemToDelete = $_POST['delete']; $itemToDelete = $_POST['delete'];
$parentDir = dirname($itemToDelete); $parentDir = remoteDirname($itemToDelete);
if ($parentDir === '/' || $parentDir === '.') { if ($parentDir === '/' || $parentDir === '.') {
$parentDir = $defaultPath; $parentDir = $defaultPath;
+1 -1
View File
@@ -41,7 +41,7 @@ try {
$fullRemotePath = rtrim($currentPath, '/') . '/' . $file; $fullRemotePath = rtrim($currentPath, '/') . '/' . $file;
$baseDirName = basename($remoteBasePath); $baseDirName = basename($remoteBasePath);
$relPathFromBase = substr($fullRemotePath, strlen(dirname($remoteBasePath)) + 1); $relPathFromBase = substr($fullRemotePath, strlen(rtrim(remoteDirname($remoteBasePath), '/')) + 1);
file_put_contents($logFile, "Processing: $fullRemotePath (relative: $relPathFromBase)\n", FILE_APPEND); file_put_contents($logFile, "Processing: $fullRemotePath (relative: $relPathFromBase)\n", FILE_APPEND);
$isDir = $sftp->is_dir($fullRemotePath); $isDir = $sftp->is_dir($fullRemotePath);
+1 -1
View File
@@ -24,7 +24,7 @@ $sftp = initializeSFTP();
// Process extraction request // Process extraction request
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['archive'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['archive'])) {
$archivePath = $_POST['archive']; $archivePath = $_POST['archive'];
$dirPath = dirname($archivePath); $dirPath = remoteDirname($archivePath);
$archiveName = basename($archivePath); $archiveName = basename($archivePath);
$extension = pathinfo($archiveName, PATHINFO_EXTENSION); $extension = pathinfo($archiveName, PATHINFO_EXTENSION);
+75 -103
View File
@@ -13,10 +13,12 @@
error_reporting(E_ALL); error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
$defaultPath = normalizePath($defaultPath);
$currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath; $currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath;
if (strpos($currentPath, $defaultPath) !== 0) { if ($currentPath !== $defaultPath && strpos($currentPath, rtrim($defaultPath, '/') . '/') !== 0) {
$currentPath = $defaultPath; $currentPath = $defaultPath;
} }
@@ -35,51 +37,41 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>FTP</title> <title>Files · USB RAID</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/index.css') ?>">
<link rel="stylesheet" href="css/index.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'files',
<i class="bi"></i> 'section' => 'Files',
<span id="themeText"></span> 'eyebrow' => 'Your remote storage',
</button> 'title' => 'All files',
</div> 'description' => 'Everything in its place. Browse, manage and transfer your files.',
<div class="custom-container dark-box"> 'connected' => true,
<header class="row border-bottom m-5"> ]); ?>
<h1>USB RAID Array</h1> <nav class="pathbar" aria-label="Directory path"><a href="index.php" title="Home directory">⌂ <span>Home</span></a><?php
<div class="mb-3 p-3"> $relativePath = trim(substr($currentPath, strlen(rtrim($defaultPath, '/'))), '/');
<a href="../logout.php" class="btn btn-danger">Logout</a> $crumbPath = rtrim($defaultPath, '/');
<a href="../changepassword.php" class="btn btn-warning">Change Password</a> foreach (array_filter(explode('/', $relativePath), 'strlen') as $segment):
<?php if (isset($_SESSION["admin"]) && $_SESSION["admin"] == true) { ?> $crumbPath .= '/' . $segment;
<a href="../adminpanel.php" class="btn btn-primary">Admin Panel</a> ?><span class="path-separator" aria-hidden="true">/</span><a href="?path=<?= urlencode($crumbPath) ?>"><?= htmlspecialchars($segment) ?></a><?php endforeach; ?><span class="path-label" title="<?= htmlspecialchars($currentPath) ?>"><?= htmlspecialchars($currentPath) ?></span></nav>
<a href="serverstat.php" class="btn btn-primary">Server Status</a> <section class="file-panel" aria-label="File browser">
<?php } ?> <div class="browser-toolbar"><label class="search-field"><span aria-hidden="true">⌕</span><input id="fileSearch" type="search" placeholder="Search this folder…" aria-label="Search this folder"><kbd>/</kbd></label><select id="typeFilter" class="form-select" aria-label="Filter by type"><option value="all">All types</option><option value="folder">Folders</option><option value="file">Files</option></select></div>
</div> <div class="file-content">
</header>
<section class="row">
<article class="col-8 border border-2 border-primary rounded p-2">
<div class="col">
<h4>Current Path: <?= htmlspecialchars($currentPath) ?></h4>
</div>
<div class="col">
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createFileModal">Create File</button> <button type="button" class="btn" data-dialog="createFileModal">+ New file</button>
<button type="button" class="btn btn-success" onclick="document.getElementById('fileInput').click()">Upload Files</button> <button type="button" class="btn btn-primary" onclick="document.getElementById('fileInput').click()">↑ Upload files</button>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createDirModal">Create Directory</button> <button type="button" class="btn" data-dialog="createDirModal">+ New folder</button>
<button type="button" class="btn btn-info" onclick="document.getElementById('dirInput').click()">Upload Directory</button> <button type="button" class="btn btn-info" onclick="document.getElementById('dirInput').click()">Upload folder</button>
<input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileSelect(event)"> <input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileSelect(event)">
<input type="file" id="dirInput" webkitdirectory directory multiple style="display: none;" onchange="handleFileSelect(event)"> <input type="file" id="dirInput" webkitdirectory directory multiple style="display: none;" onchange="handleFileSelect(event)">
<?php } ?> <?php } ?>
@@ -87,57 +79,57 @@
<!-- Modal Forms --> <!-- Modal Forms -->
<!-- Create Directory Dialog --> <!-- Create Directory Dialog -->
<div class="modal fade" id="createDirModal" tabindex="-1" aria-labelledby="createDirModalLabel" aria-hidden="true"> <dialog class="modal" id="createDirModal" tabindex="-1" aria-labelledby="createDirModalLabel">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="createDirModalLabel">Create New Directory</h5> <h5 class="modal-title" id="createDirModalLabel">Create New Directory</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-dialog-close aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="dirName" class="form-label">Directory Name</label> <label for="dirName" class="form-label">Directory Name</label>
<input type="text" class="form-control" id="dirName" placeholder="Enter directory name"> <input type="text" class="form-control" id="dirName" autofocus placeholder="Enter directory name">
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dialog-close>Cancel</button>
<button type="button" class="btn btn-primary" onclick="createDirectory()">Create</button> <button type="button" class="btn btn-primary" onclick="createDirectory()">Create</button>
</div> </div>
</div> </div>
</div> </div>
</div> </dialog>
<!-- Create File Dialog --> <!-- Create File Dialog -->
<div class="modal fade" id="createFileModal" tabindex="-1" aria-labelledby="createFileModalLabel" aria-hidden="true"> <dialog class="modal" id="createFileModal" tabindex="-1" aria-labelledby="createFileModalLabel">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="createFileModalLabel">Create New File</h5> <h5 class="modal-title" id="createFileModalLabel">Create New File</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-dialog-close aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="fileName" class="form-label">File Name (with extension)</label> <label for="fileName" class="form-label">File Name (with extension)</label>
<input type="text" class="form-control" id="fileName" placeholder="example.txt"> <input type="text" class="form-control" id="fileName" autofocus placeholder="example.txt">
<div class="form-text">Supported extensions: .txt, .html, .css, .js, .php, etc.</div> <div class="form-text">Supported extensions: .txt, .html, .css, .js, .php, etc.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dialog-close>Cancel</button>
<button type="button" class="btn btn-primary" onclick="createFile()">Create</button> <button type="button" class="btn btn-primary" onclick="createFile()">Create</button>
</div> </div>
</div> </div>
</div> </div>
</div> </dialog>
<!-- Rename Confirmation Dialog --> <!-- Rename Confirmation Dialog -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true"> <dialog class="modal" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="renameModalLabel">Rename Item</h5> <h5 class="modal-title" id="renameModalLabel">Rename Item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-dialog-close aria-label="Close"></button>
</div> </div>
<form method="POST" action="rename.php"> <form method="POST" action="rename.php">
<div class="modal-body"> <div class="modal-body">
@@ -149,31 +141,31 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="newName" class="form-label">New Name:</label> <label for="newName" class="form-label">New Name:</label>
<input type="text" class="form-control" id="newName" name="newName" required> <input type="text" class="form-control" id="newName" autofocus name="newName" required>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dialog-close>Cancel</button>
<button type="submit" class="btn btn-primary">Rename</button> <button type="submit" class="btn btn-primary">Rename</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </dialog>
<!-- End of Modal Forms --> <!-- End of Modal Forms -->
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?>
<div class="dropzone" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleDrop(event)"> <div class="dropzone" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleDrop(event)">
Drop files or folders here to upload <span class="upload-symbol" aria-hidden="true">↑</span><span><strong>Drop files or folders here</strong><small>Or use the upload buttons to browse your device</small></span>
</div> </div>
<?php } ?> <?php } ?>
<table> <div class="file-table-scroll"><table class="file-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th scope="col">Name</th>
<th>Size</th> <th scope="col">Size</th>
<th>Actions</th> <th scope="col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -197,31 +189,30 @@
if ($currentPath !== $defaultPath) : ?> if ($currentPath !== $defaultPath) : ?>
<tr> <tr>
<td colspan="3"><a class="text-danger" href="?path=<?= urlencode(dirname($currentPath)) ?>"><b>.. (Go Back)</b></a></td> <td colspan="3"><a class="text-danger" href="?path=<?= urlencode(remoteDirname($currentPath)) ?>"><b>↑ Parent folder</b></a></td>
</tr> </tr>
<?php endif; <?php endif;
foreach ($directories as $directory) : ?> foreach ($directories as $directory) : ?>
<tr> <tr data-file-name="<?= htmlspecialchars($directory) ?>" data-kind="folder">
<td colspan="2"> <td><span class="file-icon folder-icon" aria-hidden="true">▰</span>
<a href="?path=<?= urlencode($currentPath . '/' . $directory) ?>"><?= htmlspecialchars($directory) ?>/</a> <a href="?path=<?= urlencode(rtrim($currentPath, '/') . '/' . $directory) ?>"><?= htmlspecialchars($directory) ?></a></td><td class="size-cell">—</td>
</td>
<td> <td>
<?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?> <?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?>
<form method="POST" action="delete.php" style="display:inline;"> <form method="POST" action="delete.php" style="display:inline;">
<input type="hidden" name="delete" value="<?= htmlspecialchars($currentPath . '/' . $directory) ?>"> <input type="hidden" name="delete" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $directory) ?>">
<button type="submit" class="btn btn-danger mt-1 mb-1" onclick="confirmDelete(event)">Delete</button> <button type="submit" class="btn btn-danger mt-1 mb-1" onclick="confirmDelete(event)">Delete</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?> <?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?>
<form method="POST" action="download.php" style="display:inline;"> <form method="POST" action="download.php" style="display:inline;">
<input type="hidden" name="file" value="<?= htmlspecialchars($currentPath . '/' . $directory) ?>"> <input type="hidden" name="file" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $directory) ?>">
<button type="submit" class="btn btn-success mt-1 mb-1">Download</button> <button type="submit" class="btn btn-success mt-1 mb-1">Download</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?>
<button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($directory)) ?>', '<?= htmlspecialchars(addslashes($currentPath . '/' . $directory)) ?>')">Rename</button> <button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($directory)) ?>', '<?= htmlspecialchars(addslashes(rtrim($currentPath, '/') . '/' . $directory)) ?>', true)">Rename</button>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
@@ -242,11 +233,10 @@
$isArchive = true; $isArchive = true;
} }
?> ?>
<tr> <tr data-file-name="<?= htmlspecialchars($file) ?>" data-kind="file">
<td> <td><span class="file-icon" aria-hidden="true">▤</span>
<?php if ($isMedia): ?> <?php if ($isMedia): ?>
<a class="text-info-emphasis" href="view.php?file=<?= urlencode($currentPath . '/' . $file) ?>"> <a class="text-info-emphasis" href="view.php?file=<?= urlencode(rtrim($currentPath, '/') . '/' . $file) ?>">
<?= htmlspecialchars($file) ?> <?= htmlspecialchars($file) ?>
</a> </a>
<?php if ($isImage): ?> <?php if ($isImage): ?>
@@ -257,12 +247,12 @@
<span class="badge bg-info rounded-pill">Audio</span> <span class="badge bg-info rounded-pill">Audio</span>
<?php endif; ?> <?php endif; ?>
<?php elseif ($isEditable): ?> <?php elseif ($isEditable): ?>
<a class="text-warning-emphasis" href="open.php?file=<?= urlencode($currentPath . '/' . $file) ?>"> <a class="text-warning-emphasis" href="open.php?file=<?= urlencode(rtrim($currentPath, '/') . '/' . $file) ?>">
<?= htmlspecialchars($file) ?> <?= htmlspecialchars($file) ?>
</a> </a>
<span class="badge bg-secondary rounded-pill"><?= htmlspecialchars($fileExtension) ?></span> <span class="badge bg-secondary rounded-pill"><?= htmlspecialchars($fileExtension) ?></span>
<?php elseif ($isPDF): ?> <?php elseif ($isPDF): ?>
<a class="text-info-emphasis" href="pdf.php?file=<?= urlencode($currentPath . '/' . $file) ?>&type=pdf"> <a class="text-info-emphasis" href="pdf.php?file=<?= urlencode(rtrim($currentPath, '/') . '/' . $file) ?>&type=pdf">
<?= htmlspecialchars($file) ?> <?= htmlspecialchars($file) ?>
</a> </a>
<span class="badge bg-danger rounded-pill">PDF</span> <span class="badge bg-danger rounded-pill">PDF</span>
@@ -272,28 +262,28 @@
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<span class="text"><?= formatBytes($sftp->stat($currentPath . '/' . $file)['size']) ?></span> <span class="text"><?= formatBytes($sftp->stat(rtrim($currentPath, '/') . '/' . $file)['size']) ?></span>
</td> </td>
<td> <td>
<?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?> <?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?>
<form method="POST" action="delete.php" style="display:inline;"> <form method="POST" action="delete.php" style="display:inline;">
<input type="hidden" name="delete" value="<?= htmlspecialchars($currentPath . '/' . $file) ?>"> <input type="hidden" name="delete" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $file) ?>">
<button type="submit" class="btn btn-danger mt-1 mb-1" onclick="confirmDelete(event)">Delete</button> <button type="submit" class="btn btn-danger mt-1 mb-1" onclick="confirmDelete(event)">Delete</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?> <?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?>
<form method="POST" action="download.php" style="display:inline;"> <form method="POST" action="download.php" style="display:inline;">
<input type="hidden" name="file" value="<?= htmlspecialchars($currentPath . '/' . $file) ?>"> <input type="hidden" name="file" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $file) ?>">
<button type="submit" class="btn btn-success mt-1 mb-1">Download</button> <button type="submit" class="btn btn-success mt-1 mb-1">Download</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?>
<button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($file)) ?>', '<?= htmlspecialchars(addslashes($currentPath . '/' . $file)) ?>')">Rename</button> <button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($file)) ?>', '<?= htmlspecialchars(addslashes(rtrim($currentPath, '/') . '/' . $file)) ?>')">Rename</button>
<?php endif; ?> <?php endif; ?>
<?php if ($isArchive && isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?> <?php if ($isArchive && isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?>
<form method="POST" action="extract.php" style="display:inline;"> <form method="POST" action="extract.php" style="display:inline;">
<input type="hidden" name="archive" value="<?= htmlspecialchars($currentPath . '/' . $file) ?>"> <input type="hidden" name="archive" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $file) ?>">
<button type="submit" class="btn btn-info mt-1 mb-1">Extract</button> <button type="submit" class="btn btn-info mt-1 mb-1">Extract</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
@@ -301,36 +291,18 @@
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table></div>
<div id="emptySearch" class="empty-state" hidden><span aria-hidden="true">⌕</span><h3>No files to show</h3><p>Try another search or upload files to this folder.</p></div>
<div class="browser-status"><span id="fileCount" role="status"></span><span>Folders first · A–Z</span></div>
</div> </div>
</article> </section>
<?php renderWorkspaceEnd(); ?>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3">
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<script>
// Keep Bootstrap modals outside the filtered SFTP container. A parent
// backdrop-filter creates a stacking context that can block modal input.
document.querySelectorAll('.modal').forEach(function (modal) {
document.body.appendChild(modal);
});
</script>
<script> <script>
// Pass PHP variables to JavaScript // Pass PHP variables to JavaScript
var currentPath = "<?php echo $currentPath; ?>"; const currentPath = <?= json_encode($currentPath, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script> </script>
<script src="js/index.js"></script> <script src="<?= workspaceAsset('../../', 'content/ftp/js/index.js') ?>"></script>
</body> </body>
</html> </html>
+30 -45
View File
@@ -158,6 +158,10 @@ function uploadFiles(files) {
} }
}; };
uploadStatus.setAttribute('role', 'status');
xhr.upload.addEventListener('progress', event => {
if (event.lengthComputable) uploadStatus.textContent = 'Uploading ' + filesAdded + ' file(s) · ' + Math.round(event.loaded / event.total * 100) + '%';
});
xhr.send(formData); xhr.send(formData);
} }
@@ -178,8 +182,7 @@ function createDirectory() {
xhr.onload = () => { xhr.onload = () => {
if (xhr.status === 200) { if (xhr.status === 200) {
alert('Directory created successfully!'); alert('Directory created successfully!');
const modal = bootstrap.Modal.getInstance(document.getElementById('createDirModal')); document.getElementById('createDirModal').close();
if (modal) modal.hide();
window.location.reload(); window.location.reload();
} else { } else {
alert('Failed to create directory: ' + xhr.responseText || xhr.statusText); alert('Failed to create directory: ' + xhr.responseText || xhr.statusText);
@@ -221,8 +224,7 @@ function createFile() {
const response = JSON.parse(xhr.responseText); const response = JSON.parse(xhr.responseText);
if (response.success) { if (response.success) {
alert('File created successfully!'); alert('File created successfully!');
const modal = bootstrap.Modal.getInstance(document.getElementById('createFileModal')); document.getElementById('createFileModal').close();
if (modal) modal.hide();
window.location.reload(); window.location.reload();
} else { } else {
alert('Failed to create file: ' + (response.message || 'Unknown error')); alert('Failed to create file: ' + (response.message || 'Unknown error'));
@@ -236,53 +238,36 @@ function createFile() {
}; };
xhr.send(formData); xhr.send(formData);
} }
function openRenameModal(name, path) { function openRenameModal(name, path, isDirectory = false) {
document.getElementById('originalName').value = name; document.getElementById('originalName').value = name;
document.getElementById('fullPath').value = path; document.getElementById('fullPath').value = path;
document.getElementById('isDirectory').value = name.endsWith('/') ? '1' : '0'; document.getElementById('isDirectory').value = isDirectory ? '1' : '0';
document.getElementById('newName').value = name.endsWith('/') ? name.slice(0, -1) : name; document.getElementById('newName').value = name.endsWith('/') ? name.slice(0, -1) : name;
const renameModal = new bootstrap.Modal(document.getElementById('renameModal')); document.getElementById('renameModal').showModal();
renameModal.show();
} }
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('themeToggle');
const html = document.documentElement;
const themeText = document.getElementById('themeText');
const themeIcon = themeToggle.querySelector('.bi');
function setTheme(theme) { // Filtering is local to the current directory; no remote request is needed.
html.setAttribute('data-bs-theme', theme); const search = document.getElementById('fileSearch');
document.body.classList.remove('theme-light', 'theme-dark'); const typeFilter = document.getElementById('typeFilter');
document.body.classList.add('theme-' + theme); function filterFiles() {
localStorage.setItem('theme', theme); const query = search.value.trim().toLocaleLowerCase();
const rows = [...document.querySelectorAll('[data-file-name]')];
if (theme === 'dark') { let visible = 0;
themeText.textContent = 'Light Mode'; rows.forEach(row => {
themeIcon.className = 'bi bi-sun'; row.hidden = !row.dataset.fileName.toLocaleLowerCase().includes(query) || (typeFilter.value !== 'all' && row.dataset.kind !== typeFilter.value);
themeToggle.classList.remove('btn-dark'); if (!row.hidden) visible++;
themeToggle.classList.add('btn-light');
} else {
themeText.textContent = 'Dark Mode';
themeIcon.className = 'bi bi-moon';
themeToggle.classList.remove('btn-light');
themeToggle.classList.add('btn-dark');
}
}
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme) {
setTheme(savedTheme);
} else {
setTheme(prefersDark ? 'dark' : 'light');
}
themeToggle.addEventListener('click', function() {
const currentTheme = html.getAttribute('data-bs-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}); });
document.getElementById('fileCount').textContent = visible + ' of ' + rows.length + ' items';
document.getElementById('emptySearch').hidden = visible !== 0;
}
search?.addEventListener('input', filterFiles);
typeFilter?.addEventListener('change', filterFiles);
if (search) filterFiles();
document.addEventListener('keydown', event => {
if (event.key === '/' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.target.closest('input, textarea, select, [contenteditable], dialog')) {
event.preventDefault();
search?.focus();
}
}); });
+108
View File
@@ -0,0 +1,108 @@
<?php
// Pure parsers are kept separate so unavailable or malformed SSH output can be tested.
function getDiskData($ssh, $path) {
// The command runs on Linux even when PHP runs on Windows. escapeshellarg()
// follows the local OS, so quote explicitly for the remote POSIX shell.
$quotedPath = "'" . str_replace("'", "'\\''", $path) . "'";
return parseDiskData($ssh->exec('LC_ALL=C df -Pk -- ' . $quotedPath . ' 2>/dev/null'));
}
function getSystemData($ssh, $storagePath) {
$meminfo = $ssh->exec("LC_ALL=C free -m | awk '/^Mem:/{printf \"%s,%s,%s,\", \$2,\$3,\$7} /^Swap:/{printf \"%s,%s,%s\", \$2,\$3,\$4}'");
$cpuIdle = $ssh->exec("LC_ALL=C vmstat 1 2 | tail -1 | awk '{print \$15}'");
return [
'storage' => [
'system' => getDiskData($ssh, '/'),
'data' => getDiskData($ssh, $storagePath),
],
'raid' => parseRaidStatus($ssh->exec('cat /proc/mdstat 2>/dev/null')),
'resources' => parseSystemStats($cpuIdle, $meminfo),
'lastUpdate' => date('Y-m-d H:i:s'),
];
}
function parseDiskData($output) {
$unavailable = ['available_data' => false, 'size' => 'N/A', 'used' => 'N/A', 'available' => 'N/A', 'usage' => 'N/A', 'filesystem' => ''];
if (!is_string($output)) return $unavailable;
foreach (preg_split('/\r?\n/', trim($output)) as $line) {
// POSIX df -Pk gives one filesystem per line and sizes in KiB.
if (!preg_match('/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/', trim($line), $parts)) continue;
return [
'available_data' => true,
'filesystem' => $parts[1],
'size' => formatBytes((float) $parts[2] * 1024),
'used' => formatBytes((float) $parts[3] * 1024),
'available' => formatBytes((float) $parts[4] * 1024),
'usage' => $parts[5] . '%',
];
}
return $unavailable;
}
function parseRaidStatus($output) {
$status = ['active' => false, 'status' => 'unavailable', 'type' => 'N/A', 'arrays' => []];
if (!is_string($output) || trim($output) === '') return $status;
preg_match_all('/^(md\S+)\s*:\s*(active|inactive)\s+([^\r\n]+)((?:\r?\n[ \t]+[^\r\n]*)*)/m', $output, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$details = $match[3] . $match[4];
preg_match('/\b(raid\d+|linear|multipath)\b/', $match[3], $level);
$state = $match[2] === 'active' ? 'unknown' : 'inactive';
if ($state !== 'inactive') {
if (preg_match('/\[([U_]+)\]/', $details, $members)) {
$state = strpos($members[1], '_') !== false ? 'degraded' : 'healthy';
} elseif (in_array($level[1] ?? '', ['raid0', 'linear'], true)) {
$state = 'active'; // Non-redundant arrays do not expose a [UU] health bitmap.
}
if (preg_match('/\b(recovery|resync|reshape|check|repair)\s*=/', $details)) {
if ($state !== 'degraded') $state = 'syncing';
}
if (strpos($details, '(F)') !== false) $state = 'degraded';
}
$status['arrays'][] = ['name' => $match[1], 'type' => $level[1] ?? 'N/A', 'status' => $state];
}
if (!$status['arrays']) {
$status['status'] = strpos($output, 'Personalities :') !== false ? 'not configured' : 'unavailable';
return $status;
}
$states = array_column($status['arrays'], 'status');
$status['active'] = !in_array('inactive', $states, true);
$status['type'] = implode(', ', array_unique(array_column($status['arrays'], 'type')));
foreach (['inactive', 'degraded', 'unknown', 'syncing', 'active', 'healthy'] as $state) {
if (in_array($state, $states, true)) { $status['status'] = $state; break; }
}
return $status;
}
function parseSystemStats($cpuOutput, $memOutput) {
$idle = is_string($cpuOutput) ? str_replace(',', '.', trim($cpuOutput)) : '';
$cpuAvailable = is_numeric($idle) && (float) $idle >= 0 && (float) $idle <= 100;
$cpu = ['available_data' => $cpuAvailable, 'usage' => $cpuAvailable ? number_format(100 - (float) $idle, 1, '.', '') : null];
$memory = array_fill_keys(['total', 'used', 'available', 'usage', 'swap_total', 'swap_used', 'swap_free', 'swap_usage'], 'N/A');
$memory['available_data'] = false;
$parts = is_string($memOutput) ? explode(',', trim($memOutput)) : [];
if (count($parts) === 6 && count(array_filter($parts, 'is_numeric')) === 6 && min(array_map('floatval', $parts)) >= 0 && (float) $parts[0] > 0) {
[$total, $used, $available, $swapTotal, $swapUsed, $swapFree] = array_map('floatval', $parts);
$memory = [
'available_data' => true,
'total' => formatBytes($total * 1024 * 1024),
'used' => formatBytes($used * 1024 * 1024),
'available' => formatBytes($available * 1024 * 1024),
'usage' => min(100, round($used / $total * 100, 1)) . '%',
'swap_total' => formatBytes($swapTotal * 1024 * 1024),
'swap_used' => formatBytes($swapUsed * 1024 * 1024),
'swap_free' => formatBytes($swapFree * 1024 * 1024),
'swap_usage' => ($swapTotal > 0 ? min(100, round($swapUsed / $swapTotal * 100, 1)) : 0) . '%',
];
}
return ['cpu' => $cpu, 'memory' => $memory];
}
function formatBytes($bytes) {
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
$bytes = max((float) $bytes, 0);
$power = min($bytes > 0 ? (int) floor(log($bytes, 1024)) : 0, count($units) - 1);
return round($bytes / pow(1024, $power), 2) . ' ' . $units[$power];
}
+23 -22
View File
@@ -13,6 +13,7 @@ if(!isset($_SESSION['uname'])){
} }
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
$filePath = isset($_GET['file']) ? $_GET['file'] : ''; $filePath = isset($_GET['file']) ? $_GET['file'] : '';
@@ -51,34 +52,33 @@ if (!empty($filePath) && $sftp->file_exists($filePath) && !$sftp->is_dir($filePa
$showLineNumbers = in_array(strtolower($extension), ['php', 'js', 'html', 'css', 'py', 'java', 'c', 'cpp', 'h', 'hpp', 'rb', 'sh', 'xml', 'json']); $showLineNumbers = in_array(strtolower($extension), ['php', 'js', 'html', 'css', 'py', 'java', 'c', 'cpp', 'h', 'hpp', 'rb', 'sh', 'xml', 'json']);
$parentDir = dirname($filePath); $parentDir = remoteDirname($filePath);
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<title>Edit File - <?= htmlspecialchars($fileName) ?></title> <title>Edit File - <?= htmlspecialchars($fileName) ?></title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/open.css') ?>">
<link rel="stylesheet" href="css/open.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'files',
<i class="bi"></i> 'section' => 'File editor',
<span id="themeText"></span> 'eyebrow' => 'File editor',
</button> 'title' => $fileName,
</div> 'description' => 'View and edit text files in your remote workspace.',
<div class="custom-container dark-box"> 'connected' => true,
<div class="header-container"> 'back' => 'index.php?path=' . urlencode($parentDir),
<h1>Edit File: <?= htmlspecialchars($fileName) ?></h1> 'class' => 'editor-page',
<a href="index.php?path=<?= urlencode($parentDir) ?>" class="btn btn-secondary">Back to File List</a> ]); ?>
</div> <section class="content-panel">
<?php if (isset($error)): ?> <?php if (isset($error)): ?>
<div class="alert alert-danger"><?= $error ?></div> <div class="alert alert-danger"><?= $error ?></div>
<?php elseif ($editable): ?> <?php elseif ($editable): ?>
@@ -113,8 +113,9 @@ $parentDir = dirname($filePath);
<?php endif; ?> <?php endif; ?>
</form> </form>
<?php endif; ?> <?php endif; ?>
</div> </section>
<script src="../../js/theme.js"></script> <?php renderWorkspaceEnd(); ?>
<script> <script>
function updateLineNumbers() { function updateLineNumbers() {
const editor = document.getElementById('editor'); const editor = document.getElementById('editor');
+49 -58
View File
@@ -8,6 +8,7 @@ if(!isset($_SESSION['uname'])){
} }
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
if (!isset($_GET['file'])) { if (!isset($_GET['file'])) {
@@ -111,35 +112,34 @@ if (isset($_GET['stream'])) {
} }
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF Viewer</title> <title>PDF Viewer</title>
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/pdf.css') ?>">
<link rel="stylesheet" href="css/pdf.css">
<script src="../../js/bootstrap.bundle.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.js"></script>
<script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'files',
<i class="bi"></i> 'section' => 'PDF viewer',
<span id="themeText"></span> 'eyebrow' => 'Document preview',
</button> 'title' => $fileName,
</div> 'description' => 'Browse your document without leaving the workspace.',
<div class="custom-container dark-box"> 'connected' => true,
<header class="text-center border-bottom m-5"> 'back' => 'index.php?path=' . urlencode(remoteDirname($filePath)),
<h1 class="mb-4">PDF Viewer</h1> 'class' => 'pdf-page',
<div class="mb-3"> ]); ?>
<a href="index.php?path=<?= urlencode(dirname($filePath)) ?>" class="btn btn-primary">Back to Files</a>
</div>
</header>
<section class="content-panel pdf-workspace" aria-label="PDF document">
<div id="pdfContainer" class="container-fluid position-relative"> <div id="pdfContainer" class="container-fluid position-relative">
<div id="loadingMessage" class="d-flex align-items-center justify-content-center w-100 h-100"> <div id="loadingMessage" class="d-flex align-items-center justify-content-center w-100 h-100">
<div class="bg-dark bg-opacity-75 text-white p-3 rounded"> <div class="bg-dark bg-opacity-75 text-white p-3 rounded">
@@ -151,17 +151,17 @@ if (isset($_GET['stream'])) {
<div class="controls"> <div class="controls">
<div class="btn-group" role="group" aria-label="PDF Navigation"> <div class="btn-group" role="group" aria-label="PDF Navigation">
<button id="prev" class="btn btn-light border"> <button id="prev" class="btn btn-light border" disabled>
<i class="bi bi-chevron-left"></i> Previous <i class="ui-icon icon-chevron-left"></i> Previous
</button> </button>
<button class="btn btn-light border disabled"> <span class="btn btn-light border" role="status">
Page <span id="pageNum"></span> of <span id="pageCount"></span> Page <span id="pageNum"></span> of <span id="pageCount"></span>
</span>
<button id="next" class="btn btn-light border" disabled>
Next <i class="ui-icon icon-chevron-right"></i>
</button> </button>
<button id="next" class="btn btn-light border"> <button id="fullscreen" aria-label="Toggle fullscreen" class="btn btn-light border">
Next <i class="bi bi-chevron-right"></i> <i class="ui-icon icon-fullscreen"></i>
</button>
<button id="fullscreen" class="btn btn-light border">
<i class="bi bi-fullscreen"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -171,26 +171,14 @@ if (isset($_GET['stream'])) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3"> </section>
<span class="text-muted">Developed by Michal Sedlák</span> <?php renderWorkspaceEnd(); ?>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<script> <script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const url = 'pdf.php?file=<?= urlencode($filePath) ?>&stream=1'; const url = 'pdf.php?file=<?= urlencode($filePath) ?>&stream=1';
const pdfFileName = '<?= htmlspecialchars($fileName) ?>'; const pdfFileName = <?= json_encode($fileName, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
let currentPage = 1; let currentPage = 1;
let pdfDoc = null; let pdfDoc = null;
@@ -215,13 +203,14 @@ if (isset($_GET['stream'])) {
currentPage = Math.min(PdfPageStorage.loadPage(), pdfDoc.numPages); currentPage = Math.min(PdfPageStorage.loadPage(), pdfDoc.numPages);
renderPage(currentPage); await renderPage(currentPage);
document.getElementById('loadingMessage').classList.add('d-none');
} catch (error) { } catch (error) {
console.error('Error loading PDF:', error); console.error('Error loading PDF:', error);
const loadingMessage = document.getElementById('loadingMessage'); const loadingMessage = document.getElementById('loadingMessage');
loadingMessage.querySelector('div').classList.add('bg-danger'); if (loadingMessage) {
loadingMessage.querySelector('div').innerHTML = '<i class="bi bi-exclamation-triangle"></i> Error loading PDF'; loadingMessage.querySelector('div').className = 'alert alert-danger';
loadingMessage.querySelector('div').textContent = 'Unable to load this PDF.';
}
} }
} }
@@ -230,7 +219,7 @@ if (isset($_GET['stream'])) {
const pageContainer = document.createElement('div'); const pageContainer = document.createElement('div');
pageContainer.className = 'page-container'; pageContainer.className = 'page-container';
const windowWidth = window.innerWidth; const windowWidth = document.getElementById('pdfContainer').clientWidth - 32;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
const viewport = page.getViewport({ scale: 1.0 }); const viewport = page.getViewport({ scale: 1.0 });
@@ -262,6 +251,8 @@ if (isset($_GET['stream'])) {
document.getElementById('pdfContainer').innerHTML = ''; document.getElementById('pdfContainer').innerHTML = '';
document.getElementById('pdfContainer').appendChild(pageContainer); document.getElementById('pdfContainer').appendChild(pageContainer);
document.getElementById('pageNum').textContent = pageNumber; document.getElementById('pageNum').textContent = pageNumber;
document.getElementById('prev').disabled = pageNumber <= 1;
document.getElementById('next').disabled = pageNumber >= pdfDoc.numPages;
await page.render({ await page.render({
canvasContext: context, canvasContext: context,
@@ -278,7 +269,7 @@ if (isset($_GET['stream'])) {
}); });
document.getElementById('next').addEventListener('click', () => { document.getElementById('next').addEventListener('click', () => {
if (currentPage < pdfDoc.numPages) { if (pdfDoc && currentPage < pdfDoc.numPages) {
currentPage++; currentPage++;
renderPage(currentPage); renderPage(currentPage);
} }
@@ -290,14 +281,14 @@ if (isset($_GET['stream'])) {
} else if (e.key === 'ArrowLeft' && currentPage > 1) { } else if (e.key === 'ArrowLeft' && currentPage > 1) {
currentPage--; currentPage--;
renderPage(currentPage); renderPage(currentPage);
} else if (e.key === 'ArrowRight' && currentPage < pdfDoc.numPages) { } else if (e.key === 'ArrowRight' && pdfDoc && currentPage < pdfDoc.numPages) {
currentPage++; currentPage++;
renderPage(currentPage); renderPage(currentPage);
} }
}); });
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
if (currentPage) { if (pdfDoc) {
renderPage(currentPage); renderPage(currentPage);
} }
}); });
@@ -305,28 +296,28 @@ if (isset($_GET['stream'])) {
let isFullscreen = false; let isFullscreen = false;
document.getElementById('fullscreen').addEventListener('click', () => { document.getElementById('fullscreen').addEventListener('click', () => {
const container = document.querySelector('.custom-container'); const container = document.querySelector('.pdf-workspace');
const fullscreenBtn = document.getElementById('fullscreen'); const fullscreenBtn = document.getElementById('fullscreen');
const fullscreenIcon = fullscreenBtn.querySelector('i'); const fullscreenIcon = fullscreenBtn.querySelector('i');
if (!isFullscreen) { if (!isFullscreen) {
container.classList.add('fullscreen-mode'); container.classList.add('fullscreen-mode');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
fullscreenIcon.classList.remove('bi-fullscreen'); fullscreenIcon.classList.remove('icon-fullscreen');
fullscreenIcon.classList.add('bi-fullscreen-exit'); fullscreenIcon.classList.add('icon-fullscreen-exit');
} else { } else {
container.classList.remove('fullscreen-mode'); container.classList.remove('fullscreen-mode');
document.body.style.overflow = ''; document.body.style.overflow = '';
fullscreenIcon.classList.remove('bi-fullscreen-exit'); fullscreenIcon.classList.remove('icon-fullscreen-exit');
fullscreenIcon.classList.add('bi-fullscreen'); fullscreenIcon.classList.add('icon-fullscreen');
} }
isFullscreen = !isFullscreen; isFullscreen = !isFullscreen;
renderPage(currentPage); if (pdfDoc) renderPage(currentPage);
}); });
loadPDF(); loadPDF();
</script> </script>
<script src="../../js/theme.js"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -26,7 +26,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
die("Missing required information for renaming."); die("Missing required information for renaming.");
} }
$dirPath = dirname($oldPath); $dirPath = remoteDirname($oldPath);
$oldName = basename($oldPath); $oldName = basename($oldPath);
$newPath = $dirPath . '/' . $newName; $newPath = $dirPath . '/' . $newName;
+86 -360
View File
@@ -12,47 +12,20 @@ if (empty($_SESSION['admin']) || $_SESSION['admin'] !== true) {
exit(); exit();
} }
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
use phpseclib3\Net\SSH2;
// $ssh_host = 'localhost';
// $ssh_username = 'uname';
// $ssh_password = 'pswd';
// The configuration helper authenticates using SSH_HOST, SSH_USER and SSH_PASS.
$ssh = getSSHConnection(); $ssh = getSSHConnection();
if (!$ssh->login($ssh_username, $ssh_password)) {
die('SSH login failed');
}
function getDiskData($ssh, $device) { require_once __DIR__ . '/lib/serverstats.php';
return $ssh->exec("df -h $device");
}
function getSystemData($ssh) { $systemData = getSystemData($ssh, $defaultPath);
// Get memory info using awk to clean up the output
$meminfo = $ssh->exec("free -m | awk 'NR==2{printf \"%s,%s,%s,\", $2,$3,$7} NR==3{printf \"%s,%s,%s\", $2,$3,$4}'");
// Use vmstat to get CPU idle percentage, more reliable and commonly available
$cpuIdle = $ssh->exec("vmstat 1 2 | tail -1 | awk '{print $15}'");
return [
'storage' => [
'system' => parseDiskData($ssh->exec('df -h /dev/ubuntu-vg/ubuntu-lv')),
'data' => parseDiskData($ssh->exec('df -h /dev/sdb1'))
],
'raid' => parseRaidStatus($ssh->exec('cat /proc/mdstat')),
'resources' => parseSystemStats($cpuIdle, $meminfo),
'lastUpdate' => date('Y-m-d H:i:s')
];
}
$systemData = getSystemData($ssh);
$monitors = [ $monitors = [
[ [
'id' => 'SystemDisk', 'id' => 'SystemDisk',
'title' => 'System Disk (sda)', 'title' => 'System filesystem',
'icon' => 'hdd', 'icon' => 'hdd',
'color' => 'primary', 'color' => 'primary',
'type' => 'storage', 'type' => 'storage',
@@ -60,7 +33,7 @@ $monitors = [
], ],
[ [
'id' => 'DataDisk', 'id' => 'DataDisk',
'title' => 'Data Disk (sdb)', 'title' => 'Storage filesystem',
'icon' => 'hdd', 'icon' => 'hdd',
'color' => 'primary', 'color' => 'primary',
'type' => 'storage', 'type' => 'storage',
@@ -91,349 +64,101 @@ $monitors = [
] ]
]; ];
function parseDiskData($output) {
$lines = explode("\n", trim($output));
if (count($lines) < 2) return null;
$values = preg_split('/\s+/', trim($lines[1]));
return [
'size' => $values[1] ?? 'N/A',
'used' => $values[2] ?? 'N/A',
'available' => $values[3] ?? 'N/A',
'usage' => $values[4] ?? '0%'
];
}
function parseRaidStatus($output) {
if (empty($output)) {
return [
'active' => false,
'status' => 'unknown',
'type' => 'N/A'
];
}
$status = [
'active' => false,
'status' => 'unknown',
'type' => 'N/A'
];
$lines = explode("\n", trim($output));
foreach ($lines as $line) {
if (preg_match('/active\s+(\w+)/', $line, $matches)) {
$status['active'] = true;
$status['type'] = $matches[1] ?? 'N/A';
}
if (strpos($line, '[UU]') !== false) {
$status['status'] = 'healthy';
} elseif (strpos($line, '_') !== false) {
$status['status'] = 'degraded';
}
}
return $status;
}
function parseSystemStats($cpuOutput, $memOutput) {
// Parse CPU stats - convert idle percentage to usage percentage
$idlePercent = floatval(trim(str_replace(',', '.', $cpuOutput)));
$cpuUsage = 100 - $idlePercent; // Convert idle to usage percentage
if (is_nan($cpuUsage) || $cpuUsage < 0 || $cpuUsage > 100) {
$cpuUsage = 0; // Default to 0% if parsing fails
}
$cpu = ['usage' => number_format($cpuUsage, 1)];
// Parse memory stats
$parts = explode(',', $memOutput);
if (count($parts) >= 6) {
$memTotal = intval($parts[0]);
$memUsed = intval($parts[1]);
$memAvail = intval($parts[2]);
$swapTotal = intval($parts[3]);
$swapUsed = intval($parts[4]);
$swapFree = intval($parts[5]);
$memory = [
'total' => number_format($memTotal / 1024, 2) . 'G',
'used' => number_format($memUsed / 1024, 2) . 'G',
'available' => number_format($memAvail / 1024, 2) . 'G',
'usage' => ($memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0) . '%',
'swap_total' => number_format($swapTotal / 1024, 2) . 'G',
'swap_used' => number_format($swapUsed / 1024, 2) . 'G',
'swap_free' => number_format($swapFree / 1024, 2) . 'G',
'swap_usage' => ($swapTotal > 0 ? round(($swapUsed / $swapTotal) * 100, 1) : 0) . '%'
];
} else {
$memory = [
'total' => '0G',
'used' => '0G',
'available' => '0G',
'usage' => '0%',
'swap_total' => '0G',
'swap_used' => '0G',
'swap_free' => '0G',
'swap_usage' => '0%'
];
}
return ['cpu' => $cpu, 'memory' => $memory];
}
function convertToBytes($size) {
if (preg_match('/^([\d.]+)([KMGT]?)i?B?$/', trim($size), $matches)) {
$value = floatval($matches[1]);
$unit = strtoupper($matches[2]);
switch ($unit) {
case 'P': $value *= 1024;
case 'T': $value *= 1024;
case 'G': $value *= 1024;
case 'M': $value *= 1024;
case 'K': $value *= 1024;
}
return $value;
}
return 0;
}
function formatBytes($bytes, $forceUnit = '') {
$units = ['B', 'K', 'M', 'G', 'T', 'P'];
$bytes = max($bytes, 0);
if ($forceUnit) {
$unitIndex = array_search($forceUnit, $units);
if ($unitIndex !== false) {
$bytes /= pow(1024, $unitIndex);
return round($bytes, 2) . $forceUnit;
}
}
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . $units[$pow];
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<title>Server Status</title> <title>Server Status</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/serverstat.css') ?>">
<link rel="stylesheet" href="css/serverstat.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="container-fluid text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'server',
<i class="bi"></i> 'section' => 'Server status',
<span id="themeText"></span> 'eyebrow' => 'Server overview',
</button> 'title' => 'Server status',
</div> 'description' => 'Keep an eye on your storage, RAID health and system resources.',
'connected' => true,
'class' => 'server-page',
]); ?>
<div class="custom-container dark-box">
<header class="row border-bottom m-5">
<h1>Server Status</h1>
<div class="mb-3 p-3">
<a href="../logout.php" class="btn btn-danger">Logout</a>
<a href="../changepassword.php" class="btn btn-warning">Change Password</a>
<a href="../adminpanel.php" class="btn btn-primary">Admin Panel</a>
<a href="index.php" class="btn btn-primary">SFTP</a>
</div>
</header>
<div class="container py-4"> <div class="content-toolbar"><p>Live statistics · refreshes every 5 seconds</p><span class="badge bg-light text-dark"><i class="ui-icon icon-clock" aria-hidden="true"></i> <?= htmlspecialchars($systemData['lastUpdate']) ?></span></div>
<div class="row justify-content-center"> <div class="stats-grid">
<div class="col-lg-10">
<div class="card shadow-sm bg-body-tertiary">
<div class="card-header monitor-header">
<div class="d-flex justify-content-between align-items-center">
<h1 class="h4 mb-0">
<i class="bi bi-server text-primary me-2"></i>
Server Disk Status
</h1>
<span class="badge bg-light text-dark">
<i class="bi bi-clock me-1"></i>
<?= htmlspecialchars($systemData['lastUpdate']) ?>
</span>
</div>
</div>
<div class="card-body">
<div class="row g-4">
<?php foreach ($monitors as $monitor): ?> <?php foreach ($monitors as $monitor): ?>
<div class="col-md-6"> <section class="stats-cell" aria-label="<?= htmlspecialchars($monitor['title']) ?>">
<div class="monitor-card card h-100 border-<?= $monitor['color'] ?>"> <div class="monitor-card card h-100">
<div class="card-header bg-<?= $monitor['color'] ?> bg-opacity-10"> <div class="card-header"><h3 class="mb-0"><i class="ui-icon icon-<?= $monitor['icon'] ?> text-primary me-2" aria-hidden="true"></i><?= htmlspecialchars($monitor['title']) ?></h3></div>
<h3 class="h5 mb-0">
<i class="bi bi-<?= $monitor['icon'] ?> text-<?= $monitor['color'] ?> me-2"></i>
<?= $monitor['title'] ?>
</h3>
</div>
<div class="card-body"> <div class="card-body">
<?php if ($monitor['type'] === 'storage'): <?php if ($monitor['type'] === 'storage'):
$diskData = $systemData['storage'][$monitor['source']]; $diskData = $systemData['storage'][$monitor['source']];
$usagePercent = intval(rtrim($diskData['usage'], '%'));
?> ?>
<div class="mb-3"> <?php if (!$diskData['available_data']): ?>
<div class="d-flex justify-content-between mb-1"> <p class="text-muted" role="status">Storage statistics are unavailable for this path.</p>
<span>Storage Usage</span> <?php else:
<span><?= $diskData['usage'] ?></span> $usagePercent = min(100, (int) rtrim($diskData['usage'], '%'));
</div>
<div class="progress" style="height: 10px">
<div class="progress-bar bg-<?= $usagePercent > 90 ? 'danger' : ($usagePercent > 75 ? 'warning' : 'success') ?>"
role="progressbar"
style="width: <?= $usagePercent ?>%"
aria-valuenow="<?= $usagePercent ?>"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
<div class="row text-center g-2">
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Total</div>
<div class="fw-bold"><?= $diskData['size'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Used</div>
<div class="fw-bold"><?= $diskData['used'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Free</div>
<div class="fw-bold"><?= $diskData['available'] ?></div>
</div>
</div>
</div>
<?php elseif ($monitor['type'] === 'raid'): ?>
<div class="text-center">
<div class="display-4 mb-2">
<i class="bi bi-<?= $systemData['raid']['active'] ? 'check-circle-fill text-success' : 'x-circle-fill text-danger' ?>"></i>
</div>
<h4 class="h6"><?= $systemData['raid']['type'] ?> Array</h4>
<span class="badge bg-<?= $systemData['raid']['status'] === 'healthy' ? 'success' : 'warning' ?>">
<?= ucfirst($systemData['raid']['status']) ?>
</span>
</div>
<?php elseif ($monitor['type'] === 'resources' && $monitor['source'] === 'cpu'): ?>
<div class="cpu-gauge">
<canvas id="cpuGauge" width="150" height="150" data-value="<?= $systemData['resources']['cpu']['usage'] ?>"></canvas>
</div>
<?php elseif ($monitor['type'] === 'resources' && $monitor['source'] === 'memory'):
$memData = $systemData['resources']['memory'];
$usagePercent = floatval(rtrim($memData['usage'], '%'));
$swapPercent = floatval(rtrim($memData['swap_usage'], '%'));
?> ?>
<div class="mb-3"> <p class="small text-muted"><?= htmlspecialchars($diskData['filesystem']) ?> · <?= htmlspecialchars($monitor['source'] === 'system' ? '/' : $defaultPath) ?></p>
<div class="d-flex justify-content-between mb-1"> <div class="d-flex justify-content-between"><span>Storage usage</span><strong><?= htmlspecialchars($diskData['usage']) ?></strong></div>
<span>Memory Usage</span> <div class="progress mb-4"><div class="progress-bar bg-<?= $usagePercent > 90 ? 'danger' : ($usagePercent > 75 ? 'warning' : 'success') ?>" role="progressbar" style="width:<?= $usagePercent ?>%" aria-label="Storage usage" aria-valuenow="<?= $usagePercent ?>" aria-valuemin="0" aria-valuemax="100"></div></div>
<span><?= $memData['usage'] ?></span> <div class="row text-center">
</div> <?php foreach (['size' => 'Total', 'used' => 'Used', 'available' => 'Free'] as $key => $label): ?>
<div class="progress" style="height: 10px"> <div class="col-4"><div class="p-2 border rounded"><div class="small text-muted"><?= $label ?></div><div class="fw-bold"><?= htmlspecialchars($diskData[$key]) ?></div></div></div>
<div class="progress-bar bg-<?= $usagePercent > 90 ? 'danger' : ($usagePercent > 75 ? 'warning' : 'success') ?>" <?php endforeach; ?>
role="progressbar"
style="width: <?= $usagePercent ?>%"
aria-valuenow="<?= $usagePercent ?>"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
<div class="row text-center g-2 mb-3">
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Total</div>
<div class="fw-bold"><?= $memData['total'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Used</div>
<div class="fw-bold"><?= $memData['used'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Available</div>
<div class="fw-bold"><?= $memData['available'] ?></div>
</div>
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span>Swap Usage</span>
<span><?= $memData['swap_usage'] ?></span>
</div>
<div class="progress" style="height: 10px">
<div class="progress-bar bg-<?= $swapPercent > 90 ? 'danger' : ($swapPercent > 75 ? 'warning' : 'success') ?>"
role="progressbar"
style="width: <?= $swapPercent ?>%"
aria-valuenow="<?= $swapPercent ?>"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
<div class="row text-center g-2">
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Total</div>
<div class="fw-bold"><?= $memData['swap_total'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Used</div>
<div class="fw-bold"><?= $memData['swap_used'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Free</div>
<div class="fw-bold"><?= $memData['swap_free'] ?></div>
</div>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php elseif ($monitor['type'] === 'raid'): ?>
<div class="raid-summary">
<i class="ui-icon icon-<?= $systemData['raid']['status'] === 'healthy' ? 'check-circle-fill text-success' : 'x-circle-fill text-warning' ?>" aria-hidden="true"></i>
<h4><?= $systemData['raid']['arrays'] ? htmlspecialchars($systemData['raid']['type']) . ' Array' : 'Linux MD RAID' ?></h4>
<span class="badge"><?= htmlspecialchars(ucfirst($systemData['raid']['status'])) ?></span>
<?php foreach ($systemData['raid']['arrays'] as $array): ?>
<p class="small text-muted mt-2"><?= htmlspecialchars($array['name'] . ' · ' . $array['type'] . ' · ' . $array['status']) ?></p>
<?php endforeach; ?>
</div> </div>
</div> <?php elseif ($monitor['source'] === 'cpu'): ?>
</div> <?php if (!$systemData['resources']['cpu']['available_data']): ?>
<p class="text-muted" role="status">CPU statistics are unavailable.</p>
<?php else: ?>
<div class="cpu-gauge"><canvas id="cpuGauge" width="170" height="170" data-value="<?= $systemData['resources']['cpu']['usage'] ?>" aria-label="CPU usage <?= $systemData['resources']['cpu']['usage'] ?> percent"></canvas></div>
<?php endif; ?>
<?php else:
$memData = $systemData['resources']['memory'];
?>
<?php if (!$memData['available_data']): ?>
<p class="text-muted" role="status">Memory statistics are unavailable.</p>
<?php else: ?>
<?php foreach (['usage' => 'Memory usage', 'swap_usage' => 'Swap usage'] as $usageKey => $label):
$usagePercent = (float) rtrim($memData[$usageKey], '%');
$fields = $usageKey === 'usage' ? ['total' => 'Total', 'used' => 'Used', 'available' => 'Available'] : ['swap_total' => 'Total', 'swap_used' => 'Used', 'swap_free' => 'Free'];
?>
<div class="resource-section">
<div class="d-flex justify-content-between"><span><?= $label ?></span><strong><?= htmlspecialchars($memData[$usageKey]) ?></strong></div>
<div class="progress mb-3"><div class="progress-bar bg-<?= $usagePercent > 90 ? 'danger' : ($usagePercent > 75 ? 'warning' : 'success') ?>" role="progressbar" style="width:<?= $usagePercent ?>%" aria-label="<?= $label ?>" aria-valuenow="<?= $usagePercent ?>" aria-valuemin="0" aria-valuemax="100"></div></div>
<div class="row text-center">
<?php foreach ($fields as $key => $fieldLabel): ?>
<div class="col-4"><div class="p-2 border rounded"><div class="small text-muted"><?= $fieldLabel ?></div><div class="fw-bold"><?= htmlspecialchars($memData[$key]) ?></div></div></div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<?php endforeach; ?>
<?php endif; ?>
<?php endif; ?>
</div> </div>
</div> </div>
</div> </section>
<?php endforeach; ?>
</div> </div>
<?php renderWorkspaceEnd(); ?>
</div>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3">
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
<script src="../../js/theme.js"></script>
<script> <script>
let refreshInterval; let refreshInterval;
let isRefreshing = false; let isRefreshing = false;
@@ -463,11 +188,6 @@ function updateData(html) {
currentLastUpdate.innerHTML = newLastUpdate; currentLastUpdate.innerHTML = newLastUpdate;
} }
// Update CPU gauge if needed
const cpuValue = document.querySelector('.cpu-gauge .h4')?.textContent;
if (cpuValue) {
drawCpuGauge(parseFloat(cpuValue));
}
} }
function startRefresh() { function startRefresh() {
@@ -476,7 +196,10 @@ function startRefresh() {
if (!isRefreshing) { if (!isRefreshing) {
isRefreshing = true; isRefreshing = true;
fetch(window.location.href) fetch(window.location.href)
.then(response => response.text()) .then(response => {
if (!response.ok || response.redirected) throw new Error('Status refresh failed');
return response.text();
})
.then(html => { .then(html => {
updateData(html); updateData(html);
isRefreshing = false; isRefreshing = false;
@@ -506,8 +229,7 @@ document.addEventListener('visibilitychange', () => {
}); });
// Initial setup // Initial setup
startRefresh(); if (!document.hidden) startRefresh();
drawCpuGauge(parseFloat(document.querySelector('.cpu-gauge .h4')?.textContent || '0'));
function drawCpuGauge(value) { function drawCpuGauge(value) {
const canvas = document.getElementById('cpuGauge'); const canvas = document.getElementById('cpuGauge');
@@ -527,7 +249,7 @@ function drawCpuGauge(value) {
// Draw background circle // Draw background circle
ctx.beginPath(); ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.strokeStyle = getComputedStyle(document.body).getPropertyValue('--bs-border-color'); ctx.strokeStyle = getComputedStyle(document.body).getPropertyValue('--line');
ctx.lineWidth = 10; ctx.lineWidth = 10;
ctx.stroke(); ctx.stroke();
@@ -543,7 +265,7 @@ function drawCpuGauge(value) {
// Draw percentage text inside the circle // Draw percentage text inside the circle
ctx.font = 'bold 20px Arial'; ctx.font = 'bold 20px Arial';
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bs-body-color'); ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text');
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(`${value.toFixed(1)}%`, centerX, centerY - 10); ctx.fillText(`${value.toFixed(1)}%`, centerX, centerY - 10);
@@ -558,6 +280,10 @@ window.addEventListener('load', function() {
const cpuValue = parseFloat(document.querySelector('.cpu-gauge canvas')?.getAttribute('data-value') || '0'); const cpuValue = parseFloat(document.querySelector('.cpu-gauge canvas')?.getAttribute('data-value') || '0');
drawCpuGauge(cpuValue); drawCpuGauge(cpuValue);
}); });
new MutationObserver(() => {
const cpuValue = document.getElementById('cpuGauge')?.dataset.value;
if (cpuValue !== undefined) drawCpuGauge(cpuValue);
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
</script> </script>
</body> </body>
</html> </html>
+22 -37
View File
@@ -5,6 +5,7 @@ ini_set('display_errors', 1);
error_reporting(E_ALL); error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
// getDatabaseConnection relocated to central config // getDatabaseConnection relocated to central config
@@ -218,35 +219,32 @@ function formatBytes($bytes, $precision = 2) {
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<title>Shared Media - <?= htmlspecialchars($fileName) ?></title> <title>Shared Media - <?= htmlspecialchars($fileName) ?></title>
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/view.css') ?>">
<link rel="stylesheet" href="css/view.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'shared',
<i class="bi"></i> 'section' => 'Shared file',
<span id="themeText"></span> 'eyebrow' => 'Shared with you',
</button> 'title' => $fileName,
</div> 'description' => 'Preview and access the file shared with you.',
<div class="custom-container dark-box"> 'guest' => true,
<header class="row border-bottom m-5"> 'class' => 'media-page',
<h1>Shared Media</h1> ]); ?>
<div class="mb-3">
<span class="badge bg-info">Shared Link</span>
<p class="text-muted small">This link will expire on <?= date('F j, Y, g:i a', strtotime($share['expiration'])) ?></p>
</div>
</header>
<section class="row">
<div class="content-toolbar"><span class="badge">Shared link</span><p>Available until <?= htmlspecialchars(date('F j, Y, g:i a', strtotime($share['expiration']))) ?></p></div>
<section class="content-panel media-panel">
<article class="col-12"> <article class="col-12">
<div class="media-container position-relative"> <div class="media-container position-relative">
<?php if ($isImage): ?> <?php if ($isImage): ?>
@@ -293,22 +291,9 @@ function formatBytes($bytes, $precision = 2) {
</article> </article>
</section> </section>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3"> <?php renderWorkspaceEnd(); ?>
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<script src="../../js/theme.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
+2 -2
View File
@@ -20,7 +20,7 @@ function createDirectoryRecursive($sftp, $path) {
return true; return true;
} }
$parent = dirname($path); $parent = remoteDirname($path);
if ($parent != '/' && !$sftp->is_dir($parent)) { if ($parent != '/' && !$sftp->is_dir($parent)) {
createDirectoryRecursive($sftp, $parent); createDirectoryRecursive($sftp, $parent);
} }
@@ -91,7 +91,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($relativePath)) { if (!empty($relativePath)) {
$fileName = basename($relativePath); $fileName = basename($relativePath);
$dirPart = dirname($relativePath); $dirPart = remoteDirname($relativePath);
if ($dirPart !== '.' && $dirPart !== '') { if ($dirPart !== '.' && $dirPart !== '') {
$remoteDirPath = $currentPath . '/' . $dirPart; $remoteDirPath = $currentPath . '/' . $dirPart;
+31 -45
View File
@@ -14,6 +14,7 @@ error_reporting(E_ALL);
ini_set('memory_limit', '512M'); ini_set('memory_limit', '512M');
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
if (!isset($_GET['file'])) { if (!isset($_GET['file'])) {
@@ -37,7 +38,7 @@ if (isset($_POST['createShare'])) {
if ($stmt->execute()) { if ($stmt->execute()) {
$shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" . $shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" .
dirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token"; remoteDirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token";
$_SESSION['shareCreated'] = true; $_SESSION['shareCreated'] = true;
$_SESSION['shareUrl'] = $shareUrl; $_SESSION['shareUrl'] = $shareUrl;
@@ -253,34 +254,32 @@ if (isset($_GET['stream'])) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<title>Media Viewer - <?= htmlspecialchars($fileName) ?></title> <title>Media Viewer - <?= htmlspecialchars($fileName) ?></title>
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/view.css') ?>">
<link rel="stylesheet" href="css/view.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'files',
<i class="bi"></i> 'section' => 'Media preview',
<span id="themeText"></span> 'eyebrow' => 'Media preview',
</button> 'title' => $fileName,
</div> 'description' => 'Preview your media, download a copy or create a shared link.',
<div class="custom-container dark-box"> 'connected' => true,
<header class="row border-bottom m-5"> 'back' => 'index.php?path=' . urlencode(remoteDirname($filePath)),
<h1>Media Viewer</h1> 'class' => 'media-page',
<div class="mb-3 p-3"> ]); ?>
<a href="index.php?path=<?= urlencode(dirname($filePath)) ?>" class="btn btn-primary">Back to Files</a>
</div>
</header>
<section class="row">
<section class="content-panel media-panel">
<article class="col-12"> <article class="col-12">
<div class="media-container position-relative"> <div class="media-container position-relative">
<?php if ($isImage): ?> <?php if ($isImage): ?>
@@ -307,8 +306,8 @@ if (isset($_GET['stream'])) {
<?php endif; ?> <?php endif; ?>
<!-- Add Share Button --> <!-- Add Share Button -->
<button type="button" class="btn btn-info m-3" data-bs-toggle="modal" data-bs-target="#shareModal"> <button type="button" class="btn btn-info m-3" data-dialog="shareModal">
<i class="bi bi-share"></i> Share <i class="ui-icon icon-share"></i> Share
</button> </button>
</div> </div>
@@ -319,7 +318,7 @@ if (isset($_GET['stream'])) {
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-control" id="shareUrl" value="<?= htmlspecialchars($shareUrl) ?>" readonly> <input type="text" class="form-control" id="shareUrl" value="<?= htmlspecialchars($shareUrl) ?>" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyShareUrl()"> <button class="btn btn-outline-secondary" type="button" onclick="copyShareUrl()">
<i class="bi bi-clipboard"></i> Copy <i class="ui-icon icon-clipboard"></i> Copy
</button> </button>
</div> </div>
<p class="small text-muted">This link will expire on <?= date('F j, Y, g:i a', strtotime($shareExpiration)) ?></p> <p class="small text-muted">This link will expire on <?= date('F j, Y, g:i a', strtotime($shareExpiration)) ?></p>
@@ -356,28 +355,15 @@ if (isset($_GET['stream'])) {
</article> </article>
</section> </section>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3"> <?php renderWorkspaceEnd(); ?>
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<!-- Share Modal --> <!-- Share Modal -->
<div class="modal fade" id="shareModal" tabindex="-1" aria-labelledby="shareModalLabel" aria-hidden="true"> <dialog class="modal" id="shareModal" tabindex="-1" aria-labelledby="shareModalLabel">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="shareModalLabel">Share "<?= htmlspecialchars($fileName) ?>"</h5> <h5 class="modal-title" id="shareModalLabel">Share "<?= htmlspecialchars($fileName) ?>"</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-dialog-close aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" action=""> <form method="post" action="">
@@ -400,14 +386,14 @@ if (isset($_GET['stream'])) {
</div> </div>
<div class="text-end"> <div class="text-end">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dialog-close>Cancel</button>
<button type="submit" name="createShare" class="btn btn-primary">Create Share Link</button> <button type="submit" name="createShare" class="btn btn-primary">Create Share Link</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </dialog>
<script> <script>
function copyShareUrl() { function copyShareUrl() {
@@ -418,7 +404,7 @@ function copyShareUrl() {
// Show feedback // Show feedback
const button = shareUrl.nextElementSibling; const button = shareUrl.nextElementSibling;
const originalText = button.innerHTML; const originalText = button.innerHTML;
button.innerHTML = '<i class="bi bi-check"></i> Copied!'; button.innerHTML = '<i class="ui-icon icon-check"></i> Copied!';
setTimeout(() => { setTimeout(() => {
button.innerHTML = originalText; button.innerHTML = originalText;
@@ -426,7 +412,7 @@ function copyShareUrl() {
} }
</script> </script>
<script src="../../js/theme.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
+11 -47
View File
@@ -1,47 +1,11 @@
html, body { .login-page { display:flex; min-height:100dvh; flex-direction:column; }
height: 100%; .login-top,.login-footer { display:flex; align-items:center; justify-content:space-between; padding:28px 5vw; gap:16px; }
margin: 0; .login-brand { display:flex; align-items:center; gap:12px; font-size:19px; color:var(--text); font-weight:750; letter-spacing:-.03em; } .login-brand > span { display:grid; place-items:center; background:#2563eb; color:white; width:36px; height:36px; border-radius:9px; font-size:25px; }
} .login-layout { flex:1; width:min(1060px,90%); margin:48px auto; display:grid; grid-template-columns:1.1fr 1fr; align-items:center; gap:100px; }
.login-story h1 { font-size:clamp(44px,5vw,64px); margin:16px 0 20px; line-height:1.08; letter-spacing:-.055em; } .login-story h1 > span { color:var(--blue); } .login-story > p { max-width:370px; color:var(--muted); font-size:15px; line-height:1.8; }
body { .login-card { background:var(--surface); padding:40px; border:1px solid var(--line); border-radius:18px; box-shadow:var(--shadow); } .login-card h2 { margin:12px 0 8px; font-size:24px; } .login-card form { margin-top:30px; } .login-card label { font-size:12px; } .login-card .btn { width:100%; justify-content:space-between; padding:13px 16px; } .login-note { margin-top:28px; padding-top:22px; border-top:1px solid var(--line); font-size:11px; color:var(--muted); text-align:center; }
display: flex; .storage-illustration { max-width:310px; margin-top:35px; padding:18px; border:1px solid var(--line); border-radius:14px; background:var(--surface); transform:rotate(-3deg); box-shadow:12px 14px 0 var(--blue-soft); }
flex-direction: column; .storage-top { display:flex; justify-content:space-between; font-size:10px; font-weight:700; letter-spacing:.12em; margin-bottom:14px; } .storage-top span { color:var(--blue); } .storage-drive { display:flex; align-items:center; gap:16px; height:38px; padding:0 12px; border:1px solid var(--line); border-radius:6px; margin:6px 0; background:var(--bg); } .storage-drive b { font:10px ui-monospace,monospace; color:var(--muted); } .storage-drive > span { flex:1; height:5px; border-radius:4px; background:var(--line); } .storage-drive i { width:5px; height:5px; border-radius:50%; background:var(--blue); } .storage-bottom { color:var(--muted); font-size:7px; letter-spacing:.14em; margin-top:14px; }
} .login-footer { font-size:11px; } .login-footer a { color:var(--muted); }
@media(max-width:800px) { .login-layout { grid-template-columns:1fr 1fr; gap:30px; } .login-card { padding:26px; } }
main { @media(max-width:620px) { .login-layout { display:block; max-width:430px; margin:24px auto; } .login-story { margin-bottom:30px; } .login-story h1 { font-size:38px; } .login-story h1 br { display:none; } .storage-illustration { display:none; } .login-story > p { font-size:13px; } .login-top,.login-footer { padding:20px; } .login-footer { flex-wrap:wrap; } }
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding-top: 7%;
}
.fixed {
-ms-flex: 0 0 300px;
flex: 0 0 300px;
min-width: 300px;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
+71 -181
View File
@@ -1,181 +1,71 @@
/* Modern dark theme with blue accents */ /* Shared UI primitives, independent of a CSS framework. */
:root { :root { color-scheme: light; --bg:#f5f7fb; --surface:#fff; --surface-alt:#f0f4fa; --text:#17243b; --muted:#64748b; --line:#e0e7f0; --blue:#2563eb; --blue-soft:#eaf1ff; --danger:#c83b50; --success:#168364; --shadow:0 16px 48px #16294a09; --body-bg-rgb:245,247,251; }
--bg-dark: #171b22; :root[data-theme="dark"] { color-scheme:dark; --bg:#0c1220; --surface:#121c2d; --surface-alt:#19253a; --text:#e4ecfa; --muted:#96a6bf; --line:#26334a; --blue:#79a7ff; --blue-soft:#1c3053; --danger:#ff8797; --success:#64d3ae; --shadow:0 16px 48px #0002; --body-bg-rgb:12,18,32; }
--bg-dark2: #1a202c; * { box-sizing:border-box; }
--primary-blue: #3b82f6; body { margin:0; min-height:100vh; background:var(--bg); color:var(--text); font:14px/1.55 'Segoe UI',system-ui,sans-serif; }
--text-light: #e5e7eb; button,input,select,textarea { font:inherit; }
--bg-darkblue: #101722; a { color:var(--blue); text-decoration:none; } a:hover { text-decoration:underline; }
--container-bg-start: #1a2029; :focus-visible { outline:3px solid var(--blue); outline-offset:3px; }
--container-bg-end: #151d29; [hidden] { display:none !important; }
--sftp-bg-start: #151b25; h1,h2,h3,h4,h5,p { margin:0 0 1rem; }
--sftp-bg-end: #101722; h1 { font-size:clamp(26px,3vw,36px); letter-spacing:-.045em; line-height:1.2; }
--container-border: rgba(77, 145, 255, 0.7); h2 { font-size:23px; letter-spacing:-.03em; } h3 { font-size:18px; } h4,h5 { font-size:16px; }
--panel-text: #e5e7eb; small,.small,.form-text { font-size:12px; }
--panel-muted: #aab4c3; .text-muted,.form-text { color:var(--muted); } .text-danger { color:var(--danger); } .text-success { color:var(--success); }
--control-bg: #101722; .text-center { text-align:center; } .text-end { text-align:right; } .text-break,.text-wrap { overflow-wrap:anywhere; } .fw-bold { font-weight:650; }
--control-border: #39485d; .btn { display:inline-flex; justify-content:center; align-items:center; gap:8px; min-height:38px; padding:8px 14px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--text); cursor:pointer; font-weight:600; line-height:1.4; text-decoration:none; transition:background .15s,border-color .15s; }
} .btn:hover { background:var(--surface-alt); border-color:var(--muted); text-decoration:none; }
.btn-primary { background:#2563eb; border-color:#2563eb; color:#fff; } .btn-primary:hover { background:#1d4ed8; border-color:#1d4ed8; }
body { .btn-danger { color:var(--danger); } .btn-success,.btn-info { color:var(--blue); }
background: linear-gradient(135deg, var(--bg-dark), var(--bg-darkblue)); .btn-sm { min-height:32px; padding:6px 10px; font-size:12px; } .btn:disabled,.btn.disabled { opacity:.5; cursor:default; }
color: var(--text-light); .btn-group,.input-group { display:flex; gap:8px; }
min-height: 100vh; .form-control,.form-select { display:block; width:100%; min-width:0; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--text); padding:11px 13px; }
font-family: system-ui, sans-serif; .form-control::placeholder { color:var(--muted); } .form-control:focus,.form-select:focus { border-color:var(--blue); outline:3px solid var(--blue-soft); }
margin: 0; label,.form-label { display:inline-block; margin-bottom:6px; font-weight:600; } input[type="checkbox"] { accent-color:var(--blue); width:16px; height:16px; }
padding: 0; .form-check { display:flex; align-items:center; gap:8px; } .form-check label { margin:0; }
} .surface-panel,.card { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:24px; }
.card-header { border-bottom:1px solid var(--line); padding:12px; } .card-body { padding:20px; }
a, .nav-link { .custom-container.dark-box { width:calc(100% - 48px); max-width:1280px; min-height:calc(100vh - 100px); margin:0 auto 24px; padding:24px; background:var(--surface); border:1px solid var(--line); border-radius:16px; box-shadow:var(--shadow); }
color: var(--primary-blue) !important; .custom-container > header { padding-bottom:20px; margin:8px 0 24px; }
transition: opacity .2s ease-in-out; table { width:100%; border-collapse:collapse; text-align:left; } th { color:var(--muted); font-size:11px; font-weight:650; letter-spacing:.06em; text-transform:uppercase; background:var(--surface-alt); }
} th,td { padding:14px 16px; border-bottom:1px solid var(--line); overflow-wrap:anywhere; } tbody tr:hover { background:var(--surface-alt); }
.alert { padding:14px 16px; margin:16px 0; border:1px solid var(--line); border-radius:8px; background:var(--blue-soft); text-align:left; } .alert-danger { color:var(--danger); } .alert-success { color:var(--success); }
body, button, input, select, textarea { .badge { display:inline-block; padding:2px 7px; border-radius:5px; background:var(--surface-alt); color:var(--muted); font-size:10px; font-weight:600; }
font-family: system-ui, sans-serif; .progress { height:8px; background:var(--surface-alt); border-radius:8px; overflow:hidden; } .progress-bar { height:100%; background:var(--blue); }
} .bg-success { background:var(--success); } .bg-warning { background:#d99b32; } .bg-danger { background:var(--danger); }
.badge.bg-danger,.badge.bg-success,.badge.bg-warning { background:var(--surface-alt); color:var(--muted); }
/* Keep text, fields and tables readable on every page that uses the shared theme. */ .img-fluid { max-width:100%; height:auto; }
.surface-panel, html:not([data-theme="dark"]) .dark-logo,[data-theme="dark"] .light-logo { display:none; }
.dark-box { footer { color:var(--muted); font-size:12px; } footer img { opacity:.7; max-height:22px; object-fit:contain; }
color: var(--panel-text); .theme-toggle { white-space:nowrap; }
} .d-flex { display:flex; } .flex-column { flex-direction:column; } .flex-wrap { flex-wrap:wrap; } .justify-content-end { justify-content:flex-end; } .justify-content-center { justify-content:center; } .justify-content-between { justify-content:space-between; } .align-items-center { align-items:center; } .gap-3 { gap:16px; }
.row { display:flex; flex-wrap:wrap; gap:20px; } .col { flex:1; min-width:0; } .col-12 { width:100%; } .col-4 { flex:1; }
.surface-panel .text-muted, .container,.container-fluid { width:100%; margin-inline:auto; } .container { max-width:1280px; }
.dark-box .text-muted { .border { border:1px solid var(--line); } .border-top { border-top:1px solid var(--line); } .border-bottom { border-bottom:1px solid var(--line); } .rounded { border-radius:10px; }
color: var(--panel-muted) !important; .position-relative { position:relative; } .w-100 { width:100%; } .h-100 { height:100%; } .w-auto { width:auto; } .mx-auto { margin-inline:auto; }
} .p-2 { padding:12px; } .p-3 { padding:20px; } .m-3 { margin:20px; } .m-5 { margin:32px; }
.mb-1,.mt-1 { margin-block:4px; } .mb-2 { margin-bottom:8px; } .mb-3 { margin-bottom:16px; } .mb-4 { margin-bottom:24px; } .mt-2 { margin-top:8px; } .mt-3 { margin-top:16px; } .mt-4 { margin-top:24px; } .me-2 { margin-right:8px; }
.surface-panel .form-control, dialog.modal { width:min(480px,calc(100% - 32px)); padding:0; border:1px solid var(--line); border-radius:16px; background:var(--surface); color:var(--text); box-shadow:0 24px 100px #0005; max-height:calc(100dvh - 40px); }
.surface-panel .form-select, dialog::backdrop { background:#080f2199; backdrop-filter:blur(4px); }
.dark-box .form-control, .modal-header,.modal-footer { padding:20px 24px; display:flex; align-items:center; justify-content:space-between; gap:12px; } .modal-header { border-bottom:1px solid var(--line); } .modal-title { margin:0; } .modal-body { padding:24px; } .modal-footer { justify-content:flex-end; border-top:1px solid var(--line); }
.dark-box .form-select { .btn-close { border:0; background:transparent; color:var(--muted); cursor:pointer; font-size:24px; } .btn-close::before { content:'×'; } body:has(dialog[open]) { overflow:hidden; }
color: var(--panel-text); .eyebrow { color:var(--muted); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; }
background-color: var(--control-bg); @media(min-width:900px) { .col-md-6 { width:calc(50% - 10px); } .col-lg-4 { width:calc(33.333% - 14px); } }
border-color: var(--control-border); @media(max-width:700px) { .custom-container.dark-box { width:calc(100% - 24px); padding:16px; } .surface-panel { overflow-x:auto; } .row { gap:12px; } .col-md-6,.col-lg-4 { width:100%; } th,td { padding:10px; } .m-5 { margin:16px; } }
} @media(prefers-reduced-motion:reduce) { *,*::before,*::after { transition:none !important; animation:none !important; } }
/* Small local icon vocabulary used by secondary screens. */
.surface-panel .form-control::placeholder, .ui-icon { font-style:normal; display:inline-block; }
.dark-box .form-control::placeholder { .icon-chevron-left::before { content:'‹'; } .icon-chevron-right::before { content:'›'; }
color: var(--panel-muted); .icon-fullscreen::before { content:'⛶'; } .icon-fullscreen-exit::before { content:'⊡'; }
} .icon-share::before { content:'↗'; } .icon-clipboard::before { content:'▤'; }
.icon-check::before,.icon-check-circle-fill::before { content:'✓'; }
.surface-panel .form-control:focus, .icon-x-circle-fill::before { content:'×'; } .icon-exclamation-triangle::before { content:'!'; }
.surface-panel .form-select:focus, .icon-server::before,.icon-hdd::before,.icon-hdd-stack::before { content:'▤'; }
.dark-box .form-control:focus, .icon-clock::before { content:'◷'; } .icon-cpu::before { content:'▦'; } .icon-memory::before { content:'▥'; }
.dark-box .form-select:focus { .text-primary { color:var(--blue); } .text-warning { color:#c98d22; }
border-color: var(--primary-blue); .spinner-border { display:inline-block; width:14px; height:14px; border:2px solid var(--line); border-top-color:var(--blue); border-radius:50%; animation:spin .8s linear infinite; }
box-shadow: 0 0 0 .2rem rgba(59, 130, 246, .2); @keyframes spin { to { transform:rotate(360deg); } }
} /* Secondary screens share the same surfaces and spacing. */
.col-lg-10 { width:100%; } .py-4 { padding-block:24px; } .mb-0 { margin-bottom:0; } .h4 { font-size:22px; } .h5 { font-size:16px; }
.surface-panel .table, .card-header.bg-success,.card-header.bg-warning,.card-header.bg-danger,.card-header.bg-primary,.card-header.bg-info { background:var(--surface-alt); }
.surface-panel .table th, .custom-container.dark-box.fullscreen-mode { max-width:none; width:100%; height:100dvh; margin:0; border:0; border-radius:0; padding:0; }
.surface-panel .table td {
color: inherit;
}
a:hover, .btn:hover, .nav-link:hover {
opacity: 0.85;
}
/* Ensure action buttons have white text for visibility */
.btn-primary,
.btn-danger,
.btn-warning,
.btn-success,
.btn-info,
.btn-secondary {
color: var(--text-light) !important;
}
/* Override specific actions (update password, upload directory, rename) to have black text */
.btn-primary,
.btn-warning,
.btn-info {
color: #212529 !important;
}
.dark-box {
background: rgba(0,0,0,0.6);
border-radius: var(--border-radius);
padding: 1rem;
backdrop-filter: blur(8px);
}
.surface-panel {
background: linear-gradient(145deg, var(--sftp-bg-start), var(--sftp-bg-end));
color: var(--text-light);
border: 1px solid rgba(77, 145, 255, 0.3);
border-radius: 1rem;
box-shadow: 0 12px 32px rgba(3, 8, 18, 0.2);
}
.modal-content {
background: rgba(20, 20, 30, 0.9) !important;
color: var(--text-light) !important;
border: none;
}
.table {
color: var(--text-light) !important;
}
.table th, .table td {
border-color: rgba(255,255,255,0.1) !important;
}
/* Main window styling (window within window) */
.custom-container.dark-box {
box-sizing: border-box;
margin: 2rem auto;
background: linear-gradient(135deg, var(--container-bg-start), var(--container-bg-end));
border: 2px solid var(--container-border);
border-radius: var(--border-radius);
padding: 1rem;
backdrop-filter: blur(6px);
}
/* Light mode overrides */
html[data-bs-theme="light"] {
--bg-light: #f8f9fa;
--bg-light2: #ffffff;
--container-bg: rgba(255,255,255,0.85);
--container-border: var(--primary-blue);
--text-dark: #212529;
--panel-text: #212529;
--panel-muted: #596579;
--control-bg: #ffffff;
--control-border: #c8d2df;
}
html[data-bs-theme="light"] body {
background: var(--bg-light);
color: var(--text-dark);
}
html[data-bs-theme="light"] .dark-box {
background: rgba(255, 255, 255, 0.8);
color: var(--text-dark);
}
html[data-bs-theme="light"] .surface-panel .form-control,
html[data-bs-theme="light"] .surface-panel .form-select,
html[data-bs-theme="light"] .dark-box .form-control,
html[data-bs-theme="light"] .dark-box .form-select {
color: var(--panel-text);
background-color: var(--control-bg);
border-color: var(--control-border);
}
html[data-bs-theme="light"] .form-control::placeholder {
color: var(--panel-muted);
}
html[data-bs-theme="light"] .surface-panel {
background: linear-gradient(145deg, #ffffff, #edf2f8);
color: var(--text-dark);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 12px 32px rgba(31, 58, 92, 0.08);
}
html[data-bs-theme="light"] .modal-content {
background: #fff !important;
color: var(--text-dark) !important;
}
html[data-bs-theme="light"] .table {
color: var(--text-dark) !important;
}
+62
View File
@@ -0,0 +1,62 @@
/* Persistent workspace frame shared by every signed-in page and shared previews. */
.file-app { display:grid; grid-template-columns:236px minmax(0,1fr); text-align:left; }
.skip-link { position:fixed; left:250px; top:-60px; z-index:10000; padding:10px 16px; background:var(--surface); border:1px solid var(--blue); border-radius:8px; }
.skip-link:focus { top:12px; }
.sidebar { position:sticky; top:0; height:100dvh; padding:30px 20px 16px; display:flex; flex-direction:column; background:var(--surface); border-right:1px solid var(--line); overflow-y:auto; }
.brand { display:flex; align-items:center; gap:12px; color:var(--text); font-size:19px; font-weight:750; letter-spacing:-.04em; text-decoration:none; }
.brand-mark { display:grid; place-items:center; width:40px; height:40px; border-radius:11px; background:#2563eb; color:white; font-size:27px; }
.brand-caption { display:block; font-size:9px; font-weight:600; letter-spacing:.16em; color:var(--muted); margin-top:1px; }
.nav-heading { margin:48px 12px 12px; }
.nav-item { display:flex; align-items:center; gap:12px; padding:11px 12px; margin:4px 0; color:var(--muted); border-radius:8px; font-weight:550; }
.nav-item:hover { text-decoration:none; background:var(--surface-alt); color:var(--text); }
.nav-item.active { background:var(--blue-soft); color:var(--blue); }
.nav-item > span { font-size:20px; width:21px; text-align:center; }
.sidebar-bottom { margin-top:auto; padding-top:40px; }
.connection-card { border:1px solid var(--line); border-radius:10px; padding:14px 12px; font-size:12px; font-weight:600; margin-bottom:18px; }
.status-dot { display:inline-block; width:6px; height:6px; background:var(--success); border-radius:50%; margin-right:7px; }
.connection-card small { display:block; margin-top:5px; color:var(--muted); font-weight:400; font-size:10px; }
.user-card { display:flex; gap:10px; align-items:center; padding:16px 4px 0; margin-top:14px; border-top:1px solid var(--line); }
.avatar { display:grid; place-items:center; background:var(--blue-soft); color:var(--blue); width:34px; height:34px; border-radius:50%; font-weight:700; flex-shrink:0; }
.user-name { flex:1; overflow-wrap:anywhere; font-weight:600; font-size:12px; }
.user-name small { display:block; font-size:10px; font-weight:400; color:var(--muted); }
.workspace { min-width:0; display:flex; flex-direction:column; min-height:100dvh; }
.topbar { min-height:76px; display:flex; justify-content:space-between; align-items:center; gap:12px; padding:16px 40px; border-bottom:1px solid var(--line); background:var(--surface); color:var(--muted); font-size:12px; }
.topbar strong { color:var(--text); font-weight:500; }
.topbar-divider { padding:0 14px; opacity:.5; }
.file-main { flex:1; width:100%; max-width:1560px; margin:0 auto; padding:42px 40px 0; display:flex; flex-direction:column; min-width:0; }
.workspace-content { flex:1; min-width:0; }
.page-heading { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:30px; }
.page-heading .eyebrow { margin-bottom:9px; }
.page-heading h1 { margin-bottom:10px; font-size:36px; overflow-wrap:anywhere; }
.heading-dot { color:var(--blue); }
.page-heading p { color:var(--muted); margin:0; font-size:13px; }
.heading-back { white-space:nowrap; font-size:12px; flex-shrink:0; }
.protocol-badge { border:1px solid var(--line); border-radius:6px; padding:5px 10px; font:11px ui-monospace,monospace; background:var(--surface); white-space:nowrap; }
.workspace-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:22px 0; font-size:10px; margin:0; border:0; }
.workspace-footer a { color:var(--muted); }
.content-panel { padding:24px; border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:var(--shadow); min-width:0; }
.panel-heading { margin-bottom:22px; } .panel-heading h2 { margin-bottom:6px; font-size:18px; } .panel-heading p { margin:0; color:var(--muted); font-size:12px; }
.content-toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:20px; font-size:12px; color:var(--muted); }
.content-toolbar p { margin:0; }
.table-scroll { overflow-x:auto; max-width:100%; }
@media(min-width:1500px) { .file-main { padding-top:56px; } }
@media(max-width:1100px) { .file-app { grid-template-columns:200px minmax(0,1fr); } .sidebar { padding-inline:14px; } .nav-item { font-size:13px; } .file-main { padding:28px 24px 0; } .topbar { padding-inline:24px; } }
@media(max-width:760px) {
.file-app { display:block; }
.sidebar { position:static; height:auto; padding:16px; border-right:0; border-bottom:1px solid var(--line); overflow:visible; }
.brand-caption,.nav-heading,.connection-card { display:none; }
.sidebar nav { display:flex; flex-wrap:wrap; gap:6px; margin-top:14px; }
.nav-item { font-size:12px; padding:7px 10px; margin:0; }
.sidebar-bottom { margin:8px 0 0; padding:0; display:flex; }
.sidebar-bottom .nav-item { padding:4px 10px; }
.sidebar-bottom .user-card { display:flex; margin:0 0 0 auto; padding:0 10px; border:0; }
.sidebar-bottom .user-name,.sidebar-bottom .avatar { display:none; }
.topbar { min-height:58px; padding:12px 16px; }
.file-main { padding:26px 16px 0; }
.page-heading { align-items:flex-start; flex-wrap:wrap; }
.page-heading h1 { font-size:30px; }
.protocol-badge { display:none; }
.workspace-footer { flex-wrap:wrap; }
.content-panel { padding:18px; }
.skip-link { left:16px; }
}
+24
View File
@@ -0,0 +1,24 @@
<?php
/** SFTP paths use POSIX separators, regardless of the OS running PHP. */
function normalizePath(string $path): string {
$parts = array_filter(explode('/', $path), fn($part) => $part !== '' && $part !== '.');
$stack = [];
foreach ($parts as $part) {
if ($part === '..') {
array_pop($stack);
} else {
$stack[] = $part;
}
}
return '/' . implode('/', $stack);
}
/** Like dirname(), but only '/' is a separator and the root stays '/'. */
function remoteDirname(string $path): string {
$path = rtrim($path, '/');
if ($path === '') return '/';
$separator = strrpos($path, '/');
if ($separator === false) return '.';
$parent = rtrim(substr($path, 0, $separator), '/');
return $parent === '' ? '/' : $parent;
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/** Shared navigation and page frame for the SFTP workspace. */
function workspaceEscape($value) {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function workspaceAsset($root, $path) {
return workspaceEscape($root . $path . '?v=' . filemtime(__DIR__ . '/../' . $path));
}
function renderWorkspaceStart(array $page) {
$root = $page['root'];
$active = $page['active'] ?? 'files';
$guest = $page['guest'] ?? false;
$username = $_SESSION['uname'] ?? '';
$admin = !$guest && !empty($_SESSION['admin']);
$filesUrl = $root . 'content/ftp/index.php';
$nav = [
'files' => ['All files', '▱', $filesUrl],
'server' => ['Server status', '◴', $root . 'content/ftp/serverstat.php'],
'users' => ['User management', '♙', $root . 'content/adminpanel.php'],
];
?>
<body class="file-app">
<a class="skip-link" href="#workspace-content">Skip to content</a>
<aside class="sidebar">
<a class="brand" href="<?= workspaceEscape($guest ? $root . 'index.php' : $filesUrl) ?>"><span class="brand-mark" aria-hidden="true">▤</span><span>USB RAID<span class="brand-caption">SFTP WORKSPACE</span></span></a>
<div class="eyebrow nav-heading"><?= $guest ? 'Shared workspace' : 'Workspace' ?></div>
<nav aria-label="Main navigation">
<?php if ($guest): ?>
<a class="nav-item active" href="#workspace-content" aria-current="page"><span aria-hidden="true">↗</span> Shared file</a>
<?php else: ?>
<?php foreach ($nav as $key => [$label, $icon, $url]): ?>
<?php if ($key !== 'files' && !$admin) continue; ?>
<a class="nav-item<?= $active === $key ? ' active' : '' ?>" href="<?= workspaceEscape($url) ?>"<?= $active === $key ? ' aria-current="page"' : '' ?>><span aria-hidden="true"><?= $icon ?></span> <?= $label ?></a>
<?php endforeach; ?>
<?php endif; ?>
</nav>
<div class="sidebar-bottom">
<div class="connection-card"><?php if (!empty($page['connected'])): ?><span class="status-dot"></span><?php endif; ?><?= $guest ? 'Shared access' : (!empty($page['connected']) ? 'Server connected' : 'Remote storage') ?><small><?= $guest ? 'Access through a shared link' : 'Your SFTP workspace' ?></small></div>
<?php if ($guest): ?>
<a class="nav-item" href="<?= workspaceEscape($root . 'index.php') ?>">Sign in to your workspace ↗</a>
<?php else: ?>
<a class="nav-item<?= $active === 'account' ? ' active' : '' ?>" href="<?= workspaceEscape($root . 'content/changepassword.php') ?>"<?= $active === 'account' ? ' aria-current="page"' : '' ?>>Account settings</a>
<div class="user-card"><span class="avatar"><?= workspaceEscape(strtoupper(substr($username, 0, 1))) ?></span><span class="user-name"><?= workspaceEscape($username) ?><small><?= $admin ? 'Administrator' : 'Member' ?></small></span><a href="<?= workspaceEscape($root . 'content/logout.php') ?>" title="Sign out" aria-label="Sign out">↗</a></div>
<?php endif; ?>
</div>
</aside>
<div class="workspace">
<header class="topbar"><span><?= $guest ? 'Shared workspace' : 'Workspace' ?><span class="topbar-divider">/</span><strong><?= workspaceEscape($page['section']) ?></strong></span><button id="themeToggle" class="btn btn-sm theme-toggle"><i aria-hidden="true"></i><span id="themeText">Theme</span></button></header>
<main class="file-main" id="workspace-content" tabindex="-1">
<div class="page-heading"><div><div class="eyebrow"><?= workspaceEscape($page['eyebrow']) ?></div><h1><?= workspaceEscape($page['title']) ?><span class="heading-dot">.</span></h1><p><?= workspaceEscape($page['description']) ?></p></div>
<?php if (isset($page['back'])): ?><a class="btn heading-back" href="<?= workspaceEscape($page['back']) ?>">← Back to files</a><?php elseif (!empty($page['connected'])): ?><span class="protocol-badge"><span class="status-dot"></span> SFTP</span><?php endif; ?>
</div>
<div class="workspace-content <?= workspaceEscape($page['class'] ?? '') ?>">
<?php
}
function renderWorkspaceEnd() {
?>
</div>
<footer class="workspace-footer"><span>USB RAID Array <span aria-hidden="true">·</span> Developed by Michal Sedlák</span><a href="https://github.com/michalcz10/USB-RAID-Array" target="_blank" rel="noopener noreferrer">View on GitHub ↗</a></footer>
</main>
</div>
<?php
}
+12 -58
View File
@@ -15,68 +15,22 @@ if (empty($_SESSION)) {
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cz"> <html lang="en">
<head> <head>
<title>Login</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="img/favicon.ico" type="image/x-icon"> <title>Sign in · USB RAID</title>
<link rel="stylesheet" href="css/bootstrap.css"> <link rel="icon" href="img/favicon.ico">
<link rel="stylesheet" href="css/modern.css"> <link rel="stylesheet" href="css/modern.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="css/index.css"> <link rel="stylesheet" href="css/index.css">
<script src="js/bootstrap.bundle.js"></script>
</head>
<body class="container-fluid text-center">
<div class="d-flex justify-content-end p-3">
<button id="themeToggle" class="btn btn-sm theme-toggle">
<i class="bi"></i>
<span id="themeText"></span>
</button>
</div>
<div class="custom-container dark-box">
<header class="row border-bottom m-5">
<h1>USB RAID Array</h1>
</header>
<main>
<section class="row m-3">
<article class="col border border-2 border-primary rounded p-2 fixed surface-panel">
<form action="content/login.php" method="post">
<div class="mb-3">
<label for="uname">Username:</label>
<br>
<input type="text" name="uname" class="form-control" required>
</div>
<div class="mb-3">
<label for="pswd">Password:</label>
<br>
<input type="password" name="pswd" class="form-control" required>
</div>
<div class="mb-3">
<input type="submit" class="btn btn-primary" value="Login">
</div>
</form>
</article>
</section>
</main>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3">
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<script src="js/theme.js"></script> <script src="js/theme.js"></script>
</head>
<body class="login-page">
<header class="login-top"><a class="login-brand" href="index.php"><span>▤</span> USB RAID</a><button id="themeToggle" class="btn btn-sm"><i aria-hidden="true"></i><span id="themeText">Theme</span></button></header>
<main class="login-layout">
<section class="login-story"><div class="eyebrow">Your storage. Your workspace.</div><h1>A home for<br>every file<span>.</span></h1><p>Connect to your RAID storage and keep your files within reach. One simple workspace, powered by SFTP.</p><div class="storage-illustration" aria-hidden="true"><div class="storage-top">USB RAID <span>SFTP</span></div><div class="storage-drive"><b>01</b><span></span><i></i></div><div class="storage-drive"><b>02</b><span></span><i></i></div><div class="storage-drive"><b>03</b><span></span><i></i></div><div class="storage-bottom">REMOTE STORAGE / LOCAL CONTROL</div></div></section>
<section class="login-card"><div class="eyebrow">Welcome back</div><h2>Sign in to your workspace</h2><p class="text-muted">Use your account to access your files.</p><form action="content/login.php" method="post"><div class="mb-3"><label for="uname">Username</label><input id="uname" name="uname" class="form-control" autocomplete="username" placeholder="Enter your username" required></div><div class="mb-4"><label for="pswd">Password</label><input id="pswd" type="password" name="pswd" class="form-control" autocomplete="current-password" placeholder="Enter your password" required></div><button type="submit" class="btn btn-primary">Sign in <span aria-hidden="true">→</span></button></form><div class="login-note">Need access? Contact your administrator.</div></section>
</main>
<footer class="login-footer"><span>Developed by Michal Sedlák</span><a href="https://github.com/michalcz10/USB-RAID-Array" target="_blank" rel="noopener noreferrer">Project on GitHub ↗</a></footer>
</body> </body>
</html> </html>
+22 -36
View File
@@ -1,40 +1,26 @@
document.addEventListener('DOMContentLoaded', function() { (() => {
const themeToggle = document.getElementById('themeToggle'); const preference = window.matchMedia('(prefers-color-scheme: dark)');
const html = document.documentElement; let saved;
const themeText = document.getElementById('themeText'); try { saved = localStorage.getItem('theme'); } catch (_) { /* Storage may be disabled. */ }
const themeIcon = themeToggle.querySelector('.bi');
function setTheme(theme) { function setTheme(theme) {
html.setAttribute('data-bs-theme', theme); document.documentElement.dataset.theme = theme;
document.body.classList.remove('theme-light', 'theme-dark'); const button = document.getElementById('themeToggle');
document.body.classList.add('theme-' + theme); if (button) {
localStorage.setItem('theme', theme); button.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
const label = document.getElementById('themeText');
if (theme === 'dark') { if (label) label.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
themeText.textContent = 'Light Mode'; const icon = button.querySelector('i');
themeIcon.className = 'bi bi-sun'; if (icon) { icon.textContent = theme === 'dark' ? '☀' : '◐'; icon.setAttribute('aria-hidden', 'true'); }
themeToggle.classList.remove('btn-dark');
themeToggle.classList.add('btn-light');
} else {
themeText.textContent = 'Dark Mode';
themeIcon.className = 'bi bi-moon';
themeToggle.classList.remove('btn-light');
themeToggle.classList.add('btn-dark');
} }
} }
setTheme(saved === 'dark' || saved === 'light' ? saved : preference.matches ? 'dark' : 'light');
const savedTheme = localStorage.getItem('theme'); document.addEventListener('DOMContentLoaded', () => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; setTheme(document.documentElement.dataset.theme);
document.getElementById('themeToggle')?.addEventListener('click', () => {
if (savedTheme) { saved = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
setTheme(savedTheme); setTheme(saved);
} else { try { localStorage.setItem('theme', saved); } catch (_) { /* Keep this session usable. */ }
setTheme(prefersDark ? 'dark' : 'light');
}
themeToggle.addEventListener('click', function() {
const currentTheme = html.getAttribute('data-bs-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}); });
}); });
preference.addEventListener('change', () => { if (saved !== 'dark' && saved !== 'light') setTheme(preference.matches ? 'dark' : 'light'); });
})();
+10
View File
@@ -0,0 +1,10 @@
// Native dialogs supply focus containment, Escape and focus restoration.
document.addEventListener('click', event => {
const trigger = event.target.closest('[data-dialog]');
if (trigger) document.getElementById(trigger.dataset.dialog)?.showModal();
if (event.target.closest('[data-dialog-close]')) event.target.closest('dialog')?.close();
if (event.target instanceof HTMLDialogElement) {
const rect = event.target.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) event.target.close();
}
});
+31
View File
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/../Web/includes/remotepaths.php';
$parents = [
'/' => '/',
'/etc' => '/',
'/etc/' => '/',
'/etc/file.txt' => '/etc',
'//etc//file.txt' => '//etc',
'/folder with spaces/item.txt' => '/folder with spaces',
'folder/file.txt' => 'folder',
'file.txt' => '.',
'/a\\b/file.txt' => '/a\\b', // Backslash is a valid POSIX filename character.
];
foreach ($parents as $path => $expected) {
if (remoteDirname($path) !== $expected) throw new RuntimeException('Incorrect parent for ' . $path);
}
foreach (['/' => '/', '//etc/../' => '/', '/etc/../../' => '/', '/etc/./ssh/' => '/etc/ssh'] as $path => $expected) {
if (normalizePath($path) !== $expected) throw new RuntimeException('Incorrect normalized path for ' . $path);
}
// Round trips must remain correct on both Windows/XAMPP and Linux.
foreach (['/etc', '/mnt/raid', '/folder with spaces'] as $directory) {
$parent = normalizePath(remoteDirname($directory));
if ($parent !== ($directory === '/mnt/raid' ? '/mnt' : '/')) throw new RuntimeException('Invalid navigation round trip');
}
// ZIP paths keep the top-level directory name when it is directly under root.
foreach (['/etc' => '/etc/config.txt', '/mnt/raid' => '/mnt/raid/config.txt'] as $base => $file) {
$relative = substr($file, strlen(rtrim(remoteDirname($base), '/')) + 1);
if ($relative !== ($base === '/etc' ? 'etc/config.txt' : 'raid/config.txt')) throw new RuntimeException('Incorrect ZIP member path');
}
echo "Remote paths: POSIX parents, normalization, root navigation and ZIP paths passed.\n";
+57
View File
@@ -0,0 +1,57 @@
<?php
require_once __DIR__ . '/../Web/content/ftp/lib/serverstats.php';
require_once __DIR__ . '/../Web/includes/workspace.php';
// Treat warnings and deprecations as failures, including when rendering missing data.
set_error_handler(function ($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
});
function check($condition, $message) {
if (!$condition) throw new RuntimeException($message);
}
$disk = parseDiskData("Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/mapper/vg-root 1048576 262144 786432 25% /\n");
check($disk['available_data'] && $disk['size'] === '1 GiB' && $disk['usage'] === '25%', 'Parse POSIX df sizes and usage');
foreach ([false, null, '', 'df: /dev/sdb1: No such file or directory', "Filesystem 1024-blocks Used Available Capacity Mounted on\n"] as $output) {
check(!parseDiskData($output)['available_data'], 'Missing disk output must be unavailable');
}
$healthy = "Personalities : [raid1] [raid5]\nmd0 : active raid5 sda[0] sdb[1] sdc[2]\n 1000 blocks [3/3] [UUU]\n\nunused devices: <none>\n";
check(parseRaidStatus($healthy)['status'] === 'healthy', 'Three-disk RAID5 should be healthy');
check(parseRaidStatus(str_replace('[UUU]', '[U_U]', $healthy))['status'] === 'degraded', 'Missing RAID member should be degraded');
check(parseRaidStatus($healthy . "md1 : active raid1 sdd[0] sde[1]\n 1000 blocks [2/1] [U_]\n")['status'] === 'degraded', 'One failed array must make overall status degraded');
check(parseRaidStatus(str_replace('[UUU]', "[UUU]\n resync = 42.0%", $healthy))['status'] === 'syncing', 'RAID resync is visible');
check(parseRaidStatus("md0 : active raid0 sda[0] sdb[1]\n 1000 blocks super 1.2 512k chunks\n")['status'] === 'active', 'RAID0 does not require a redundancy bitmap');
check(parseRaidStatus("md0 : inactive sda[0](S)\n 1000 blocks\n")['status'] === 'inactive', 'Inactive array remains inactive');
check(parseRaidStatus("Personalities : [raid1]\nunused devices: <none>\n")['status'] === 'not configured', 'No configured Linux MD array');
check(parseRaidStatus(false)['status'] === 'unavailable', 'Unreadable mdstat is unavailable');
$resources = parseSystemStats('97,5', '1024,256,768,0,0,0');
check($resources['cpu']['usage'] === '2.5' && $resources['memory']['usage'] === '25%' && $resources['memory']['swap_usage'] === '0%', 'Resource values and zero swap');
check(!parseSystemStats(false, false)['cpu']['available_data'], 'Missing CPU output must not show 100%');
check(!parseSystemStats('not a number', '')['memory']['available_data'], 'Missing memory output must be unavailable');
// Check remote command construction and path quoting with a harmless stub.
$ssh = new class {
public $commands = [];
function exec($command) { $this->commands[] = $command; return false; }
};
$defaultPath = "/storage/owner's files";
$systemData = getSystemData($ssh, $defaultPath);
check(in_array("LC_ALL=C df -Pk -- '/' 2>/dev/null", $ssh->commands, true), 'System filesystem uses root instead of a device name');
check(in_array("LC_ALL=C df -Pk -- '/storage/owner'\\''s files' 2>/dev/null", $ssh->commands, true), 'Storage path is quoted for remote Linux even on Windows');
// Exercise the actual page template with missing output and with valid output.
$source = file_get_contents(__DIR__ . '/../Web/content/ftp/serverstat.php');
$template = substr($source, strpos($source, '$monitors ='));
foreach ([false, true] as $available) {
if ($available) {
$systemData['storage'] = ['system' => $disk, 'data' => $disk];
$systemData['resources'] = $resources;
$systemData['raid'] = parseRaidStatus($healthy);
}
ob_start();
eval($template);
$html = ob_get_clean();
check(strpos($html, 'Server status') !== false, 'Status page renders');
check($available ? strpos($html, '1 GiB') !== false : strpos($html, 'Storage statistics are unavailable') !== false, 'Template handles available and missing data');
}
echo "Server statistics: parser, RAID, command quoting and page rendering checks passed.\n";
+95
View File
@@ -0,0 +1,95 @@
<?php
require_once __DIR__ . '/../Web/includes/workspace.php';
require_once __DIR__ . '/../Web/includes/remotepaths.php';
require_once __DIR__ . '/../Web/content/ftp/lib/serverstats.php';
set_error_handler(function ($severity, $message, $file, $line) {
if (error_reporting() & $severity) throw new ErrorException($message, 0, $severity, $file, $line);
});
function expectWorkspace($condition, $message) {
if (!$condition) throw new RuntimeException($message);
}
function renderWorkspaceFixture($name, $admin = true, $defaultPath = '/mnt/raid', $currentPath = '/mnt/raid/Workspace') {
$_SESSION = ['uname' => 'michal', 'admin' => $admin, 'upPer' => true, 'downPer' => true, 'delPer' => true];
$filePath = $currentPath . '/Example.pdf'; $fileName = basename($filePath); $parentDir = $currentPath;
$fileExtension = 'png'; $fileSize = 245760; $mimeType = 'image/png';
$isImage = true; $isVideo = false; $isAudio = false;
$editable = true; $showLineNumbers = true; $content = "{\n \"workspace\": \"USB RAID\",\n \"status\": \"connected\"\n}\n";
$message = ''; $messageType = ''; $message_type = ''; $shareCreated = false; $shareError = '';
$share = ['expiration' => '2030-01-01']; $downloadAllowed = false; $token = 'test-only';
$items = ['Documents', 'Photos', 'README.md', 'Example.pdf'];
$imageExtensions = ['png']; $videoExtensions = []; $audioExtensions = []; $editableExtensions = ['md']; $archiveExtensions = [];
$sftp = new class {
function is_dir($name) { return strpos($name, '.') === false; }
function stat($path) { return ['size' => 245760]; }
};
$result = new class {
private $rows = [
['uname' => 'michal', 'admin' => true, 'defPath' => '/mnt/raid', 'delPer' => true, 'downPer' => true, 'upPer' => true],
['uname' => 'guest', 'admin' => false, 'defPath' => '/mnt/raid/shared', 'delPer' => false, 'downPer' => true, 'upPer' => false],
];
function fetch_assoc() { return array_shift($this->rows); }
};
$systemData = [
'storage' => array_fill_keys(['system', 'data'], parseDiskData("Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/md0 1048576 262144 786432 25% /mnt/raid\n")),
'raid' => parseRaidStatus("md0 : active raid5 sda[0] sdb[1] sdc[2]\n 1000 blocks [3/3] [UUU]\n"),
'resources' => parseSystemStats('93', '8192,2048,6144,1024,0,1024'), 'lastUpdate' => '2026-10-16 10:00:00',
];
$top = in_array($name, ['adminpanel', 'changepassword'], true);
$source = file_get_contents(__DIR__ . '/../Web/content/' . ($top ? '' : 'ftp/') . $name . '.php');
if ($name === 'serverstat') {
$start = strpos($source, '$monitors =');
eval(substr($source, $start, strpos($source, '?>', $start) - $start));
}
$start = strpos($source, '<!DOCTYPE html>');
$template = substr($source, $start, strpos($source, '</html>', $start) + 7 - $start);
ob_start(); eval('?>' . $template); return ob_get_clean();
}
$preview = in_array('--preview', $argv, true);
$directory = __DIR__ . '/../Web/.layout-preview';
if ($preview && !is_dir($directory)) mkdir($directory);
$pages = ['index', 'serverstat', 'adminpanel', 'changepassword', 'open', 'view', 'pdf', 'shared'];
foreach ($pages as $name) {
$html = renderWorkspaceFixture($name);
$dom = new DOMDocument();
@$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
expectWorkspace($xpath->query('//body')->length === 1, "$name has one body");
expectWorkspace($xpath->query('//main')->length === 1, "$name has one main region");
expectWorkspace($xpath->query('//aside[contains(@class,"sidebar")]')->length === 1, "$name has shared sidebar");
expectWorkspace($xpath->query('//*[@id="themeToggle"]')->length === 1, "$name has one theme switch");
expectWorkspace($xpath->query('//a[@aria-current="page"]')->length === 1, "$name has one active navigation item");
expectWorkspace($xpath->query('//main//footer')->length === 1, "$name footer is inside the main region");
if ($name === 'shared') expectWorkspace(strpos($html, 'User management') === false && strpos($html, 'Account settings') === false, 'Public shared preview does not expose account navigation');
if ($preview) {
$base = $name === 'adminpanel' || $name === 'changepassword' ? '../content/' : '../content/ftp/';
$html = str_replace('<head>', '<head><base href="' . $base . '">', $html);
// Browser previews never request a real remote file or perform mutations.
$image = 'data:image/svg+xml,' . rawurlencode('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="380"><rect width="800" height="380" fill="#1c3053"/><circle cx="400" cy="150" r="70" fill="#2563eb"/><text x="400" y="290" text-anchor="middle" fill="white" font-size="28" font-family="sans-serif">USB RAID / Workspace preview</text></svg>');
$html = preg_replace('/src="(?:view|shared)\.php\?[^"\n]+"/', 'src="' . $image . '"', $html);
$html = str_replace("const url = 'pdf.php?file=%2Fmnt%2Fraid%2FWorkspace%2FExample.pdf&stream=1';", "const url = '/Web/.layout-preview/sample.pdf';", $html);
file_put_contents($directory . '/' . $name . '.html', $html);
}
}
$member = renderWorkspaceFixture('index', false);
expectWorkspace(strpos($member, 'User management') === false && strpos($member, '> Server status</a>') === false, 'Member navigation omits admin pages');
expectWorkspace(strpos($member, 'Account settings') !== false, 'Members can access account settings');
$rootChild = renderWorkspaceFixture('index', true, '/', '/etc');
expectWorkspace(strpos($rootChild, 'href="?path=%2F"') !== false, 'Parent link from a root child uses a POSIX slash');
expectWorkspace(strpos($rootChild, '%5C') === false, 'No Windows separator in remote links');
$root = renderWorkspaceFixture('index', true, '/', '/');
expectWorkspace(strpos($root, 'Parent folder') === false, 'Root has no parent-folder link');
expectWorkspace(strpos($root, 'href="?path=%2FDocuments"') !== false, 'Root children use a single leading slash');
if ($preview) {
// One-page PDF fixture for viewer navigation and fullscreen checks.
$stream = "BT /F1 24 Tf 60 700 Td (USB RAID workspace preview) Tj ET";
$objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', '<< /Length ' . strlen($stream) . ">>\nstream\n" . $stream . "\nendstream"];
$pdf = "%PDF-1.4\n"; $offsets = [0];
foreach ($objects as $i => $object) { $offsets[] = strlen($pdf); $pdf .= ($i + 1) . " 0 obj\n$object\nendobj\n"; }
$xref = strlen($pdf); $pdf .= "xref\n0 6\n0000000000 65535 f \n";
foreach (array_slice($offsets, 1) as $offset) $pdf .= sprintf("%010d 00000 n \n", $offset);
$pdf .= "trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n$xref\n%%EOF";
file_put_contents($directory . '/sample.pdf', $pdf);
}
echo "Workspace: all eight page templates, navigation roles and shared layout checks passed.\n";