Compare commits

...

19 Commits

Author SHA1 Message Date
Ryan
76f5ed5c96 shared-folder: externalize gallery view JS & enforce CSP compliance 2025-04-27 18:18:00 -04:00
Ryan
18f588dc24 Update Manual Installation 2025-04-27 17:23:43 -04:00
Ryan
491c686762 fix: advancedSearchToggle in renderFileTable & renderGalleryView 2025-04-27 17:08:49 -04:00
Ryan
25303df677 fixed: Pagination controls & Items-per-page dropdown 2025-04-27 16:50:22 -04:00
Ryan
ae0d63b86f fix: checkbox in toolbar does not select all files (Fixes #25) 2025-04-27 15:34:41 -04:00
Ryan
41ade2e205 refactor/fix: api redoc 2025-04-26 17:31:51 -04:00
Ryan
0a9d332d60 refactor(auth): relocate logout handler to main.js 2025-04-26 04:33:01 -04:00
Ryan
1983f7705f enhance CSP for iframe and refactor gallery view event handlers 2025-04-26 04:08:56 -04:00
Ryan
6b2bf0ba70 Refactor event binding in domUtils & fileListView 2025-04-26 03:33:23 -04:00
Ryan
6d9715169c Harden security: enable CSP, add SRI, and externalize inline scripts 2025-04-26 02:28:02 -04:00
Ryan
0645a3712a Use Material icons for dark/light toggle and simplify download flows 2025-04-25 20:40:00 -04:00
Ryan
ebc32ea965 consolidate & protect API docs with php wrapper 2025-04-24 19:34:09 -04:00
Ryan
078db33458 Embed API documentation as a full-screen modal 2025-04-24 17:35:41 -04:00
Ryan
04f5cbe31f chore: update install docs, secure API docs, refine Docker vhost, remove unused folders 2025-04-24 17:02:50 -04:00
Ryan
b5a7d8d559 continue breadcrumb update 2025-04-23 23:17:23 -04:00
Ryan
58f8485b02 fix(breadcrumb): prevent XSS in title breadcrumbs – closes #24 2025-04-23 22:45:25 -04:00
Ryan
3e1da9c335 Add missing permissions in UserModel.php for TOTP login. 2025-04-23 21:15:55 -04:00
Ryan
6bf6206e1c Add missing permissions for TOTP login 2025-04-23 21:14:59 -04:00
Ryan
f9c60951c9 Removed Old CSRF logic 2025-04-23 19:53:47 -04:00
24 changed files with 1034 additions and 566 deletions

View File

@@ -1,5 +1,118 @@
# Changelog # Changelog
## Changes 4/27/2025 1.2.7
- **Select-All** checkbox now correctly toggles all `.file-checkbox` inputs
- Updated `toggleAllCheckboxes(masterCheckbox)` to call `updateRowHighlight()` on each row so selections get the `.row-selected` highlight
- **Master checkbox sync** in toolbar
- Enhanced `updateFileActionButtons()` to set the header checkbox to checked, unchecked, or indeterminate based on how many files are selected
- Fixed Pagination controls & Items-per-page dropdown
- Fixed `#advancedSearchToggle` in both `renderFileTable()` and `renderGalleryView()`
- **Shared folder gallery view logic**
- Introduced new `public/js/sharedFolderView.js` containing all DOMContentLoaded wiring, `toggleViewMode()`, gallery rendering, and event listeners
- Embedded a non-executing JSON payload in `shareFolder.php`
- **`FolderController::shareFolder()` / `shareFolder.php`**
- Removed all inline `onclick="…"` attributes and inline `<script>` blocks
- Added `<script type="application/json" id="shared-data">…</script>` to export `$token` and `$files`
- Added `<script src="/js/sharedFolderView.js" defer></script>` to load the external view logic
- **Styling updates**
- Added `.toggle-btn` CSS for blue header-style toggle button and applied it in JS
- Added `.pagination a:hover { background-color: #0056b3; }` to match button hover
- Tweaked `body` padding and `header h1` margins to reduce whitespace above header
---
## Changes 4/26/2025 1.2.6
**Apache / Dockerfile (CSP)**
- Enabled Apaches `mod_headers` in the Dockerfile (`a2enmod headers ssl deflate expires proxy proxy_fcgi rewrite`)
- Added a strong `Content-Security-Policy` header in the vhost configs to lock down allowed sources for scripts, styles, fonts, images, and connections
**index.html & CDN Includes**
- Applied Subresource Integrity (`integrity` + `crossorigin="anonymous"`) to all static CDN assets (Bootstrap CSS, CodeMirror CSS/JS, Resumable.js, DOMPurify, Fuse.js)
- Omitted SRI on Google Fonts & Material Icons links (dynamic per-browser CSS)
- Removed all inline `<script>` and `onclick` attributes; now all behaviors live in external JS modules
**auth.js (Logout Handling)**
- Moved the logout-on-`?logout=1` snippet from inline HTML into `auth.js`
- In `DOMContentLoaded`, attached a `click` listener to `#logoutBtn` that POSTs to `/api/auth/logout.php` and reloads
**fileActions.js (Modal Button Handlers)**
- Externalized the cancel/download buttons for single-file and ZIP-download modals by adding `click` listeners in `fileActions.js`
- Removed the inline `onclick` attributes from `#cancelDownloadFile` and `#confirmSingleDownloadButton` in the HTML
- Ensured all file-action modals (delete, download, extract, copy, move, rename) now use JS event handlers instead of inline code
**domUtils.js**
- **Removed** all inline `onclick` and `onchange` attributes from:
- `buildSearchAndPaginationControls` (advanced search toggle, prev/next buttons, items-per-page selector)
- `buildFileTableHeader` (select-all checkbox)
- `buildFileTableRow` (download, edit, preview, rename buttons)
- **Retained** all original logic (file-type icon detection, shift-select, debounce, custom confirm modal, etc.)
**fileListView.js**
- **Stopped** generating inline `onclick` handlers in both table and gallery views.
- **Added** `data-` attributes on actionable elements:
- `data-download-name`, `data-download-folder`
- `data-edit-name`, `data-edit-folder`
- `data-rename-name`, `data-rename-folder`
- `data-preview-url`, `data-preview-name`
- IDs on controls: `#advancedSearchToggle`, `#searchInput`, `#prevPageBtn`, `#nextPageBtn`, `#selectAll`, `#itemsPerPageSelect`
- **Introduced** `attachListControlListeners()` to bind all events via `addEventListener` immediately after rendering, preserving every interaction without inline code.
**Additional changes**
- **Security**: Added `frame-src 'self'` to the Content-Security-Policy header so that the embedded API docs iframe can load from our own origin without relaxing JS restrictions.
- **Controller**: Updated `FolderController::shareFolder()` (folderController) to include the gallery-view toggle script block intact, ensuring the “Switch to Gallery View” button works when sharing folders.
- **UI (fileListView.js)**: Refactored `renderGalleryView` to remove all inline `onclick=` handlers; switched to using data-attributes and `addEventListener()` for preview, download, edit and rename buttons, fully CSP-compliant.
- Moved logout button handler out of inline `<script>` in `index.html` and into the `DOMContentLoaded` init in **main.js** (via `auth.js`), so it now attaches reliably after the CSRF token is loaded and DOM is ready.
- Added Content-Security-Policy for `<Files "api.php">` block to allow embedding the ReDoc iframe.
- Extracted inline ReDoc init into `public/js/redoc-init.js` and updated `public/api.php` to use deferred `<script>` tags.
---
## Changes 4/25/2025
- Switch singlefile download to native `<a>` link (no JS buffering)
- Keep spinner modal during ZIP creation and download blob on POST response
- Replace text toggle with a single button showing sun/moon icons and hover tooltip
## Changes 4/24/2025 1.2.5
- Enhance README and wiki with expanded installation instructions
- Adjusted Dockerfiles Apache vhost to:
- Alias `/uploads/` to `/var/www/uploads/` with PHP engine disabled and directory indexes off
- Disable HTTP TRACE and tune keep-alive (On, max 100 requests, 5s timeout) and server Timeout (60s)
- Add security headers (`X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`, `Referrer-Policy`)
- Enable `mod_deflate` compression for HTML, plain text, CSS, JS and JSON
- Configure `mod_expires` caching for images (1 month), CSS (1 week) and JS (3 hour)
- Deny access to hidden files (dot-files)
~~- Add access control in public/.htaccess for api.html & openapi.json; update Nginx example in wiki~~
- Remove obsolete folders from repo root
- Embed API documentation (`api.php`) directly in the FileRise UI as a full-screen modal
- Introduced `openApiModalBtn` in the user panel to launch the API modal
- Added `#apiModal` container with a same-origin `<iframe src="api.php">` so session cookies authenticate automatically
- Close control uses the existing `.editor-close-btn` for consistent styling and hover effects
- public/api.html has been replaced by the new api.php wrapper
- **`public/api.php`**
- Single PHP endpoint for both UI and spec
- Enforces `$_SESSION['authenticated']`
- Renders the Redoc API docs when accessed normally
- Streams the JSON spec from `openapi.json.dist` when called as `api.php?spec=1`
- Redirects unauthenticated users to `index.html?redirect=/api.php`
- **Moved** `public/openapi.json``openapi.json.dist` (moved outside of `public/`) to prevent direct static access
- **Dockerfile**: enabled required Apache modules for rewrite, security headers, proxying, caching and compression:
```dockerfile
RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate
```
## Changes 4/23/2025 1.2.4 ## Changes 4/23/2025 1.2.4
**AuthModel** **AuthModel**
@@ -30,6 +143,21 @@
- **start.sh** - **start.sh**
- Session directory setup - Session directory setup
- Always sends `credentials: 'include'` and `X-CSRF-Token: window.csrfToken` s
- On HTTP 403, automatically fetches a fresh CSRF token (from the response header or `/api/auth/token.php`) and retries the request once
- Always returns the real `Response` object (no more “clone.json” on every 200)
- Now calls `fetchWithCsrf('/api/auth/token.php')` to guarantee a fresh token
- Checks `res.ok`, then parses JSON to extract `csrf_token` and `share_url`
- Updates both `window.csrfToken` and the `<meta name="csrf-token">` & `<meta name="share-url">` tags
- Removed Old CSRF logic that cloned every successful response and parsed its JSON body
- Removed Any “soft-failure” JSON peek on non-403 responses
- Add missing permissions in `UserModel.php` for TOTP login.
- **Prevent XSS in breadcrumbs**
- Replaced `innerHTML` calls in `fileListTitle` with a new `updateBreadcrumbTitle()` helper that uses `textContent` + `DocumentFragment`.
- Introduced `renderBreadcrumbFragment()` to build each breadcrumb segment as a `<span class="breadcrumb-link" data-folder="…">` node.
- Added `setupBreadcrumbDelegation()` to handle clicks via event delegation on the container, eliminating per-element listeners.
- Removed any raw HTML concatenation to satisfy CodeQL and ensure all breadcrumb text is safely escaped.
## Changes 4/22/2025 v1.2.3 ## Changes 4/22/2025 v1.2.3
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user` - Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
@@ -104,7 +232,7 @@
Refactored to: Refactored to:
1. Fetch CSRF 1. Fetch CSRF
2. POST credentials to `/api/auth/auth.php` 2. POST credentials to `/api/auth/auth.php`
3. On `totp_required`, refetch CSRF *again* before calling `openTOTPLoginModal()` 3. On `totp_required`, refetch CSRF again before calling `openTOTPLoginModal()`
4. Handle full logins vs. TOTP flows cleanly. 4. Handle full logins vs. TOTP flows cleanly.
- **TOTP handlers update** - **TOTP handlers update**
@@ -1050,7 +1178,7 @@ The enhancements extend the existing drag-and-drop functionality by adding a hea
- Adjusted file preview and icon styling for better alignment. - Adjusted file preview and icon styling for better alignment.
- Centered the header and optimized the layout for a clean, modern appearance. - Centered the header and optimized the layout for a clean, modern appearance.
*This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.* This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.
--- ---

View File

@@ -62,26 +62,76 @@ RUN chown -R root:www-data /var/www && \
# Apache site configuration # Apache site configuration
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80> <VirtualHost *:80>
# Global settings
TraceEnable off
KeepAlive On
MaxKeepAliveRequests 100
KeepAliveTimeout 5
Timeout 60
ServerAdmin webmaster@localhost ServerAdmin webmaster@localhost
DocumentRoot /var/www/public DocumentRoot /var/www/public
# Security headers for all responses
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
</IfModule>
# Compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json
</IfModule>
# Cache static assets
<IfModule mod_expires.c>
ExpiresActive on
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType text/css "access plus 1 week"
ExpiresByType application/javascript "access plus 3 hour"
</IfModule>
# Protect uploads directory
Alias /uploads/ /var/www/uploads/ Alias /uploads/ /var/www/uploads/
<Directory "/var/www/uploads/"> <Directory "/var/www/uploads/">
Options -Indexes Options -Indexes
AllowOverride None AllowOverride None
<IfModule mod_php7.c>
php_flag engine off
</IfModule>
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Require all granted Require all granted
</Directory> </Directory>
# Public directory
<Directory "/var/www/public"> <Directory "/var/www/public">
AllowOverride All AllowOverride All
Require all granted Require all granted
DirectoryIndex index.html DirectoryIndex index.html index.php
</Directory> </Directory>
# Deny access to hidden files
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<Files "api.php">
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.redoc.ly; style-src 'self' 'unsafe-inline'; worker-src 'self' https://cdn.redoc.ly blob:; connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
</Files>
ErrorLog /var/www/metadata/log/error.log ErrorLog /var/www/metadata/log/error.log
CustomLog /var/www/metadata/log/access.log combined CustomLog /var/www/metadata/log/access.log combined
</VirtualHost> </VirtualHost>
EOF EOF
# Enable required modules # Enable required modules
RUN a2enmod rewrite headers RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate ssl
EXPOSE 80 443 EXPOSE 80 443
COPY start.sh /usr/local/bin/start.sh COPY start.sh /usr/local/bin/start.sh

View File

@@ -1,7 +1,7 @@
# FileRise # FileRise
**Elevate your File Management** A modern, self-hosted web file manager. **Elevate your File Management** A modern, self-hosted web file manager.
Upload, organize, and share files through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze. Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
**4/3/2025 Video demo:** **4/3/2025 Video demo:**
@@ -108,16 +108,16 @@ FileRise will be accessible at `http://localhost:8080` (or your servers IP).
If you prefer to run FileRise on a traditional web server (LAMP stack or similar): If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
- **Requirements:** PHP 8.1 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed. - **Requirements:** PHP 8.3 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed.
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases). - **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
``` bash ``` bash
git clone https://github.com/error311/FileRise.git git clone https://github.com/error311/FileRise.git
``` ```
Place the files into your web servers directory (e.g., `/var/www/html/filerise`). It can be in a subfolder (just adjust the `BASE_URL` in config as below). Place the files into your web servers directory (e.g., `/var/www/`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
- **Composer Dependencies:** If you plan to use OIDC (SSO login), install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.) - **Composer Dependencies:** Install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist): - **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist):

