Compare commits

..
5 Commits
Author SHA1 Message Date
Michal Sedlák 99b1c96f4b revise backend 2026-10-18 15:55:34 +02:00
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
44 changed files with 2253 additions and 2705 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;
+160 -2
View File
@@ -1,2 +1,160 @@
# 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
php tests/backend_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.
Test backendu navíc ověřuje přihlášení, oprávnění operací, hranice vzdálených cest, vytváření prázdných souborů a upload. Spouští handlery v oddělených PHP procesech s testovacími náhradami databáze a SFTP; vyžaduje dostupné `proc_open`.
V backendu používej popisné názvy lokálních proměnných ve stylu `camelCase`, například `$username`, `$passwordHash`, `$database`, `$statement`, `$directoryPath` a `$canUpload`. Konfigurační proměnné `$DB_*`, `$SFTP_*`, `$SSH_*` a existující klíče databáze, formulářů a session (`uname`, `pswd`, `defPath`, `upPer` apod.) zůstávají kvůli kompatibilitě zachované. Společné kontroly přihlášení, oprávnění a hesel jsou v `Web/includes/auth.php`; práci s POSIX cestami soustřeď do `Web/includes/remotepaths.php`.
## Č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 *)"
]
}
}
+7 -19
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;
@@ -18,7 +19,7 @@ $SFTP_HOST = '172.16.1.11';
$SFTP_USER = 'usbraid'; $SFTP_USER = 'usbraid';
$SFTP_PASS = 'usbraid'; $SFTP_PASS = 'usbraid';
// Default remote path – uses session value if set // Default remote path – uses session value if set
$defaultPath = $_SESSION['defPath'] ?? '/'; $defaultPath = normalizePath($_SESSION['defPath'] ?? '/');
// SSH credentials (placeholders) // SSH credentials (placeholders)
$SSH_HOST = '172.16.1.11'; $SSH_HOST = '172.16.1.11';
@@ -28,12 +29,12 @@ $SSH_PASS = 'usbraid';
/** Helper: Get a MySQLi connection */ /** Helper: Get a MySQLi connection */
function getDatabaseConnection(): mysqli { function getDatabaseConnection(): mysqli {
global $DB_HOST, $DB_USER, $DB_PASS, $DB_NAME; global $DB_HOST, $DB_USER, $DB_PASS, $DB_NAME;
$conn = new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME); $database = new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
if ($conn->connect_error) { if ($database->connect_error) {
die('Connection failed: ' . $conn->connect_error); die('Connection failed: ' . $database->connect_error);
} }
$conn->set_charset('utf8'); $database->set_charset('utf8');
return $conn; return $database;
} }
/** Helper: Initialise SFTP connection */ /** Helper: Initialise SFTP connection */
@@ -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);
}
?> ?>
+105 -182
View File
@@ -1,150 +1,92 @@
<?php <?php
session_start(); require_once __DIR__ . '/../includes/auth.php';
if (empty($_SESSION['admin']) || $_SESSION['admin'] !== true) { requireLogin('../index.php');
session_destroy(); requirePermission('admin', 'You do not have permission to manage users.');
header("location: ../index.php");
exit();
}
ini_set('display_errors', 1);
error_reporting(E_ALL);
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
// $username = "UNAME"; require_once __DIR__ . '/../includes/workspace.php';
// $password = "PSWD";
// $db = "usbraidlogin";
$conn = getDatabaseConnection(); $database = getDatabaseConnection();
// charset already set in getDatabaseConnection()
if ($conn->connect_error) {
die("Database connection failed: " . $conn->connect_error);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['uname']) && isset($_POST['pswd'])) { $username = is_string($_POST['uname'] ?? null) ? trim($_POST['uname']) : '';
$uname = trim($_POST['uname']); $password = is_string($_POST['pswd'] ?? null) ? $_POST['pswd'] : '';
$pswd = trim($_POST['pswd']); $defaultPath = is_string($_POST['defPath'] ?? null) ? normalizePath(trim($_POST['defPath'])) : '/';
$is_admin = isset($_POST['admin']) ? 1 : 0; $isAdmin = isset($_POST['admin']) ? 1 : 0;
$defPath = isset($_POST['defPath']) ? trim($_POST['defPath']) : ''; $canDelete = isset($_POST['delPer']) ? 1 : 0;
$delPer = isset($_POST['delPer']) ? (int)$_POST['delPer'] : 0; $canDownload = isset($_POST['downPer']) ? 1 : 0;
$dowPer = isset($_POST['downPer']) ? (int)$_POST['downPer'] : 0; $canUpload = isset($_POST['upPer']) ? 1 : 0;
$upPer = isset($_POST['upPer']) ? (int)$_POST['upPer'] : 0; $_SESSION['message_type'] = 'error';
if (empty($uname) || empty($pswd)) { if ($username === '' || $password === '') {
$_SESSION['message'] = 'Error: Username and password are required!'; $_SESSION['message'] = 'Error: Username and password are required!';
$_SESSION['message_type'] = 'error'; } elseif (!isPasswordValid($password)) {
} else if (!CheckPassword($pswd)) { $_SESSION['message'] = 'Error: Password must be at least 8 characters long, contain at least one number and one uppercase letter!';
$_SESSION['message'] = 'Error: Password must be at least 8 characters long, contain at least one number and one uppercase letter!'; } else {
$_SESSION['message_type'] = 'error'; try {
} else { $statement = $database->prepare('SELECT uname FROM users WHERE uname = ?');
$sql_check = "SELECT * FROM users WHERE uname = ?"; $statement->bind_param('s', $username);
$stmt_check = $conn->prepare($sql_check); $statement->execute();
if (!$stmt_check) { $result = $statement->get_result();
$_SESSION['message'] = 'Error: Database preparation failed.'; $userExists = $result->num_rows > 0;
$_SESSION['message_type'] = 'error'; $result->free();
$statement->close();
if ($userExists) {
$_SESSION['message'] = 'Error: Username already exists!';
} else { } else {
$stmt_check->bind_param("s", $uname); $passwordHash = password_hash($password, PASSWORD_BCRYPT);
$stmt_check->execute(); $statement = $database->prepare('INSERT INTO users (uname, pswd, admin, defPath, delPer, downPer, upPer) VALUES (?, ?, ?, ?, ?, ?, ?)');
$result_check = $stmt_check->get_result(); $statement->bind_param('ssisiii', $username, $passwordHash, $isAdmin, $defaultPath, $canDelete, $canDownload, $canUpload);
$statement->execute();
if ($result_check->num_rows > 0) { $statement->close();
$_SESSION['message'] = 'Error: Username already exists!'; $_SESSION['message'] = 'User added successfully!';
$_SESSION['message_type'] = 'error'; $_SESSION['message_type'] = 'success';
} else {
$sql_insert = "INSERT INTO users (uname, pswd, admin, defPath, delPer, downPer, upPer) VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt_insert = $conn->prepare($sql_insert);
if (!$stmt_insert) {
$_SESSION['message'] = 'Error: Database preparation failed.';
$_SESSION['message_type'] = 'error';
} else {
$hash = password_hash($pswd, PASSWORD_BCRYPT);
if (!$hash) {
$_SESSION['message'] = 'Error: Password hashing failed.';
$_SESSION['message_type'] = 'error';
} else {
$stmt_insert->bind_param("ssisiii", $uname, $hash, $is_admin, $defPath, $delPer, $dowPer, $upPer);
if ($stmt_insert->execute()) {
$_SESSION['message'] = 'User added successfully!';
$_SESSION['message_type'] = 'success';
} else {
$_SESSION['message'] = 'Error: Failed to add user. Please try again later.';
$_SESSION['message_type'] = 'error';
}
}
}
}
$stmt_check->close();
if (isset($stmt_insert)) {
$stmt_insert->close();
}
} }
} catch (mysqli_sql_exception $exception) {
error_log($exception->getMessage());
$_SESSION['message'] = 'Error: Failed to add user. Please try again later.';
} }
header("Location: adminpanel.php");
exit();
} else {
$_SESSION['message'] = 'Error: Missing form data!';
$_SESSION['message_type'] = 'error';
header("Location: adminpanel.php");
exit();
} }
$database->close();
header('Location: adminpanel.php');
exit;
} }
if (isset($_GET['delete'])) { if (isset($_GET['delete']) && is_string($_GET['delete'])) {
$delete_uname = htmlspecialchars($_GET['delete']); $usernameToDelete = $_GET['delete'];
$sql = "DELETE FROM users WHERE uname=?"; try {
$stmt = $conn->prepare($sql); $statement = $database->prepare('DELETE FROM users WHERE uname = ?');
if ($stmt) { $statement->bind_param('s', $usernameToDelete);
$stmt->bind_param("s", $delete_uname); $statement->execute();
if ($stmt->execute()) { $statement->close();
$_SESSION['message'] = 'User deleted successfully!'; $_SESSION['message'] = 'User deleted successfully!';
$_SESSION['message_type'] = 'success'; $_SESSION['message_type'] = 'success';
} else { } catch (mysqli_sql_exception $exception) {
$_SESSION['message'] = 'Error: Failed to delete user.'; error_log($exception->getMessage());
$_SESSION['message_type'] = 'error'; $_SESSION['message'] = 'Error: Failed to delete user.';
}
$stmt->close();
} else {
$_SESSION['message'] = 'Error: Database preparation failed.';
$_SESSION['message_type'] = 'error'; $_SESSION['message_type'] = 'error';
} }
$database->close();
header("Location: adminpanel.php"); header('Location: adminpanel.php');
exit(); exit;
} }
$result = $conn->query("SELECT uname, admin, defPath, delPer, downPer, upPer FROM users"); $result = $database->query('SELECT uname, admin, defPath, delPer, downPer, upPer FROM users');
$message = $_SESSION['message'] ?? ''; $message = $_SESSION['message'] ?? '';
$message_type = $_SESSION['message_type'] ?? ''; $messageType = $_SESSION['message_type'] ?? '';
unset($_SESSION['message']); unset($_SESSION['message'], $_SESSION['message_type']);
unset($_SESSION['message_type']);
function CheckPassword($password) {
if (strlen($password) < 8) {
return false;
}
if (!preg_match('/[0-9]/', $password)) {
return false;
}
if (!preg_match('/[A-Z]/', $password)) {
return false;
}
return true;
}
?> ?>
<!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 +94,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 $messageType === '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 +147,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>
@@ -226,41 +164,26 @@ function CheckPassword($password) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php while ($row = $result->fetch_assoc()) { ?> <?php while ($user = $result->fetch_assoc()) { ?>
<tr> <tr>
<td><?php echo htmlspecialchars($row['uname']); ?></td> <td><?php echo htmlspecialchars($user['uname']); ?></td>
<td><?php echo $row['admin'] ? 'Yes' : 'No'; ?></td> <td><?php echo $user['admin'] ? 'Yes' : 'No'; ?></td>
<td><?php echo htmlspecialchars($row['defPath']); ?></td> <td><?php echo htmlspecialchars($user['defPath']); ?></td>
<td><?php echo $row['delPer'] ? 'Yes' : 'No'; ?></td> <td><?php echo $user['delPer'] ? 'Yes' : 'No'; ?></td>
<td><?php echo $row['downPer'] ? 'Yes' : 'No'; ?></td> <td><?php echo $user['downPer'] ? 'Yes' : 'No'; ?></td>
<td><?php echo $row['upPer'] ? 'Yes' : 'No'; ?></td> <td><?php echo $user['upPer'] ? 'Yes' : 'No'; ?></td>
<td> <td>
<button class="btn btn-danger btn-sm" onclick="confirmDelete('<?php echo htmlspecialchars($row['uname']); ?>')">Delete</button> <button class="btn btn-danger btn-sm" onclick="confirmDelete(<?= workspaceEscape(json_encode($user['uname'])) ?>)">Delete</button>
</td> </td>
</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
$conn->close(); $database->close();
?> ?>
+76 -138
View File
@@ -1,174 +1,112 @@
<?php <?php
session_start(); require_once __DIR__ . '/../includes/auth.php';
requireLogin('../index.php');
if (!isset($_SESSION['uname'])) {
header("location: ../index.php");
exit();
}
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
// $username = "UNAME"; require_once __DIR__ . '/../includes/workspace.php';
// $password = "PSWD";
// $db = "usbraidlogin";
$message = ""; $message = '';
$messageType = ""; $messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['current_password'], $_POST['new_password'], $_POST['confirm_password'])) { $messageType = 'danger';
$current_password = htmlspecialchars($_POST['current_password']); $currentPassword = $_POST['current_password'] ?? null;
$new_password = htmlspecialchars($_POST['new_password']); $newPassword = $_POST['new_password'] ?? null;
$confirm_password = htmlspecialchars($_POST['confirm_password']); $confirmPassword = $_POST['confirm_password'] ?? null;
$uname = $_SESSION['uname'];
if(!CheckPassword($new_password)) { if (!is_string($currentPassword) || !is_string($newPassword) || !is_string($confirmPassword)) {
$message = "New password must be at least 8 characters long, contain at least one number and one uppercase letter!"; $message = 'All fields are required!';
$messageType = "danger"; } elseif (!isPasswordValid($newPassword)) {
} else if ($new_password !== $confirm_password) { $message = 'New password must be at least 8 characters long, contain at least one number and one uppercase letter!';
$message = "New passwords do not match!"; } elseif ($newPassword !== $confirmPassword) {
$messageType = "danger"; $message = 'New passwords do not match!';
} else {
$conn = getDatabaseConnection();
// charset already set in getDatabaseConnection()
if ($conn->connect_error) {
$message = "Database connection failed!";
$messageType = "danger";
} else {
$sql = "SELECT pswd FROM users WHERE uname=?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("s", $uname);
$stmt->execute();
$result = $stmt->get_result();
if ($result && $result->num_rows > 0) {
$row = $result->fetch_assoc();
$stored_hash = $row["pswd"];
if (password_verify($current_password, $stored_hash)) {
$new_hash = password_hash($new_password, PASSWORD_BCRYPT);
$update_sql = "UPDATE users SET pswd=? WHERE uname=?";
$update_stmt = $conn->prepare($update_sql);
$update_stmt->bind_param("ss", $new_hash, $uname);
if ($update_stmt->execute()) {
$message = "Password changed successfully!";
$messageType = "success";
} else {
$message = "Error updating password: " . $conn->error;
$messageType = "danger";
}
$update_stmt->close();
} else {
$message = "Current password is incorrect!";
$messageType = "danger";
}
} else {
$message = "User not found!";
$messageType = "danger";
}
$stmt->close();
$conn->close();
}
}
} else { } else {
$message = "All fields are required!"; $database = getDatabaseConnection();
$messageType = "danger"; try {
} $username = $_SESSION['uname'];
} $statement = $database->prepare('SELECT pswd FROM users WHERE uname = ?');
$statement->bind_param('s', $username);
$statement->execute();
$result = $statement->get_result();
$user = $result->fetch_assoc();
$result->free();
$statement->close();
function CheckPassword($password) { if (!$user) {
if (strlen($password) < 8) { $message = 'User not found!';
return false; } elseif (!password_verify($currentPassword, $user['pswd'])) {
$message = 'Current password is incorrect!';
} else {
$passwordHash = password_hash($newPassword, PASSWORD_BCRYPT);
$statement = $database->prepare('UPDATE users SET pswd = ? WHERE uname = ?');
$statement->bind_param('ss', $passwordHash, $username);
$statement->execute();
$statement->close();
$message = 'Password changed successfully!';
$messageType = 'success';
}
} catch (mysqli_sql_exception $exception) {
error_log($exception->getMessage());
$message = 'Unable to change password. Please try again.';
} finally {
$database->close();
}
} }
if (!preg_match('/[0-9]/', $password)) {
return false;
}
if (!preg_match('/[A-Z]/', $password)) {
return false;
}
return true;
} }
?> ?>
<!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"> <section class="account-layout">
<div class="btn-group-responsive"> <article class="content-panel">
<a href="logout.php" class="btn btn-danger">Logout</a> <div class="panel-heading"><h2>Change password</h2><p>Choose a strong password to protect your files.</p></div>
<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">
<article class="col-12 border border-2 border-primary rounded p-2 p-md-4 surface-panel">
<h2 class="mb-4">Change Password</h2>
<?php if ($message): ?> <?php if ($message): ?>
<div class="alert alert-<?php echo $messageType; ?> mb-4"> <div class="alert alert-<?php echo $messageType; ?> mb-4">
<?php echo $message; ?> <?php echo $message; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<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;
}
}
+12 -12
View File
@@ -1,15 +1,14 @@
<?php <?php
// Include this at the top to see potential errors require_once __DIR__ . '/../../includes/auth.php';
// Comment out in production requireLogin('../../index.php');
ini_set('display_errors', 1); requirePermission('upPer', 'You do not have permission to perform this operation.');
error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'createDir') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'createDir') {
$currentPath = isset($_POST['currentPath']) ? normalizePath($_POST['currentPath']) : $defaultPath; $currentPath = requireRemotePath($_POST['currentPath'] ?? $defaultPath, $defaultPath);
if (!isset($_POST['dirName'])) { if (!isset($_POST['dirName'])) {
http_response_code(400); http_response_code(400);
@@ -17,17 +16,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
exit; exit;
} }
$dirName = $_POST['dirName']; $directoryName = $_POST['dirName'];
$dirName = preg_replace('/[^\w\-\.]/', '_', $dirName); $directoryName = preg_replace('/[^\w\-\.]/', '_', $directoryName);
if (strpos($currentPath, $defaultPath) !== 0) { if (!isRemoteNameValid($directoryName)) {
$currentPath = $defaultPath; http_response_code(400);
exit('Invalid directory name.');
} }
$newDirPath = $currentPath . '/' . $dirName; $newDirectoryPath = rtrim($currentPath, '/') . '/' . $directoryName;
if ($sftp->mkdir($newDirPath)) { if ($sftp->mkdir($newDirectoryPath)) {
echo "Directory created successfully!"; echo "Directory created successfully!";
} else { } else {
http_response_code(500); http_response_code(500);
@@ -38,4 +38,4 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
http_response_code(400); http_response_code(400);
echo "Invalid request"; echo "Invalid request";
exit; exit;
+11 -32
View File
@@ -1,29 +1,14 @@
<?php <?php
// Include this at the top to see potential errors require_once __DIR__ . '/../../includes/auth.php';
// Comment out in production requireLogin('../../index.php');
ini_set('display_errors', 1); requirePermission('upPer', 'You do not have permission to perform this operation.');
error_reporting(E_ALL);
session_start();
if(!isset($_SESSION['uname'])){
header("location: ../index.html");
session_destroy();
exit;
}
if(!isset($_SESSION["upPer"]) || $_SESSION["upPer"] != true) {
http_response_code(403);
echo "You don't have permission to create files.";
exit;
}
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'createFile') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'createFile') {
$currentPath = isset($_POST['currentPath']) ? normalizePath($_POST['currentPath']) : $defaultPath; $currentPath = requireRemotePath($_POST['currentPath'] ?? $defaultPath, $defaultPath);
if (!isset($_POST['fileName'])) { if (!isset($_POST['fileName'])) {
http_response_code(400); http_response_code(400);
@@ -41,22 +26,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
$fileName = preg_replace('/[^\w\-\.]/', '_', $fileName); $fileName = preg_replace('/[^\w\-\.]/', '_', $fileName);
if (strpos($currentPath, $defaultPath) !== 0) { if (!isRemoteNameValid($fileName)) {
$currentPath = $defaultPath; http_response_code(400);
exit('Invalid file name.');
} }
$newFilePath = $currentPath . '/' . $fileName; $newFilePath = rtrim($currentPath, '/') . '/' . $fileName;
header('Content-Type: application/json; charset=UTF-8');
$tempFile = tempnam(sys_get_temp_dir(), 'new_file_'); if ($sftp->put($newFilePath, '')) {
file_put_contents($tempFile, '');
if ($sftp->put($newFilePath, $tempFile)) {
@unlink($tempFile);
echo json_encode(['success' => true, 'filePath' => $newFilePath]); echo json_encode(['success' => true, 'filePath' => $newFilePath]);
} else { } else {
@unlink($tempFile);
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Failed to create file.']); echo json_encode(['success' => false, 'message' => 'Failed to create file.']);
} }
@@ -65,4 +44,4 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
http_response_code(400); http_response_code(400);
echo "Invalid request"; echo "Invalid request";
exit; exit;
+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%;
}
}
+21 -8
View File
@@ -1,26 +1,35 @@
<?php <?php
require_once __DIR__ . '/../../includes/auth.php';
requireLogin('../../index.php');
requirePermission('delPer', 'You do not have permission to perform this operation.');
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) {
$itemToDelete = $_POST['delete']; $itemToDelete = requireRemotePath($_POST['delete'], $defaultPath);
$parentDir = dirname($itemToDelete); if ($itemToDelete === $defaultPath) {
http_response_code(400);
exit('Cannot delete the workspace root.');
}
$parentPath = remoteDirname($itemToDelete);
if ($parentDir === '/' || $parentDir === '.') { if ($parentPath === '/' || $parentPath === '.') {
$parentDir = $defaultPath; $parentPath = $defaultPath;
} }
if ($sftp->is_dir($itemToDelete)) { if ($sftp->is_dir($itemToDelete)) {
function deleteFolder($sftp, $folderPath) { function deleteFolder($sftp, $folderPath) {
$items = $sftp->nlist($folderPath); $items = $sftp->nlist($folderPath);
if ($items === false) return false;
foreach ($items as $item) { foreach ($items as $item) {
if ($item === '.' || $item === '..') continue; if ($item === '.' || $item === '..') continue;
$itemPath = $folderPath . '/' . $item; $itemPath = $folderPath . '/' . $item;
if ($sftp->is_dir($itemPath)) { if ($sftp->is_dir($itemPath)) {
deleteFolder($sftp, $itemPath); if (!deleteFolder($sftp, $itemPath)) return false;
} else { } else {
$sftp->delete($itemPath); if (!$sftp->delete($itemPath)) return false;
} }
} }
return $sftp->rmdir($folderPath); return $sftp->rmdir($folderPath);
@@ -31,9 +40,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) {
$success = $sftp->delete($itemToDelete); $success = $sftp->delete($itemToDelete);
} }
header("Location: index.php?path=" . urlencode($parentDir)); if (!$success) {
http_response_code(500);
exit('Failed to delete the item.');
}
header("Location: index.php?path=" . urlencode($parentPath));
exit; exit;
} }
header("Location: index.php"); header("Location: index.php");
exit; exit;
+59 -60
View File
@@ -1,10 +1,10 @@
<?php <?php
set_time_limit(900); // 15 minutes max execution time require_once __DIR__ . '/../../includes/auth.php';
requireLogin('../../index.php');
requirePermission('downPer', 'You do not have permission to perform this operation.');
session_write_close();
// Include this at the top to see potential errors set_time_limit(900); // 15 minutes max execution time
// Comment out in production
ini_set('display_errors', 0);
error_reporting(0);
ob_start(); ob_start();
@@ -18,12 +18,12 @@ try {
try { try {
$sftp = initializeSFTP(); $sftp = initializeSFTP();
file_put_contents($logFile, "SFTP connection established\n", FILE_APPEND); file_put_contents($logFile, "SFTP connection established\n", FILE_APPEND);
} catch (Exception $e) { } catch (Exception $exception) {
file_put_contents($logFile, "SFTP connection failed: " . $e->getMessage() . "\n", FILE_APPEND); file_put_contents($logFile, "SFTP connection failed: " . $exception->getMessage() . "\n", FILE_APPEND);
throw new Exception("Failed to connect to SFTP server: " . $e->getMessage()); throw new Exception("Failed to connect to SFTP server: " . $exception->getMessage());
} }
function zipFolderRecursive($sftp, $remoteBasePath, $currentPath, $zip, $logFile, &$tempFiles) { function zipFolderRecursive($sftp, $remoteBasePath, $currentPath, $zip, $logFile, &$temporaryFiles) {
file_put_contents($logFile, "Processing directory: $currentPath\n", FILE_APPEND); file_put_contents($logFile, "Processing directory: $currentPath\n", FILE_APPEND);
$files = $sftp->nlist($currentPath); $files = $sftp->nlist($currentPath);
@@ -35,36 +35,35 @@ try {
file_put_contents($logFile, "Found " . count($files) . " items in $currentPath\n", FILE_APPEND); file_put_contents($logFile, "Found " . count($files) . " items in $currentPath\n", FILE_APPEND);
foreach ($files as $file) { foreach ($files as $fileName) {
if ($file == '.' || $file == '..') continue; if ($fileName == '.' || $fileName == '..') continue;
$fullRemotePath = rtrim($currentPath, '/') . '/' . $file; $remotePath = rtrim($currentPath, '/') . '/' . $fileName;
$baseDirName = basename($remoteBasePath); $relativePath = substr($remotePath, strlen(rtrim(remoteDirname($remoteBasePath), '/')) + 1);
$relPathFromBase = substr($fullRemotePath, strlen(dirname($remoteBasePath)) + 1); file_put_contents($logFile, "Processing: $remotePath (relative: $relativePath)\n", FILE_APPEND);
file_put_contents($logFile, "Processing: $fullRemotePath (relative: $relPathFromBase)\n", FILE_APPEND);
$isDir = $sftp->is_dir($fullRemotePath); $isDirectory = $sftp->is_dir($remotePath);
if ($isDir) { if ($isDirectory) {
file_put_contents($logFile, "Found subdirectory: $fullRemotePath\n", FILE_APPEND); file_put_contents($logFile, "Found subdirectory: $remotePath\n", FILE_APPEND);
$zip->addEmptyDir($relPathFromBase); $zip->addEmptyDir($relativePath);
zipFolderRecursive($sftp, $remoteBasePath, $fullRemotePath, $zip, $logFile, $tempFiles); zipFolderRecursive($sftp, $remoteBasePath, $remotePath, $zip, $logFile, $temporaryFiles);
} else { } else {
$localTempFile = tempnam(sys_get_temp_dir(), 'sftp'); $temporaryFile = tempnam(sys_get_temp_dir(), 'sftp');
$tempFiles[] = $localTempFile; $temporaryFiles[] = $temporaryFile;
file_put_contents($logFile, "Downloading to temp file: $localTempFile\n", FILE_APPEND); file_put_contents($logFile, "Downloading to temp file: $temporaryFile\n", FILE_APPEND);
$downloadStart = time(); $downloadStart = time();
$downloadSuccess = $sftp->get($fullRemotePath, $localTempFile); $downloadSuccess = $sftp->get($remotePath, $temporaryFile);
$downloadTime = time() - $downloadStart; $downloadTime = time() - $downloadStart;
if ($downloadSuccess) { if ($downloadSuccess) {
$fileSize = filesize($localTempFile); $fileSize = filesize($temporaryFile);
file_put_contents($logFile, "Download successful ($downloadTime seconds), size: $fileSize bytes\n", FILE_APPEND); file_put_contents($logFile, "Download successful ($downloadTime seconds), size: $fileSize bytes\n", FILE_APPEND);
$zip->addFile($localTempFile, $relPathFromBase); $zip->addFile($temporaryFile, $relativePath);
} else { } else {
file_put_contents($logFile, "Failed to download file after $downloadTime seconds\n", FILE_APPEND); file_put_contents($logFile, "Failed to download file after $downloadTime seconds\n", FILE_APPEND);
} }
@@ -83,17 +82,17 @@ try {
} }
$tempFiles = []; $temporaryFiles = [];
try { try {
zipFolderRecursive($sftp, $folderPath, $folderPath, $zip, $logFile, $tempFiles); zipFolderRecursive($sftp, $folderPath, $folderPath, $zip, $logFile, $temporaryFiles);
file_put_contents($logFile, "Closing zip file\n", FILE_APPEND); file_put_contents($logFile, "Closing zip file\n", FILE_APPEND);
$zipSuccess = $zip->close(); $zipSuccess = $zip->close();
foreach ($tempFiles as $tempFile) { foreach ($temporaryFiles as $temporaryFile) {
if (file_exists($tempFile)) { if (file_exists($temporaryFile)) {
@unlink($tempFile); @unlink($temporaryFile);
} }
} }
@@ -104,39 +103,39 @@ try {
file_put_contents($logFile, "Failed to create zip\n", FILE_APPEND); file_put_contents($logFile, "Failed to create zip\n", FILE_APPEND);
return false; return false;
} }
} catch (Exception $e) { } catch (Exception $exception) {
foreach ($tempFiles as $tempFile) { foreach ($temporaryFiles as $temporaryFile) {
if (file_exists($tempFile)) { if (file_exists($temporaryFile)) {
@unlink($tempFile); @unlink($temporaryFile);
} }
} }
file_put_contents($logFile, "Error during zip creation: " . $e->getMessage() . "\n", FILE_APPEND); file_put_contents($logFile, "Error during zip creation: " . $exception->getMessage() . "\n", FILE_APPEND);
throw $e; throw $exception;
} }
} }
if (($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['file'])) || if (($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['file'])) ||
($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['file']))) { ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['file']))) {
$file = $_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST['file'] : $_GET['file']; $filePath = requireRemotePath($_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST['file'] : $_GET['file'], $defaultPath);
file_put_contents($logFile, "Requested file: $file\n", FILE_APPEND); file_put_contents($logFile, "Requested file: $filePath\n", FILE_APPEND);
$fileExists = $sftp->file_exists($file); $fileExists = $sftp->file_exists($filePath);
$isDir = $sftp->is_dir($file); $isDirectory = $sftp->is_dir($filePath);
file_put_contents($logFile, "File exists: " . ($fileExists ? "Yes" : "No") . "\n", FILE_APPEND); file_put_contents($logFile, "File exists: " . ($fileExists ? "Yes" : "No") . "\n", FILE_APPEND);
file_put_contents($logFile, "Is directory: " . ($isDir ? "Yes" : "No") . "\n", FILE_APPEND); file_put_contents($logFile, "Is directory: " . ($isDirectory ? "Yes" : "No") . "\n", FILE_APPEND);
if (!$fileExists && !$isDir) { if (!$fileExists && !$isDirectory) {
throw new Exception("File not found: $file"); throw new Exception("File not found: $filePath");
} }
if ($isDir) { if ($isDirectory) {
$zipFilePath = tempnam(sys_get_temp_dir(), 'folder_') . '.zip'; $zipFilePath = tempnam(sys_get_temp_dir(), 'folder_') . '.zip';
file_put_contents($logFile, "Creating zip at: $zipFilePath\n", FILE_APPEND); file_put_contents($logFile, "Creating zip at: $zipFilePath\n", FILE_APPEND);
if (zipFolder($sftp, $file, $zipFilePath, $logFile)) { if (zipFolder($sftp, $filePath, $zipFilePath, $logFile)) {
if (file_exists($zipFilePath) && filesize($zipFilePath) > 0) { if (file_exists($zipFilePath) && filesize($zipFilePath) > 0) {
file_put_contents($logFile, "Sending zip file to browser, size: " . filesize($zipFilePath) . " bytes\n", FILE_APPEND); file_put_contents($logFile, "Sending zip file to browser, size: " . filesize($zipFilePath) . " bytes\n", FILE_APPEND);
@@ -145,7 +144,7 @@ try {
} }
header('Content-Type: application/zip'); header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . basename($file) . '.zip"'); header('Content-Disposition: attachment; filename="' . basename($filePath) . '.zip"');
header('Content-Length: ' . filesize($zipFilePath)); header('Content-Length: ' . filesize($zipFilePath));
header('Cache-Control: no-cache, must-revalidate'); header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache'); header('Pragma: no-cache');
@@ -161,19 +160,19 @@ try {
throw new Exception("Failed to create zip archive"); throw new Exception("Failed to create zip archive");
} }
} else { } else {
$localFile = basename($file); $fileName = basename($filePath);
file_put_contents($logFile, "Downloading single file: $localFile\n", FILE_APPEND); file_put_contents($logFile, "Downloading single file: $fileName\n", FILE_APPEND);
$tempFile = tempnam(sys_get_temp_dir(), 'file_'); $temporaryFile = tempnam(sys_get_temp_dir(), 'file_');
$downloadStart = time(); $downloadStart = time();
$downloadSuccess = $sftp->get($file, $tempFile); $downloadSuccess = $sftp->get($filePath, $temporaryFile);
$downloadTime = time() - $downloadStart; $downloadTime = time() - $downloadStart;
file_put_contents($logFile, "Download " . ($downloadSuccess ? "successful" : "failed") . " ($downloadTime seconds)\n", FILE_APPEND); file_put_contents($logFile, "Download " . ($downloadSuccess ? "successful" : "failed") . " ($downloadTime seconds)\n", FILE_APPEND);
if ($downloadSuccess) { if ($downloadSuccess) {
$fileSize = filesize($tempFile); $fileSize = filesize($temporaryFile);
file_put_contents($logFile, "Downloaded file size: $fileSize bytes\n", FILE_APPEND); file_put_contents($logFile, "Downloaded file size: $fileSize bytes\n", FILE_APPEND);
if ($fileSize > 0) { if ($fileSize > 0) {
@@ -182,29 +181,29 @@ try {
} }
header('Content-Type: application/octet-stream'); header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $localFile . '"'); header('Content-Disposition: attachment; filename="' . $fileName . '"');
header('Content-Length: ' . $fileSize); header('Content-Length: ' . $fileSize);
header('Cache-Control: no-cache, must-revalidate'); header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache'); header('Pragma: no-cache');
readfile($tempFile); readfile($temporaryFile);
file_put_contents($logFile, "Download completed\n", FILE_APPEND); file_put_contents($logFile, "Download completed\n", FILE_APPEND);
@unlink($tempFile); @unlink($temporaryFile);
exit; exit;
} else { } else {
@unlink($tempFile); @unlink($temporaryFile);
throw new Exception("Downloaded file is empty"); throw new Exception("Downloaded file is empty");
} }
} else { } else {
@unlink($tempFile); @unlink($temporaryFile);
throw new Exception("Failed to download file from SFTP server"); throw new Exception("Failed to download file from SFTP server");
} }
} }
} else { } else {
throw new Exception("Invalid request method or missing file parameter"); throw new Exception("Invalid request method or missing file parameter");
} }
} catch (Exception $e) { } catch (Exception $exception) {
$errorMessage = "Error: " . $e->getMessage(); $errorMessage = "Error: " . $exception->getMessage();
file_put_contents($logFile, $errorMessage . "\n", FILE_APPEND); file_put_contents($logFile, $errorMessage . "\n", FILE_APPEND);
while (ob_get_level()) { while (ob_get_level()) {
@@ -214,4 +213,4 @@ try {
header("HTTP/1.1 500 Internal Server Error"); header("HTTP/1.1 500 Internal Server Error");
echo $errorMessage; echo $errorMessage;
} }
?> ?>
+24 -37
View File
@@ -1,20 +1,7 @@
<?php <?php
session_start(); require_once __DIR__ . '/../../includes/auth.php';
requireLogin('../../index.php');
if(!isset($_SESSION['uname'])){ requirePermission('upPer', 'You do not have permission to perform this operation.');
header("location: ../../index.php");
session_destroy();
exit;
}
// Check if user has permission to upload/modify
if(!isset($_SESSION["upPer"]) || $_SESSION["upPer"] != true) {
die("You don't have permission to extract archives.");
}
// Include error reporting for debugging
ini_set('display_errors', 1);
error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
@@ -23,21 +10,21 @@ $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 = requireRemotePath($_POST['archive'], $defaultPath);
$dirPath = dirname($archivePath); $directoryPath = remoteDirname($archivePath);
$archiveName = basename($archivePath); $archiveName = basename($archivePath);
$extension = pathinfo($archiveName, PATHINFO_EXTENSION); $extension = pathinfo($archiveName, PATHINFO_EXTENSION);
// Create a temporary local directory for extraction // Create a temporary local directory for extraction
$tempDir = sys_get_temp_dir() . '/sftp_extract_' . time(); $temporaryDirectory = sys_get_temp_dir() . '/sftp_extract_' . time();
if (!mkdir($tempDir, 0777, true)) { if (!mkdir($temporaryDirectory, 0777, true)) {
die("Failed to create temporary directory"); die("Failed to create temporary directory");
} }
// Download the archive to the temp directory // Download the archive to the temp directory
$localArchivePath = $tempDir . '/' . $archiveName; $localArchivePath = $temporaryDirectory . '/' . $archiveName;
if (!$sftp->get($archivePath, $localArchivePath)) { if (!$sftp->get($archivePath, $localArchivePath)) {
rmdir($tempDir); rmdir($temporaryDirectory);
die("Failed to download the archive"); die("Failed to download the archive");
} }
@@ -48,11 +35,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['archive'])) {
// Determine the extraction method based on file extension // Determine the extraction method based on file extension
switch (strtolower($extension)) { switch (strtolower($extension)) {
case 'zip': case 'zip':
$extractionSuccess = extractZip($localArchivePath, $tempDir); $extractionSuccess = extractZip($localArchivePath, $temporaryDirectory);
break; break;
case 'rar': case 'rar':
$extractionSuccess = extractRar($localArchivePath, $tempDir); $extractionSuccess = extractRar($localArchivePath, $temporaryDirectory);
break; break;
case 'tar': case 'tar':
@@ -60,7 +47,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['archive'])) {
case 'bz2': case 'bz2':
case 'xz': case 'xz':
case '7z': case '7z':
$extractionSuccess = extractArchive($localArchivePath, $tempDir); $extractionSuccess = extractArchive($localArchivePath, $temporaryDirectory);
break; break;
default: default:
@@ -69,20 +56,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['archive'])) {
if ($extractionSuccess) { if ($extractionSuccess) {
// Upload extracted files back to the server // Upload extracted files back to the server
uploadExtractedFiles($sftp, $tempDir, $dirPath); uploadExtractedFiles($sftp, $temporaryDirectory, $directoryPath);
// Clean up temporary directory // Clean up temporary directory
deleteDirectory($tempDir); deleteDirectory($temporaryDirectory);
// Redirect back to the file listing // Redirect back to the file listing
header("Location: index.php?path=" . urlencode($dirPath)); header("Location: index.php?path=" . urlencode($directoryPath));
exit; exit;
} else { } else {
die("Failed to extract the archive"); die("Failed to extract the archive");
} }
} catch (Exception $e) { } catch (Exception $exception) {
deleteDirectory($tempDir); deleteDirectory($temporaryDirectory);
die("Error during extraction: " . $e->getMessage()); die("Error during extraction: " . $exception->getMessage());
} }
} else { } else {
header("Location: index.php"); header("Location: index.php");
@@ -140,7 +127,7 @@ function extractArchive($archivePath, $destination) {
$command = ''; $command = '';
// Change to the destination directory // Change to the destination directory
$currentDir = getcwd(); $originalDirectory = getcwd();
chdir($destination); chdir($destination);
if (PHP_OS_FAMILY === 'Windows') { if (PHP_OS_FAMILY === 'Windows') {
@@ -189,20 +176,20 @@ function extractArchive($archivePath, $destination) {
exec($command, $output, $returnCode); exec($command, $output, $returnCode);
// Change back to the original directory // Change back to the original directory
chdir($currentDir); chdir($originalDirectory);
return $returnCode === 0; return $returnCode === 0;
} }
// Upload extracted files back to the SFTP server // Upload extracted files back to the SFTP server
function uploadExtractedFiles($sftp, $localDir, $remoteDir) { function uploadExtractedFiles($sftp, $localDirectory, $remoteDirectory) {
$items = scandir($localDir); $items = scandir($localDirectory);
foreach ($items as $item) { foreach ($items as $item) {
if ($item === '.' || $item === '..') continue; if ($item === '.' || $item === '..') continue;
$localPath = $localDir . '/' . $item; $localPath = $localDirectory . '/' . $item;
$remotePath = $remoteDir . '/' . $item; $remotePath = $remoteDirectory . '/' . $item;
if (is_dir($localPath)) { if (is_dir($localPath)) {
// Create the directory on the remote server // Create the directory on the remote server
+107 -142
View File
@@ -1,85 +1,70 @@
<?php <?php
session_start(); require_once __DIR__ . '/../../includes/auth.php';
requireLogin('../../index.php');
if(!isset($_SESSION['uname'])){ require_once __DIR__ . '/../../config/config.php';
header("location: ../../index.php"); require_once __DIR__ . '/../../includes/workspace.php';
session_destroy(); $sftp = initializeSFTP();
exit;
}
// Include this at the top to see potential errors $currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath;
// Comment out in production if (!isRemotePathWithin($currentPath, $defaultPath)) {
ini_set('display_errors', 1); $currentPath = $defaultPath;
error_reporting(E_ALL); }
require_once __DIR__ . '/../../config/config.php'; if (!$sftp->chdir($currentPath)) {
$sftp = initializeSFTP(); die("Failed to navigate to the selected folder: $currentPath");
}
$currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath; $items = $sftp->nlist();
if (strpos($currentPath, $defaultPath) !== 0) { if ($items === false) {
$currentPath = $defaultPath; http_response_code(500);
} exit('Failed to list directory contents.');
}
if (!$sftp->chdir($currentPath)) {
die("Failed to navigate to the selected folder: $currentPath");
}
$items = $sftp->nlist(); $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
$videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'];
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']; $audioExtensions = ['mp3', 'wav', 'm4a', 'flac', 'aac'];
$videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv']; $editableExtensions = ['txt', 'html', 'css', 'js', 'php', 'xml', 'json', 'md', 'csv', 'log', 'ini', 'conf', 'sh', 'bat', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp'];
$audioExtensions = ['mp3', 'wav', 'm4a', 'flac', 'aac']; $archiveExtensions = ['zip', 'rar', 'tar', 'gz', '7z', 'bz2', 'xz', 'tar.gz', 'tar.bz2', 'tar.xz'];
$editableExtensions = ['txt', 'html', 'css', 'js', 'php', 'xml', 'json', 'md', 'csv', 'log', 'ini', 'conf', 'sh', 'bat', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp'];
$archiveExtensions = ['zip', 'rar', 'tar', 'gz', '7z', 'bz2', 'xz', 'tar.gz', 'tar.bz2', 'tar.xz'];
?> ?>
<!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 (hasPermission('upPer')) { ?>
<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 +72,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 +134,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 (hasPermission('upPer')) { ?>
<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 +182,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 (hasPermission('delPer')) : ?>
<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 (hasPermission('downPer')) : ?>
<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 (hasPermission('upPer')) : ?>
<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>
@@ -229,9 +213,9 @@
foreach ($files as $file) : ?> foreach ($files as $file) : ?>
<?php <?php
$fileExtension = pathinfo($file, PATHINFO_EXTENSION); $fileExtension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
$isPDF = strtolower($fileExtension) === 'pdf'; $isPdf = $fileExtension === 'pdf';
$isImage = in_array($fileExtension, $imageExtensions); $isImage = in_array($fileExtension, $imageExtensions);
$isVideo = in_array($fileExtension, $videoExtensions); $isVideo = in_array($fileExtension, $videoExtensions);
$isAudio = in_array($fileExtension, $audioExtensions); $isAudio = in_array($fileExtension, $audioExtensions);
@@ -242,11 +226,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 +240,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 +255,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 (hasPermission('delPer')) : ?>
<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 (hasPermission('downPer')) : ?>
<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 (hasPermission('upPer')) : ?>
<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 && hasPermission('upPer')) : ?>
<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 +284,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>
+32 -47
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'); // Filtering is local to the current directory; no remote request is needed.
const html = document.documentElement; const search = document.getElementById('fileSearch');
const themeText = document.getElementById('themeText'); const typeFilter = document.getElementById('typeFilter');
const themeIcon = themeToggle.querySelector('.bi'); function filterFiles() {
const query = search.value.trim().toLocaleLowerCase();
function setTheme(theme) { const rows = [...document.querySelectorAll('[data-file-name]')];
html.setAttribute('data-bs-theme', theme); let visible = 0;
document.body.classList.remove('theme-light', 'theme-dark'); rows.forEach(row => {
document.body.classList.add('theme-' + theme); row.hidden = !row.dataset.fileName.toLocaleLowerCase().includes(query) || (typeFilter.value !== 'all' && row.dataset.kind !== typeFilter.value);
localStorage.setItem('theme', theme); if (!row.hidden) visible++;
if (theme === 'dark') {
themeText.textContent = 'Light Mode';
themeIcon.className = 'bi bi-sun';
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');
}
}
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];
}
+48 -57
View File
@@ -1,21 +1,12 @@
<?php <?php
// Include this at the top to see potential errors require_once __DIR__ . '/../../includes/auth.php';
// Comment out in production requireLogin('../../index.php');
ini_set('display_errors', 1);
error_reporting(E_ALL);
session_start();
if(!isset($_SESSION['uname'])){
header("location: ../../index.php");
session_destroy();
exit;
}
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 = requireRemotePath($_GET['file'] ?? $defaultPath, $defaultPath);
$content = ''; $content = '';
$fileName = basename($filePath); $fileName = basename($filePath);
@@ -28,7 +19,7 @@ if (!empty($filePath) && $sftp->file_exists($filePath) && !$sftp->is_dir($filePa
if (in_array(strtolower($extension), $editableExtensions)) { if (in_array(strtolower($extension), $editableExtensions)) {
$editable = true; $editable = true;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['content']) && isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['content']) && hasPermission('upPer')) {
$newContent = $_POST['content']; $newContent = $_POST['content'];
if ($sftp->put($filePath, $newContent)) { if ($sftp->put($filePath, $newContent)) {
@@ -37,7 +28,7 @@ if (!empty($filePath) && $sftp->file_exists($filePath) && !$sftp->is_dir($filePa
$saveError = "Failed to save changes. Check permissions."; $saveError = "Failed to save changes. Check permissions.";
} }
} }
$content = $sftp->get($filePath); $content = $sftp->get($filePath);
if ($content === false) { if ($content === false) {
$error = "Failed to read file contents."; $error = "Failed to read file contents.";
@@ -51,50 +42,49 @@ 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); $parentPath = 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($parentPath),
<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): ?>
<?php if (isset($saveSuccess)): ?> <?php if (isset($saveSuccess)): ?>
<div class="alert alert-success">File saved successfully!</div> <div class="alert alert-success">File saved successfully!</div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($saveError)): ?> <?php if (isset($saveError)): ?>
<div class="alert alert-danger"><?= $saveError ?></div> <div class="alert alert-danger"><?= $saveError ?></div>
<?php endif; ?> <?php endif; ?>
<form method="POST" id="editorForm"> <form method="POST" id="editorForm">
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<label for="editor" class="form-label">File Content</label> <label for="editor" class="form-label">File Content</label>
<?php if (!isset($_SESSION["upPer"]) || $_SESSION["upPer"] != true): ?> <?php if (!hasPermission('upPer')): ?>
<span class="readonly-notice">Read-only mode (you don't have upload permissions)</span> <span class="readonly-notice">Read-only mode (you don't have upload permissions)</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -102,65 +92,66 @@ $parentDir = dirname($filePath);
<?php if ($showLineNumbers): ?> <?php if ($showLineNumbers): ?>
<div id="lineNumbers" class="line-numbers"></div> <div id="lineNumbers" class="line-numbers"></div>
<?php endif; ?> <?php endif; ?>
<textarea id="editor" name="content" class="form-control" <?= (!isset($_SESSION["upPer"]) || $_SESSION["upPer"] != true) ? 'readonly' : '' ?>><?= htmlspecialchars($content) ?></textarea> <textarea id="editor" name="content" class="form-control" <?= (!hasPermission('upPer')) ? 'readonly' : '' ?>><?= htmlspecialchars($content) ?></textarea>
</div> </div>
</div> </div>
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true): ?> <?php if (hasPermission('upPer')): ?>
<div class="mb-3"> <div class="mb-3">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
</div> </div>
<?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');
const lineNumbers = document.getElementById('lineNumbers'); const lineNumbers = document.getElementById('lineNumbers');
if (!lineNumbers) return; if (!lineNumbers) return;
const lines = editor.value.split('\n'); const lines = editor.value.split('\n');
const lineCount = lines.length; const lineCount = lines.length;
let html = ''; let html = '';
for (let i = 1; i <= lineCount; i++) { for (let i = 1; i <= lineCount; i++) {
html += i + '<br>'; html += i + '<br>';
} }
lineNumbers.innerHTML = html; lineNumbers.innerHTML = html;
syncLineNumbersHeight(); syncLineNumbersHeight();
lineNumbers.scrollTop = editor.scrollTop; lineNumbers.scrollTop = editor.scrollTop;
} }
function syncLineNumbersHeight() { function syncLineNumbersHeight() {
const editor = document.getElementById('editor'); const editor = document.getElementById('editor');
const lineNumbers = document.getElementById('lineNumbers'); const lineNumbers = document.getElementById('lineNumbers');
if (!lineNumbers || !editor) return; if (!lineNumbers || !editor) return;
lineNumbers.style.height = editor.clientHeight + 'px'; lineNumbers.style.height = editor.clientHeight + 'px';
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const editor = document.getElementById('editor'); const editor = document.getElementById('editor');
const lineNumbers = document.getElementById('lineNumbers'); const lineNumbers = document.getElementById('lineNumbers');
if (editor && lineNumbers) { if (editor && lineNumbers) {
setTimeout(() => { setTimeout(() => {
updateLineNumbers(); updateLineNumbers();
syncLineNumbersHeight(); syncLineNumbersHeight();
}, 0); }, 0);
editor.addEventListener('input', updateLineNumbers); editor.addEventListener('input', updateLineNumbers);
editor.addEventListener('keydown', function(e) { editor.addEventListener('keydown', function(e) {
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
const start = this.selectionStart; const start = this.selectionStart;
const end = this.selectionEnd; const end = this.selectionEnd;
@@ -183,10 +174,10 @@ $parentDir = dirname($filePath);
const observer = new MutationObserver(function(mutations) { const observer = new MutationObserver(function(mutations) {
syncLineNumbersHeight(); syncLineNumbersHeight();
}); });
observer.observe(editor, { observer.observe(editor, {
attributes: true, attributes: true,
attributeFilter: ['style'] attributeFilter: ['style']
}); });
if (typeof ResizeObserver === 'function') { if (typeof ResizeObserver === 'function') {
+80 -95
View File
@@ -1,20 +1,16 @@
<?php <?php
session_start(); require_once __DIR__ . '/../../includes/auth.php';
requireLogin('../../index.php');
if(!isset($_SESSION['uname'])){
header("location: ../../index.php");
session_destroy();
exit;
}
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'])) {
die("No file specified"); die("No file specified");
} }
$filePath = $_GET['file']; $filePath = requireRemotePath($_GET['file'], $defaultPath);
$fileName = basename($filePath); $fileName = basename($filePath);
$fileSize = $sftp->stat($filePath)['size']; $fileSize = $sftp->stat($filePath)['size'];
@@ -38,11 +34,11 @@ if (isset($_GET['stream'])) {
$matches = []; $matches = [];
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) { if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = intval($matches[1]); $start = intval($matches[1]);
if (!empty($matches[2])) { if (!empty($matches[2])) {
$end = intval($matches[2]); $end = intval($matches[2]);
} }
$length = $end - $start + 1; $length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content'); header('HTTP/1.1 206 Partial Content');
@@ -77,13 +73,13 @@ if (isset($_GET['stream'])) {
$readSize = min($chunkSize, $bytesRemaining); $readSize = min($chunkSize, $bytesRemaining);
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize); $chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
if ($chunkData !== false) { if ($chunkData !== false) {
$bytesSent = strlen($chunkData); $bytesSent = strlen($chunkData);
echo $chunkData; echo $chunkData;
$bytesRemaining -= $bytesSent; $bytesRemaining -= $bytesSent;
$currentPosition += $bytesSent; $currentPosition += $bytesSent;
if (ob_get_level()) { if (ob_get_level()) {
ob_flush(); ob_flush();
} }
@@ -104,42 +100,40 @@ if (isset($_GET['stream'])) {
break; break;
} }
} }
} catch (Exception $e) { } catch (Exception $exception) {
error_log("Streaming error: " . $e->getMessage()); error_log("Streaming error: " . $exception->getMessage());
} }
exit; exit;
} }
?> ?>
<!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,46 +145,34 @@ 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>
<div class="actionButton"> <div class="actionButton">
<?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?> <?php if (hasPermission('downPer')) : ?>
<a href="download.php?file=<?= urlencode($filePath) ?>" class="btn btn-success m-3">Download</a> <a href="download.php?file=<?= urlencode($filePath) ?>" class="btn btn-success m-3">Download</a>
<?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;
@@ -198,46 +180,47 @@ if (isset($_GET['stream'])) {
getStorageKey() { getStorageKey() {
return `pdf_page_${pdfFileName}`; return `pdf_page_${pdfFileName}`;
}, },
savePage(pageNum) { savePage(pageNum) {
localStorage.setItem(this.getStorageKey(), pageNum.toString()); localStorage.setItem(this.getStorageKey(), pageNum.toString());
}, },
loadPage() { loadPage() {
return parseInt(localStorage.getItem(this.getStorageKey())) || 1; return parseInt(localStorage.getItem(this.getStorageKey())) || 1;
} }
}; };
async function loadPDF() { async function loadPDF() {
try { try {
pdfDoc = await pdfjsLib.getDocument(url).promise; pdfDoc = await pdfjsLib.getDocument(url).promise;
document.getElementById('pageCount').textContent = pdfDoc.numPages; document.getElementById('pageCount').textContent = pdfDoc.numPages;
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.';
}
} }
} }
async function renderPage(pageNumber) { async function renderPage(pageNumber) {
const page = await pdfDoc.getPage(pageNumber); const page = await pdfDoc.getPage(pageNumber);
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 });
const pixelRatio = window.devicePixelRatio || 1; const pixelRatio = window.devicePixelRatio || 1;
const widthScale = (windowWidth / viewport.width); const widthScale = (windowWidth / viewport.width);
const heightScale = (windowHeight / viewport.height); const heightScale = (windowHeight / viewport.height);
let scale; let scale;
if (isFullscreen) { if (isFullscreen) {
scale = Math.min(widthScale, heightScale) * 0.95; scale = Math.min(widthScale, heightScale) * 0.95;
@@ -246,28 +229,30 @@ if (isset($_GET['stream'])) {
} else { } else {
scale = Math.min(widthScale, heightScale, 2); scale = Math.min(widthScale, heightScale, 2);
} }
const scaledViewport = page.getViewport({ scale: scale * pixelRatio }); const scaledViewport = page.getViewport({ scale: scale * pixelRatio });
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
canvas.height = scaledViewport.height; canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width; canvas.width = scaledViewport.width;
canvas.style.width = `${scaledViewport.width / pixelRatio}px`; canvas.style.width = `${scaledViewport.width / pixelRatio}px`;
canvas.style.height = `${scaledViewport.height / pixelRatio}px`; canvas.style.height = `${scaledViewport.height / pixelRatio}px`;
pageContainer.appendChild(canvas); pageContainer.appendChild(canvas);
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,
viewport: scaledViewport // Use scaledViewport instead of viewport viewport: scaledViewport // Use scaledViewport instead of viewport
}).promise; }).promise;
PdfPageStorage.savePage(pageNumber); PdfPageStorage.savePage(pageNumber);
} }
document.getElementById('prev').addEventListener('click', () => { document.getElementById('prev').addEventListener('click', () => {
@@ -276,9 +261,9 @@ if (isset($_GET['stream'])) {
renderPage(currentPage); renderPage(currentPage);
} }
}); });
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 +275,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 +290,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>
+12 -20
View File
@@ -1,18 +1,7 @@
<?php <?php
session_start(); require_once __DIR__ . '/../../includes/auth.php';
requireLogin('../../index.php');
if(!isset($_SESSION['uname'])){ requirePermission('upPer', 'You do not have permission to perform this operation.');
header("location: ../../index.php");
session_destroy();
exit;
}
if(!isset($_SESSION["upPer"]) || $_SESSION["upPer"] != true) {
die("You don't have permission to rename files or directories.");
}
ini_set('display_errors', 1);
error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
@@ -20,23 +9,26 @@ $sftp = initializeSFTP();
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$oldPath = isset($_POST['path']) ? $_POST['path'] : ''; $oldPath = isset($_POST['path']) ? $_POST['path'] : '';
$newName = isset($_POST['newName']) ? trim($_POST['newName']) : ''; $newName = isset($_POST['newName']) ? trim($_POST['newName']) : '';
$isDirectory = isset($_POST['isDirectory']) && $_POST['isDirectory'] == '1';
if (empty($oldPath) || empty($newName)) { if (empty($oldPath) || empty($newName)) {
die("Missing required information for renaming."); die("Missing required information for renaming.");
} }
$dirPath = dirname($oldPath); $oldPath = requireRemotePath($oldPath, $defaultPath);
$oldName = basename($oldPath); if ($oldPath === $defaultPath || !isRemoteNameValid($newName)) {
http_response_code(400);
exit('Invalid item name or workspace root.');
}
$directoryPath = remoteDirname($oldPath);
$newPath = $dirPath . '/' . $newName; $newPath = rtrim($directoryPath, '/') . '/' . $newName;
if ($sftp->file_exists($newPath) || $sftp->is_dir($newPath)) { if ($sftp->file_exists($newPath) || $sftp->is_dir($newPath)) {
die("Error: A file or directory with this name already exists."); die("Error: A file or directory with this name already exists.");
} }
if ($sftp->rename($oldPath, $newPath)) { if ($sftp->rename($oldPath, $newPath)) {
header("Location: index.php?path=" . urlencode($dirPath)); header("Location: index.php?path=" . urlencode($directoryPath));
exit; exit;
} else { } else {
die("Failed to rename the item. Please try again."); die("Failed to rename the item. Please try again.");
@@ -45,4 +37,4 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header("Location: index.php"); header("Location: index.php");
exit; exit;
} }
?> ?>
+106 -389
View File
@@ -1,58 +1,23 @@
<?php <?php
// Include this at the top to see potential errors require_once __DIR__ . '/../../includes/auth.php';
// Comment out in production requireLogin('../../index.php');
ini_set('display_errors', 1);
error_reporting(E_ALL);
session_start(); requirePermission('admin', 'You do not have permission to view server statistics.');
if (empty($_SESSION['admin']) || $_SESSION['admin'] !== true) { require_once __DIR__ . '/../../config/config.php';
session_destroy(); require_once __DIR__ . '/../../includes/workspace.php';
header("location: ../../index.php");
exit();
}
require_once __DIR__ . '/../config/config.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 +25,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 +56,100 @@ $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"> <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>
<header class="row border-bottom m-5"> <div class="stats-grid">
<h1>Server Status</h1> <?php foreach ($monitors as $monitor): ?>
<div class="mb-3 p-3"> <section class="stats-cell" aria-label="<?= htmlspecialchars($monitor['title']) ?>">
<a href="../logout.php" class="btn btn-danger">Logout</a> <div class="monitor-card card h-100">
<a href="../changepassword.php" class="btn btn-warning">Change Password</a> <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>
<a href="../adminpanel.php" class="btn btn-primary">Admin Panel</a> <div class="card-body">
<a href="index.php" class="btn btn-primary">SFTP</a> <?php if ($monitor['type'] === 'storage'):
</div> $diskData = $systemData['storage'][$monitor['source']];
</header> ?>
<?php if (!$diskData['available_data']): ?>
<div class="container py-4"> <p class="text-muted" role="status">Storage statistics are unavailable for this path.</p>
<div class="row justify-content-center"> <?php else:
<div class="col-lg-10"> $usagePercent = min(100, (int) rtrim($diskData['usage'], '%'));
<div class="card shadow-sm bg-body-tertiary"> ?>
<div class="card-header monitor-header"> <p class="small text-muted"><?= htmlspecialchars($diskData['filesystem']) ?> · <?= htmlspecialchars($monitor['source'] === 'system' ? '/' : $defaultPath) ?></p>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between"><span>Storage usage</span><strong><?= htmlspecialchars($diskData['usage']) ?></strong></div>
<h1 class="h4 mb-0"> <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>
<i class="bi bi-server text-primary me-2"></i> <div class="row text-center">
Server Disk Status <?php foreach (['size' => 'Total', 'used' => 'Used', 'available' => 'Free'] as $key => $label): ?>
</h1> <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>
<span class="badge bg-light text-dark"> <?php endforeach; ?>
<i class="bi bi-clock me-1"></i> </div>
<?= htmlspecialchars($systemData['lastUpdate']) ?> <?php endif; ?>
</span> <?php elseif ($monitor['type'] === 'raid'): ?>
</div> <div class="raid-summary">
</div> <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>
<div class="card-body"> <span class="badge"><?= htmlspecialchars(ucfirst($systemData['raid']['status'])) ?></span>
<div class="row g-4"> <?php foreach ($systemData['raid']['arrays'] as $array): ?>
<?php foreach ($monitors as $monitor): ?> <p class="small text-muted mt-2"><?= htmlspecialchars($array['name'] . ' · ' . $array['type'] . ' · ' . $array['status']) ?></p>
<div class="col-md-6">
<div class="monitor-card card h-100 border-<?= $monitor['color'] ?>">
<div class="card-header bg-<?= $monitor['color'] ?> bg-opacity-10">
<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">
<?php if ($monitor['type'] === 'storage'):
$diskData = $systemData['storage'][$monitor['source']];
$usagePercent = intval(rtrim($diskData['usage'], '%'));
?>
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span>Storage Usage</span>
<span><?= $diskData['usage'] ?></span>
</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">
<div class="d-flex justify-content-between mb-1">
<span>Memory Usage</span>
<span><?= $memData['usage'] ?></span>
</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 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>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> <?php elseif ($monitor['source'] === 'cpu'): ?>
<?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; ?>
</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;
@@ -441,13 +157,13 @@ let isRefreshing = false;
function updateData(html) { function updateData(html) {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
// Update monitor cards // Update monitor cards
document.querySelectorAll('.monitor-card').forEach((card, index) => { document.querySelectorAll('.monitor-card').forEach((card, index) => {
const newCard = doc.querySelectorAll('.monitor-card')[index]; const newCard = doc.querySelectorAll('.monitor-card')[index];
if (newCard && card.querySelector('.card-body')) { if (newCard && card.querySelector('.card-body')) {
card.querySelector('.card-body').innerHTML = newCard.querySelector('.card-body').innerHTML; card.querySelector('.card-body').innerHTML = newCard.querySelector('.card-body').innerHTML;
// Redraw CPU gauge if this is the CPU card // Redraw CPU gauge if this is the CPU card
if (card.querySelector('.cpu-gauge')) { if (card.querySelector('.cpu-gauge')) {
const cpuValue = parseFloat(newCard.querySelector('canvas').getAttribute('data-value') || '0'); const cpuValue = parseFloat(newCard.querySelector('canvas').getAttribute('data-value') || '0');
@@ -455,7 +171,7 @@ function updateData(html) {
} }
} }
}); });
// Update last update time // Update last update time
const newLastUpdate = doc.querySelector('.badge.bg-light.text-dark')?.innerHTML; const newLastUpdate = doc.querySelector('.badge.bg-light.text-dark')?.innerHTML;
const currentLastUpdate = document.querySelector('.badge.bg-light.text-dark'); const currentLastUpdate = document.querySelector('.badge.bg-light.text-dark');
@@ -463,11 +179,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 +187,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,17 +220,16 @@ 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');
if (!canvas) return; if (!canvas) return;
// Ensure value is a valid number between 0 and 100 // Ensure value is a valid number between 0 and 100
value = parseFloat(value) || 0; value = parseFloat(value) || 0;
value = Math.max(0, Math.min(100, value)); value = Math.max(0, Math.min(100, value));
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const centerX = canvas.width / 2; const centerX = canvas.width / 2;
const centerY = canvas.height / 2; const centerY = canvas.height / 2;
@@ -527,14 +240,14 @@ 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();
// Draw value arc // Draw value arc
const startAngle = -Math.PI / 2; const startAngle = -Math.PI / 2;
const endAngle = startAngle + (Math.PI * 2 * value / 100); const endAngle = startAngle + (Math.PI * 2 * value / 100);
ctx.beginPath(); ctx.beginPath();
ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.arc(centerX, centerY, radius, startAngle, endAngle);
ctx.strokeStyle = value > 90 ? '#dc3545' : value > 75 ? '#ffc107' : '#198754'; ctx.strokeStyle = value > 90 ? '#dc3545' : value > 75 ? '#ffc107' : '#198754';
@@ -543,7 +256,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 +271,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>
+49 -72
View File
@@ -1,25 +1,19 @@
<?php <?php
// Include this at the top to see potential errors
// Comment out in production
ini_set('display_errors', 1);
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
if (!isset($_GET['token'])) { if (!isset($_GET['token'])) {
die("Invalid request. No token provided."); die("Invalid request. No token provided.");
} }
$token = $_GET['token']; $token = $_GET['token'];
$conn = getDatabaseConnection(); $database = getDatabaseConnection();
// Get share information // Get share information
$stmt = $conn->prepare("SELECT * FROM share WHERE token = ? AND expiration > NOW()"); $statement = $database->prepare("SELECT * FROM share WHERE token = ? AND expiration > NOW()");
$stmt->bind_param("s", $token); $statement->bind_param("s", $token);
$stmt->execute(); $statement->execute();
$result = $stmt->get_result(); $result = $statement->get_result();
if ($result->num_rows === 0) { if ($result->num_rows === 0) {
die("Invalid or expired share link."); die("Invalid or expired share link.");
@@ -28,7 +22,9 @@ if ($result->num_rows === 0) {
$share = $result->fetch_assoc(); $share = $result->fetch_assoc();
$filePath = $share['file_path']; $filePath = $share['file_path'];
$downloadAllowed = $share['download_allowed'] == 1; $downloadAllowed = $share['download_allowed'] == 1;
$stmt->close(); $result->free();
$statement->close();
$database->close();
// Initialize SFTP // Initialize SFTP
$sftp = initializeSFTP(); $sftp = initializeSFTP();
@@ -41,13 +37,13 @@ $fileName = basename($filePath);
$fileExtension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); $fileExtension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
$fileSize = $sftp->stat($filePath)['size']; $fileSize = $sftp->stat($filePath)['size'];
$imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']; $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
$videoTypes = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv']; $videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'];
$audioTypes = ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac']; $audioExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac'];
$isImage = in_array($fileExtension, $imageTypes); $isImage = in_array($fileExtension, $imageExtensions);
$isVideo = in_array($fileExtension, $videoTypes); $isVideo = in_array($fileExtension, $videoExtensions);
$isAudio = in_array($fileExtension, $audioTypes); $isAudio = in_array($fileExtension, $audioExtensions);
if (!$isImage && !$isVideo && !$isAudio) { if (!$isImage && !$isVideo && !$isAudio) {
die("Unsupported file type"); die("Unsupported file type");
@@ -98,11 +94,11 @@ if (isset($_GET['stream'])) {
$matches = []; $matches = [];
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) { if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = intval($matches[1]); $start = intval($matches[1]);
if (!empty($matches[2])) { if (!empty($matches[2])) {
$end = intval($matches[2]); $end = intval($matches[2]);
} }
$length = $end - $start + 1; $length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content'); header('HTTP/1.1 206 Partial Content');
@@ -135,13 +131,13 @@ if (isset($_GET['stream'])) {
$readSize = min($chunkSize, $bytesRemaining); $readSize = min($chunkSize, $bytesRemaining);
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize); $chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
if ($chunkData !== false) { if ($chunkData !== false) {
$bytesSent = strlen($chunkData); $bytesSent = strlen($chunkData);
echo $chunkData; echo $chunkData;
$bytesRemaining -= $bytesSent; $bytesRemaining -= $bytesSent;
$currentPosition += $bytesSent; $currentPosition += $bytesSent;
if (ob_get_level()) { if (ob_get_level()) {
ob_flush(); ob_flush();
} }
@@ -164,8 +160,8 @@ if (isset($_GET['stream'])) {
usleep(100000); usleep(100000);
} }
} catch (Exception $e) { } catch (Exception $exception) {
error_log("Shared streaming error: " . $e->getMessage()); error_log("Shared streaming error: " . $exception->getMessage());
} }
exit; exit;
} }
@@ -175,25 +171,25 @@ if (isset($_GET['download'])) {
if (!$downloadAllowed) { if (!$downloadAllowed) {
die("Download not permitted for this share."); die("Download not permitted for this share.");
} }
// Download file code similar to download.php // Download file code similar to download.php
header("Content-Type: application/octet-stream"); header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"$fileName\""); header("Content-Disposition: attachment; filename=\"$fileName\"");
header("Content-Length: $fileSize"); header("Content-Length: $fileSize");
$minChunkSize = 64 * 1024; $minChunkSize = 64 * 1024;
$maxChunkSize = 2 * 1024 * 1024; $maxChunkSize = 2 * 1024 * 1024;
$chunkSize = 256 * 1024; $chunkSize = 256 * 1024;
$currentPosition = 0; $currentPosition = 0;
$bytesRemaining = $fileSize; $bytesRemaining = $fileSize;
set_time_limit(0); set_time_limit(0);
while ($bytesRemaining > 0) { while ($bytesRemaining > 0) {
$readSize = min($chunkSize, $bytesRemaining); $readSize = min($chunkSize, $bytesRemaining);
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize); $chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
if ($chunkData !== false) { if ($chunkData !== false) {
$bytesRead = strlen($chunkData); $bytesRead = strlen($chunkData);
echo $chunkData; echo $chunkData;
@@ -218,35 +214,31 @@ 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,33 +285,18 @@ 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', () => {
const mediaElement = document.querySelector('video, audio'); const mediaElement = document.querySelector('video, audio');
if (mediaElement) { if (mediaElement) {
const savedVolume = localStorage.getItem('mediaVolume'); const savedVolume = localStorage.getItem('mediaVolume');
if (savedVolume !== null) { if (savedVolume !== null) {
mediaElement.volume = parseFloat(savedVolume); mediaElement.volume = parseFloat(savedVolume);
} }
mediaElement.addEventListener('volumechange', () => { mediaElement.addEventListener('volumechange', () => {
localStorage.setItem('mediaVolume', mediaElement.volume); localStorage.setItem('mediaVolume', mediaElement.volume);
}); });
@@ -327,4 +304,4 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
</script> </script>
</body> </body>
</html> </html>
+66 -111
View File
@@ -1,130 +1,85 @@
<?php <?php
// Include this at the top to see potential errors require_once __DIR__ . '/../../includes/auth.php';
// Comment out in production requireLogin('../../index.php');
ini_set('display_errors', 1); requirePermission('upPer', 'You do not have permission to upload files.');
error_reporting(E_ALL); session_write_close();
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(400);
exit('No files received or invalid request.');
}
$currentPath = requireRemotePath($_POST['currentPath'] ?? $defaultPath, $defaultPath);
$sftp = initializeSFTP(); $sftp = initializeSFTP();
$currentPath = isset($_POST['currentPath']) ? normalizePath($_POST['currentPath']) : $defaultPath; if (isset($_POST['create_dirs'])) {
$directories = json_decode($_POST['create_dirs'], true);
if (strpos($currentPath, $defaultPath) !== 0) { if (!is_array($directories)) {
$currentPath = $defaultPath; http_response_code(400);
exit('Invalid directory list.');
}
foreach ($directories as $directory) {
$directoryPath = requireRemotePath(rtrim($currentPath, '/') . '/' . $directory, $currentPath);
if (!$sftp->is_dir($directoryPath) && !$sftp->mkdir($directoryPath, 0755, true)) {
http_response_code(500);
exit('Failed to create upload directory.');
}
}
} }
function createDirectoryRecursive($sftp, $path) { $uploadedFiles = $_FILES['files'] ?? null;
if ($sftp->is_dir($path)) { if (!$uploadedFiles || empty($uploadedFiles['name'][0])) {
return true; http_response_code(400);
} exit('No files received or file size exceeds PHP limits.');
$parent = dirname($path);
if ($parent != '/' && !$sftp->is_dir($parent)) {
createDirectoryRecursive($sftp, $parent);
}
return $sftp->mkdir($path, 0755);
} }
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $uploadErrors = [
if (isset($_POST['create_dirs'])) { UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive in php.ini',
$directories = json_decode($_POST['create_dirs'], true); UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive in the HTML form',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
if (is_array($directories)) { UPLOAD_ERR_NO_FILE => 'No file was uploaded',
foreach ($directories as $dir) { UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
$remoteDirPath = $currentPath . '/' . $dir; UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
createDirectoryRecursive($sftp, $remoteDirPath); UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload',
} ];
} $uploadMessages = [];
$anySuccess = false;
$failureStatus = 500;
foreach ($uploadedFiles['tmp_name'] as $index => $temporaryPath) {
$fileName = $uploadedFiles['name'][$index];
$relativePath = $_POST['paths'][$index] ?? '';
$uploadError = $uploadedFiles['error'][$index];
if ($uploadError !== UPLOAD_ERR_OK) {
$failureStatus = 400;
$uploadMessages[] = $fileName . ': ' . ($uploadErrors[$uploadError] ?? 'Unknown upload error');
continue;
} }
if (!isset($_FILES['files']) || empty($_FILES['files']['name'][0])) { $remotePath = requireRemotePath(rtrim($currentPath, '/') . '/' . ($relativePath !== '' ? $relativePath : $fileName), $currentPath);
echo "No files received or file size exceeds PHP limits."; if ($remotePath === $currentPath) {
http_response_code(400); http_response_code(400);
exit; exit('Invalid upload path.');
} }
$directoryPath = remoteDirname($remotePath);
if ($_FILES['files']['error'][0] !== 0) { if (!$sftp->is_dir($directoryPath) && !$sftp->mkdir($directoryPath, 0755, true)) {
$error = $_FILES['files']['error'][0]; $uploadMessages[] = 'Failed to create directory for: ' . $fileName;
$errorMessage = "Upload error code: $error"; continue;
switch ($error) {
case UPLOAD_ERR_INI_SIZE:
$errorMessage = "File exceeds upload_max_filesize directive in php.ini";
break;
case UPLOAD_ERR_FORM_SIZE:
$errorMessage = "File exceeds MAX_FILE_SIZE directive in the HTML form";
break;
case UPLOAD_ERR_PARTIAL:
$errorMessage = "File was only partially uploaded";
break;
case UPLOAD_ERR_NO_FILE:
$errorMessage = "No file was uploaded";
break;
case UPLOAD_ERR_NO_TMP_DIR:
$errorMessage = "Missing a temporary folder";
break;
case UPLOAD_ERR_CANT_WRITE:
$errorMessage = "Failed to write file to disk";
break;
case UPLOAD_ERR_EXTENSION:
$errorMessage = "A PHP extension stopped the file upload";
break;
}
echo $errorMessage;
http_response_code(400);
exit;
} }
$uploadStatus = array(); if ($sftp->put($remotePath, $temporaryPath, SFTP::SOURCE_LOCAL_FILE)) {
$anySuccess = false; $uploadMessages[] = 'Uploaded: ' . ($relativePath !== '' ? $relativePath : $fileName);
$anySuccess = true;
foreach ($_FILES['files']['tmp_name'] as $index => $tmpName) {
if (empty($tmpName)) continue;
$fileName = $_FILES['files']['name'][$index];
$relativePath = isset($_POST['paths']) && isset($_POST['paths'][$index]) ? $_POST['paths'][$index] : '';
if (!empty($relativePath)) {
$fileName = basename($relativePath);
$dirPart = dirname($relativePath);
if ($dirPart !== '.' && $dirPart !== '') {
$remoteDirPath = $currentPath . '/' . $dirPart;
if (!$sftp->is_dir($remoteDirPath)) {
createDirectoryRecursive($sftp, $remoteDirPath);
}
$remotePath = $remoteDirPath . '/' . $fileName;
} else {
$remotePath = $currentPath . '/' . $fileName;
}
} else {
$remotePath = $currentPath . '/' . $fileName;
}
if ($sftp->put($remotePath, $tmpName, SFTP::SOURCE_LOCAL_FILE)) {
$uploadStatus[] = "Uploaded: " . ($relativePath ? $relativePath : $fileName);
$anySuccess = true;
} else {
$uploadStatus[] = "Failed to upload: " . ($relativePath ? $relativePath : $fileName);
}
}
if ($anySuccess) {
echo implode("\n", $uploadStatus);
exit;
} else { } else {
echo "Failed to upload any files. Please check SFTP connection and permissions."; $uploadMessages[] = 'Failed to upload: ' . ($relativePath !== '' ? $relativePath : $fileName);
http_response_code(500);
exit;
} }
} }
echo "No files received or invalid request."; if (!$anySuccess) {
http_response_code(400); http_response_code($failureStatus);
exit; }
header('Content-Type: text/plain; charset=UTF-8');
echo implode("\n", $uploadMessages);
+70 -95
View File
@@ -1,19 +1,11 @@
<?php <?php
session_start(); require_once __DIR__ . '/../../includes/auth.php';
requireLogin('../../index.php');
if(!isset($_SESSION['uname'])){
header("location: ../../index.php");
session_destroy();
exit;
}
// Include this at the top to see potential errors
// Comment out in production
ini_set('display_errors', 1);
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'])) {
@@ -23,7 +15,7 @@ if (!isset($_GET['file'])) {
if (isset($_POST['createShare'])) { if (isset($_POST['createShare'])) {
$duration = intval($_POST['duration']); $duration = intval($_POST['duration']);
$downloadAllowed = isset($_POST['downloadAllowed']) ? 1 : 0; $downloadAllowed = isset($_POST['downloadAllowed']) ? 1 : 0;
$filePath = $_POST['filePath']; $filePath = requireRemotePath($_POST['filePath'], $defaultPath);
$token = bin2hex(random_bytes(16)); $token = bin2hex(random_bytes(16));
@@ -31,13 +23,17 @@ if (isset($_POST['createShare'])) {
$expiration = date('Y-m-d H:i:s', strtotime("+$duration hours")); $expiration = date('Y-m-d H:i:s', strtotime("+$duration hours"));
$conn = getDatabaseConnection(); $database = getDatabaseConnection();
$stmt = $conn->prepare("INSERT INTO share (token, file_path, expiration, download_allowed) VALUES (?, ?, ?, ?)"); $statement = $database->prepare("INSERT INTO share (token, file_path, expiration, download_allowed) VALUES (?, ?, ?, ?)");
$stmt->bind_param("sssi", $token, $filePath, $expiration, $downloadAllowed); $statement->bind_param("sssi", $token, $filePath, $expiration, $downloadAllowed);
if ($stmt->execute()) { $shareSaved = $statement->execute();
$shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" . $statement->close();
dirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token"; $database->close();
if ($shareSaved) {
$shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" .
remoteDirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token";
$_SESSION['shareCreated'] = true; $_SESSION['shareCreated'] = true;
$_SESSION['shareUrl'] = $shareUrl; $_SESSION['shareUrl'] = $shareUrl;
@@ -46,13 +42,11 @@ if (isset($_POST['createShare'])) {
header("Location: view.php?file=" . urlencode($filePath) . "&shared=1"); header("Location: view.php?file=" . urlencode($filePath) . "&shared=1");
exit; exit;
} else { } else {
$_SESSION['shareError'] = "Failed to create share: " . $conn->error; $_SESSION['shareError'] = 'Failed to create share. Please try again.';
header("Location: view.php?file=" . urlencode($filePath) . "&shared=0"); header("Location: view.php?file=" . urlencode($filePath) . "&shared=0");
exit; exit;
} }
$stmt->close();
$conn->close();
} }
$shareCreated = false; $shareCreated = false;
@@ -70,7 +64,7 @@ if (isset($_GET['shared'])) {
} }
} }
$filePath = $_GET['file']; $filePath = requireRemotePath($_GET['file'], $defaultPath);
$fileName = basename($filePath); $fileName = basename($filePath);
$fileExtension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); $fileExtension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
@@ -78,13 +72,13 @@ if (!$sftp->stat($filePath)) {
die("File not found: $filePath"); die("File not found: $filePath");
} }
$imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']; $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
$videoTypes = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv']; $videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'];
$audioTypes = ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac']; $audioExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac'];
$isImage = in_array($fileExtension, $imageTypes); $isImage = in_array($fileExtension, $imageExtensions);
$isVideo = in_array($fileExtension, $videoTypes); $isVideo = in_array($fileExtension, $videoExtensions);
$isAudio = in_array($fileExtension, $audioTypes); $isAudio = in_array($fileExtension, $audioExtensions);
if (!$isImage && !$isVideo && !$isAudio) { if (!$isImage && !$isVideo && !$isAudio) {
die("Unsupported file type"); die("Unsupported file type");
@@ -142,11 +136,11 @@ if (isset($_GET['stream'])) {
$matches = []; $matches = [];
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) { if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = intval($matches[1]); $start = intval($matches[1]);
if (!empty($matches[2])) { if (!empty($matches[2])) {
$end = intval($matches[2]); $end = intval($matches[2]);
} }
$length = $end - $start + 1; $length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content'); header('HTTP/1.1 206 Partial Content');
@@ -193,18 +187,18 @@ if (isset($_GET['stream'])) {
// Calculate adaptive chunk size // Calculate adaptive chunk size
$readSize = min($chunkSize, $bytesRemaining); $readSize = min($chunkSize, $bytesRemaining);
// Get chunk from SFTP // Get chunk from SFTP
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize); $chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
if ($chunkData !== false) { if ($chunkData !== false) {
$bytesSent = strlen($chunkData); $bytesSent = strlen($chunkData);
// Output chunk // Output chunk
echo $chunkData; echo $chunkData;
$bytesRemaining -= $bytesSent; $bytesRemaining -= $bytesSent;
$currentPosition += $bytesSent; $currentPosition += $bytesSent;
// Flush buffers // Flush buffers
if (ob_get_level()) { if (ob_get_level()) {
ob_flush(); ob_flush();
@@ -237,10 +231,10 @@ if (isset($_GET['stream'])) {
// Throttle to prevent CPU overload // Throttle to prevent CPU overload
usleep(100000); // 100ms usleep(100000); // 100ms
} }
} catch (Exception $e) { } catch (Exception $exception) {
error_log("Streaming error: " . $e->getMessage()); error_log("Streaming error: " . $exception->getMessage());
if (isset($_GET['debug'])) { if (isset($_GET['debug'])) {
header("X-Stream-Error: " . $e->getMessage()); header("X-Stream-Error: " . $exception->getMessage());
} }
} }
@@ -251,36 +245,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): ?>
@@ -302,16 +292,16 @@ if (isset($_GET['stream'])) {
</audio> </audio>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?> <?php if (hasPermission('downPer')) : ?>
<a href="download.php?file=<?= urlencode($filePath) ?>" class="btn btn-success m-3">Download</a> <a href="download.php?file=<?= urlencode($filePath) ?>" class="btn btn-success m-3">Download</a>
<?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>
<!-- Share success message --> <!-- Share success message -->
<?php if ($shareCreated): ?> <?php if ($shareCreated): ?>
<div class="alert alert-success mt-3"> <div class="alert alert-success mt-3">
@@ -319,7 +309,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,33 +346,20 @@ 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="">
<input type="hidden" name="filePath" value="<?= htmlspecialchars($filePath) ?>"> <input type="hidden" name="filePath" value="<?= htmlspecialchars($filePath) ?>">
<div class="mb-3"> <div class="mb-3">
<label for="duration" class="form-label">Link expires after</label> <label for="duration" class="form-label">Link expires after</label>
<select class="form-select" id="duration" name="duration"> <select class="form-select" id="duration" name="duration">
@@ -393,45 +370,43 @@ if (isset($_GET['stream'])) {
<option value="720">30 days</option> <option value="720">30 days</option>
</select> </select>
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="downloadAllowed" name="downloadAllowed"> <input type="checkbox" class="form-check-input" id="downloadAllowed" name="downloadAllowed">
<label class="form-check-label" for="downloadAllowed">Allow downloading</label> <label class="form-check-label" for="downloadAllowed">Allow downloading</label>
</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() {
const shareUrl = document.getElementById('shareUrl'); const shareUrl = document.getElementById('shareUrl');
shareUrl.select(); shareUrl.select();
document.execCommand('copy'); document.execCommand('copy');
// 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;
}, 2000); }, 2000);
} }
</script> </script>
<script src="../../js/theme.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const mediaElement = document.querySelector('video, audio'); const mediaElement = document.querySelector('video, audio');
if (mediaElement) { if (mediaElement) {
// Restore volume from localStorage // Restore volume from localStorage
const savedVolume = localStorage.getItem('mediaVolume'); const savedVolume = localStorage.getItem('mediaVolume');
@@ -458,4 +433,4 @@ function formatBytes($bytes, $precision = 2) {
$bytes /= (1 << (10 * $pow)); $bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow]; return round($bytes, $precision) . ' ' . $units[$pow];
} }
?> ?>
+46 -78
View File
@@ -1,83 +1,51 @@
<?php <?php
session_start(); session_start();
if (isset($_POST['uname'], $_POST['pswd'])) {
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
// $username = "UNAME";
// $password = "PSWD";
// $db = "usbraidlogin";
$uname = htmlspecialchars($_POST['uname']); if (!isset($_POST['uname'], $_POST['pswd'])
$pswd = htmlspecialchars($_POST['pswd']); || !is_string($_POST['uname']) || !is_string($_POST['pswd'])) {
$_SESSION['login_error'] = 'Please fill in all fields.';
$conn = getDatabaseConnection(); header('Location: ../index.php');
// charset already set in getDatabaseConnection() exit;
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
}
$sql = "SELECT * FROM users WHERE uname=?";
$stmt = $conn->prepare($sql);
if (!$stmt) {
$_SESSION['login_error'] = "Database error. Please try again.";
$conn->close();
header("Location: ../index.php");
exit();
}
$stmt->bind_param("s", $uname);
$stmt->execute();
$result = $stmt->get_result();
if ($result && $result->num_rows > 0) {
$login_successful = false;
while ($row = $result->fetch_assoc()) {
$hash = $row["pswd"];
$admin = (bool)$row["admin"];
$defPath = $row["defPath"];
$delPer = $row["delPer"];
$downPer = $row["downPer"];
$upPer = $row["upPer"];
if (password_verify($pswd, $hash)) {
// Login successful - store info in session
$_SESSION['uname'] = $uname;
$_SESSION['admin'] = $admin;
$_SESSION['defPath'] = $defPath;
$_SESSION['downPer'] = $downPer;
$_SESSION['delPer'] = $delPer;
$_SESSION['upPer'] = $upPer;
$login_successful = true;
break;
}
}
$result->free_result();
$stmt->close();
$conn->close();
if ($login_successful) {
header("Location: ftp/index.php");
exit();
} else {
// If password verification fails
$_SESSION['login_error'] = "Invalid username or password.";
header("Location: ../index.php");
exit();
}
} else {
// If no user is found
$result->free_result();
$stmt->close();
$conn->close();
$_SESSION['login_error'] = "Invalid username or password.";
header("Location: ../index.php");
exit();
}
} else {
// If form fields are not set
$_SESSION['login_error'] = "Please fill in all fields.";
header("Location: ../index.php");
exit();
} }
?>
$username = trim($_POST['uname']);
$password = $_POST['pswd'];
$loginSuccessful = false;
$database = getDatabaseConnection();
try {
$statement = $database->prepare('SELECT uname, pswd, admin, defPath, downPer, delPer, upPer FROM users WHERE uname = ?');
$statement->bind_param('s', $username);
$statement->execute();
$result = $statement->get_result();
while ($user = $result->fetch_assoc()) {
if (!password_verify($password, $user['pswd'])) {
continue;
}
session_regenerate_id(true);
$_SESSION['uname'] = $user['uname'];
$_SESSION['admin'] = (bool) $user['admin'];
$_SESSION['defPath'] = $user['defPath'];
foreach (['downPer', 'delPer', 'upPer'] as $permission) {
$_SESSION[$permission] = (bool) $user[$permission];
}
unset($_SESSION['login_error']);
$loginSuccessful = true;
break;
}
$result->free();
$statement->close();
if (!$loginSuccessful) {
$_SESSION['login_error'] = 'Invalid username or password.';
}
} catch (mysqli_sql_exception $exception) {
error_log($exception->getMessage());
$_SESSION['login_error'] = 'Database error. Please try again.';
} finally {
$database->close();
}
header('Location: ' . ($loginSuccessful ? 'ftp/index.php' : '../index.php'));
exit;
+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; }
}
+28
View File
@@ -0,0 +1,28 @@
<?php
/** Shared session checks. Database, form and session keys retain their legacy names. */
function requireLogin(string $loginUrl): void {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
if (!isset($_SESSION['uname'])) {
header('Location: ' . $loginUrl);
exit;
}
}
function hasPermission(string $permission): bool {
return !empty($_SESSION[$permission]);
}
function requirePermission(string $permission, string $message): void {
if (!hasPermission($permission)) {
http_response_code(403);
exit($message);
}
}
function isPasswordValid(string $password): bool {
return strlen($password) >= 8
&& preg_match('/[0-9]/', $password) === 1
&& preg_match('/[A-Z]/', $password) === 1;
}
+46
View File
@@ -0,0 +1,46 @@
<?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;
}
/** Compare directory boundaries, so /data-other is not considered part of /data. */
function isRemotePathWithin(string $path, string $basePath): bool {
$path = normalizePath($path);
$basePath = normalizePath($basePath);
return $path === $basePath || strpos($path, rtrim($basePath, '/') . '/') === 0;
}
/** Validate a single POSIX filename before joining it to a directory. */
function isRemoteNameValid(string $name): bool {
return $name !== '' && $name !== '.' && $name !== '..'
&& strpos($name, '/') === false && strpos($name, "\0") === false;
}
/** Resolve a request path inside the user's configured workspace. */
function requireRemotePath(string $path, string $basePath): string {
if (strpos($path, "\0") !== false || !isRemotePathWithin($path, $basePath)) {
http_response_code(403);
exit('Path is outside your workspace.');
}
return normalizePath($path);
}
+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();
}
});
+147
View File
@@ -0,0 +1,147 @@
<?php
require_once __DIR__ . '/../Web/includes/auth.php';
function expectBackend(bool $condition, string $message): void {
if (!$condition) throw new RuntimeException($message);
}
// Run handlers in separate processes because redirects and permission checks exit.
if (($argv[1] ?? '') === '--handler') {
session_set_save_handler(new class implements SessionHandlerInterface {
public function open(string $path, string $name): bool { return true; }
public function close(): bool { return true; }
public function read(string $id): string|false { return ''; }
public function write(string $id, string $data): bool { return true; }
public function destroy(string $id): bool { return true; }
public function gc(int $maxLifetime): int|false { return 0; }
}, true);
session_start(['use_cookies' => false, 'cache_limiter' => '']);
http_response_code(200);
$scenario = json_decode($argv[3], true, 512, JSON_THROW_ON_ERROR);
$_SESSION = $scenario['session'] ?? [];
$_POST = $scenario['post'] ?? [];
$_GET = $scenario['get'] ?? [];
$_FILES = $scenario['files'] ?? [];
$_SERVER['REQUEST_METHOD'] = $scenario['method'] ?? 'POST';
$defaultPath = '/mnt/raid';
$calls = [];
function initializeSFTP() {
global $calls;
$calls[] = ['connect'];
return new class {
function put($path, $data, $mode = null) {
global $calls;
$calls[] = $mode === null ? ['put', $path, $data] : ['put', $path, $data, $mode];
return true;
}
function is_dir($path) { return true; }
function mkdir($path) {
global $calls;
$calls[] = ['mkdir', $path];
return true;
}
};
}
// Minimal database double exercises login without a live database or network.
function getDatabaseConnection() {
return new class {
function prepare($sql) {
return new class {
function bind_param($types, &...$values) {}
function execute() {}
function get_result() {
return new class {
private $fetched = false;
function fetch_assoc() {
if ($this->fetched) return null;
$this->fetched = true;
return ['uname' => 'member', 'pswd' => password_hash(' Secret1<&" ', PASSWORD_BCRYPT),
'admin' => 0, 'defPath' => '/mnt/raid', 'downPer' => 1, 'delPer' => 0, 'upPer' => 1];
}
function free() {}
};
}
function close() {}
};
}
function close() {}
};
}
register_shutdown_function(function () use (&$calls) {
echo "\n__RESULT__" . json_encode(['status' => http_response_code(), 'calls' => $calls, 'session' => $_SESSION]);
});
$path = realpath(__DIR__ . '/../Web/content/' . $argv[2] . '.php');
$source = file_get_contents($path);
// Replace only configuration loading, retaining the real guards and handlers.
$source = preg_replace("~require_once __DIR__ \\. '/(?:\\.\\./)+config/config\\.php';~", '', $source);
$source = str_replace('__DIR__', var_export(dirname($path), true), $source);
$source = str_replace('session_start();', '', $source);
require_once __DIR__ . '/../Web/includes/remotepaths.php';
require_once __DIR__ . '/../Web/vendor/autoload.php';
eval('?>' . $source);
exit;
}
function runBackendHandler(string $handler, array $scenario): array {
$process = proc_open([PHP_BINARY, __FILE__, '--handler', $handler, json_encode($scenario)],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
expectBackend($exitCode === 0 && $errors === '', $handler . ' failed: ' . $errors . $output);
[$body, $result] = explode("\n__RESULT__", $output, 2);
return ['body' => $body] + json_decode($result, true, 512, JSON_THROW_ON_ERROR);
}
foreach (['short1A' => false, 'lowercase1' => false, 'UppercaseOnly' => false, 'Password1' => true, ' Secret1<&" ' => true] as $password => $expected) {
expectBackend(isPasswordValid($password) === $expected, 'Incorrect password validation');
}
$member = ['uname' => 'member', 'upPer' => true, 'downPer' => true, 'delPer' => true];
foreach (['upload' => 'upPer', 'createdir' => 'upPer', 'createfile' => 'upPer', 'rename' => 'upPer', 'extract' => 'upPer', 'delete' => 'delPer', 'download' => 'downPer'] as $handler => $permission) {
$result = runBackendHandler('ftp/' . $handler, []);
expectBackend($result['calls'] === [] && $result['body'] === '', $handler . ' must require login before connecting');
$result = runBackendHandler('ftp/' . $handler, ['session' => ['uname' => 'member', $permission => false]]);
expectBackend($result['status'] === 403 && $result['calls'] === [], $handler . ' must enforce permissions before connecting');
}
$result = runBackendHandler('ftp/createfile', ['session' => $member,
'post' => ['action' => 'createFile', 'currentPath' => '/mnt/raid', 'fileName' => 'empty.txt']]);
expectBackend($result['status'] === 200 && $result['calls'][1] === ['put', '/mnt/raid/empty.txt', ''], 'New files must contain zero bytes');
expectBackend(json_decode($result['body'], true)['success'] === true, 'Create file response must retain its API');
foreach (['/mnt/raid-other', '/mnt/raid/../private'] as $path) {
$result = runBackendHandler('ftp/createdir', ['session' => $member,
'post' => ['action' => 'createDir', 'currentPath' => $path, 'dirName' => 'new']]);
expectBackend($result['status'] === 403 && count($result['calls']) === 1, 'Reject writes outside workspace');
}
$result = runBackendHandler('ftp/createdir', ['session' => $member, 'post' => ['action' => 'createDir', 'dirName' => '..']]);
expectBackend($result['status'] === 400 && count($result['calls']) === 1, 'Reject parent directory names');
$result = runBackendHandler('ftp/delete', ['session' => $member, 'post' => ['delete' => '/mnt/raid']]);
expectBackend($result['status'] === 400 && count($result['calls']) === 1, 'Protect workspace root from deletion');
$result = runBackendHandler('ftp/rename', ['session' => $member, 'post' => ['path' => '/mnt/raid/item', 'newName' => '../escape']]);
expectBackend($result['status'] === 400 && count($result['calls']) === 1, 'Rename must accept a single filename');
$upload = ['session' => $member, 'post' => ['paths' => ['folder/ok.txt', 'bad.txt']],
'files' => ['files' => ['name' => ['ok.txt', 'bad.txt'], 'tmp_name' => ['local-upload', ''],
'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_PARTIAL]]]];
$result = runBackendHandler('ftp/upload', $upload);
expectBackend($result['status'] === 200 && $result['calls'][1] === ['put', '/mnt/raid/folder/ok.txt', 'local-upload', 1], 'Upload must use local file mode and preserve relative directories');
expectBackend(strpos($result['body'], 'File was only partially uploaded') !== false, 'Report errors for each uploaded file');
$upload['post']['paths'][0] = '../private.txt';
$result = runBackendHandler('ftp/upload', $upload);
expectBackend($result['status'] === 403 && count($result['calls']) === 1, 'Upload paths must stay in the selected directory');
$result = runBackendHandler('login', ['post' => ['uname' => 'member', 'pswd' => ' Secret1<&" ']]);
expectBackend(($result['session']['uname'] ?? '') === 'member', 'Login must preserve special characters and whitespace in passwords');
expectBackend($result['session']['admin'] === false && $result['session']['delPer'] === false && $result['session']['upPer'] === true, 'Login must retain boolean permissions');
$result = runBackendHandler('login', ['post' => ['uname' => 'member', 'pswd' => 'wrong']]);
expectBackend(!isset($result['session']['uname']) && isset($result['session']['login_error']), 'Reject incorrect passwords');
echo "Backend: handler permissions, path boundaries, empty files and login checks passed.\n";
+45
View File
@@ -0,0 +1,45 @@
<?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');
}
foreach ([
['/mnt/raid', '/mnt/raid', true],
['/mnt/raid/folder', '/mnt/raid/', true],
['/mnt/raid-other/file', '/mnt/raid', false],
['/mnt/raid/../private', '/mnt/raid', false],
['/mnt/raid/sub/../../private', '/mnt/raid', false],
['/mnt/raid/sub/../file', '/mnt/raid', true],
['/etc', '/', true],
] as [$path, $basePath, $expected]) {
if (isRemotePathWithin($path, $basePath) !== $expected) throw new RuntimeException('Incorrect path boundary: ' . $path);
}
foreach (['' => false, '.' => false, '..' => false, 'a/b' => false, "a\0b" => false, 'file.txt' => true, '0' => true, 'a\\b' => true] as $name => $expected) {
if (isRemoteNameValid((string) $name) !== $expected) throw new RuntimeException('Incorrect filename validation');
}
echo "Remote paths: POSIX parents, normalization, workspace boundaries, names 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";
+96
View File
@@ -0,0 +1,96 @@
<?php
require_once __DIR__ . '/../Web/includes/workspace.php';
require_once __DIR__ . '/../Web/includes/auth.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); $parentPath = $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 = ''; $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";