View File

@@ -1,20 +0,0 @@
<!-- public/api.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FileRise API Docs</title>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX" crossorigin="anonymous"></script>
</head>
<body>
<redoc spec-url="openapi.json"></redoc>
<div id="redoc-container"></div>
<script>
// If the <redoc> tag didnt render, fall back to init()
if (!customElements.get('redoc')) {
Redoc.init('openapi.json', {}, document.getElementById('redoc-container'));
}
</script>
</body>
</html>

31
public/api.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
// public/api.php
require_once __DIR__ . '/../config/config.php';
if (empty($_SESSION['authenticated'])) {
header('Location: /index.html?redirect=/api.php');
exit;
}
if (isset($_GET['spec'])) {
header('Content-Type: application/json');
readfile(__DIR__ . '/../openapi.json.dist');
exit;
}
?><!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FileRise API Docs</title>
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
crossorigin="anonymous"></script>
<script defer src="/js/redoc-init.js"></script>
</head>
<body>
<redoc spec-url="api.php?spec=1"></redoc>
<div id="redoc-container"></div>
</body>
</html>

View File

@@ -80,6 +80,9 @@ body.dark-mode .header-container {
background-color: #1f1f1f; background-color: #1f1f1f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
} }
#darkModeIcon {
color: #fff;
}
.header-logo { .header-logo {
max-height: 50px; max-height: 50px;

View File

@@ -5,13 +5,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n-key="title">FileRise</title> <title data-i18n-key="title">FileRise</title>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('logout') === '1') {
localStorage.removeItem("username");
localStorage.removeItem("userTOTPEnabled");
}
</script>
<link rel="icon" type="image/png" href="/assets/logo.png"> <link rel="icon" type="image/png" href="/assets/logo.png">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg"> <link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="csrf-token" content=""> <meta name="csrf-token" content="">
@@ -20,9 +13,12 @@
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" /> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" /> integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
integrity="sha384-zaeBlB/vwYsDRSlFajnDd7OydJ0cWk+c2OWybl3eSUf6hW2EbhlCsQPqKr3gkznT" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma" integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
@@ -41,9 +37,9 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js"
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E" integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js" <script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa" integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<link rel="stylesheet" href="css/styles.css" /> <link rel="stylesheet" href="css/styles.css" />
</head> </head>
@@ -159,7 +155,11 @@
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;"> <button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
<i class="material-icons">person_remove</i> <i class="material-icons">person_remove</i>
</button> </button>
<button id="darkModeToggle" class="dark-mode-toggle" data-i18n-key="dark_mode_toggle">Dark Mode</button> <button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
<span class="material-icons" id="darkModeIcon">
dark_mode
</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -200,7 +200,8 @@
</div> </div>
<!-- Basic HTTP Login Option --> <!-- Basic HTTP Login Option -->
<div class="text-center mt-3"> <div class="text-center mt-3">
<a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic HTTP <a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic
HTTP
Login</a> Login</a>
</div> </div>
</div> </div>
@@ -287,7 +288,7 @@
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder"> <button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i> <i class="material-icons">share</i>
</button> </button>
<button id="deleteFolderBtn" class="btn btn-danger ml-2" data-i18n-title="delete_folder"> <button id="deleteFolderBtn" class="btn btn-danger ml-2" data-i18n-title="delete_folder">
<i class="material-icons">delete</i> <i class="material-icons">delete</i>
</button> </button>
@@ -391,36 +392,43 @@
</div> <!-- end mainColumn --> </div> <!-- end mainColumn -->
</div> <!-- end main-wrapper --> </div> <!-- end main-wrapper -->
<!-- Download Progress Modal --> <!-- Download Progress Modal -->
<div id="downloadProgressModal" class="modal" style="display: none;"> <div id="downloadProgressModal" class="modal" style="display: none;">
<div class="modal-content" style="text-align: center; padding: 20px;"> <div class="modal-content" style="text-align: center; padding: 20px;">
<!-- Material icon spinner with a dedicated class --> <h4 id="downloadProgressTitle" data-i18n-key="preparing_download">
<span class="material-icons download-spinner">autorenew</span> Preparing your download...
<p data-i18n-key="preparing_download">Preparing your download...</p> </h4>
</div>
</div>
<!-- Single File Download Modal --> <!-- spinner -->
<div id="downloadFileModal" class="modal" style="display: none;"> <span class="material-icons download-spinner">autorenew</span>
<div class="modal-content" style="text-align: center; padding: 20px;">
<h4 data-i18n-key="download_file">Download File</h4> <!-- these were missing -->
<p data-i18n-key="confirm_or_change_filename">Confirm or change the download file name:</p> <progress id="downloadProgressBar" value="0" max="100" style="width:100%; height:1.5em; display:none;"></progress>
<input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename" placeholder="Filename" /> <p>
<div style="margin-top: 15px; text-align: right;"> <span id="downloadProgressPercent" style="display:none;">0%</span>
<button id="cancelDownloadFile" class="btn btn-secondary" </p>
onclick="document.getElementById('downloadFileModal').style.display = 'none';" </div>
data-i18n-key="cancel">Cancel</button> </div>
<button id="confirmSingleDownloadButton" class="btn btn-primary"
onclick="confirmSingleDownload()" <!-- Single File Download Modal -->
data-i18n-key="download">Download</button> <div id="downloadFileModal" class="modal" style="display: none;">
<div class="modal-content" style="text-align: center; padding: 20px;">
<h4 data-i18n-key="download_file">Download File</h4>
<p data-i18n-key="confirm_or_change_filename">Confirm or change the download file name:</p>
<input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename"
placeholder="Filename" />
<div style="margin-top: 15px; text-align: right;">
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button>
</div>
</div> </div>
</div> </div>
</div>
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) --> <!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
<div id="changePasswordModal" class="modal" style="display:none;"> <div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;"> <div class="modal-content" style="max-width:400px; margin:auto;">
<span id="closeChangePasswordModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</span> <span id="closeChangePasswordModal"
style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</span>
<h3 data-i18n-key="change_password_title">Change Password</h3> <h3 data-i18n-key="change_password_title">Change Password</h3>
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password" <input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
placeholder="Old Password" style="width:100%; margin: 5px 0;" /> placeholder="Old Password" style="width:100%; margin: 5px 0;" />

View File

@@ -52,28 +52,24 @@ const originalFetch = window.fetch;
* @returns {Promise<Response>} * @returns {Promise<Response>}
*/ */
export async function fetchWithCsrf(url, options = {}) { export async function fetchWithCsrf(url, options = {}) {
options = { credentials: 'include', headers: {}, ...options }; // 1) Merge in credentials + header
options.headers['X-CSRF-Token'] = window.csrfToken; options = {
credentials: 'include',
...options,
};
options.headers = {
...(options.headers || {}),
'X-CSRF-Token': window.csrfToken,
};
// 1) First attempt using the original fetch // 2) First attempt
let res = await originalFetch(url, options); let res = await originalFetch(url, options);
// 2) Softfailure JSON check (200 + {csrf_expired}) // 3) If we got a 403, try to refresh token & retry
if (res.ok && res.headers.get('content-type')?.includes('application/json')) {
const clone = res.clone();
const data = await clone.json();
if (data.csrf_expired) {
const newToken = data.csrf_token;
window.csrfToken = newToken;
document.querySelector('meta[name="csrf-token"]').content = newToken;
options.headers['X-CSRF-Token'] = newToken;
return originalFetch(url, options);
}
}
// 3) HTTP 403 fallback
if (res.status === 403) { if (res.status === 403) {
// 3a) See if the server gave us a new token header
let newToken = res.headers.get('X-CSRF-Token'); let newToken = res.headers.get('X-CSRF-Token');
// 3b) Otherwise fall back to the /api/auth/token endpoint
if (!newToken) { if (!newToken) {
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' }); const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
if (tokRes.ok) { if (tokRes.ok) {
@@ -82,17 +78,21 @@ export async function fetchWithCsrf(url, options = {}) {
} }
} }
if (newToken) { if (newToken) {
// 3c) Update global + meta
window.csrfToken = newToken; window.csrfToken = newToken;
document.querySelector('meta[name="csrf-token"]').content = newToken; const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.content = newToken;
// 3d) Retry the original request with the new token
options.headers['X-CSRF-Token'] = newToken; options.headers['X-CSRF-Token'] = newToken;
res = await originalFetch(url, options); res = await originalFetch(url, options);
} }
} }
// 4) Return the real Response—no body peeking here!
return res; return res;
} }
// wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows // wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
function openTOTPLoginModal() { function openTOTPLoginModal() {
originalOpenTOTPLoginModal(); originalOpenTOTPLoginModal();
@@ -437,13 +437,7 @@ function initAuth() {
submitLogin(formData); submitLogin(formData);
}); });
} }
document.getElementById("logoutBtn").addEventListener("click", function () {
fetch("/api/auth/logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
}).then(() => window.location.reload(true)).catch(() => { });
});
document.getElementById("addUserBtn").addEventListener("click", function () { document.getElementById("addUserBtn").addEventListener("click", function () {
resetUserForm(); resetUserForm();
toggleVisibility("addUserModal", true); toggleVisibility("addUserModal", true);

View File

@@ -3,7 +3,7 @@ import { sendRequest } from './networkUtils.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js'; import { loadAdminConfigFunc } from './auth.js';
const version = "v1.2.4"; // Update this version string as needed const version = "v1.2.7"; // Update this version string as needed
const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size: 12px; color: gray;">${version}</small>`;
let lastLoginData = null; let lastLoginData = null;
@@ -230,14 +230,39 @@ export function openUserPanel() {
<!-- New API Docs link --> <!-- New API Docs link -->
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 15px;">
<a href="api.html" target="_blank" class="btn btn-secondary"> <button type="button" id="openApiModalBtn" class="btn btn-secondary">
${t("api_docs") || "API Docs"} ${t("api_docs") || "API Docs"}
</a> </button>
</div> </div>
</div> </div>
`; `;
document.body.appendChild(userPanelModal); document.body.appendChild(userPanelModal);
const apiModal = document.createElement("div");
apiModal.id = "apiModal";
apiModal.style.cssText = `
position: fixed; top:0; left:0; width:100vw; height:100vh;
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
align-items: center; justify-content: center;
`;
// api.php
apiModal.innerHTML = `
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
<div class="editor-close-btn" id="closeApiModal">&times;</div>
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
</div>
`;
document.body.appendChild(apiModal);
document.getElementById("openApiModalBtn").addEventListener("click", () => {
apiModal.style.display = "flex";
});
document.getElementById("closeApiModal").addEventListener("click", () => {
apiModal.style.display = "none";
});
// Handlers… // Handlers…
document.getElementById("closeUserPanel").addEventListener("click", () => { document.getElementById("closeUserPanel").addEventListener("click", () => {
userPanelModal.style.display = "none"; userPanelModal.style.display = "none";
@@ -246,6 +271,7 @@ export function openUserPanel() {
document.getElementById("changePasswordModal").style.display = "block"; document.getElementById("changePasswordModal").style.display = "block";
}); });
// TOTP checkbox // TOTP checkbox
const totpCheckbox = document.getElementById("userTOTPEnabled"); const totpCheckbox = document.getElementById("userTOTPEnabled");
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true"; totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";

View File

@@ -25,8 +25,9 @@ export function toggleAllCheckboxes(masterCheckbox) {
const checkboxes = document.querySelectorAll(".file-checkbox"); const checkboxes = document.querySelectorAll(".file-checkbox");
checkboxes.forEach(chk => { checkboxes.forEach(chk => {
chk.checked = masterCheckbox.checked; chk.checked = masterCheckbox.checked;
updateRowHighlight(chk);
}); });
updateFileActionButtons(); // update buttons based on current selection updateFileActionButtons();
} }
export function updateFileActionButtons() { export function updateFileActionButtons() {
@@ -38,6 +39,21 @@ export function updateFileActionButtons() {
const zipBtn = document.getElementById("downloadZipBtn"); const zipBtn = document.getElementById("downloadZipBtn");
const extractZipBtn = document.getElementById("extractZipBtn"); const extractZipBtn = document.getElementById("extractZipBtn");
// keep the “select all” in sync ——
const master = document.getElementById("selectAll");
if (master) {
if (selectedCheckboxes.length === fileCheckboxes.length) {
master.checked = true;
master.indeterminate = false;
} else if (selectedCheckboxes.length === 0) {
master.checked = false;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
if (fileCheckboxes.length === 0) { if (fileCheckboxes.length === 0) {
if (copyBtn) copyBtn.style.display = "none"; if (copyBtn) copyBtn.style.display = "none";
if (moveBtn) moveBtn.style.display = "none"; if (moveBtn) moveBtn.style.display = "none";
@@ -101,7 +117,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
<div class="input-group"> <div class="input-group">
<!-- Advanced Search Toggle Button --> <!-- Advanced Search Toggle Button -->
<div class="input-group-prepend"> <div class="input-group-prepend">
<button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" onclick="toggleAdvancedSearch()" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}"> <button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}">
<i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i> <i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i>
</button> </button>
</div> </div>
@@ -117,9 +133,9 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
</div> </div>
<div class="col-12 col-md-4 text-left"> <div class="col-12 col-md-4 text-left">
<div class="d-flex justify-content-center justify-content-md-start align-items-center"> <div class="d-flex justify-content-center justify-content-md-start align-items-center">
<button class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""} onclick="changePage(${currentPage - 1})">${t("prev")}</button> <button id="prevPageBtn" class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""}>${t("prev")}</button>
<span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span> <span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span>
<button class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""} onclick="changePage(${currentPage + 1})">${t("next")}</button> <button id="nextPageBtn" class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""}>${t("next")}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -131,7 +147,7 @@ export function buildFileTableHeader(sortOrder) {
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll" onclick="toggleAllCheckboxes(this)"></th> <th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
@@ -162,15 +178,15 @@ export function buildFileTableRow(file, folderPath) {
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) { } else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`; previewIcon = `<i class="material-icons">audiotrack</i>`;
} }
previewButton = `<button class="btn btn-sm btn-info preview-btn" onclick="event.stopPropagation(); previewFile('${folderPath + encodeURIComponent(file.name)}', '${safeFileName}')"> previewButton = `<button class="btn btn-sm btn-info preview-btn" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-name="${safeFileName}">
${previewIcon} ${previewIcon}
</button>`; </button>`;
} }
return ` return `
<tr onclick="toggleRowSelection(event, '${safeFileName}')" class="clickable-row"> <tr class="clickable-row">
<td> <td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);"> <input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td> </td>
<td class="file-name-cell">${safeFileName}</td> <td class="file-name-cell">${safeFileName}</td>
<td class="hide-small nowrap">${safeModified}</td> <td class="hide-small nowrap">${safeModified}</td>
@@ -179,22 +195,16 @@ export function buildFileTableRow(file, folderPath) {
<td class="hide-small hide-medium nowrap">${safeUploader}</td> <td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td> <td>
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;"> <div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
<button type="button" class="btn btn-sm btn-success download-btn" <button type="button" class="btn btn-sm btn-success download-btn" data-download-name="${file.name}" data-download-folder="${file.folder || 'root'}" title="${t('download')}">
onclick="openDownloadModal('${file.name}', '${file.folder || 'root'}')"
title="${t('download')}">
<i class="material-icons">file_download</i> <i class="material-icons">file_download</i>
</button> </button>
${file.editable ? ` ${file.editable ? `
<button class="btn btn-sm edit-btn" <button class="btn btn-sm edit-btn" data-edit-name="${file.name}" data-edit-folder="${file.folder || 'root'}" title="${t('edit')}">
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="${t('edit')}">
<i class="material-icons">edit</i> <i class="material-icons">edit</i>
</button> </button>
` : ""} ` : ""}
${previewButton} ${previewButton}
<button class="btn btn-sm btn-warning rename-btn" <button class="btn btn-sm btn-warning rename-btn" data-rename-name="${file.name}" data-rename-folder="${file.folder || 'root'}" title="${t('rename')}">
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
</div> </div>
@@ -207,10 +217,10 @@ export function buildBottomControls(itemsPerPageSetting) {
return ` return `
<div class="d-flex align-items-center mt-3 bottom-controls"> <div class="d-flex align-items-center mt-3 bottom-controls">
<label class="label-inline mr-2 mb-0">${t("show")}</label> <label class="label-inline mr-2 mb-0">${t("show")}</label>
<select class="form-control bottom-select" onchange="changeItemsPerPage(this.value)"> <select class="form-control bottom-select" id="itemsPerPageSelect">
${[10, 20, 50, 100] ${[10, 20, 50, 100]
.map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`) .map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`)
.join("")} .join("")}
</select> </select>
<span class="items-per-page-text ml-2 mb-0">${t("items_per_page")}</span> <span class="items-per-page-text ml-2 mb-0">${t("items_per_page")}</span>
</div> </div>
@@ -277,8 +287,6 @@ export function toggleRowSelection(event, fileName) {
const start = Math.min(currentIndex, lastIndex); const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex); const end = Math.max(currentIndex, lastIndex);
// If neither CTRL nor Meta is pressed, you might choose
// to clear existing selections. For this example we leave existing selections intact.
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
const cb = allRows[i].querySelector(".file-checkbox"); const cb = allRows[i].querySelector(".file-checkbox");
if (cb) { if (cb) {
@@ -346,3 +354,6 @@ export function showCustomConfirmModal(message) {
noBtn.addEventListener("click", onNo); noBtn.addEventListener("click", onNo);
}); });
} }
window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight;

View File

@@ -97,58 +97,34 @@ export function openDownloadModal(fileName, folder) {
} }
export function confirmSingleDownload() { export function confirmSingleDownload() {
// Get the file name from the modal. Users can change it if desired. // 1) Get and validate the filename
let fileName = document.getElementById("downloadFileNameInput").value.trim(); const input = document.getElementById("downloadFileNameInput");
const fileName = input.value.trim();
if (!fileName) { if (!fileName) {
showToast("Please enter a name for the file."); showToast("Please enter a name for the file.");
return; return;
} }
// Hide the download modal. // 2) Hide the download-name modal
document.getElementById("downloadFileModal").style.display = "none"; document.getElementById("downloadFileModal").style.display = "none";
// Show the progress modal (same as in your ZIP download flow).
document.getElementById("downloadProgressModal").style.display = "block";
// Build the URL for download.php using GET parameters. // 3) Build the direct download URL
const folder = window.currentFolder || "root"; const folder = window.currentFolder || "root";
const downloadURL = "/api/file/download.php?folder=" + encodeURIComponent(folder) + const downloadURL = "/api/file/download.php"
"&file=" + encodeURIComponent(window.singleFileToDownload); + "?folder=" + encodeURIComponent(folder)
+ "&file=" + encodeURIComponent(window.singleFileToDownload);
fetch(downloadURL, { // 4) Trigger native browser download
method: "GET", const a = document.createElement("a");
credentials: "include" a.href = downloadURL;
}) a.download = fileName;
.then(response => { a.style.display = "none";
if (!response.ok) { document.body.appendChild(a);
return response.text().then(text => { a.click();
throw new Error("Failed to download file: " + text); document.body.removeChild(a);
});
} // 5) Notify the user
return response.blob(); showToast("Download started. Check your browsers download manager.");
})
.then(blob => {
if (!blob || blob.size === 0) {
throw new Error("Received empty file.");
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
// Hide the progress modal.
document.getElementById("downloadProgressModal").style.display = "none";
showToast("Download started.");
})
.catch(error => {
// Hide progress modal and show error.
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error downloading file:", error);
showToast("Error downloading file: " + error.message);
});
} }
export function handleExtractZipSelected(e) { export function handleExtractZipSelected(e) {
@@ -169,14 +145,19 @@ export function handleExtractZipSelected(e) {
return; return;
} }
// Change progress modal text to "Extracting files..." // Prepare and show the spinner-only modal
const progressText = document.querySelector("#downloadProgressModal p"); const modal = document.getElementById("downloadProgressModal");
if (progressText) { const titleEl = document.getElementById("downloadProgressTitle");
progressText.textContent = "Extracting files..."; const spinner = modal.querySelector(".download-spinner");
} const progressBar = document.getElementById("downloadProgressBar");
const progressPct = document.getElementById("downloadProgressPercent");
// Show the progress modal. if (titleEl) titleEl.textContent = "Extracting files…";
document.getElementById("downloadProgressModal").style.display = "block"; if (spinner) spinner.style.display = "inline-block";
if (progressBar) progressBar.style.display = "none";
if (progressPct) progressPct.style.display = "none";
modal.style.display = "block";
fetch("/api/file/extractZip.php", { fetch("/api/file/extractZip.php", {
method: "POST", method: "POST",
@@ -192,45 +173,42 @@ export function handleExtractZipSelected(e) {
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
// Hide the progress modal once the request has completed. modal.style.display = "none";
document.getElementById("downloadProgressModal").style.display = "none";
if (data.success) { if (data.success) {
let toastMessage = "Zip file(s) extracted successfully!"; let msg = "Zip file(s) extracted successfully!";
if (data.extractedFiles && Array.isArray(data.extractedFiles) && data.extractedFiles.length) { if (Array.isArray(data.extractedFiles) && data.extractedFiles.length) {
toastMessage = "Extracted: " + data.extractedFiles.join(", "); msg = "Extracted: " + data.extractedFiles.join(", ");
} }
showToast(toastMessage); showToast(msg);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
} else { } else {
showToast("Error extracting zip: " + (data.error || "Unknown error")); showToast("Error extracting zip: " + (data.error || "Unknown error"));
} }
}) })
.catch(error => { .catch(error => {
// Hide the progress modal on error. modal.style.display = "none";
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error extracting zip files:", error); console.error("Error extracting zip files:", error);
showToast("Error extracting zip files."); showToast("Error extracting zip files.");
}); });
} }
const extractZipBtn = document.getElementById("extractZipBtn"); document.addEventListener("DOMContentLoaded", () => {
if (extractZipBtn) { const zipNameModal = document.getElementById("downloadZipModal");
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true)); const progressModal = document.getElementById("downloadProgressModal");
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected); const cancelZipBtn = document.getElementById("cancelDownloadZip");
} const confirmZipBtn = document.getElementById("confirmDownloadZip");
document.addEventListener("DOMContentLoaded", function () { // 1) Cancel button hides the name modal
const cancelDownloadZip = document.getElementById("cancelDownloadZip"); if (cancelZipBtn) {
if (cancelDownloadZip) { cancelZipBtn.addEventListener("click", () => {
cancelDownloadZip.addEventListener("click", function () { zipNameModal.style.display = "none";
document.getElementById("downloadZipModal").style.display = "none";
}); });
} }
// This part remains in your confirmDownloadZip event handler: // 2) Confirm button kicks off the zip+download
const confirmDownloadZip = document.getElementById("confirmDownloadZip"); if (confirmZipBtn) {
if (confirmDownloadZip) { confirmZipBtn.addEventListener("click", async () => {
confirmDownloadZip.addEventListener("click", function () { // a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim(); let zipName = document.getElementById("zipFileNameInput").value.trim();
if (!zipName) { if (!zipName) {
showToast("Please enter a name for the zip file."); showToast("Please enter a name for the zip file.");
@@ -239,52 +217,56 @@ document.addEventListener("DOMContentLoaded", function () {
if (!zipName.toLowerCase().endsWith(".zip")) { if (!zipName.toLowerCase().endsWith(".zip")) {
zipName += ".zip"; zipName += ".zip";
} }
// Hide the ZIP name input modal
document.getElementById("downloadZipModal").style.display = "none"; // b) Hide the nameinput modal, show the spinner modal
// Show the progress modal here only on confirm zipNameModal.style.display = "none";
console.log("Download confirmed. Showing progress modal."); progressModal.style.display = "block";
document.getElementById("downloadProgressModal").style.display = "block";
const folder = window.currentFolder || "root"; // c) (Optional) update the “Preparing…” text if you gave it an ID
fetch("/api/file/downloadZip.php", { const titleEl = document.getElementById("downloadProgressTitle");
method: "POST", if (titleEl) titleEl.textContent = `Preparing ${zipName}`;
credentials: "include",
headers: { try {
"Content-Type": "application/json", // d) POST and await the ZIP blob
"X-CSRF-Token": window.csrfToken const res = await fetch("/api/file/downloadZip.php", {
}, method: "POST",
body: JSON.stringify({ folder: folder, files: window.filesToDownload }) credentials: "include",
}) headers: {
.then(response => { "Content-Type": "application/json",
if (!response.ok) { "X-CSRF-Token": window.csrfToken
return response.text().then(text => { },
throw new Error("Failed to create zip file: " + text); body: JSON.stringify({
}); folder: window.currentFolder || "root",
} files: window.filesToDownload
return response.blob(); })
})
.then(blob => {
if (!blob || blob.size === 0) {
throw new Error("Received empty zip file.");
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
// Hide the progress modal after download starts
document.getElementById("downloadProgressModal").style.display = "none";
showToast("Download started.");
})
.catch(error => {
// Hide the progress modal on error
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error downloading zip:", error);
showToast("Error downloading selected files as zip: " + error.message);
}); });
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || `Status ${res.status}`);
}
const blob = await res.blob();
if (!blob || blob.size === 0) {
throw new Error("Received empty ZIP file.");
}
// e) Hand off to the browsers download manager
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
} catch (err) {
console.error("Error downloading ZIP:", err);
showToast("Error: " + err.message);
} finally {
// f) Always hide spinner modal
progressModal.style.display = "none";
}
}); });
} }
}); });
@@ -573,4 +555,22 @@ export function initFileActions() {
} }
} }
// Hook up the singlefile download modal buttons
document.addEventListener("DOMContentLoaded", () => {
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
if (cancelDownloadFileBtn) {
cancelDownloadFileBtn.addEventListener("click", () => {
document.getElementById("downloadFileModal").style.display = "none";
});
}
const confirmSingleDownloadBtn = document.getElementById("confirmSingleDownloadButton");
if (confirmSingleDownloadBtn) {
confirmSingleDownloadBtn.addEventListener("click", confirmSingleDownload);
}
// Make Enter also confirm the download
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
});
window.renameFile = renameFile; window.renameFile = renameFile;

View File

@@ -340,6 +340,88 @@ export function renderFileTable(folder, container) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
// pagination clicks
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) {
window.currentPage--;
renderFileTable(folder, container);
}
});
const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => {
// totalPages is computed above in this scope
if (window.currentPage < totalPages) {
window.currentPage++;
renderFileTable(folder, container);
}
});
// ADD: advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch();
});
// items-per-page selector
const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10);
localStorage.setItem("itemsPerPage", window.itemsPerPage);
window.currentPage = 1;
renderFileTable(folder, container);
});
// hook up the master checkbox
const selectAll = document.getElementById("selectAll");
if (selectAll) {
selectAll.addEventListener("change", () => {
toggleAllCheckboxes(selectAll);
});
}
// 1) Row-click selects the row
fileListContent.querySelectorAll("tbody tr").forEach(row => {
row.addEventListener("click", e => {
// grab the underlying checkbox value
const cb = row.querySelector(".file-checkbox");
if (!cb) return;
toggleRowSelection(e, cb.value);
});
});
// 2) Download buttons
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
});
});
// 3) Edit buttons
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
editFile(btn.dataset.editName, btn.dataset.editFolder);
});
});
// 4) Rename buttons
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
});
});
// 5) Preview buttons (if you still have a .preview-btn)
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
});
});
createViewToggleButton(); createViewToggleButton();
// Setup event listeners. // Setup event listeners.
@@ -476,23 +558,26 @@ export function renderGalleryView(folder, container) {
pageFiles.forEach((file, idx) => { pageFiles.forEach((file, idx) => {
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx); const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
const cacheKey = folderPath + encodeURIComponent(file.name);
// thumbnail // thumbnail
let thumbnail; let thumbnail;
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) { if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
const cacheKey = folderPath + encodeURIComponent(file.name);
if (window.imageCache && window.imageCache[cacheKey]) { if (window.imageCache && window.imageCache[cacheKey]) {
thumbnail = `<img src="${window.imageCache[cacheKey]}" thumbnail = `<img
class="gallery-thumbnail" src="${window.imageCache[cacheKey]}"
alt="${escapeHTML(file.name)}" class="gallery-thumbnail"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`; data-cache-key="${cacheKey}"
alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
} else { } else {
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now(); const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
thumbnail = `<img src="${imageUrl}" thumbnail = `<img
onload="cacheImage(this,'${cacheKey}')" src="${imageUrl}"
class="gallery-thumbnail" class="gallery-thumbnail"
alt="${escapeHTML(file.name)}" data-cache-key="${cacheKey}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`; alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
} }
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) { } else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`; thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`;
@@ -529,9 +614,9 @@ export function renderGalleryView(folder, container) {
<label for="cb-${idSafe}" <label for="cb-${idSafe}"
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label> style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
<div class="gallery-preview" <div class="gallery-preview" style="cursor:pointer;"
style="cursor:pointer;" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t='+Date.now(), '${file.name}')"> data-preview-name="${file.name}">
${thumbnail} ${thumbnail}
</div> </div>
@@ -544,22 +629,25 @@ export function renderGalleryView(folder, container) {
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;"> <div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
<button type="button" class="btn btn-sm btn-success download-btn" <button type="button" class="btn btn-sm btn-success download-btn"
onclick="openDownloadModal('${file.name}', '${file.folder || "root"}')" data-download-name="${escapeHTML(file.name)}"
data-download-folder="${file.folder || "root"}"
title="${t('download')}"> title="${t('download')}">
<i class="material-icons">file_download</i> <i class="material-icons">file_download</i>
</button> </button>
${file.editable ? ` ${file.editable ? `
<button class="btn btn-sm edit-btn" <button type="button" class="btn btn-sm edit-btn"
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' data-edit-name="${escapeHTML(file.name)}"
title="${t('Edit')}"> data-edit-folder="${file.folder || "root"}"
title="${t('edit')}">
<i class="material-icons">edit</i> <i class="material-icons">edit</i>
</button>` : ""} </button>` : ""}
<button class="btn btn-sm btn-warning rename-btn" <button type="button" class="btn btn-sm btn-warning rename-btn"
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' data-rename-name="${escapeHTML(file.name)}"
data-rename-folder="${file.folder || "root"}"
title="${t('rename')}"> title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
<button class="btn btn-sm btn-secondary share-btn" <button type="button" class="btn btn-sm btn-secondary share-btn"
data-file="${escapeHTML(file.name)}" data-file="${escapeHTML(file.name)}"
title="${t('share')}"> title="${t('share')}">
<i class="material-icons">share</i> <i class="material-icons">share</i>
@@ -579,13 +667,93 @@ export function renderGalleryView(folder, container) {
// render // render
fileListContent.innerHTML = galleryHTML; fileListContent.innerHTML = galleryHTML;
// ensure toggle button // --- Now wire up all behaviors without inline handlers ---
createViewToggleButton();
// attach listeners // ADD: pagination buttons for gallery
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) {
window.currentPage--;
renderGalleryView(folder, container);
}
});
const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => {
if (window.currentPage < totalPages) {
window.currentPage++;
renderGalleryView(folder, container);
}
});
// ←— ADD: advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch();
});
// ←— ADD: wire up context-menu in gallery
bindFileListContextMenu();
// ADD: items-per-page selector for gallery
const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10);
localStorage.setItem("itemsPerPage", window.itemsPerPage);
window.currentPage = 1;
renderGalleryView(folder, container);
});
// cache images on load
fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => {
const key = img.dataset.cacheKey;
img.addEventListener('load', () => cacheImage(img, key));
});
// preview clicks
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
el.addEventListener("click", () => {
previewFile(el.dataset.previewUrl, el.dataset.previewName);
});
});
// download clicks
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
});
});
// edit clicks
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
editFile(btn.dataset.editName, btn.dataset.editFolder);
});
});
// rename clicks
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
});
});
// share clicks
fileListContent.querySelectorAll(".share-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
const fileName = btn.dataset.file;
const fileObj = fileData.find(f => f.name === fileName);
if (fileObj) {
import('./filePreview.js').then(m => m.openShareModal(fileObj, folder));
}
});
});
// checkboxes // checkboxes
document.querySelectorAll(".file-checkbox").forEach(cb => { fileListContent.querySelectorAll(".file-checkbox").forEach(cb => {
cb.addEventListener("change", () => updateFileActionButtons()); cb.addEventListener("change", () => updateFileActionButtons());
}); });
@@ -603,14 +771,13 @@ export function renderGalleryView(folder, container) {
}); });
} }
// pagination // pagination functions
window.changePage = newPage => { window.changePage = newPage => {
window.currentPage = newPage; window.currentPage = newPage;
if (window.viewMode === "gallery") renderGalleryView(folder); if (window.viewMode === "gallery") renderGalleryView(folder);
else renderFileTable(folder); else renderFileTable(folder);
}; };
// items per page
window.changeItemsPerPage = cnt => { window.changeItemsPerPage = cnt => {
window.itemsPerPage = +cnt; window.itemsPerPage = +cnt;
localStorage.setItem("itemsPerPage", cnt); localStorage.setItem("itemsPerPage", cnt);
@@ -619,8 +786,9 @@ export function renderGalleryView(folder, container) {
else renderFileTable(folder); else renderFileTable(folder);
}; };
// update toolbar buttons // update toolbar and toggle button
updateFileActionButtons(); updateFileActionButtons();
createViewToggleButton();
} }
// Responsive slider constraints based on screen size. // Responsive slider constraints based on screen size.

View File

@@ -104,24 +104,26 @@ export function setupBreadcrumbDelegation() {
// Click handler via delegation // Click handler via delegation
function breadcrumbClickHandler(e) { function breadcrumbClickHandler(e) {
// find the nearest .breadcrumb-link
const link = e.target.closest(".breadcrumb-link"); const link = e.target.closest(".breadcrumb-link");
if (!link) return; if (!link) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
const folder = link.getAttribute("data-folder"); const folder = link.dataset.folder;
window.currentFolder = folder; window.currentFolder = folder;
localStorage.setItem("lastOpenedFolder", folder); localStorage.setItem("lastOpenedFolder", folder);
// Update the container with sanitized breadcrumbs. // rebuild the title safely
const container = document.getElementById("fileListTitle"); updateBreadcrumbTitle(folder);
const sanitizedBreadcrumb = DOMPurify.sanitize(renderBreadcrumb(folder));
container.innerHTML = t("files_in") + " (" + sanitizedBreadcrumb + ")";
expandTreePath(folder); expandTreePath(folder);
document.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected")); document.querySelectorAll(".folder-option").forEach(el =>
const targetOption = document.querySelector(`.folder-option[data-folder="${folder}"]`); el.classList.remove("selected")
if (targetOption) targetOption.classList.add("selected"); );
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (target) target.classList.add("selected");
loadFileList(folder); loadFileList(folder);
} }
@@ -335,6 +337,38 @@ function folderDropHandler(event) {
/* ---------------------- /* ----------------------
Main Folder Tree Rendering and Event Binding Main Folder Tree Rendering and Event Binding
----------------------*/ ----------------------*/
// --- Helpers for safe breadcrumb rendering ---
function renderBreadcrumbFragment(folderPath) {
const frag = document.createDocumentFragment();
const parts = folderPath.split("/");
let acc = "";
parts.forEach((part, idx) => {
acc = idx === 0 ? part : acc + "/" + part;
const span = document.createElement("span");
span.classList.add("breadcrumb-link");
span.dataset.folder = acc;
span.textContent = part;
frag.appendChild(span);
if (idx < parts.length - 1) {
frag.appendChild(document.createTextNode(" / "));
}
});
return frag;
}
function updateBreadcrumbTitle(folder) {
const titleEl = document.getElementById("fileListTitle");
titleEl.textContent = "";
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
titleEl.appendChild(renderBreadcrumbFragment(folder));
titleEl.appendChild(document.createTextNode(")"));
setupBreadcrumbDelegation();
}
export async function loadFolderTree(selectedFolder) { export async function loadFolderTree(selectedFolder) {
try { try {
// Check if the user has folder-only permission. // Check if the user has folder-only permission.
@@ -420,9 +454,8 @@ export async function loadFolderTree(selectedFolder) {
} }
localStorage.setItem("lastOpenedFolder", window.currentFolder); localStorage.setItem("lastOpenedFolder", window.currentFolder);
const titleEl = document.getElementById("fileListTitle"); // Initial breadcrumb update
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")"; updateBreadcrumbTitle(window.currentFolder);
setupBreadcrumbDelegation();
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
const folderState = loadFolderTreeState(); const folderState = loadFolderTreeState();
@@ -436,6 +469,7 @@ export async function loadFolderTree(selectedFolder) {
selectedEl.classList.add("selected"); selectedEl.classList.add("selected");
} }
// Folder-option click: update selection, breadcrumbs, and file list
container.querySelectorAll(".folder-option").forEach(el => { container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("click", function (e) { el.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
@@ -444,13 +478,14 @@ export async function loadFolderTree(selectedFolder) {
const selected = this.getAttribute("data-folder"); const selected = this.getAttribute("data-folder");
window.currentFolder = selected; window.currentFolder = selected;
localStorage.setItem("lastOpenedFolder", selected); localStorage.setItem("lastOpenedFolder", selected);
const titleEl = document.getElementById("fileListTitle");
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(selected) + ")"; // Safe breadcrumb update
setupBreadcrumbDelegation(); updateBreadcrumbTitle(selected);
loadFileList(selected); loadFileList(selected);
}); });
}); });
// Root toggle handler
const rootToggle = container.querySelector("#rootRow .folder-toggle"); const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) { if (rootToggle) {
rootToggle.addEventListener("click", function (e) { rootToggle.addEventListener("click", function (e) {
@@ -474,6 +509,7 @@ export async function loadFolderTree(selectedFolder) {
}); });
} }
// Other folder-toggle handlers
container.querySelectorAll(".folder-toggle").forEach(toggle => { container.querySelectorAll(".folder-toggle").forEach(toggle => {
toggle.addEventListener("click", function (e) { toggle.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
@@ -502,6 +538,7 @@ export async function loadFolderTree(selectedFolder) {
} }
} }
// For backward compatibility. // For backward compatibility.
export function loadFolderList(selectedFolder) { export function loadFolderList(selectedFolder) {
loadFolderTree(selectedFolder); loadFolderTree(selectedFolder);

View File

@@ -176,6 +176,8 @@ const translations = {
// Dark Mode Toggle // Dark Mode Toggle
"dark_mode_toggle": "Dark Mode", "dark_mode_toggle": "Dark Mode",
"light_mode_toggle": "Light Mode", "light_mode_toggle": "Light Mode",
"switch_to_light_mode": "Switch to light mode",
"switch_to_dark_mode": "Switch to dark mode",
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS: // NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
"admin_panel": "Admin Panel", "admin_panel": "Admin Panel",

View File

@@ -14,36 +14,20 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
import { editFile, saveFile } from './fileEditor.js'; import { editFile, saveFile } from './fileEditor.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
// Remove the retry logic version and just use loadCsrfToken directly:
/**
* Fetches the current CSRF token (and share URL), updates window globals
* and <meta> tags, and returns the data.
*
* @returns {Promise<{csrf_token: string, share_url: string}>}
*/
export function loadCsrfToken() { export function loadCsrfToken() {
return fetch('/api/auth/token.php', { return fetchWithCsrf('/api/auth/token.php', {
method: 'GET', method: 'GET'
credentials: 'include'
}) })
.then(response => { .then(res => {
if (!response.ok) { if (!res.ok) {
throw new Error(`Token fetch failed with status: ${response.status}`); throw new Error(`Token fetch failed with status ${res.status}`);
} }
// Prefer header if set, otherwise fall back to body return res.json();
const headerToken = response.headers.get('X-CSRF-Token');
return response.json()
.then(body => ({
csrf_token: headerToken || body.csrf_token,
share_url: body.share_url
}));
}) })
.then(({ csrf_token, share_url }) => { .then(({ csrf_token, share_url }) => {
// Update globals // Update global and <meta>
window.csrfToken = csrf_token; window.csrfToken = csrf_token;
window.SHARE_URL = share_url;
// Sync <meta name="csrf-token">
let meta = document.querySelector('meta[name="csrf-token"]'); let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) { if (!meta) {
meta = document.createElement('meta'); meta = document.createElement('meta');
@@ -52,7 +36,6 @@ export function loadCsrfToken() {
} }
meta.content = csrf_token; meta.content = csrf_token;
// Sync <meta name="share-url">
let shareMeta = document.querySelector('meta[name="share-url"]'); let shareMeta = document.querySelector('meta[name="share-url"]');
if (!shareMeta) { if (!shareMeta) {
shareMeta = document.createElement('meta'); shareMeta = document.createElement('meta');
@@ -65,6 +48,27 @@ export function loadCsrfToken() {
}); });
} }
// 1) Immediately clear “?logout=1” flag
const params = new URLSearchParams(window.location.search);
if (params.get('logout') === '1') {
localStorage.removeItem("username");
localStorage.removeItem("userTOTPEnabled");
}
// 2) Wire up logoutBtn right away
const logoutBtn = document.getElementById("logoutBtn");
if (logoutBtn) {
logoutBtn.addEventListener("click", () => {
fetch("/api/auth/logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
})
.then(() => window.location.reload(true))
.catch(() => {});
});
}
// Expose functions for inline handlers. // Expose functions for inline handlers.
window.sendRequest = sendRequest; window.sendRequest = sendRequest;
@@ -135,48 +139,55 @@ document.addEventListener("DOMContentLoaded", function () {
// --- Dark Mode Persistence --- // --- Dark Mode Persistence ---
const darkModeToggle = document.getElementById("darkModeToggle"); const darkModeToggle = document.getElementById("darkModeToggle");
const storedDarkMode = localStorage.getItem("darkMode"); const darkModeIcon = document.getElementById("darkModeIcon");
if (storedDarkMode === "true") { if (darkModeToggle && darkModeIcon) {
document.body.classList.add("dark-mode"); // 1) Load stored preference (or null)
} else if (storedDarkMode === "false") { let stored = localStorage.getItem("darkMode");
document.body.classList.remove("dark-mode"); const hasStored = stored !== null;
} else {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { // 2) Determine initial mode
document.body.classList.add("dark-mode"); const isDark = hasStored
} else { ? (stored === "true")
document.body.classList.remove("dark-mode"); : (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.body.classList.toggle("dark-mode", isDark);
darkModeToggle.classList.toggle("active", isDark);
// 3) Helper to update icon & aria-label
function updateIcon() {
const dark = document.body.classList.contains("dark-mode");
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
darkModeToggle.setAttribute(
"aria-label",
dark ? t("light_mode") : t("dark_mode")
);
darkModeToggle.setAttribute(
"title",
dark
? t("switch_to_light_mode")
: t("switch_to_dark_mode")
);
} }
}
if (darkModeToggle) { updateIcon();
darkModeToggle.textContent = document.body.classList.contains("dark-mode")
? t("light_mode")
: t("dark_mode");
darkModeToggle.addEventListener("click", function () { // 4) Click handler: always override and store preference
if (document.body.classList.contains("dark-mode")) { darkModeToggle.addEventListener("click", () => {
document.body.classList.remove("dark-mode"); const nowDark = document.body.classList.toggle("dark-mode");
localStorage.setItem("darkMode", "false"); localStorage.setItem("darkMode", nowDark ? "true" : "false");
darkModeToggle.textContent = t("dark_mode"); updateIcon();
} else {
document.body.classList.add("dark-mode");
localStorage.setItem("darkMode", "true");
darkModeToggle.textContent = t("light_mode");
}
}); });
}
if (localStorage.getItem("darkMode") === null && window.matchMedia) { // 5) OSlevel change: only if no stored pref at load
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => { if (!hasStored && window.matchMedia) {
if (event.matches) { window
document.body.classList.add("dark-mode"); .matchMedia("(prefers-color-scheme: dark)")
if (darkModeToggle) darkModeToggle.textContent = t("light_mode"); .addEventListener("change", e => {
} else { document.body.classList.toggle("dark-mode", e.matches);
document.body.classList.remove("dark-mode"); updateIcon();
if (darkModeToggle) darkModeToggle.textContent = t("dark_mode"); });
} }
});
} }
// --- End Dark Mode Persistence --- // --- End Dark Mode Persistence ---

6
public/js/redoc-init.js Normal file
View File

@@ -0,0 +1,6 @@
// public/js/redoc-init.js
if (!customElements.get('redoc')) {
Redoc.init(window.location.origin + '/api.php?spec=1',
{},
document.getElementById('redoc-container'));
}

View File

@@ -0,0 +1,60 @@
// sharedFolderView.js
document.addEventListener('DOMContentLoaded', function() {
let viewMode = 'list';
const payload = JSON.parse(
document.getElementById('shared-data').textContent
);
const token = payload.token;
const filesData = payload.files;
const downloadBase = `${window.location.origin}/api/folder/downloadSharedFile.php?token=${encodeURIComponent(token)}&file=`;
function toggleViewMode() {
const listEl = document.getElementById('listViewContainer');
const galleryEl = document.getElementById('galleryViewContainer');
const btn = document.getElementById('toggleBtn');
if (btn) {
btn.classList.add('toggle-btn');
}
if (viewMode === 'list') {
viewMode = 'gallery';
listEl.style.display = 'none';
renderGalleryView();
galleryEl.style.display = 'block';
btn.textContent = 'Switch to List View';
} else {
viewMode = 'list';
galleryEl.style.display = 'none';
listEl.style.display = 'block';
btn.textContent = 'Switch to Gallery View';
}
}
document.getElementById('toggleBtn').addEventListener('click', toggleViewMode);
function renderGalleryView() {
const galleryContainer = document.getElementById('galleryViewContainer');
let html = '<div class="shared-gallery-container">';
filesData.forEach(file => {
const url = downloadBase + encodeURIComponent(file);
const ext = file.split('.').pop().toLowerCase();
const thumb = /^(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/.test(ext)
? `<img src="${url}" alt="${file}">`
: `<span class="material-icons">insert_drive_file</span>`;
html += `
<div class="shared-gallery-card">
<div class="gallery-preview" data-url="${url}" style="cursor:pointer;">${thumb}</div>
<div class="gallery-info"><span class="gallery-file-name">${file}</span></div>
</div>`;
});
html += '</div>';
galleryContainer.innerHTML = html;
galleryContainer.querySelectorAll('.gallery-preview')
.forEach(el => el.addEventListener('click', () => {
window.location.href = el.dataset.url;
}));
}
window.renderGalleryView = renderGalleryView;
});

View File

@@ -402,18 +402,18 @@ class FolderController
* @return void Outputs HTML content. * @return void Outputs HTML content.
*/ */
function formatBytes($bytes) function formatBytes($bytes)
{ {
if ($bytes < 1024) { if ($bytes < 1024) {
return $bytes . " B"; return $bytes . " B";
} elseif ($bytes < 1024 * 1024) { } elseif ($bytes < 1024 * 1024) {
return round($bytes / 1024, 2) . " KB"; return round($bytes / 1024, 2) . " KB";
} elseif ($bytes < 1024 * 1024 * 1024) { } elseif ($bytes < 1024 * 1024 * 1024) {
return round($bytes / (1024 * 1024), 2) . " MB"; return round($bytes / (1024 * 1024), 2) . " MB";
} else { } else {
return round($bytes / (1024 * 1024 * 1024), 2) . " GB"; return round($bytes / (1024 * 1024 * 1024), 2) . " GB";
} }
} }
public function shareFolder(): void public function shareFolder(): void
{ {
@@ -550,13 +550,18 @@ class FolderController
body { body {
background: #f2f2f2; background: #f2f2f2;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
padding: 20px; padding: 0px 20px 20px 20px;
color: #333; color: #333;
} }
.header { .header {
text-align: center; text-align: center;
margin-bottom: 30px; margin-bottom: 30px;
margin-top: 0;
}
.header h1 {
margin-top: 0;
} }
.container { .container {
@@ -661,6 +666,28 @@ class FolderController
font-size: 0.9rem; font-size: 0.9rem;
color: #777; color: #777;
} }
.toggle-btn {
background-color: #007BFF;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 1rem;
cursor: pointer;
}
.toggle-btn:hover {
background-color: #0056b3;
}
.pagination a:hover {
background-color: #0056b3;
}
.pagination span {
cursor: default;
}
</style> </style>
</head> </head>
@@ -670,7 +697,7 @@ class FolderController
</div> </div>
<div class="container"> <div class="container">
<!-- Toggle Button --> <!-- Toggle Button -->
<button id="toggleBtn" class="toggle-btn" onclick="toggleViewMode()">Switch to Gallery View</button> <button id="toggleBtn" class="toggle-btn">Switch to Gallery View</button>
<!-- List View Container --> <!-- List View Container -->
<div id="listViewContainer"> <div id="listViewContainer">
@@ -757,75 +784,14 @@ class FolderController
<div class="footer"> <div class="footer">
&copy; <?php echo date("Y"); ?> FileRise. All rights reserved. &copy; <?php echo date("Y"); ?> FileRise. All rights reserved.
</div> </div>
<!-- non-executing JSON payload, never blocked by CSP -->
<script> <script type="application/json" id="shared-data">
// (Optional) JavaScript for toggling view modes (list/gallery). {
var viewMode = 'list'; "token": <?php echo json_encode($token, JSON_HEX_TAG); ?>,
window.imageCache = window.imageCache || {}; "files": <?php echo json_encode($files, JSON_HEX_TAG); ?>
var filesData = <?php echo json_encode($files); ?>;
// Use the sharedfolder relative path (from your model), not realFolderPath
// $data['folder'] should be something like "eafwef/testfolder2/test/new folder two"
var rawRelPath = "<?php echo addslashes($data['folder']); ?>";
// Split into segments, encode each segment, then re-join
var folderSegments = rawRelPath
.split('/')
.map(encodeURIComponent)
.join('/');
function renderGalleryView() {
var galleryContainer = document.getElementById("galleryViewContainer");
var html = '<div class="shared-gallery-container">';
filesData.forEach(function(file) {
// Encode the filename too
var fileName = encodeURIComponent(file);
var fileUrl = window.location.origin +
'/uploads/' +
folderSegments +
'/' +
fileName +
'?t=' +
Date.now();
var ext = file.split('.').pop().toLowerCase();
var thumbnail;
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].indexOf(ext) >= 0) {
thumbnail = '<img src="' + fileUrl + '" alt="' + file + '">';
} else {
thumbnail = '<span class="material-icons">insert_drive_file</span>';
}
html +=
'<div class="shared-gallery-card">' +
'<div class="gallery-preview" ' +
'onclick="window.location.href=\'' + fileUrl + '\'" ' +
'style="cursor:pointer;">' +
thumbnail +
'</div>' +
'<div class="gallery-info">' +
'<span class="gallery-file-name">' + file + '</span>' +
'</div>' +
'</div>';
});
html += '</div>';
galleryContainer.innerHTML = html;
}
function toggleViewMode() {
if (viewMode === 'list') {
viewMode = 'gallery';
document.getElementById("listViewContainer").style.display = "none";
renderGalleryView();
document.getElementById("galleryViewContainer").style.display = "block";
document.getElementById("toggleBtn").textContent = "Switch to List View";
} else {
viewMode = 'list';
document.getElementById("galleryViewContainer").style.display = "none";
document.getElementById("listViewContainer").style.display = "block";
document.getElementById("toggleBtn").textContent = "Switch to Gallery View";
}
} }
</script> </script>
<script src="/js/sharedFolderView.js" defer></script>
</body> </body>
</html> </html>

View File

@@ -867,126 +867,123 @@ class UserController
* ) * )
*/ */
public function verifyTOTP() public function verifyTOTP()
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
// Ratelimit // Rate-limit
if (!isset($_SESSION['totp_failures'])) { if (!isset($_SESSION['totp_failures'])) {
$_SESSION['totp_failures'] = 0; $_SESSION['totp_failures'] = 0;
} }
if ($_SESSION['totp_failures'] >= 5) { if ($_SESSION['totp_failures'] >= 5) {
http_response_code(429); http_response_code(429);
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
exit; exit;
} }
// Must be authenticated OR pending login // Must be authenticated OR pending login
if (!((!empty($_SESSION['authenticated'])) || isset($_SESSION['pending_login_user']))) { if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
http_response_code(403); http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
exit; exit;
} }
// CSRF check // CSRF check
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER); $headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfHeader = $headersArr['x-csrf-token'] ?? ''; $csrfHeader = $headersArr['x-csrf-token'] ?? '';
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403); http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']); echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
exit; exit;
} }
// Parse and validate input // Parse & validate input
$inputData = json_decode(file_get_contents("php://input"), true); $inputData = json_decode(file_get_contents("php://input"), true);
$code = trim($inputData['totp_code'] ?? ''); $code = trim($inputData['totp_code'] ?? '');
if (!preg_match('/^\d{6}$/', $code)) { if (!preg_match('/^\d{6}$/', $code)) {
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
exit; exit;
} }
// TFA helper // TFA helper
$tfa = new \RobThree\Auth\TwoFactorAuth( $tfa = new \RobThree\Auth\TwoFactorAuth(
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
'FileRise', 'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
6, );
30,
\RobThree\Auth\Algorithm::Sha1
);
// Pendinglogin flow (first password step passed) // === Pending-login flow (we just came from auth and need to finish login) ===
if (isset($_SESSION['pending_login_user'])) { if (isset($_SESSION['pending_login_user'])) {
$username = $_SESSION['pending_login_user']; $username = $_SESSION['pending_login_user'];
$pendingSecret = $_SESSION['pending_login_secret'] ?? null; $pendingSecret = $_SESSION['pending_login_secret'] ?? null;
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false; $rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
$_SESSION['totp_failures']++; $_SESSION['totp_failures']++;
http_response_code(400); http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
exit; exit;
} }
// === Issue “remember me” token if requested === // Issue “remember me” token if requested
if ($rememberMe) { if ($rememberMe) {
$tokFile = USERS_DIR . 'persistent_tokens.json'; $tokFile = USERS_DIR . 'persistent_tokens.json';
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
$expiry = time() + 30 * 24 * 60 * 60; $expiry = time() + 30 * 24 * 60 * 60;
$all = []; $all = [];
if (file_exists($tokFile)) {
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
$all = json_decode($dec, true) ?: [];
}
$all[$token] = [
'username' => $username,
'expiry' => $expiry,
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
'disableUpload'=> loadUserPermissions($username)['disableUpload']?? false
];
file_put_contents(
$tokFile,
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
LOCK_EX
);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
}
if (file_exists($tokFile)) { // === Finalize login into session exactly as finalizeLogin() would ===
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); session_regenerate_id(true);
$all = json_decode($dec, true) ?: []; $_SESSION['authenticated'] = true;
} $_SESSION['username'] = $username;
$isAdmin = ((int)userModel::getUserRole($username) === 1); $_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
$all[$token] = [ $perms = loadUserPermissions($username);
'username' => $username, $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
'expiry' => $expiry, $_SESSION['readOnly'] = $perms['readOnly'] ?? false;
'isAdmin' => $isAdmin $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
];
file_put_contents(
$tokFile,
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
LOCK_EX
);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); // Clean up pending markers
unset(
$_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'],
$_SESSION['pending_login_remember_me'],
$_SESSION['totp_failures']
);
// Persistent cookie // Send back full login payload
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); echo json_encode([
'status' => 'ok',
// Reissue PHP session cookie 'success' => 'Login successful',
setcookie( 'isAdmin' => $_SESSION['isAdmin'],
session_name(), 'folderOnly' => $_SESSION['folderOnly'],
session_id(), 'readOnly' => $_SESSION['readOnly'],
$expiry, 'disableUpload' => $_SESSION['disableUpload'],
'/', 'username' => $_SESSION['username']
'', ]);
$secure, exit;
true }
);
}
// Finalize login
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
$_SESSION['isAdmin'] = $isAdmin;
$_SESSION['folderOnly'] = loadUserPermissions($username);
// Clean up
unset(
$_SESSION['pending_login_user'],
$_SESSION['pending_login_secret'],
$_SESSION['pending_login_remember_me'],
$_SESSION['totp_failures']
);
echo json_encode(['status' => 'ok', 'message' => 'Login successful']);
exit;
}
// Setup/verification flow (not pending) // Setup/verification flow (not pending)
$username = $_SESSION['username'] ?? ''; $username = $_SESSION['username'] ?? '';

View File

View File

@@ -1,7 +0,0 @@
<IfModule mod_php7.c>
php_flag engine off
</IfModule>
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Options -Indexes

View File

View File

@@ -1,3 +0,0 @@
<Files "users.txt">
Require all denied
</Files>