Compare commits

..

20 Commits

Author SHA1 Message Date
Ryan
7c3ce0803a User Permissions (User Folder, Read Only, Disable Upload) & more changes 2025-04-02 04:49:36 -04:00
Ryan
119aefc209 adjust chunk merging logic 2025-03-31 05:44:19 -04:00
Ryan
52ddf8268f Implemented Video Progress Saving and Resuming 2025-03-31 05:33:52 -04:00
Ryan
8d7187d538 File Tagging and Global Tag Management added 2025-03-31 04:41:55 -04:00
Ryan
394e7ef041 animate merging chunks 2025-03-30 17:31:27 -04:00
Ryan
9c71c46c4e remove chunk folder php RecursiveDirectoryIterator 2025-03-30 16:49:00 -04:00
Ryan
d228dc10b0 Admin panel move user management to top 2025-03-30 15:08:59 -04:00
Ryan
3f1007b1b3 New User Panel with TOTP & change password. Admin Panel added Global OTPAuth URL 2025-03-30 14:44:53 -04:00
Ryan
27de0a9a48 composer dependencies for OIDC 2025-03-29 04:47:21 -04:00
Ryan
051544dc5a New Admin Panel, OIDC Integration & Card dragDrop adjustments 2025-03-29 04:33:10 -04:00
Ryan
89777584cf Merge pull request #5 from BitOlli/patch-1
Readme fixes
2025-03-28 11:59:19 -04:00
BitOlli
ed47e3c3bc Update README.md
fixed PR, typo
2025-03-28 15:30:20 +01:00
BitOlli
edd9094218 Update README.md
corrected docker-compose.yml section on readme.md to have proper YAML indentation
2025-03-28 15:27:33 +01:00
Ryan
3b0083516b Additional audio formats to GalleryView 2025-03-27 13:51:15 -04:00
Ryan
fee3b544dd basic auth 2025-03-27 13:43:51 -04:00
Ryan
99ed05d3de basic auth login 2025-03-27 13:39:47 -04:00
Ryan
32469778dc audio files playback added 2025-03-27 13:14:14 -04:00
Ryan
ecb4ac2c75 update 2025-03-27 11:30:21 -04:00
Ryan
4ae509acd2 Contributing added 2025-03-27 11:28:45 -04:00
Ryan
b1cd4b7bdc contribute md 2025-03-27 11:23:52 -04:00
49 changed files with 3664 additions and 1041 deletions

92
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,92 @@
# Contributing to FileRise
Thank you for your interest in contributing to FileRise! We appreciate your help in making this self-hosted file manager even better.
## Table of Contents
- [Getting Started](#getting-started)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
- [Pull Requests](#pull-requests)
- [Coding Guidelines](#coding-guidelines)
- [Documentation](#documentation)
- [Questions and Support](#questions-and-support)
## Getting Started
1. **Fork the Repository**
Click the **Fork** button on the top-right of the FileRise GitHub page to create your own copy.
2. **Clone Your Fork**
```bash
git clone https://github.com/yourusername/FileRise.git
cd FileRise
```
3. **Set Up a Local Environment**
FileRise runs on a standard LAMP stack. Ensure you have PHP, Apache, and the necessary dependencies installed. For frontend development, Node.js may be required for build tasks if applicable.
4. **Configuration**
Copy any example configuration files (if provided) and adjust them as needed for your local setup.
## Reporting Bugs
If you discover a bug, please open an issue on GitHub and include:
- A clear and descriptive title.
- Detailed steps to reproduce the bug.
- The expected and actual behavior.
- Screenshots or error logs (if applicable).
- Environment details (PHP version, Apache version, OS, etc.).
## Suggesting Enhancements
Have an idea for a new feature or improvement? Before opening a new issue, please check if a similar suggestion already exists. If not, open an issue with:
- A clear description of the enhancement.
- Use cases or examples of how it would be beneficial.
- Any potential drawbacks or alternatives.
## Pull Requests
We welcome pull requests! To submit one, please follow these guidelines:
1. **Create a New Branch**
Always create a feature branch from master.
```bash
git checkout -b feature/your-feature-name
```
2. **Make Your Changes**
Commit your changes with clear, descriptive messages. Make sure your code follows the projects style guidelines.
3. **Write Tests**
If applicable, add tests to cover your changes to help us maintain code quality.
4. **Submit the Pull Request**
Push your branch to your fork and open a pull request against the master branch in the main repository. Provide a detailed description of your changes and why theyre needed.
## Coding Guidelines
- **Code Style:**
Follow the conventions used in the project. Consistent indentation, naming conventions, and clear code organization are key.
- **Documentation:**
Update documentation if your changes affect the usage or configuration of FileRise.
- **Commit Messages:**
Write meaningful commit messages that clearly describe the purpose of your changes.
## Documentation
If you notice any areas in the documentation that need improvement or updating, please feel free to include those changes in your pull requests. Clear documentation is essential for helping others understand and use FileRise.
## Questions and Support
If you have any questions, ideas, or need support, please open an issue or join our discussion on [GitHub Discussions](https://github.com/error311/FileRise/discussions). Were here to help and appreciate your contributions.
---
Thank you for helping to improve FileRise and happy coding!

130
README.md
View File

@@ -34,7 +34,7 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
- **Enhanced File Editing Check:** Files with a Content-Length of 0 KB are now allowed to be edited.
- **Built-in File Preview:**
- Users can quickly preview images, videos, and PDFs directly in modal popups without leaving the page.
- Users can quickly preview images, videos, audio and PDFs directly in modal popups without leaving the page.
- The preview modal supports inline display of images (with proper scaling) and videos with playback controls.
- Navigation (prev/next) within image previews is supported for a seamless browsing experience.
@@ -54,28 +54,42 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
- **Right-Click Context Menu:**
- A custom context menu appears on right-clicking within the file list.
- For multiple selections, options include Delete Selected, Copy Selected, Move Selected, Download Zip, and (if applicable) Extract Zip.
- When exactly one file is selected, additional options (Preview, Edit [if editable], and Rename) are available.
- When exactly one file is selected, additional options (Preview, Edit [if editable], Rename, and Tag File) are available.
- **Keyboard Shortcut for Deletion:**
- A global keydown listener detects Delete/Backspace key presses (when no input is focused) to trigger the delete operation.
- **File Tagging and Global Tag Management:**
- **Context Menu Tagging:**
- Single-file tagging: “Tag File” option in the right-click menu opens a modal to add a tag (with name and color) to the file.
- Multi-file tagging: When multiple files are selected, a “Tag Selected” option opens a multifile tagging modal to apply the same tag to all selected files.
- **Tagging Modals & Custom Dropdown:**
- Dedicated modals provide an interface for adding and updating tags.
- A custom dropdown in each modal displays available global tags with a colored preview and a remove icon.
- **Global Tag Store:**
- Tags are stored globally (persisted in a JSON file) for reuse across files and sessions.
- New tags added to any file are automatically added to the global store.
- Users can remove a global tag directly from the dropdown, which removes it from the available tag list for all files.
- **Unified Search Filtering:**
- The single search box now filters files based on both file names and tag names (caseinsensitive).
- **Folder Management:**
- Organize files into folders and subfolders with the ability to create, rename, and delete folders.
- A dynamic folder tree in the UI allows users to navigate directories easily, and any changes are immediately reflected in real time.
- **Per-Folder Metadata Storage:** Each folder has its own metadata JSON file (e.g., `root_metadata.json`, `FolderName_metadata.json`), and operations (copy/move/rename) update these metadata files accordingly.
- **Intuitive Breadcrumb Navigation:** Clickable breadcrumbs enable users to quickly jump to any parent folder, streamlining navigation across subfolders. Supports drag & drop to move files.
- A dynamic folder tree in the UI allows users to navigate directories easily, with real-time updates.
- **Per-Folder Metadata Storage:** Each folder has its own metadata JSON file (e.g., `root_metadata.json`, `FolderName_metadata.json`), updated with operations like copy/move/rename.
- **Intuitive Breadcrumb Navigation:** Clickable breadcrumbs enable users to quickly jump to any parent folder; supports drag & drop for moving files.
- **Folder Manager Context Menu:**
- Right-clicking on a folder (in the folder tree or breadcrumb) brings up a custom context menu with options for creating, renaming, and deleting folders.
- Right-clicking on a folder brings up a custom context menu with options for creating, renaming, and deleting folders.
- **Keyboard Shortcut for Folder Deletion:**
- A global key listener (Delete/Backspace) is provided to trigger folder deletion (with safeguards to prevent deleting the root folder).
- A global key listener (Delete/Backspace) triggers folder deletion with safeguards to prevent deletion of the root folder.
- **Sorting & Pagination:**
- The file list can be sorted by name, modified date, upload date, file size, or uploader.
- Pagination controls let users navigate through files with selectable page sizes (10, 20, 50, or 100 items per page) and “Prev”/“Next” navigation buttons.
- Files can be sorted by name, modified date, upload date, file size, or uploader.
- Pagination controls let users navigate through files with selectable page sizes (10, 20, 50, or 100 items per page) and “Prev”/“Next” buttons.
- **Share Link Functionality:**
- Generate shareable links for files with configurable expiration times (e.g., 30, 60, 120, 180, 240 minutes, and a 1-day option) and optional password protection.
- Share links are stored in a JSON file with details including the folder, file, expiration timestamp, and hashed password.
- The share endpoint (`share.php`) validates tokens, expiration, and password before serving files (or forcing downloads).
- Generate shareable links for files with configurable expiration times (e.g., 30, 60, 120, 180, 240 minutes, and 1 day) and optional password protection.
- Share links are stored in a JSON file with details including folder, file, expiration timestamp, and hashed password.
- The share endpoint validates tokens, expiration, and password before serving files (or forcing downloads).
- The share URL is configurable via environment variables or auto-detected from the server.
- **User Authentication & Management:**
@@ -83,27 +97,28 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
- Admin users can add or remove users through the interface.
- Passwords are hashed using PHPs `password_hash()` for security.
- All state-changing endpoints include CSRF token validation.
- Change password supported for all users.
- Password change functionality is supported for all users.
- Basic Auth is available for login.
- **Persistent Login (Remember Me) with Encrypted Tokens:**
- Users can remain logged in across sessions securely.
- Persistent tokens are encrypted using AES256CBC before being stored in a JSON file.
- On auto-login, the tokens are decrypted on the server to re-establish user sessions without requiring re-authentication.
- On auto-login, tokens are decrypted on the server to re-establish user sessions without re-authentication.
- **Responsive, Dynamic & Persistent UI:**
- The interface is mobile-friendly and adapts to various screen sizes by hiding non-critical columns on small devices.
- Asynchronous updates (via Fetch API and XMLHttpRequest) keep the UI responsive without full page reloads.
- Persistent settings (such as items per page, dark/light mode preference, folder tree state, and the last open folder) ensure a smooth and customized user experience.
- Persistent settings (such as items per page, dark/light mode preference, folder tree state, and the last open folder) ensure a smooth, customized user experience.
- **Dark Mode/Light Mode:**
- The application automatically adapts to the operating systems theme preference by default and offers a manual toggle.
- The dark mode provides a darker background with lighter text and adjusts UI elements (including the CodeMirror editor) for optimal readability in low-light conditions.
- The light mode maintains a bright interface for well-lit environments.
- The application automatically adapts to the operating systems theme preference by default, with a manual toggle available.
- Dark mode provides a darker background with lighter text, and UI elements (including the CodeMirror editor) are adjusted for optimal readability in low-light conditions.
- Light mode maintains a bright interface suitable for well-lit environments.
- **Server & Security Enhancements:**
- The Apache configuration (or .htaccess files) is set to disable directory indexing (e.g., using `Options -Indexes` in the uploads directory), preventing unauthorized users from viewing directory contents.
- Direct access to sensitive files (e.g., `users.txt`) is restricted through .htaccess rules.
- A proxy download mechanism has been implemented (via endpoints like `download.php` and `downloadZip.php`) so that every file download request goes through a PHP script. This script validates the session and CSRF token before streaming the file, ensuring that even if a file URL is guessed, only authenticated users can access it.
- Administrators are advised to deploy the app on a secure internal network or use the proxy download mechanism for public deployments to further protect file content.
- Apache (or .htaccess) configurations disable directory indexing (e.g., using `Options -Indexes` in the uploads directory), preventing unauthorized file browsing.
- Direct access to sensitive files (e.g., `users.txt`) is restricted via .htaccess rules.
- A proxy download mechanism (via endpoints like `download.php` and `downloadZip.php`) routes all file downloads through PHP, ensuring session and CSRF token validation before file access.
- Administrators are advised to deploy the app on a secure internal network or use the proxy download mechanism for public deployments.
- **Trash Management with Restore & Delete:**
- **Trash Storage & Metadata:**
@@ -114,32 +129,59 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
- Uploader information (and optionally who deleted it)
- Additional metadata (e.g., file type)
- **Restore Functionality:**
- Admins can view trashed files in a modal.
- They can restore individual files (with conflict checks) or restore all files back to their original location.
- Admins can view trashed files in a modal and restore individual or all files back to their original location (with conflict checks).
- **Delete Functionality:**
- Users can permanently delete trashed files via:
- **Delete Selected:** Remove specific files from the Trash and update `trash.json`.
- **Delete All:** Permanently remove every file from the Trash after confirmation.
- **Auto-Purge Mechanism:**
- The system automatically purges (permanently deletes) any files in the Trash older than three days, helping manage storage and prevent the accumulation of outdated files.
- **User Interface:**
- The trash modal displays details such as file name, uploader/deleter, and the trashed date/time.
- Material icons with tooltips visually represent the restore and delete actions.
- The system automatically purges files in the Trash older than three days, managing storage and preventing accumulation of outdated files.
- **Trash UI:**
- The trash modal displays file name, uploader/deleter, and trashed date/time.
- Material icons with tooltips represent restore and delete actions.
- **Drag & Drop Cards with Dedicated Drop Zones:**
- **Sidebar Drop Zone:**
- Cards (such as the upload card or folder management card) can be dragged into a dedicated sidebar drop zone for quick access to frequently used operations.
- Cards (e.g., upload or folder management) can be dragged into a dedicated sidebar drop zone for quick access to frequently used operations.
- The sidebar drop zone expands dynamically to accept drops anywhere within its visual area.
- **Top Bar Drop Zone:**
- A top drop zone is available for reordering or managing cards quickly.
- Dragging a card to the top drop zone provides immediate visual feedback, ensuring a fluid and customizable workflow.
- **Seamless Interaction:**
- Both drop zones support smooth drag and drop interactions with animations and pointer event adjustments to prevent interference, ensuring that cards can be dropped reliably regardless of screen position.
- Both drop zones support smooth drag-and-drop interactions with animations and pointer event adjustments, ensuring reliable card placement regardless of screen position.
## 🔒 Admin Panel, TOTP & OpenID Connect (OIDC) Integration
- **Flexible Authentication:**
- Supports multiple authentication methods including Form-based Login, Basic Auth, OpenID Connect (OIDC), and TOTP-based Two-Factor Authentication.
- Ensures continuous secure access by allowing administrators to disable only two of the available login options at any time.
- **Secure OIDC Authentication:**
- Seamlessly integrates with OIDC providers (e.g., Keycloak, Okta).
- Provides admin-configurable OIDC settings—including Provider URL, Client ID, Client Secret, and Redirect URI.
- Stores all sensitive configurations in an encrypted JSON file.
- **TOTP Two-Factor Authentication:**
- Enhances security by integrating Time-based One-Time Password (TOTP) functionality.
- The new User Panel automatically displays the TOTP setup modal when users enable TOTP, presenting a QR code for easy configuration in authenticator apps.
- Administrators can customize a global OTPAuth URL template for consistent TOTP provisioning across accounts.
- **Dynamic Admin Panel:**
- Features an intuitive interface with Material Icons for quick recognition and access.
- Allows administrators to manage authentication settings, user management, and login methods in real time.
- Includes real-time validation that prevents the accidental disabling of all authentication methods simultaneously.
- User Permissions options
- Folder Only gives user their own root folder
- Read Only makes it so user can only read the files
- Disable upload
---
## Screenshots
**Light mode:**
![Dark Admin Panel](https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-admin-panel.png)
**Light mode:**
![Light Mode](https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/light-mode.png)
@@ -185,9 +227,19 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
2. **Deploy to Your Web Server:**
- Place the project files in your Apache web directory (e.g., `/var/www/html`).
- Ensure PHP 8.1+ is installed along with the required extensions (php-json, php-curl, php-zip, etc.).
- Ensure PHP 8.1+ is installed along with the required extensions (`php-json`, `php-curl`, `php-zip`, etc.).
3. **Directory Setup & Permissions:**
3. **Install Composer Dependencies (Required for OIDC Support):**
- Install Composer if you haven't already ([Installation Guide](https://getcomposer.org/download/)).
- Navigate to the project directory and run:
```bash
composer install
```
- This step will install necessary dependencies like `jumbojett/openid-connect-php` and `phpseclib/phpseclib`.
4. **Directory Setup & Permissions:**
- Create the following directories if they do not exist, and set appropriate permissions:
- `uploads/` for file storage.
- `users/` to store `users.txt` (user authentication data).
@@ -199,11 +251,11 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
chmod -R 775 /var/www/uploads /var/www/users /var/www/metadata
```
4. **Configure Apache:**
5. **Configure Apache:**
- Ensure that directory indexing is disabled (using `Options -Indexes` in your `.htaccess` or Apache configuration).
- Make sure the Apache configuration allows URL rewriting if needed.
5. **Configuration File:**
6. **Configuration File:**
- Open `config.php` and adjust the following constants as necessary:
- `BASE_URL`: Set this to your web apps base URL.
- `UPLOAD_DIR`: Adjust the directory path for uploads.
@@ -220,7 +272,9 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
## Docker Usage
For users who prefer containerization, a Docker image is available
For users who prefer containerization, a Docker image is available.
**Note:** The Docker image already includes Composer dependencies pre-installed (including OIDC support).
### Quickstart
@@ -313,4 +367,8 @@ The `config.php` file contains several key constants that may need adjustment fo
- **Logging & Troubleshooting:**
Check Apache logs (located in `/var/log/apache2/`) for troubleshooting any issues during deployment or operation.
Enjoy using the Multi File Upload Editor! For any issues or contributions, please refer to the [GitHub repository](https://github.com/error311/FileRise).
---
## Contributing
We welcome contributions! Please check out our [Contributing Guidelines](CONTRIBUTING.md) before getting started.

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE;

509
auth.js
View File

@@ -2,23 +2,74 @@ import { sendRequest } from './networkUtils.js';
import { toggleVisibility, showToast, attachEnterKeyListener, showCustomConfirmModal } from './domUtils.js';
import { loadFileList, renderFileTable, displayFilePreview, initFileActions } from './fileManager.js';
import { loadFolderTree } from './folderManager.js';
import {
openTOTPLoginModal,
openUserPanel,
openTOTPModal,
closeTOTPModal,
openAdminPanel,
closeAdminPanel,
setLastLoginData
} from './authModals.js';
/**
* Updates the select element to reflect the stored items-per-page value.
*/
// Production OIDC configuration (override via API as needed)
const currentOIDCConfig = {
providerUrl: "https://your-oidc-provider.com",
clientId: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET",
redirectUri: "https://yourdomain.com/auth.php?oidc=callback",
globalOtpauthUrl: ""
};
window.currentOIDCConfig = currentOIDCConfig;
/* ----------------- Utility Functions ----------------- */
function updateItemsPerPageSelect() {
const selectElem = document.querySelector(".form-control.bottom-select");
if (selectElem) {
const stored = localStorage.getItem("itemsPerPage") || "10";
selectElem.value = stored;
selectElem.value = localStorage.getItem("itemsPerPage") || "10";
}
}
/**
* Updates the UI for an authenticated user.
* This includes showing the main UI panels, attaching key listeners, updating header buttons,
* and displaying admin-only buttons if applicable.
*/
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
const authForm = document.getElementById("authForm");
if (authForm) authForm.style.display = disableFormLogin ? "none" : "block";
const basicAuthLink = document.querySelector("a[href='login_basic.php']");
if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block";
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
if (oidcLoginBtn) oidcLoginBtn.style.display = disableOIDCLogin ? "none" : "inline-block";
}
function updateLoginOptionsUIFromStorage() {
updateLoginOptionsUI({
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true"
});
}
function loadAdminConfigFunc() {
return fetch("getConfig.php", { credentials: "include" })
.then(response => response.json())
.then(config => {
localStorage.setItem("disableFormLogin", config.loginOptions.disableFormLogin);
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/FileRise?issuer=FileRise");
updateLoginOptionsUIFromStorage();
})
.catch(() => {
localStorage.setItem("disableFormLogin", "false");
localStorage.setItem("disableBasicAuth", "false");
localStorage.setItem("disableOIDCLogin", "false");
localStorage.setItem("globalOtpauthUrl", "otpauth://totp/FileRise?issuer=FileRise");
updateLoginOptionsUIFromStorage();
});
}
function insertAfter(newNode, referenceNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}
function updateAuthenticatedUI(data) {
toggleVisibility("loginForm", false);
toggleVisibility("mainOperations", true);
@@ -29,45 +80,79 @@ function updateAuthenticatedUI(data) {
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
document.querySelector(".header-buttons").style.visibility = "visible";
// If admin, show admin-only buttons; otherwise hide them.
if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
}
if (data.username) {
localStorage.setItem("username", data.username);
}
if (typeof data.folderOnly !== "undefined") {
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
}
const headerButtons = document.querySelector(".header-buttons");
const firstButton = headerButtons.firstElementChild;
if (data.isAdmin) {
const addUserBtn = document.getElementById("addUserBtn");
const removeUserBtn = document.getElementById("removeUserBtn");
if (addUserBtn) addUserBtn.style.display = "block";
if (removeUserBtn) removeUserBtn.style.display = "block";
let restoreBtn = document.getElementById("restoreFilesBtn");
if (!restoreBtn) {
restoreBtn = document.createElement("button");
restoreBtn.id = "restoreFilesBtn";
restoreBtn.classList.add("btn", "btn-warning");
// Using a material icon for restore.
restoreBtn.innerHTML = '<i class="material-icons" title="Restore/Delete Trash">restore_from_trash</i>';
const headerButtons = document.querySelector(".header-buttons");
if (headerButtons) {
if (headerButtons.children.length >= 5) {
headerButtons.insertBefore(restoreBtn, headerButtons.children[5]);
if (firstButton) {
insertAfter(restoreBtn, firstButton);
} else {
headerButtons.appendChild(restoreBtn);
}
}
}
restoreBtn.style.display = "block";
let adminPanelBtn = document.getElementById("adminPanelBtn");
if (!adminPanelBtn) {
adminPanelBtn = document.createElement("button");
adminPanelBtn.id = "adminPanelBtn";
adminPanelBtn.classList.add("btn", "btn-info");
adminPanelBtn.innerHTML = '<i class="material-icons" title="Admin Panel">admin_panel_settings</i>';
insertAfter(adminPanelBtn, restoreBtn);
adminPanelBtn.addEventListener("click", openAdminPanel);
} else {
adminPanelBtn.style.display = "block";
}
} else {
const addUserBtn = document.getElementById("addUserBtn");
const removeUserBtn = document.getElementById("removeUserBtn");
if (addUserBtn) addUserBtn.style.display = "none";
if (removeUserBtn) removeUserBtn.style.display = "none";
const restoreBtn = document.getElementById("restoreFilesBtn");
if (restoreBtn) restoreBtn.style.display = "none";
const adminPanelBtn = document.getElementById("adminPanelBtn");
if (adminPanelBtn) adminPanelBtn.style.display = "none";
}
let userPanelBtn = document.getElementById("userPanelBtn");
if (!userPanelBtn) {
userPanelBtn = document.createElement("button");
userPanelBtn.id = "userPanelBtn";
userPanelBtn.classList.add("btn", "btn-user");
userPanelBtn.innerHTML = '<i class="material-icons" title="User Panel">account_circle</i>';
let adminPanelBtn = document.getElementById("adminPanelBtn");
if (adminPanelBtn) {
insertAfter(userPanelBtn, adminPanelBtn);
} else {
const firstButton = headerButtons.firstElementChild;
if (firstButton) {
insertAfter(userPanelBtn, firstButton);
} else {
headerButtons.appendChild(userPanelBtn);
}
}
userPanelBtn.addEventListener("click", openUserPanel);
} else {
userPanelBtn.style.display = "block";
}
updateItemsPerPageSelect();
updateLoginOptionsUIFromStorage();
}
/**
* Checks the user's authentication state and updates the UI accordingly.
* If in setup mode or not authenticated, it shows the proper UI elements.
* When authenticated, it calls updateAuthenticatedUI to handle the UI updates.
*/
function checkAuthentication(showLoginToast = true) {
return sendRequest("checkAuth.php")
.then(data => {
@@ -78,11 +163,14 @@ function checkAuthentication(showLoginToast = true) {
toggleVisibility("mainOperations", false);
document.querySelector(".header-buttons").style.visibility = "hidden";
toggleVisibility("addUserModal", true);
document.getElementById('newUsername').focus();
document.getElementById("newUsername").focus();
return false;
}
window.setupMode = false;
if (data.authenticated) {
if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
}
updateAuthenticatedUI(data);
return data;
} else {
@@ -95,44 +183,22 @@ function checkAuthentication(showLoginToast = true) {
return false;
}
})
.catch(error => {
console.error("Error checking authentication:", error);
return false;
});
.catch(() => false);
}
/**
* Initializes authentication by checking the user's state and setting up event listeners.
* The UI will update automatically based on the auth state.
*/
function initAuth() {
checkAuthentication(false).catch(error => {
console.error("Error checking authentication:", error);
});
// Attach login event listener.
const authForm = document.getElementById("authForm");
if (authForm) {
authForm.addEventListener("submit", function (event) {
event.preventDefault();
const rememberMe = document.getElementById("rememberMeCheckbox")
? document.getElementById("rememberMeCheckbox").checked
: false;
const formData = {
username: document.getElementById("loginUsername").value.trim(),
password: document.getElementById("loginPassword").value.trim(),
remember_me: rememberMe
};
sendRequest("auth.php", "POST", formData, { "X-CSRF-Token": window.csrfToken })
.then(data => {
if (data.success) {
console.log("✅ Login successful. Reloading page.");
sessionStorage.setItem("welcomeMessage", "Welcome back, " + formData.username + "!");
/* ----------------- Authentication Submission ----------------- */
function submitLogin(data) {
setLastLoginData(data);
sendRequest("auth.php", "POST", data, { "X-CSRF-Token": window.csrfToken })
.then(response => {
if (response.success) {
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
window.location.reload();
} else {
if (data.error && data.error.includes("Too many failed login attempts")) {
showToast(data.error);
const loginButton = authForm.querySelector("button[type='submit']");
} else if (response.totp_required) {
openTOTPLoginModal();
} else if (response.error && response.error.includes("Too many failed login attempts")) {
showToast(response.error);
const loginButton = document.getElementById("authForm").querySelector("button[type='submit']");
if (loginButton) {
loginButton.disabled = true;
setTimeout(() => {
@@ -141,174 +207,21 @@ function initAuth() {
}, 30 * 60 * 1000);
}
} else {
showToast("Login failed: " + (data.error || "Unknown error"));
}
showToast("Login failed: " + (response.error || "Unknown error"));
}
})
.catch(error => console.error("❌ Error logging in:", error));
});
}
// Attach logout event listener.
document.getElementById("logoutBtn").addEventListener("click", function () {
fetch("logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
})
.then(() => window.location.reload(true))
.catch(error => console.error("Logout error:", error));
});
// Add User functionality.
document.getElementById("addUserBtn").addEventListener("click", function () {
resetUserForm();
toggleVisibility("addUserModal", true);
document.getElementById('newUsername').focus();
});
document.getElementById("saveUserBtn").addEventListener("click", function () {
const newUsername = document.getElementById("newUsername").value.trim();
const newPassword = document.getElementById("addUserPassword").value.trim();
const isAdmin = document.getElementById("isAdmin").checked;
if (!newUsername || !newPassword) {
showToast("Username and password are required!");
return;
}
let url = "addUser.php";
if (window.setupMode) {
url += "?setup=1";
}
fetch(url, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast("User added successfully!");
closeAddUserModal();
// Re-check auth state to update the UI after adding a user.
checkAuthentication(false);
} else {
showToast("Error: " + (data.error || "Could not add user"));
}
})
.catch(error => console.error("Error adding user:", error));
});
document.getElementById("cancelUserBtn").addEventListener("click", function () {
closeAddUserModal();
});
// Remove User functionality.
document.getElementById("removeUserBtn").addEventListener("click", function () {
loadUserList();
toggleVisibility("removeUserModal", true);
});
document.getElementById("deleteUserBtn").addEventListener("click", async function () {
const selectElem = document.getElementById("removeUsernameSelect");
const usernameToRemove = selectElem.value;
if (!usernameToRemove) {
showToast("Please select a user to remove.");
return;
}
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
if (!confirmed) {
return;
}
fetch("removeUser.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ username: usernameToRemove })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast("User removed successfully!");
closeRemoveUserModal();
loadUserList();
} else {
showToast("Error: " + (data.error || "Could not remove user"));
}
})
.catch(error => console.error("Error removing user:", error));
});
document.getElementById("cancelRemoveUserBtn").addEventListener("click", function () {
closeRemoveUserModal();
});
document.getElementById("changePasswordBtn").addEventListener("click", function () {
document.getElementById("changePasswordModal").style.display = "block";
document.getElementById("oldPassword").focus();
});
document.getElementById("closeChangePasswordModal").addEventListener("click", function () {
document.getElementById("changePasswordModal").style.display = "none";
});
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
const oldPassword = document.getElementById("oldPassword").value.trim();
const newPassword = document.getElementById("newPassword").value.trim();
const confirmPassword = document.getElementById("confirmPassword").value.trim();
if (!oldPassword || !newPassword || !confirmPassword) {
showToast("Please fill in all fields.");
return;
}
if (newPassword !== confirmPassword) {
showToast("New passwords do not match.");
return;
}
const data = { oldPassword, newPassword, confirmPassword };
fetch("changePassword.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showToast(result.success);
document.getElementById("oldPassword").value = "";
document.getElementById("newPassword").value = "";
document.getElementById("confirmPassword").value = "";
document.getElementById("changePasswordModal").style.display = "none";
} else {
showToast("Error: " + (result.error || "Could not change password."));
}
})
.catch(error => {
console.error("Error changing password:", error);
showToast("Error changing password.");
});
.catch(() => {
showToast("Login failed: Unknown error");
});
}
window.submitLogin = submitLogin;
/* ----------------- Other Helpers and Initialization ----------------- */
window.changeItemsPerPage = function (value) {
localStorage.setItem("itemsPerPage", value);
const folder = window.currentFolder || "root";
if (typeof renderFileTable === "function") {
renderFileTable(folder);
}
if (typeof renderFileTable === "function") renderFileTable(window.currentFolder || "root");
};
document.addEventListener("DOMContentLoaded", function () {
updateItemsPerPageSelect();
});
function resetUserForm() {
document.getElementById("newUsername").value = "";
document.getElementById("addUserPassword").value = "";
@@ -342,7 +255,151 @@ function loadUserList() {
closeRemoveUserModal();
}
})
.catch(error => console.error("Error loading user list:", error));
.catch(() => { });
}
window.loadUserList = loadUserList;
function initAuth() {
checkAuthentication(false);
loadAdminConfigFunc();
const authForm = document.getElementById("authForm");
if (authForm) {
authForm.addEventListener("submit", function (event) {
event.preventDefault();
const rememberMe = document.getElementById("rememberMeCheckbox")
? document.getElementById("rememberMeCheckbox").checked
: false;
const formData = {
username: document.getElementById("loginUsername").value.trim(),
password: document.getElementById("loginPassword").value.trim(),
remember_me: rememberMe
};
submitLogin(formData);
});
}
document.getElementById("logoutBtn").addEventListener("click", function () {
fetch("logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
}).then(() => window.location.reload(true)).catch(() => { });
});
document.getElementById("addUserBtn").addEventListener("click", function () {
resetUserForm();
toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus();
});
document.getElementById("saveUserBtn").addEventListener("click", function () {
const newUsername = document.getElementById("newUsername").value.trim();
const newPassword = document.getElementById("addUserPassword").value.trim();
const isAdmin = document.getElementById("isAdmin").checked;
if (!newUsername || !newPassword) {
showToast("Username and password are required!");
return;
}
let url = "addUser.php";
if (window.setupMode) url += "?setup=1";
fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast("User added successfully!");
closeAddUserModal();
checkAuthentication(false);
} else {
showToast("Error: " + (data.error || "Could not add user"));
}
})
.catch(() => { });
});
document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal);
document.getElementById("removeUserBtn").addEventListener("click", function () {
loadUserList();
toggleVisibility("removeUserModal", true);
});
document.getElementById("deleteUserBtn").addEventListener("click", async function () {
const selectElem = document.getElementById("removeUsernameSelect");
const usernameToRemove = selectElem.value;
if (!usernameToRemove) {
showToast("Please select a user to remove.");
return;
}
const confirmed = await showCustomConfirmModal("Are you sure you want to delete user " + usernameToRemove + "?");
if (!confirmed) return;
fetch("removeUser.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ username: usernameToRemove })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast("User removed successfully!");
closeRemoveUserModal();
loadUserList();
} else {
showToast("Error: " + (data.error || "Could not remove user"));
}
})
.catch(() => { });
});
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
document.getElementById("changePasswordBtn").addEventListener("click", function () {
document.getElementById("changePasswordModal").style.display = "block";
document.getElementById("oldPassword").focus();
});
document.getElementById("closeChangePasswordModal").addEventListener("click", function () {
document.getElementById("changePasswordModal").style.display = "none";
});
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
const oldPassword = document.getElementById("oldPassword").value.trim();
const newPassword = document.getElementById("newPassword").value.trim();
const confirmPassword = document.getElementById("confirmPassword").value.trim();
if (!oldPassword || !newPassword || !confirmPassword) {
showToast("Please fill in all fields.");
return;
}
if (newPassword !== confirmPassword) {
showToast("New passwords do not match.");
return;
}
const data = { oldPassword, newPassword, confirmPassword };
fetch("changePassword.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showToast(result.success);
document.getElementById("oldPassword").value = "";
document.getElementById("newPassword").value = "";
document.getElementById("confirmPassword").value = "";
document.getElementById("changePasswordModal").style.display = "none";
} else {
showToast("Error: " + (result.error || "Could not change password."));
}
})
.catch(() => { showToast("Error changing password."); });
});
}
document.addEventListener("DOMContentLoaded", function () {
updateItemsPerPageSelect();
updateLoginOptionsUI({
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true"
});
});
export { initAuth, checkAuthentication };

184
auth.php
View File

@@ -1,19 +1,105 @@
<?php
require 'config.php';
require_once 'vendor/autoload.php';
require_once 'config.php';
header('Content-Type: application/json');
/**
* Helper: Get the user's role from users.txt.
*/
function getUserRole($username) {
$usersFile = USERS_DIR . USERS_FILE;
if (file_exists($usersFile)) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(":", trim($line));
if (count($parts) >= 3 && $parts[0] === $username) {
return trim($parts[2]);
}
}
}
return null;
}
/* --- OIDC Authentication Flow --- */
if (isset($_GET['oidc'])) {
// Read and decrypt OIDC configuration from JSON file.
$adminConfigFile = USERS_DIR . 'adminConfig.json';
if (file_exists($adminConfigFile)) {
$encryptedContent = file_get_contents($adminConfigFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
if ($decryptedContent === false) {
// Log internal error and return a generic message.
error_log("Failed to decrypt admin configuration.");
echo json_encode(['error' => 'Internal error.']);
exit;
}
$adminConfig = json_decode($decryptedContent, true);
if (isset($adminConfig['oidc'])) {
$oidcConfig = $adminConfig['oidc'];
$oidc_provider_url = !empty($oidcConfig['providerUrl']) ? $oidcConfig['providerUrl'] : 'https://your-oidc-provider.com';
$oidc_client_id = !empty($oidcConfig['clientId']) ? $oidcConfig['clientId'] : 'YOUR_CLIENT_ID';
$oidc_client_secret = !empty($oidcConfig['clientSecret']) ? $oidcConfig['clientSecret'] : 'YOUR_CLIENT_SECRET';
$oidc_redirect_uri = !empty($oidcConfig['redirectUri']) ? $oidcConfig['redirectUri'] : 'https://yourdomain.com/auth.php?oidc=callback';
} else {
$oidc_provider_url = 'https://your-oidc-provider.com';
$oidc_client_id = 'YOUR_CLIENT_ID';
$oidc_client_secret = 'YOUR_CLIENT_SECRET';
$oidc_redirect_uri = 'https://yourdomain.com/auth.php?oidc=callback';
}
} else {
$oidc_provider_url = 'https://your-oidc-provider.com';
$oidc_client_id = 'YOUR_CLIENT_ID';
$oidc_client_secret = 'YOUR_CLIENT_SECRET';
$oidc_redirect_uri = 'https://yourdomain.com/auth.php?oidc=callback';
}
$oidc = new Jumbojett\OpenIDConnectClient(
$oidc_provider_url,
$oidc_client_id,
$oidc_client_secret
);
$oidc->setRedirectURL($oidc_redirect_uri);
if ($_GET['oidc'] === 'callback') {
try {
$oidc->authenticate();
$username = $oidc->requestUserInfo('preferred_username');
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
// Determine the user role from users.txt.
$userRole = getUserRole($username);
$_SESSION["isAdmin"] = ($userRole === "1");
// *** Use loadUserPermissions() here instead of loadFolderPermission() ***
$_SESSION["folderOnly"] = loadUserPermissions($username);
header("Location: index.html");
exit();
} catch (Exception $e) {
error_log("OIDC authentication error: " . $e->getMessage());
echo json_encode(["error" => "Authentication failed."]);
exit();
}
} else {
try {
$oidc->authenticate();
exit();
} catch (Exception $e) {
error_log("OIDC initiation error: " . $e->getMessage());
echo json_encode(["error" => "Authentication initiation failed."]);
exit();
}
}
}
/* --- Fallback: Form-based Authentication --- */
// (Form-based branch code remains unchanged. It calls loadUserPermissions() in its basic auth branch.)
$usersFile = USERS_DIR . USERS_FILE;
// --- Brute Force Protection Settings ---
$maxAttempts = 5;
$lockoutTime = 30 * 60; // 30 minutes in seconds
$attemptsFile = USERS_DIR . 'failed_logins.json'; // JSON file for tracking failed login attempts
$failedLogFile = USERS_DIR . 'failed_login.log'; // Plain text log for fail2ban
// Persistent tokens file for "Remember me"
$lockoutTime = 30 * 60;
$attemptsFile = USERS_DIR . 'failed_logins.json';
$failedLogFile = USERS_DIR . 'failed_login.log';
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
// Load failed attempts data from file.
function loadFailedAttempts($file) {
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true);
@@ -24,83 +110,90 @@ function loadFailedAttempts($file) {
return [];
}
// Save failed attempts data to file.
function saveFailedAttempts($file, $data) {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
// Get current IP address.
$ip = $_SERVER['REMOTE_ADDR'];
$currentTime = time();
// Load failed attempts.
$failedAttempts = loadFailedAttempts($attemptsFile);
// Check if this IP is currently locked out.
if (isset($failedAttempts[$ip])) {
$attemptData = $failedAttempts[$ip];
if ($attemptData['count'] >= $maxAttempts && ($currentTime - $attemptData['last_attempt']) < $lockoutTime) {
echo json_encode(["error" => "Too many failed login attempts. Please try again later."]);
exit;
exit();
}
}
// --- Authentication Function ---
function authenticate($username, $password)
{
global $usersFile;
function authenticate($username, $password) {
global $usersFile, $encryptionKey;
if (!file_exists($usersFile)) {
return false;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
list($storedUser, $storedPass, $storedRole) = explode(':', trim($line), 3);
if ($username === $storedUser && password_verify($password, $storedPass)) {
return $storedRole; // Return the user's role
$parts = explode(':', trim($line));
if (count($parts) < 3) continue;
if ($username === $parts[0] && password_verify($password, $parts[1])) {
$result = ['role' => $parts[2]];
if (isset($parts[3]) && !empty($parts[3])) {
$result['totp_secret'] = decryptData($parts[3], $encryptionKey);
} else {
$result['totp_secret'] = null;
}
return $result;
}
}
return false;
}
// Get JSON input.
$data = json_decode(file_get_contents("php://input"), true);
$username = trim($data["username"] ?? "");
$password = trim($data["password"] ?? "");
$rememberMe = isset($data["remember_me"]) && $data["remember_me"] === true;
// Validate input: ensure both fields are provided.
if (!$username || !$password) {
echo json_encode(["error" => "Username and password are required"]);
exit;
exit();
}
// Validate username format.
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
echo json_encode(["error" => "Invalid username format. Only letters, numbers, underscores, dashes, and spaces are allowed."]);
exit;
exit();
}
// Attempt to authenticate the user.
$userRole = authenticate($username, $password);
if ($userRole !== false) {
// On successful login, reset failed attempts for this IP.
$user = authenticate($username, $password);
if ($user !== false) {
if (!empty($user['totp_secret'])) {
if (empty($data['totp_code'])) {
echo json_encode([
"totp_required" => true,
"message" => "TOTP code required"
]);
exit();
} else {
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
$providedCode = trim($data['totp_code']);
if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) {
echo json_encode(["error" => "Invalid TOTP code"]);
exit();
}
}
}
if (isset($failedAttempts[$ip])) {
unset($failedAttempts[$ip]);
saveFailedAttempts($attemptsFile, $failedAttempts);
}
// Regenerate session ID to mitigate session fixation attacks.
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = ($userRole === "1"); // "1" indicates admin
$_SESSION["isAdmin"] = ($user['role'] === "1");
$_SESSION["folderOnly"] = loadUserPermissions($username);
// If "Remember me" is checked, generate a persistent login token.
if ($rememberMe) {
// Generate a secure random token.
$token = bin2hex(random_bytes(32));
$expiry = time() + (30 * 24 * 60 * 60); // 30 days
// Load existing persistent tokens.
$expiry = time() + (30 * 24 * 60 * 60);
$persistentTokens = [];
if (file_exists($persistentTokensFile)) {
$encryptedContent = file_get_contents($persistentTokensFile);
@@ -110,21 +203,23 @@ if ($userRole !== false) {
$persistentTokens = [];
}
}
// Save token along with username, expiry, and admin status.
$persistentTokens[$token] = [
"username" => $username,
"expiry" => $expiry,
"isAdmin" => ($userRole === "1")
"isAdmin" => ($_SESSION["isAdmin"] === true)
];
$encryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
file_put_contents($persistentTokensFile, $encryptedContent, LOCK_EX);
// Set the cookie. (Assuming $secure is defined in config.php.)
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
}
echo json_encode(["success" => "Login successful", "isAdmin" => $_SESSION["isAdmin"]]);
echo json_encode([
"success" => "Login successful",
"isAdmin" => $_SESSION["isAdmin"],
"folderOnly"=> $_SESSION["folderOnly"],
"username" => $_SESSION["username"]
]);
} else {
// On failed login, update failed attempts.
if (isset($failedAttempts[$ip])) {
$failedAttempts[$ip]['count']++;
$failedAttempts[$ip]['last_attempt'] = $currentTime;
@@ -132,11 +227,8 @@ if ($userRole !== false) {
$failedAttempts[$ip] = ['count' => 1, 'last_attempt' => $currentTime];
}
saveFailedAttempts($attemptsFile, $failedAttempts);
// Log the failed attempt to the plain text log for fail2ban.
$logLine = date('Y-m-d H:i:s') . " - Failed login attempt for username: " . $username . " from IP: " . $ip . PHP_EOL;
file_put_contents($failedLogFile, $logLine, FILE_APPEND);
echo json_encode(["error" => "Invalid credentials"]);
}
?>

655
authModals.js Normal file
View File

@@ -0,0 +1,655 @@
import { showToast, toggleVisibility } from './domUtils.js';
import { sendRequest } from './networkUtils.js';
const version = "v1.0.5";
const adminTitle = `Admin Panel <small style="font-size: 12px; color: gray;">${version}</small>`;
let lastLoginData = null;
export function setLastLoginData(data) {
lastLoginData = data;
}
export function openTOTPLoginModal() {
let totpLoginModal = document.getElementById("totpLoginModal");
const isDarkMode = document.body.classList.contains("dark-mode");
const modalBg = isDarkMode ? "#2c2c2c" : "#fff";
const textColor = isDarkMode ? "#e0e0e0" : "#000";
if (!totpLoginModal) {
totpLoginModal = document.createElement("div");
totpLoginModal.id = "totpLoginModal";
totpLoginModal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 3200;
`;
totpLoginModal.innerHTML = `
<div style="background: ${modalBg}; padding: 20px; border-radius: 8px; text-align: center; position: relative; color: ${textColor};">
<span id="closeTOTPLoginModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>Enter TOTP Code</h3>
<input type="text" id="totpLoginInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
</div>
`;
document.body.appendChild(totpLoginModal);
document.getElementById("closeTOTPLoginModal").addEventListener("click", () => {
totpLoginModal.style.display = "none";
});
const totpInput = document.getElementById("totpLoginInput");
totpInput.focus();
totpInput.addEventListener("input", function () {
if (this.value.trim().length === 6 && lastLoginData) {
lastLoginData.totp_code = this.value.trim();
totpLoginModal.style.display = "none";
if (typeof window.submitLogin === "function") {
window.submitLogin(lastLoginData);
}
}
});
} else {
totpLoginModal.style.display = "flex";
const modalContent = totpLoginModal.firstElementChild;
modalContent.style.background = modalBg;
modalContent.style.color = textColor;
}
}
export function openUserPanel() {
const username = localStorage.getItem("username") || "User";
let userPanelModal = document.getElementById("userPanelModal");
const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const modalContentStyles = `
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
color: ${isDarkMode ? "#e0e0e0" : "#000"};
padding: 20px;
max-width: 600px;
width: 90%;
border-radius: 8px;
position: relative;
overflow-y: auto;
max-height: 90vh;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
transform: none;
transition: none;
`;
if (!userPanelModal) {
userPanelModal = document.createElement("div");
userPanelModal.id = "userPanelModal";
userPanelModal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground};
display: flex;
justify-content: center;
align-items: center;
z-index: 3000;
`;
userPanelModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}">
<span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>User Panel (${username})</h3>
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">Change Password</button>
<fieldset style="margin-bottom: 15px;">
<legend>TOTP Settings</legend>
<div class="form-group">
<label for="userTOTPEnabled">Enable TOTP:</label>
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
</div>
</fieldset>
</div>
`;
document.body.appendChild(userPanelModal);
document.getElementById("closeUserPanel").addEventListener("click", () => {
userPanelModal.style.display = "none";
});
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
document.getElementById("changePasswordModal").style.display = "block";
});
const totpCheckbox = document.getElementById("userTOTPEnabled");
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
totpCheckbox.addEventListener("change", function () {
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
const enabled = this.checked;
fetch("updateUserPanel.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_enabled: enabled })
})
.then(r => r.json())
.then(result => {
if (!result.success) {
showToast("Error updating TOTP setting: " + result.error);
} else if (enabled) {
openTOTPModal();
}
})
.catch(() => { showToast("Error updating TOTP setting."); });
});
} else {
userPanelModal.style.backgroundColor = overlayBackground;
const modalContent = userPanelModal.querySelector(".modal-content");
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
}
userPanelModal.style.display = "flex";
}
export function openTOTPModal() {
let totpModal = document.getElementById("totpModal");
const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const modalContentStyles = `
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
color: ${isDarkMode ? "#e0e0e0" : "#000"};
padding: 20px;
max-width: 400px;
width: 90%;
border-radius: 8px;
position: relative;
`;
if (!totpModal) {
totpModal = document.createElement("div");
totpModal.id = "totpModal";
totpModal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground};
display: flex;
justify-content: center;
align-items: center;
z-index: 3100;
`;
totpModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}">
<span id="closeTOTPModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>TOTP Setup</h3>
<p>Scan this QR code with your authenticator app:</p>
<img src="totp_setup.php?csrf=${encodeURIComponent(window.csrfToken)}" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
<br/>
<p>Enter the 6-digit code from your app to confirm setup:</p>
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
<br/><br/>
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">Confirm</button>
</div>
`;
document.body.appendChild(totpModal);
// Bind the X button to call closeTOTPModal with disable=true
document.getElementById("closeTOTPModal").addEventListener("click", () => {
closeTOTPModal(true);
});
// Add event listener for TOTP confirmation
document.getElementById("confirmTOTPBtn").addEventListener("click", function () {
const code = document.getElementById("totpConfirmInput").value.trim();
if (code.length !== 6) {
showToast("Please enter a valid 6-digit code.");
return;
}
// Call the endpoint to verify the TOTP code
fetch("totp_verify.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({ totp_code: code })
})
.then(r => r.json())
.then(result => {
if (result.success) {
showToast("TOTP successfully enabled.");
// On success, close the modal without disabling
closeTOTPModal(false);
} else {
showToast("TOTP verification failed: " + (result.error || "Invalid code."));
}
})
.catch(() => { showToast("Error verifying TOTP code."); });
});
} else {
totpModal.style.display = "flex";
totpModal.style.backgroundColor = overlayBackground;
const modalContent = totpModal.querySelector(".modal-content");
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
}
}
// Updated closeTOTPModal function with a disable parameter
export function closeTOTPModal(disable = true) {
const totpModal = document.getElementById("totpModal");
if (totpModal) totpModal.style.display = "none";
if (disable) {
// Uncheck the Enable TOTP checkbox
const totpCheckbox = document.getElementById("userTOTPEnabled");
if (totpCheckbox) {
totpCheckbox.checked = false;
localStorage.setItem("userTOTPEnabled", "false");
}
// Call endpoint to remove the TOTP secret from the user's record
fetch("totp_disable.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
}
})
.then(r => r.json())
.then(result => {
if (!result.success) {
showToast("Error disabling TOTP setting: " + result.error);
}
})
.catch(() => { showToast("Error disabling TOTP setting."); });
}
}
export function openAdminPanel() {
fetch("getConfig.php", { credentials: "include" })
.then(response => response.json())
.then(config => {
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const modalContentStyles = `
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
color: ${isDarkMode ? "#e0e0e0" : "#000"};
padding: 20px;
max-width: 600px;
width: 90%;
border-radius: 8px;
position: relative;
overflow-y: auto;
max-height: 90vh;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"};
`;
let adminModal = document.getElementById("adminPanelModal");
if (!adminModal) {
adminModal = document.createElement("div");
adminModal.id = "adminPanelModal";
adminModal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground};
display: flex;
justify-content: center;
align-items: center;
z-index: 3000;
`;
// Added a version number next to "Admin Panel"
adminModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}">
<span id="closeAdminPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>
<h3>${adminTitle}</h3>
</h3>
<form id="adminPanelForm">
<fieldset style="margin-bottom: 15px;">
<legend>User Management</legend>
<div style="display: flex; gap: 10px;">
<button type="button" id="adminOpenAddUser" class="btn btn-success">Add User</button>
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger">Remove User</button>
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">User Permissions</button>
</div>
</fieldset>
<fieldset style="margin-bottom: 15px;">
<legend>OIDC Configuration</legend>
<div class="form-group">
<label for="oidcProviderUrl">OIDC Provider URL:</label>
<input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig.providerUrl}" />
</div>
<div class="form-group">
<label for="oidcClientId">OIDC Client ID:</label>
<input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig.clientId}" />
</div>
<div class="form-group">
<label for="oidcClientSecret">OIDC Client Secret:</label>
<input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig.clientSecret}" />
</div>
<div class="form-group">
<label for="oidcRedirectUri">OIDC Redirect URI:</label>
<input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig.redirectUri}" />
</div>
</fieldset>
<fieldset style="margin-bottom: 15px;">
<legend>Global TOTP Settings</legend>
<div class="form-group">
<label for="globalOtpauthUrl">Global OTPAuth URL:</label>
<input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" />
</div>
</fieldset>
<fieldset style="margin-bottom: 15px;">
<legend>Login Options</legend>
<div class="form-group">
<input type="checkbox" id="disableFormLogin" />
<label for="disableFormLogin">Disable Login Form</label>
</div>
<div class="form-group">
<input type="checkbox" id="disableBasicAuth" />
<label for="disableBasicAuth">Disable Basic HTTP Auth</label>
</div>
<div class="form-group">
<input type="checkbox" id="disableOIDCLogin" />
<label for="disableOIDCLogin">Disable OIDC Login</label>
</div>
</fieldset>
<div style="display: flex; justify-content: space-between;">
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">Cancel</button>
<button type="button" id="saveAdminSettings" class="btn btn-primary">Save Settings</button>
</div>
</form>
</div>
`;
document.body.appendChild(adminModal);
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
adminModal.addEventListener("click", (e) => {
if (e.target === adminModal) closeAdminPanel();
});
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
document.getElementById("adminOpenAddUser").addEventListener("click", () => {
toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus();
});
document.getElementById("adminOpenRemoveUser").addEventListener("click", () => {
if (typeof window.loadUserList === "function") {
window.loadUserList();
}
toggleVisibility("removeUserModal", true);
});
// New event binding for the User Permissions button:
document.getElementById("adminOpenUserPermissions").addEventListener("click", () => {
openUserPermissionsModal();
});
document.getElementById("saveAdminSettings").addEventListener("click", () => {
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
if (totalDisabled === 3) {
showToast("At least one login method must remain enabled.");
disableOIDCLoginCheckbox.checked = false;
localStorage.setItem("disableOIDCLogin", "false");
if (typeof window.updateLoginOptionsUI === "function") {
window.updateLoginOptionsUI({
disableFormLogin: disableFormLoginCheckbox.checked,
disableBasicAuth: disableBasicAuthCheckbox.checked,
disableOIDCLogin: disableOIDCLoginCheckbox.checked
});
}
return;
}
const newOIDCConfig = {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
clientId: document.getElementById("oidcClientId").value.trim(),
clientSecret: document.getElementById("oidcClientSecret").value.trim(),
redirectUri: document.getElementById("oidcRedirectUri").value.trim()
};
const disableFormLogin = disableFormLoginCheckbox.checked;
const disableBasicAuth = disableBasicAuthCheckbox.checked;
const disableOIDCLogin = disableOIDCLoginCheckbox.checked;
const globalOtpauthUrl = document.getElementById("globalOtpauthUrl").value.trim();
sendRequest("updateConfig.php", "POST", {
oidc: newOIDCConfig,
disableFormLogin,
disableBasicAuth,
disableOIDCLogin,
globalOtpauthUrl
}, { "X-CSRF-Token": window.csrfToken })
.then(response => {
if (response.success) {
showToast("Settings updated successfully.");
localStorage.setItem("disableFormLogin", disableFormLogin);
localStorage.setItem("disableBasicAuth", disableBasicAuth);
localStorage.setItem("disableOIDCLogin", disableOIDCLogin);
if (typeof window.updateLoginOptionsUI === "function") {
window.updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
}
closeAdminPanel();
} else {
showToast("Error updating settings: " + (response.error || "Unknown error"));
}
})
.catch(() => { });
});
const disableFormLoginCheckbox = document.getElementById("disableFormLogin");
const disableBasicAuthCheckbox = document.getElementById("disableBasicAuth");
const disableOIDCLoginCheckbox = document.getElementById("disableOIDCLogin");
function enforceLoginOptionConstraint(changedCheckbox) {
const totalDisabled = [disableFormLoginCheckbox, disableBasicAuthCheckbox, disableOIDCLoginCheckbox].filter(cb => cb.checked).length;
if (changedCheckbox.checked && totalDisabled === 3) {
showToast("At least one login method must remain enabled.");
changedCheckbox.checked = false;
}
}
disableFormLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
disableBasicAuthCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
disableOIDCLoginCheckbox.addEventListener("change", function () { enforceLoginOptionConstraint(this); });
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
} else {
adminModal.style.backgroundColor = overlayBackground;
const modalContent = adminModal.querySelector(".modal-content");
if (modalContent) {
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
}
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl;
document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId;
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret;
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri;
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/FileRise?issuer=FileRise';
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
adminModal.style.display = "flex";
}
})
.catch(() => {
let adminModal = document.getElementById("adminPanelModal");
if (adminModal) {
adminModal.style.backgroundColor = "rgba(0,0,0,0.5)";
const modalContent = adminModal.querySelector(".modal-content");
if (modalContent) {
modalContent.style.background = "#fff";
modalContent.style.color = "#000";
modalContent.style.border = "1px solid #ccc";
}
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl;
document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId;
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret;
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri;
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/FileRise?issuer=FileRise';
document.getElementById("disableFormLogin").checked = localStorage.getItem("disableFormLogin") === "true";
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
adminModal.style.display = "flex";
} else {
openAdminPanel();
}
});
}
export function closeAdminPanel() {
const adminModal = document.getElementById("adminPanelModal");
if (adminModal) adminModal.style.display = "none";
}
// --- New: User Permissions Modal ---
export function openUserPermissionsModal() {
let userPermissionsModal = document.getElementById("userPermissionsModal");
const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const modalContentStyles = `
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
color: ${isDarkMode ? "#e0e0e0" : "#000"};
padding: 20px;
max-width: 500px;
width: 90%;
border-radius: 8px;
position: relative;
`;
if (!userPermissionsModal) {
userPermissionsModal = document.createElement("div");
userPermissionsModal.id = "userPermissionsModal";
userPermissionsModal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground};
display: flex;
justify-content: center;
align-items: center;
z-index: 3500;
`;
userPermissionsModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}">
<span id="closeUserPermissionsModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span>
<h3>User Permissions</h3>
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">
<!-- User rows will be loaded here -->
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">Cancel</button>
<button type="button" id="saveUserPermissionsBtn" class="btn btn-primary">Save Permissions</button>
</div>
</div>
`;
document.body.appendChild(userPermissionsModal);
document.getElementById("closeUserPermissionsModal").addEventListener("click", () => {
userPermissionsModal.style.display = "none";
});
document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => {
userPermissionsModal.style.display = "none";
});
document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => {
// Collect permissions data from each user row.
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
const permissionsData = [];
rows.forEach(row => {
const username = row.getAttribute("data-username");
const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']");
const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']");
const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']");
permissionsData.push({
username,
folderOnly: folderOnlyCheckbox.checked,
readOnly: readOnlyCheckbox.checked,
disableUpload: disableUploadCheckbox.checked
});
});
// Send the permissionsData to the server.
sendRequest("updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
.then(response => {
if (response.success) {
showToast("User permissions updated successfully.");
userPermissionsModal.style.display = "none";
} else {
showToast("Error updating permissions: " + (response.error || "Unknown error"));
}
})
.catch(() => {
showToast("Error updating permissions.");
});
});
} else {
userPermissionsModal.style.display = "flex";
}
// Load the list of users into the modal.
loadUserPermissionsList();
}
function loadUserPermissionsList() {
const listContainer = document.getElementById("userPermissionsList");
if (!listContainer) return;
listContainer.innerHTML = "";
// First, fetch the current permissions from the server.
fetch("getUserPermissions.php", { credentials: "include" })
.then(response => response.json())
.then(permissionsData => {
// Then, fetch the list of users.
return fetch("getUsers.php", { credentials: "include" })
.then(response => response.json())
.then(usersData => {
const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
if (users.length === 0) {
listContainer.innerHTML = "<p>No users found.</p>";
return;
}
users.forEach(user => {
// Skip admin users.
if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return;
// Use stored permissions if available; otherwise fall back to localStorage defaults.
const defaultPerm = {
folderOnly: localStorage.getItem("folderOnly") === "true",
readOnly: localStorage.getItem("readOnly") === "true",
disableUpload: localStorage.getItem("disableUpload") === "true"
};
const userPerm = (permissionsData && typeof permissionsData === "object" && permissionsData[user.username]) || defaultPerm;
// Create a row for the user.
const row = document.createElement("div");
row.classList.add("user-permission-row");
row.setAttribute("data-username", user.username);
row.style.padding = "10px 0";
row.innerHTML = `
<div style="font-weight: bold; margin-bottom: 5px;">${user.username}</div>
<div style="display: flex; flex-direction: column; gap: 5px;">
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} />
User Folder Only
</label>
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} />
Read Only
</label>
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} />
Disable Upload
</label>
</div>
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;">
`;
listContainer.appendChild(row);
});
});
})
.catch(() => {
listContainer.innerHTML = "<p>Error loading users.</p>";
});
}

View File

@@ -1,10 +1,8 @@
<?php
// changePassword.php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
// Make sure the user is logged in.
session_start();
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
exit;
@@ -54,7 +52,19 @@ $userFound = false;
$newLines = [];
foreach ($lines as $line) {
list($storedUser, $storedHash, $storedRole) = explode(':', trim($line));
$parts = explode(':', trim($line));
// Expect at least 3 parts: username, hashed password, and role.
if (count($parts) < 3) {
// Skip invalid lines.
$newLines[] = $line;
continue;
}
$storedUser = $parts[0];
$storedHash = $parts[1];
$storedRole = $parts[2];
// Preserve TOTP secret if it exists.
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
if ($storedUser === $username) {
$userFound = true;
// Verify the old password.
@@ -64,8 +74,12 @@ foreach ($lines as $line) {
}
// Hash the new password.
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
// Rebuild the line with the new hash.
// Rebuild the line with the new hash and preserve TOTP secret if present.
if ($totpSecret !== "") {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
} else {
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole;
}
} else {
$newLines[] = $line;
}

View File

@@ -1,22 +1,70 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
// Check if users.txt is empty or doesn't exist
// Check if users.txt is empty or doesn't exist.
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') {
// Return JSON indicating setup mode
// In production, you might log that the system is in setup mode.
error_log("checkAuth: users file not found or empty; entering setup mode.");
echo json_encode(["setup" => true]);
exit();
}
// Check session authentication.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["authenticated" => false]);
exit;
exit();
}
echo json_encode([
/**
* Helper function to get a user's role from users.txt.
* Returns the role as a string (e.g. "1") or null if not found.
*/
function getUserRole($username) {
global $usersFile;
if (file_exists($usersFile)) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(":", trim($line));
if (count($parts) >= 3 && $parts[0] === $username) {
return trim($parts[2]);
}
}
}
return null;
}
// Determine if TOTP is enabled by checking users.txt.
$totp_enabled = false;
$username = $_SESSION['username'] ?? '';
if ($username) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(":", trim($line));
// Assuming first field is username and fourth (if exists) is the TOTP secret.
if ($parts[0] === $username) {
if (isset($parts[3]) && trim($parts[3]) !== "") {
$totp_enabled = true;
}
break;
}
}
}
// Use getUserRole() to determine admin status.
// We cast the role to an integer so that "1" (string) is treated as true.
$userRole = getUserRole($username);
$isAdmin = ((int)$userRole === 1);
// Build and return the JSON response.
$response = [
"authenticated" => true,
"isAdmin" => isset($_SESSION["isAdmin"]) ? $_SESSION["isAdmin"] : false
]);
"isAdmin" => $isAdmin,
"totp_enabled" => $totp_enabled,
"username" => $username,
"folderOnly" => isset($_SESSION["folderOnly"]) ? $_SESSION["folderOnly"] : false
];
echo json_encode($response);
?>

View File

@@ -1,7 +1,7 @@
<?php
// config.php
// Define constants first.
// Define constants.
define('UPLOAD_DIR', '/var/www/uploads/');
define('USERS_DIR', '/var/www/users/');
define('USERS_FILE', 'users.txt');
@@ -12,17 +12,17 @@ define('TIMEZONE', 'America/New_York');
define('DATE_TIME_FORMAT', 'm/d/y h:iA');
define('TOTAL_UPLOAD_SIZE', '5G');
// Set the default timezone.
date_default_timezone_set(TIMEZONE);
/**
* Encrypts data using AES-256-CBC.
*
* @param string $data The plaintext data.
* @param string $encryptionKey The secret encryption key.
* @param string $data The plaintext.
* @param string $encryptionKey The encryption key.
* @return string Base64-encoded string containing IV and ciphertext.
*/
function encryptData($data, $encryptionKey) {
function encryptData($data, $encryptionKey)
{
$cipher = 'AES-256-CBC';
$ivlen = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($ivlen);
@@ -33,11 +33,12 @@ function encryptData($data, $encryptionKey) {
/**
* Decrypts data encrypted with AES-256-CBC.
*
* @param string $encryptedData The Base64-encoded data containing IV and ciphertext.
* @param string $encryptionKey The secret encryption key.
* @param string $encryptedData Base64-encoded data containing IV and ciphertext.
* @param string $encryptionKey The encryption key.
* @return string|false The decrypted plaintext or false on failure.
*/
function decryptData($encryptedData, $encryptionKey) {
function decryptData($encryptedData, $encryptionKey)
{
$cipher = 'AES-256-CBC';
$data = base64_decode($encryptedData);
$ivlen = openssl_cipher_iv_length($cipher);
@@ -46,16 +47,49 @@ function decryptData($encryptedData, $encryptionKey) {
return openssl_decrypt($ciphertext, $cipher, $encryptionKey, OPENSSL_RAW_DATA, $iv);
}
// Load encryption key from an environment variable (default for testing; override in production)
// Load encryption key from environment (override in production).
$encryptionKey = getenv('PERSISTENT_TOKENS_KEY') ?: 'default_please_change_this_key';
if (!$encryptionKey) {
die('Encryption key for persistent tokens is not set.');
}
// Allow an environment variable to override HTTPS detection.
function loadUserPermissions($username)
{
global $encryptionKey; // Ensure $encryptionKey is available
$permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
// Try to decrypt the content.
$decryptedContent = decryptData($content, $encryptionKey);
if ($decryptedContent !== false) {
$permissions = json_decode($decryptedContent, true);
} else {
$permissions = json_decode($content, true);
}
if (!is_array($permissions)) {
} else {
}
if (is_array($permissions) && array_key_exists($username, $permissions)) {
$result = $permissions[$username];
if (empty($result)) {
return false;
}
return $result;
} else {
}
} else {
error_log("loadUserPermissions: Permissions file not found: $permissionsFile");
}
return false; // Return false if no permissions found.
}
// Determine whether HTTPS is used.
$envSecure = getenv('SECURE');
if ($envSecure !== false) {
// Convert the environment variable value to a boolean.
$secure = filter_var($envSecure, FILTER_VALIDATE_BOOLEAN);
} else {
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
@@ -64,22 +98,23 @@ if ($envSecure !== false) {
$cookieParams = [
'lifetime' => 7200,
'path' => '/',
'domain' => '', // Specify your domain if needed
'domain' => '', // Set your domain as needed.
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax'
];
session_set_cookie_params($cookieParams);
ini_set('session.gc_maxlifetime', 7200);
session_start();
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params($cookieParams);
ini_set('session.gc_maxlifetime', 7200);
session_start();
}
// Generate CSRF token if not already set.
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Auto-login via persistent token if session is not active.
// Auto-login via persistent token.
if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token'])) {
$persistentTokensFile = USERS_DIR . 'persistent_tokens.json';
$persistentTokens = [];
@@ -91,15 +126,14 @@ if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token']))
$persistentTokens = [];
}
}
if (is_array($persistentTokens) && isset($persistentTokens[$_COOKIE['remember_me_token']])) {
if (isset($persistentTokens[$_COOKIE['remember_me_token']])) {
$tokenData = $persistentTokens[$_COOKIE['remember_me_token']];
if ($tokenData['expiry'] >= time()) {
// Token is valid; auto-authenticate the user.
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $tokenData["username"];
$_SESSION["isAdmin"] = $tokenData["isAdmin"]; // Restore admin status from the token
// IMPORTANT: Set the folderOnly flag here for auto-login.
$_SESSION["folderOnly"] = loadFolderPermission($tokenData["username"]);
} else {
// Token expired; remove it and clear the cookie.
unset($persistentTokens[$_COOKIE['remember_me_token']]);
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
file_put_contents($persistentTokensFile, $newEncryptedContent, LOCK_EX);
@@ -108,11 +142,8 @@ if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token']))
}
}
// Define BASE_URL (this should point to where index.html is, e.g. your uploads directory)
define('BASE_URL', 'http://yourwebsite/uploads/');
// If BASE_URL is still the default placeholder, use the server's HTTP_HOST.
// Otherwise, use BASE_URL and append share.php.
if (strpos(BASE_URL, 'yourwebsite') !== false) {
$defaultShareUrl = isset($_SERVER['HTTP_HOST'])
? "http://" . $_SERVER['HTTP_HOST'] . "/share.php"
@@ -120,6 +151,4 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
} else {
$defaultShareUrl = rtrim(BASE_URL, '/') . "/share.php";
}
define('SHARE_URL', getenv('SHARE_URL') ? getenv('SHARE_URL') : $defaultShareUrl);
?>

View File

@@ -18,6 +18,17 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
exit;
}
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to copy files."]);
exit();
}
}
$data = json_decode(file_get_contents("php://input"), true);
if (
!$data ||

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
// Ensure user is authenticated
@@ -23,6 +23,16 @@ if ($receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
exit;
}
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to create folders."]);
exit();
}
}
// Get the JSON input and decode it
$input = json_decode(file_get_contents('php://input'), true);

View File

@@ -19,6 +19,20 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
exit;
}
// Define $username first.
$username = $_SESSION['username'] ?? '';
// Now load the user's permissions.
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only.
if ($username) {
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to delete files."]);
exit();
}
}
// --- Setup Trash Folder & Metadata ---
$trashDir = rtrim(TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (!file_exists($trashDir)) {

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
// Ensure user is authenticated
@@ -23,6 +23,16 @@ if ($receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
exit;
}
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to delete folders."]);
exit();
}
}
// Get the JSON input and decode it
$input = json_decode(file_get_contents('php://input'), true);

View File

@@ -1,5 +1,4 @@
<?php
session_start();
require_once 'config.php';
header('Content-Type: application/json');

View File

@@ -97,7 +97,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
<i class="material-icons">search</i>
</span>
</div>
<input type="text" id="searchInput" class="form-control" placeholder="Search files..." value="${safeSearchTerm}" aria-describedby="searchIcon">
<input type="text" id="searchInput" class="form-control" placeholder="Search files or tag..." value="${safeSearchTerm}" aria-describedby="searchIcon">
</div>
</div>
<div class="col-12 col-md-4 text-left">
@@ -136,14 +136,16 @@ export function buildFileTableRow(file, folderPath) {
const safeUploader = escapeHTML(file.uploader || "Unknown");
let previewButton = "";
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|ogg)$/i.test(file.name)) {
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
let previewIcon = "";
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">image</i>`;
} else if (/\.(mp4|webm|mov|ogg)$/i.test(file.name)) {
} else if (/\.(mp4|webm|mov)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">videocam</i>`;
} else if (/\.pdf$/i.test(file.name)) {
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
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}')">
${previewIcon}
@@ -155,7 +157,7 @@ export function buildFileTableRow(file, folderPath) {
<td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);">
</td>
<td>${safeFileName}</td>
<td class="file-name-cell">${safeFileName}</td>
<td class="hide-small nowrap">${safeModified}</td>
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
<td class="hide-small nowrap">${safeSize}</td>
@@ -231,86 +233,6 @@ export function toggleRowSelection(event, fileName) {
updateFileActionButtons();
}
export function previewFile(fileUrl, fileName) {
let modal = document.getElementById("filePreviewModal");
if (!modal) {
modal = document.createElement("div");
modal.id = "filePreviewModal";
Object.assign(modal.style, {
display: "none",
position: "fixed",
top: "0",
left: "0",
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0,0,0,0.7)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: "1000"
});
modal.innerHTML = `
<div class="modal-content image-preview-modal-content">
<span id="closeFileModal" class="close-image-modal">&times;</span>
<h4 class="image-modal-header"></h4>
<div class="file-preview-container"></div>
</div>`;
document.body.appendChild(modal);
document.getElementById("closeFileModal").addEventListener("click", function () {
const video = modal.querySelector("video");
if (video) {
video.pause();
video.currentTime = 0;
}
modal.style.display = "none";
});
modal.addEventListener("click", function (e) {
if (e.target === modal) {
const video = modal.querySelector("video");
if (video) {
video.pause();
video.currentTime = 0;
}
modal.style.display = "none";
}
});
}
modal.querySelector("h4").textContent = fileName;
const container = modal.querySelector(".file-preview-container");
container.innerHTML = "";
const extension = fileName.split('.').pop().toLowerCase();
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(fileName)) {
const img = document.createElement("img");
img.src = fileUrl;
img.className = "image-modal-img";
container.appendChild(img);
} else if (extension === "pdf") {
const embed = document.createElement("embed");
const separator = fileUrl.indexOf('?') === -1 ? '?' : '&';
embed.src = fileUrl + separator + 't=' + new Date().getTime();
embed.type = "application/pdf";
embed.style.width = "80vw";
embed.style.height = "80vh";
embed.style.border = "none";
container.appendChild(embed);
} else if (/\.(mp4|webm|mov|ogg)$/i.test(fileName)) {
const video = document.createElement("video");
video.src = fileUrl;
video.controls = true;
video.className = "image-modal-img";
container.appendChild(video);
} else {
container.textContent = "Preview not available for this file type.";
}
modal.style.display = "flex";
}
export function attachEnterKeyListener(modalId, buttonId) {
const modal = document.getElementById(modalId);
if (modal) {

View File

@@ -205,34 +205,42 @@ export function initDragAndDrop() {
header.addEventListener('mousedown', function (e) {
e.preventDefault();
const card = this.closest('.card');
const rect = card.getBoundingClientRect();
const originX = ((e.clientX - rect.left) / rect.width) * 100;
const originY = ((e.clientY - rect.top) / rect.height) * 100;
// Capture the card's initial bounding rectangle once.
const initialRect = card.getBoundingClientRect();
const originX = ((e.clientX - initialRect.left) / initialRect.width) * 100;
const originY = ((e.clientY - initialRect.top) / initialRect.height) * 100;
card.style.transformOrigin = `${originX}% ${originY}%`;
// Store the initial rect so we use it later.
dragTimer = setTimeout(() => {
isDragging = true;
card.classList.add('dragging');
// Disable pointer events on the card so it doesn't block drop detection.
card.style.pointerEvents = 'none';
addTopZoneHighlight();
const sidebar = document.getElementById('sidebarDropArea');
if (sidebar) {
sidebar.classList.add('active');
sidebar.style.display = 'block';
sidebar.classList.add('highlight');
// Force the sidebar to have a tall drop zone while dragging.
sidebar.style.height = '800px';
}
const rect = card.getBoundingClientRect();
initialLeft = rect.left + window.pageXOffset;
initialTop = rect.top + window.pageYOffset;
// Use the stored initialRect rather than recalculating.
initialLeft = initialRect.left + window.pageXOffset;
initialTop = initialRect.top + window.pageYOffset;
offsetX = e.pageX - initialLeft;
offsetY = e.pageY - initialTop;
// Append card to body and fix its dimensions to prevent shrinking.
document.body.appendChild(card);
card.style.position = 'absolute';
card.style.left = initialLeft + 'px';
card.style.top = initialTop + 'px';
card.style.width = rect.width + 'px';
card.style.width = initialRect.width + 'px';
card.style.height = initialRect.height + 'px';
card.style.minWidth = initialRect.width + 'px';
card.style.flexShrink = '0';
card.style.zIndex = '10000';
}, 500);
});
@@ -251,28 +259,24 @@ export function initDragAndDrop() {
document.addEventListener('mouseup', function (e) {
if (isDragging) {
isDragging = false;
// Re-enable pointer events on the card.
card.style.pointerEvents = '';
card.classList.remove('dragging');
removeTopZoneHighlight();
const sidebar = document.getElementById('sidebarDropArea');
if (sidebar) {
sidebar.classList.remove('highlight');
// Remove the forced height once the drag ends.
sidebar.style.height = '';
}
const leftCol = document.getElementById('leftCol');
const rightCol = document.getElementById('rightCol');
let droppedInSidebar = false;
let droppedInTop = false;
// Check if dropped in sidebar drop zone.
const sidebarElem = document.getElementById('sidebarDropArea');
if (sidebarElem) {
// Instead of using elementsFromPoint, use a virtual drop zone.
const rect = sidebarElem.getBoundingClientRect();
// Define a drop zone from the top of the sidebar to 1000px below its top.
const dropZoneBottom = rect.top + 800;
const dropZoneBottom = rect.top + 800; // Virtual drop zone height.
if (
e.clientX >= rect.left &&
e.clientX <= rect.right &&
@@ -281,10 +285,9 @@ export function initDragAndDrop() {
) {
insertCardInSidebar(card, e);
droppedInSidebar = true;
sidebarElem.blur();
}
}
// If not dropped in sidebar, check the top drop zone.
const topRow = document.getElementById('uploadFolderRow');
if (!droppedInSidebar && topRow) {
const rect = topRow.getBoundingClientRect();
@@ -302,53 +305,50 @@ export function initDragAndDrop() {
}
if (container) {
ensureTopZonePlaceholder();
updateTopZoneLayout();
container.appendChild(card);
droppedInTop = true;
container.style.position = 'relative';
card.style.position = 'absolute';
card.style.left = '0px';
// Animate vertical slide/fade.
card.style.transform = 'translateY(30px)';
card.style.opacity = '0';
card.offsetWidth; // Force reflow.
requestAnimationFrame(() => {
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
card.style.transform = 'translateY(0)';
card.style.opacity = '1';
});
// Use computed style to determine container's width.
const containerWidth = parseFloat(window.getComputedStyle(container).width);
card.style.width = "363px";
// Animate the card sliding in.
animateVerticalSlide(card);
// After animation completes, clear the inline width.
setTimeout(() => {
card.style.position = '';
container.style.position = '';
card.style.transition = '';
card.style.transform = '';
card.style.opacity = '';
card.style.width = '';
}, 310);
card.style.removeProperty('width');
}, 210);
}
}
}
if (droppedInSidebar || droppedInTop) {
card.style.position = '';
card.style.left = '';
card.style.top = '';
card.style.zIndex = '';
} else {
// If dropped in neither area, return card to its original container.
if (!droppedInSidebar && !droppedInTop) {
const orig = document.getElementById(card.dataset.originalContainerId);
if (orig) {
orig.appendChild(card);
card.style.position = '';
card.style.left = '';
card.style.top = '';
card.style.zIndex = '';
card.style.width = '';
card.style.removeProperty('width');
}
}
// Clear inline styles from dragging.
[
'position',
'left',
'top',
'z-index',
'height',
'min-width',
'flex-shrink',
'transition',
'transform',
'opacity'
].forEach(prop => card.style.removeProperty(prop));
// For sidebar drops, force width to 100%.
if (droppedInSidebar) {
card.style.width = '100%';
}
updateTopZoneLayout();
updateSidebarVisibility();
}

View File

@@ -17,6 +17,16 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to extract zip files"]);
exit();
}
}
// Read and decode the JSON input.
$rawData = file_get_contents("php://input");

View File

@@ -9,13 +9,14 @@ import {
showToast,
updateRowHighlight,
toggleRowSelection,
attachEnterKeyListener,
previewFile as originalPreviewFile
attachEnterKeyListener
} from './domUtils.js';
export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
import { initTagSearch, openTagModal, openMultiTagModal } from './fileTags.js';
window.itemsPerPage = window.itemsPerPage || 10;
window.currentPage = window.currentPage || 1;
window.viewMode = localStorage.getItem("viewMode") || "table"; // "table" or "gallery"
@@ -153,7 +154,7 @@ function openShareModal(file, folder) {
// ==============================================
// FEATURE: Enhanced Preview Modal with Navigation
// ==============================================
function enhancedPreviewFile(fileUrl, fileName) {
function previewFile(fileUrl, fileName) {
let modal = document.getElementById("filePreviewModal");
if (!modal) {
modal = document.createElement("div");
@@ -178,12 +179,27 @@ function enhancedPreviewFile(fileUrl, fileName) {
</div>`;
document.body.appendChild(modal);
document.getElementById("closeFileModal").addEventListener("click", function () {
modal.style.display = "none";
function closeModal() {
// Pause media elements without resetting currentTime for video elements
const mediaElements = modal.querySelectorAll("video, audio");
mediaElements.forEach(media => {
media.pause();
// Only reset if it's not a video
if (media.tagName.toLowerCase() !== 'video') {
try {
media.currentTime = 0;
} catch(e) {
// Some media types might not support setting currentTime.
}
}
});
modal.style.display = "none";
}
document.getElementById("closeFileModal").addEventListener("click", closeModal);
modal.addEventListener("click", function (e) {
if (e.target === modal) {
modal.style.display = "none";
closeModal();
}
});
}
@@ -252,7 +268,36 @@ function enhancedPreviewFile(fileUrl, fileName) {
video.src = fileUrl;
video.controls = true;
video.className = "image-modal-img";
// Create a unique key for this video (using fileUrl here)
const progressKey = 'videoProgress-' + fileUrl;
// When the video's metadata is loaded, check for saved progress
video.addEventListener("loadedmetadata", () => {
const savedTime = localStorage.getItem(progressKey);
if (savedTime) {
video.currentTime = parseFloat(savedTime);
}
});
// Listen for time updates and save the current time
video.addEventListener("timeupdate", () => {
localStorage.setItem(progressKey, video.currentTime);
});
video.addEventListener("ended", () => {
localStorage.removeItem(progressKey);
});
container.appendChild(video);
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(fileName)) {
const audio = document.createElement("audio");
audio.src = fileUrl;
audio.controls = true;
audio.className = "audio-modal";
audio.style.maxWidth = "80vw";
container.appendChild(audio);
} else {
container.textContent = "Preview not available for this file type.";
}
@@ -260,10 +305,6 @@ function enhancedPreviewFile(fileUrl, fileName) {
modal.style.display = "flex";
}
export function previewFile(fileUrl, fileName) {
enhancedPreviewFile(fileUrl, fileName);
}
// ==============================================
// ORIGINAL FILE MANAGER FUNCTIONS
// ==============================================
@@ -405,17 +446,19 @@ function fileDragStartHandler(event) {
//
export function renderFileTable(folder) {
const fileListContainer = document.getElementById("fileList");
const searchTerm = window.currentSearchTerm || "";
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
let currentPage = window.currentPage || 1;
const filteredFiles = fileData.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Filter files: include a file if its name OR any of its tags include the search term.
const filteredFiles = fileData.filter(file => {
const nameMatch = file.name.toLowerCase().includes(searchTerm);
const tagMatch = file.tags && file.tags.some(tag => tag.name.toLowerCase().includes(searchTerm));
return nameMatch || tagMatch;
});
const totalFiles = filteredFiles.length;
const totalPages = Math.ceil(totalFiles / itemsPerPageSetting);
if (currentPage > totalPages) {
currentPage = totalPages > 0 ? totalPages : 1;
window.currentPage = currentPage;
@@ -428,19 +471,40 @@ export function renderFileTable(folder) {
const topControlsHTML = buildSearchAndPaginationControls({
currentPage,
totalPages,
searchTerm
searchTerm: window.currentSearchTerm || ""
});
let headerHTML = buildFileTableHeader(sortOrder);
const startIndex = (currentPage - 1) * itemsPerPageSetting;
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
let rowsHTML = "<tbody>";
if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach(file => {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
// Build the table row HTML.
let rowHTML = buildFileTableRow(file, folderPath);
// Add a unique id attribute so that tag updates can target this row.
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
// Build tag badges HTML.
let tagBadgesHTML = "";
if (file.tags && file.tags.length > 0) {
tagBadgesHTML = '<div class="tag-badges" style="display:inline-block; margin-left:5px;">';
file.tags.forEach(tag => {
tagBadgesHTML += `<span style="background-color: ${tag.color}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
});
tagBadgesHTML += "</div>";
}
// Insert tag badges into the file name cell.
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
return p1 + p2 + tagBadgesHTML + p3;
});
// Insert share button into the actions cell.
rowHTML = rowHTML.replace(/(<\/div>\s*<\/td>\s*<\/tr>)/, `<button class="share-btn btn btn-sm btn-secondary" data-file="${escapeHTML(file.name)}" title="Share">
<i class="material-icons">share</i>
</button>$1`);
rowsHTML += rowHTML;
});
} else {
@@ -458,12 +522,10 @@ export function renderFileTable(folder) {
window.currentSearchTerm = newSearchInput.value;
window.currentPage = 1;
renderFileTable(folder);
// After rerender, re-select the input element and set focus.
setTimeout(() => {
const freshInput = document.getElementById("searchInput");
if (freshInput) {
freshInput.focus();
// Place the caret at the end of the text.
const len = freshInput.value.length;
freshInput.setSelectionRange(len, len);
}
@@ -505,29 +567,48 @@ export function renderFileTable(folder) {
});
}
//
// --- RENDER GALLERY VIEW ---
//
export function renderGalleryView(folder) {
const fileListContainer = document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
// Filter files using the same logic as table view.
const filteredFiles = fileData.filter(file => {
return file.name.toLowerCase().includes(searchTerm) ||
(file.tags && file.tags.some(tag => tag.name.toLowerCase().includes(searchTerm)));
});
const folderPath = folder === "root"
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
const gridStyle = "display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; padding: 10px;";
let galleryHTML = `<div class="gallery-container" style="${gridStyle}">`;
fileData.forEach((file) => {
filteredFiles.forEach((file) => {
let thumbnail;
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
thumbnail = `<img src="${folderPath + encodeURIComponent(file.name)}?t=${new Date().getTime()}" class="gallery-thumbnail" alt="${escapeHTML(file.name)}" style="max-width: 100%; max-height: 150px; display: block; margin: 0 auto;">`;
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`;
} else {
thumbnail = `<span class="material-icons gallery-icon">insert_drive_file</span>`;
}
// Build tag badges HTML for the gallery view.
let tagBadgesHTML = "";
if (file.tags && file.tags.length > 0) {
tagBadgesHTML = `<div class="tag-badges" style="margin-top:4px;">`;
file.tags.forEach(tag => {
tagBadgesHTML += `<span style="background-color: ${tag.color}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
});
tagBadgesHTML += `</div>`;
}
galleryHTML += `<div class="gallery-card" style="border: 1px solid #ccc; padding: 5px; text-align: center;">
<div class="gallery-preview" style="cursor: pointer;" onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t=' + new Date().getTime(), '${file.name}')">
${thumbnail}
</div>
<div class="gallery-info" style="margin-top: 5px;">
<span class="gallery-file-name" style="display: block;">${escapeHTML(file.name)}</span>
${tagBadgesHTML}
<div class="button-wrap" style="display: flex; justify-content: center; gap: 5px;">
<a class="btn btn-sm btn-success download-btn"
href="download.php?folder=${encodeURIComponent(file.folder || 'root')}&file=${encodeURIComponent(file.name)}"
@@ -549,22 +630,10 @@ export function renderGalleryView(folder) {
</div>
</div>`;
});
galleryHTML += "</div>";
fileListContainer.innerHTML = galleryHTML;
// Re-bind share button events if necessary.
document.querySelectorAll(".gallery-share-btn").forEach(btn => {
btn.addEventListener("click", function (e) {
e.stopPropagation();
const fileName = this.getAttribute("data-file");
const folder = this.getAttribute("data-folder");
const file = fileData.find(f => f.name === fileName);
if (file) {
openShareModal(file, folder);
}
});
});
createViewToggleButton();
updateFileActionButtons();
}
@@ -1438,6 +1507,7 @@ function hideFileContextMenu() {
// Context menu handler for the file list.
function fileListContextMenuHandler(e) {
e.preventDefault();
// If no file is selected, try to select the row that was right-clicked.
let row = e.target.closest("tr");
if (row) {
@@ -1467,11 +1537,20 @@ function fileListContextMenuHandler(e) {
});
}
if (selected.length === 1) {
// Look up the file object.
// If multiple files are selected, add a "Tag Selected" option.
if (selected.length > 1) {
menuItems.push({
label: "Tag Selected",
action: () => {
const files = fileData.filter(f => selected.includes(f.name));
openMultiTagModal(files);
}
});
}
// If exactly one file is selected, add options specific to that file.
else if (selected.length === 1) {
const file = fileData.find(f => f.name === selected[0]);
// Add Preview option.
menuItems.push({
label: "Preview",
action: () => {
@@ -1483,7 +1562,6 @@ function fileListContextMenuHandler(e) {
}
});
// Only show Edit option if file is editable.
if (canEditFile(file.name)) {
menuItems.push({
label: "Edit",
@@ -1491,11 +1569,15 @@ function fileListContextMenuHandler(e) {
});
}
// Add Rename option.
menuItems.push({
label: "Rename",
action: () => { renameFile(selected[0], window.currentFolder); }
});
menuItems.push({
label: "Tag File",
action: () => { openTagModal(file); }
});
}
showFileContextMenu(e.clientX, e.clientY, menuItems);

466
fileTags.js Normal file
View File

@@ -0,0 +1,466 @@
// fileTags.js
// This module provides functions for opening the tag modal,
// adding tags to files (with a global tag store for reuse),
// updating the file row display with tag badges,
// filtering the file list by tag, and persisting tag data.
import { escapeHTML } from './domUtils.js';
export function openTagModal(file) {
// Create the modal element.
let modal = document.createElement('div');
modal.id = 'tagModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Tag File: ${file.name}</h3>
<span id="closeTagModal" style="cursor:pointer; font-size:24px;">&times;</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">Tag Name:</label>
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
<br><br>
<label for="tagColorInput">Tag Color:</label>
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
<!-- Custom tag options will be populated here -->
</div>
<br>
<div style="text-align:right;">
<button id="saveTagBtn" class="btn btn-primary">Save Tag</button>
</div>
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
<!-- Existing tags will be listed here -->
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
updateCustomTagDropdown();
document.getElementById('closeTagModal').addEventListener('click', () => {
modal.remove();
});
updateTagModalDisplay(file);
document.getElementById('tagNameInput').addEventListener('input', (e) => {
updateCustomTagDropdown(e.target.value);
});
document.getElementById('saveTagBtn').addEventListener('click', () => {
const tagName = document.getElementById('tagNameInput').value.trim();
const tagColor = document.getElementById('tagColorInput').value;
if (!tagName) {
alert('Please enter a tag name.');
return;
}
addTagToFile(file, { name: tagName, color: tagColor });
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
document.getElementById('tagNameInput').value = '';
updateCustomTagDropdown();
});
}
/**
* Open a modal to tag multiple files.
* @param {Array} files - Array of file objects to tag.
*/
export function openMultiTagModal(files) {
let modal = document.createElement('div');
modal.id = 'multiTagModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
<span id="closeMultiTagModal" style="cursor:pointer; font-size:24px;">&times;</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">Tag Name:</label>
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
<br><br>
<label for="multiTagColorInput">Tag Color:</label>
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
<!-- Custom tag options will be populated here -->
</div>
<br>
<div style="text-align:right;">
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
updateMultiCustomTagDropdown();
document.getElementById('closeMultiTagModal').addEventListener('click', () => {
modal.remove();
});
document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
updateMultiCustomTagDropdown(e.target.value);
});
document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
const tagName = document.getElementById('multiTagNameInput').value.trim();
const tagColor = document.getElementById('multiTagColorInput').value;
if (!tagName) {
alert('Please enter a tag name.');
return;
}
files.forEach(file => {
addTagToFile(file, { name: tagName, color: tagColor });
updateFileRowTagDisplay(file);
saveFileTags(file);
});
modal.remove();
});
}
/**
* Update the custom dropdown for multi-tag modal.
* Similar to updateCustomTagDropdown but includes a remove icon.
*/
function updateMultiCustomTagDropdown(filterText = "") {
const dropdown = document.getElementById("multiCustomTagDropdown");
if (!dropdown) return;
dropdown.innerHTML = "";
let tags = window.globalTags || [];
if (filterText) {
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (tags.length > 0) {
tags.forEach(tag => {
const item = document.createElement("div");
item.style.cursor = "pointer";
item.style.padding = "5px";
item.style.borderBottom = "1px solid #eee";
// Display colored square and tag name with remove icon.
item.innerHTML = `
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
${escapeHTML(tag.name)}
<span class="global-remove" style="color:red; font-weight:bold; margin-left:5px; cursor:pointer;">×</span>
`;
item.addEventListener("click", function(e) {
if (e.target.classList.contains("global-remove")) return;
document.getElementById("multiTagNameInput").value = tag.name;
document.getElementById("multiTagColorInput").value = tag.color;
});
item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation();
removeGlobalTag(tag.name);
});
dropdown.appendChild(item);
});
} else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
}
}
function updateCustomTagDropdown(filterText = "") {
const dropdown = document.getElementById("customTagDropdown");
if (!dropdown) return;
dropdown.innerHTML = "";
let tags = window.globalTags || [];
if (filterText) {
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (tags.length > 0) {
tags.forEach(tag => {
const item = document.createElement("div");
item.style.cursor = "pointer";
item.style.padding = "5px";
item.style.borderBottom = "1px solid #eee";
item.innerHTML = `
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
${escapeHTML(tag.name)}
<span class="global-remove" style="color:red; font-weight:bold; margin-left:5px; cursor:pointer;">×</span>
`;
item.addEventListener("click", function(e){
if (e.target.classList.contains('global-remove')) return;
document.getElementById("tagNameInput").value = tag.name;
document.getElementById("tagColorInput").value = tag.color;
});
item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation();
removeGlobalTag(tag.name);
});
dropdown.appendChild(item);
});
} else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
}
}
// Update the modal display to show current tags on the file.
function updateTagModalDisplay(file) {
const container = document.getElementById('currentTags');
if (!container) return;
container.innerHTML = '<strong>Current Tags:</strong> ';
if (file.tags && file.tags.length > 0) {
file.tags.forEach(tag => {
const tagElem = document.createElement('span');
tagElem.textContent = tag.name;
tagElem.style.backgroundColor = tag.color;
tagElem.style.color = '#fff';
tagElem.style.padding = '2px 6px';
tagElem.style.marginRight = '5px';
tagElem.style.borderRadius = '3px';
tagElem.style.display = 'inline-block';
tagElem.style.position = 'relative';
const removeIcon = document.createElement('span');
removeIcon.textContent = ' ✕';
removeIcon.style.fontWeight = 'bold';
removeIcon.style.marginLeft = '3px';
removeIcon.style.cursor = 'pointer';
removeIcon.addEventListener('click', (e) => {
e.stopPropagation();
removeTagFromFile(file, tag.name);
});
tagElem.appendChild(removeIcon);
container.appendChild(tagElem);
});
} else {
container.innerHTML += 'None';
}
}
function removeTagFromFile(file, tagName) {
file.tags = file.tags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
}
/**
* Remove a tag from the global tag store.
* This function updates window.globalTags and calls the backend endpoint
* to remove the tag from the persistent store.
*/
function removeGlobalTag(tagName) {
window.globalTags = window.globalTags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
saveGlobalTagRemoval(tagName);
}
// NEW: Save global tag removal to the server.
function saveGlobalTagRemoval(tagName) {
fetch("saveFileTag.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder: "root",
file: "global",
deleteGlobal: true,
tagToDelete: tagName,
tags: []
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log("Global tag removed:", tagName);
if (data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
}
} else {
console.error("Error removing global tag:", data.error);
}
})
.catch(err => {
console.error("Error removing global tag:", err);
});
}
// Global store for reusable tags.
window.globalTags = window.globalTags || [];
if (localStorage.getItem('globalTags')) {
try {
window.globalTags = JSON.parse(localStorage.getItem('globalTags'));
} catch (e) { }
}
// New function to load global tags from the server's persistent JSON.
export function loadGlobalTags() {
fetch("metadata/createdTags.json", { credentials: "include" })
.then(response => {
if (!response.ok) {
// If the file doesn't exist, assume there are no global tags.
return [];
}
return response.json();
})
.then(data => {
window.globalTags = data;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
})
.catch(err => {
console.error("Error loading global tags:", err);
window.globalTags = [];
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
});
}
loadGlobalTags();
// Add (or update) a tag in the file object.
export function addTagToFile(file, tag) {
if (!file.tags) {
file.tags = [];
}
const exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (exists) {
exists.color = tag.color;
} else {
file.tags.push(tag);
}
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (!globalExists) {
window.globalTags.push(tag);
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
}
}
// Update the file row (in table view) to show tag badges.
export function updateFileRowTagDisplay(file) {
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
console.log('Updating tags for rows:', rows);
rows.forEach(row => {
let cell = row.querySelector('.file-name-cell');
if (cell) {
let badgeContainer = cell.querySelector('.tag-badges');
if (!badgeContainer) {
badgeContainer = document.createElement('div');
badgeContainer.className = 'tag-badges';
badgeContainer.style.display = 'inline-block';
badgeContainer.style.marginLeft = '5px';
cell.appendChild(badgeContainer);
}
badgeContainer.innerHTML = '';
if (file.tags && file.tags.length > 0) {
file.tags.forEach(tag => {
const badge = document.createElement('span');
badge.textContent = tag.name;
badge.style.backgroundColor = tag.color;
badge.style.color = '#fff';
badge.style.padding = '2px 4px';
badge.style.marginRight = '2px';
badge.style.borderRadius = '3px';
badge.style.fontSize = '0.8em';
badge.style.verticalAlign = 'middle';
badgeContainer.appendChild(badge);
});
}
}
});
}
export function initTagSearch() {
const searchInput = document.getElementById('searchInput');
if (searchInput) {
let tagSearchInput = document.getElementById('tagSearchInput');
if (!tagSearchInput) {
tagSearchInput = document.createElement('input');
tagSearchInput.id = 'tagSearchInput';
tagSearchInput.placeholder = 'Filter by tag';
tagSearchInput.style.marginLeft = '10px';
tagSearchInput.style.padding = '5px';
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
tagSearchInput.addEventListener('input', () => {
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
if (window.currentFolder) {
renderFileTable(window.currentFolder);
}
});
}
}
}
export function filterFilesByTag(files) {
if (window.currentTagFilter && window.currentTagFilter !== '') {
return files.filter(file => {
if (file.tags && file.tags.length > 0) {
return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
}
return false;
});
}
return files;
}
function updateGlobalTagList() {
const dataList = document.getElementById("globalTagList");
if (dataList) {
dataList.innerHTML = "";
window.globalTags.forEach(tag => {
const option = document.createElement("option");
option.value = tag.name;
dataList.appendChild(option);
});
}
}
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
const folder = file.folder || "root";
const payload = {
folder: folder,
file: file.name,
tags: file.tags
};
if (deleteGlobal && tagToDelete) {
payload.file = "global";
payload.deleteGlobal = true;
payload.tagToDelete = tagToDelete;
}
fetch("saveFileTag.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log("Tags saved:", data);
if (data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
}
} else {
console.error("Error saving tags:", data.error);
}
})
.catch(err => {
console.error("Error saving tags:", err);
});
}

View File

@@ -3,9 +3,9 @@
import { loadFileList } from './fileManager.js';
import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js';
// ----------------------
// Helper Functions (Data/State)
// ----------------------
/* ----------------------
Helper Functions (Data/State)
----------------------*/
// Formats a folder name for display (e.g. adding indentations).
export function formatFolderName(folder) {
@@ -26,7 +26,6 @@ export function formatFolderName(folder) {
function buildFolderTree(folders) {
const tree = {};
folders.forEach(folderPath => {
// Ensure folderPath is a string
if (typeof folderPath !== "string") return;
const parts = folderPath.split('/');
let current = tree;
@@ -40,9 +39,9 @@ function buildFolderTree(folders) {
return tree;
}
// ----------------------
// Folder Tree State (Save/Load)
// ----------------------
/* ----------------------
Folder Tree State (Save/Load)
----------------------*/
function loadFolderTreeState() {
const state = localStorage.getItem("folderTreeState");
return state ? JSON.parse(state) : {};
@@ -59,58 +58,41 @@ function getParentFolder(folder) {
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
}
// ----------------------
// Breadcrumb Functions
// ----------------------
// Render breadcrumb for a normalized folder path.
/* ----------------------
Breadcrumb Functions
----------------------*/
function renderBreadcrumb(normalizedFolder) {
if (normalizedFolder === "root") {
return `<span class="breadcrumb-link" data-folder="root">Root</span>`;
}
if (!normalizedFolder || normalizedFolder === "") return "";
const parts = normalizedFolder.split("/");
let breadcrumbItems = [];
// Always start with "Root".
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="root">Root</span>`);
let cumulative = "";
parts.forEach((part, index) => {
cumulative = index === 0 ? part : cumulative + "/" + part;
// Use the first segment as the root.
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${parts[0]}">${escapeHTML(parts[0])}</span>`);
let cumulative = parts[0];
parts.slice(1).forEach(part => {
cumulative += "/" + part;
breadcrumbItems.push(`<span class="breadcrumb-separator"> / </span>`);
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${cumulative}">${escapeHTML(part)}</span>`);
});
return breadcrumbItems.join('');
}
// Bind click and drag-and-drop events to breadcrumb links.
function bindBreadcrumbEvents() {
const breadcrumbLinks = document.querySelectorAll(".breadcrumb-link");
breadcrumbLinks.forEach(link => {
// Click event for navigation.
link.addEventListener("click", function (e) {
e.stopPropagation();
let folder = this.getAttribute("data-folder");
window.currentFolder = folder;
localStorage.setItem("lastOpenedFolder", folder);
const titleEl = document.getElementById("fileListTitle");
if (folder === "root") {
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
} else {
titleEl.innerHTML = "Files in (" + renderBreadcrumb(folder) + ")";
}
// Expand the folder tree to ensure the target is visible.
expandTreePath(folder);
// Update folder tree selection.
document.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
const targetOption = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (targetOption) {
targetOption.classList.add("selected");
}
// Load the file list.
if (targetOption) targetOption.classList.add("selected");
loadFileList(folder);
// Re-bind breadcrumb events to ensure all links remain active.
bindBreadcrumbEvents();
});
// Drag-and-drop events.
link.addEventListener("dragover", function (e) {
e.preventDefault();
this.classList.add("drop-hover");
@@ -161,19 +143,56 @@ function bindBreadcrumbEvents() {
});
}
// ----------------------
// DOM Building Functions for Folder Tree
// ----------------------
/* ----------------------
Check Current User's Folder-Only Permission
----------------------*/
// This function uses localStorage values (set during login) to determine if the current user is restricted.
// If folderOnly is "true", then the personal folder (i.e. username) is forced as the effective root.
function checkUserFolderPermission() {
const username = localStorage.getItem("username");
console.log("checkUserFolderPermission: username =", username);
if (!username) {
console.warn("No username in localStorage; skipping getUserPermissions fetch.");
return Promise.resolve(false);
}
if (localStorage.getItem("folderOnly") === "true") {
window.userFolderOnly = true;
console.log("checkUserFolderPermission: using localStorage.folderOnly = true");
localStorage.setItem("lastOpenedFolder", username);
window.currentFolder = username;
return Promise.resolve(true);
}
return fetch("getUserPermissions.php", { credentials: "include" })
.then(response => response.json())
.then(permissionsData => {
console.log("checkUserFolderPermission: permissionsData =", permissionsData);
if (permissionsData && permissionsData[username] && permissionsData[username].folderOnly) {
window.userFolderOnly = true;
localStorage.setItem("folderOnly", "true");
localStorage.setItem("lastOpenedFolder", username);
window.currentFolder = username;
return true;
} else {
window.userFolderOnly = false;
localStorage.setItem("folderOnly", "false");
return false;
}
})
.catch(err => {
console.error("Error fetching user permissions:", err);
window.userFolderOnly = false;
return false;
});
}
// Recursively builds HTML for the folder tree as nested <ul> elements.
/* ----------------------
DOM Building Functions for Folder Tree
----------------------*/
function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
const state = loadFolderTreeState();
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
for (const folder in tree) {
// Skip the trash folder (case-insensitive)
if (folder.toLowerCase() === "trash") {
continue;
}
if (folder.toLowerCase() === "trash") continue;
const fullPath = parentPath ? parentPath + "/" + folder : folder;
const hasChildren = Object.keys(tree[folder]).length > 0;
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
@@ -194,7 +213,6 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
return html;
}
// Expands the folder tree along a given normalized path.
function expandTreePath(path) {
const parts = path.split("/");
let cumulative = "";
@@ -219,9 +237,9 @@ function expandTreePath(path) {
});
}
// ----------------------
// Drag & Drop Support for Folder Tree Nodes
// ----------------------
/* ----------------------
Drag & Drop Support for Folder Tree Nodes
----------------------*/
function folderDragOverHandler(event) {
event.preventDefault();
event.currentTarget.classList.add("drop-hover");
@@ -272,32 +290,66 @@ function folderDropHandler(event) {
});
}
// ----------------------
// Main Folder Tree Rendering and Event Binding
// ----------------------
/* ----------------------
Main Folder Tree Rendering and Event Binding
----------------------*/
export async function loadFolderTree(selectedFolder) {
try {
const response = await fetch('getFolderList.php');
// Check if the user has folder-only permission.
await checkUserFolderPermission();
// Determine effective root folder.
const username = localStorage.getItem("username") || "root";
let effectiveRoot = "root";
let effectiveLabel = "(Root)";
if (window.userFolderOnly) {
effectiveRoot = username; // Use the username as the personal root.
effectiveLabel = `(Root)`;
// Force override of any saved folder.
localStorage.setItem("lastOpenedFolder", username);
window.currentFolder = username;
} else {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
}
// Build fetch URL.
let fetchUrl = 'getFolderList.php';
if (window.userFolderOnly) {
fetchUrl += '?restricted=1';
}
console.log("Fetching folder list from:", fetchUrl);
// Fetch folder list from the server.
const response = await fetch(fetchUrl);
if (response.status === 401) {
console.error("Unauthorized: Please log in to view folders.");
showToast("Session expired. Please log in again.");
window.location.href = "logout.php";
return;
}
let folders = await response.json();
// If returned items are objects (with a "folder" property), extract folder paths.
if (Array.isArray(folders) && folders.length && typeof folders[0] === "object" && folders[0].folder) {
folders = folders.map(item => item.folder);
}
// Filter out duplicate "root" entries if present.
folders = folders.filter(folder => folder !== "root");
if (!Array.isArray(folders)) {
console.error("Folder list response is not an array:", folders);
return;
let folderData = await response.json();
console.log("Folder data received:", folderData);
let folders = [];
if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) {
folders = folderData.map(item => item.folder);
} else if (Array.isArray(folderData)) {
folders = folderData;
}
// Remove any global "root" entry.
folders = folders.filter(folder => folder.toLowerCase() !== "root");
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself).
if (window.userFolderOnly && effectiveRoot !== "root") {
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
// Force current folder to be the effective root.
localStorage.setItem("lastOpenedFolder", effectiveRoot);
window.currentFolder = effectiveRoot;
}
localStorage.setItem("lastOpenedFolder", window.currentFolder);
// Render the folder tree.
const container = document.getElementById("folderTreeContainer");
if (!container) {
console.error("Folder tree container not found.");
@@ -305,8 +357,8 @@ export async function loadFolderTree(selectedFolder) {
}
let html = `<div id="rootRow" class="root-row">
<span class="folder-toggle" data-folder="root">[<span class="custom-dash">-</span>]</span>
<span class="folder-option root-folder-option" data-folder="root">(Root)</span>
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
</div>`;
if (folders.length > 0) {
const tree = buildFolderTree(folders);
@@ -314,48 +366,34 @@ export async function loadFolderTree(selectedFolder) {
}
container.innerHTML = html;
// Attach drag-and-drop event listeners to folder nodes.
// Attach drag/drop event listeners.
container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
});
// Determine current folder (normalized).
if (selectedFolder) {
window.currentFolder = selectedFolder;
} else {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
}
localStorage.setItem("lastOpenedFolder", window.currentFolder);
// Update file list title using breadcrumb.
const titleEl = document.getElementById("fileListTitle");
if (window.currentFolder === "root") {
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
} else {
titleEl.innerHTML = "Files in (" + renderBreadcrumb(window.currentFolder) + ")";
}
// Bind breadcrumb events (click and drag/drop).
bindBreadcrumbEvents();
// Load file list.
loadFileList(window.currentFolder);
// Expand tree to current folder.
const folderState = loadFolderTreeState();
if (window.currentFolder !== "root" && folderState[window.currentFolder] !== "none") {
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
expandTreePath(window.currentFolder);
}
// Highlight current folder in folder tree.
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
if (selectedEl) {
container.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
selectedEl.classList.add("selected");
}
// Event binding for folder selection in folder tree.
container.querySelectorAll(".folder-option").forEach(el => {
el.addEventListener("click", function (e) {
e.stopPropagation();
@@ -365,18 +403,12 @@ export async function loadFolderTree(selectedFolder) {
window.currentFolder = selected;
localStorage.setItem("lastOpenedFolder", selected);
const titleEl = document.getElementById("fileListTitle");
if (selected === "root") {
titleEl.innerHTML = "Files in (" + renderBreadcrumb("root") + ")";
} else {
titleEl.innerHTML = "Files in (" + renderBreadcrumb(selected) + ")";
}
// Re-bind breadcrumb events so the new breadcrumb is clickable.
bindBreadcrumbEvents();
loadFileList(selected);
});
});
// Event binding for toggling folders.
const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) {
rootToggle.addEventListener("click", function (e) {
@@ -388,12 +420,12 @@ export async function loadFolderTree(selectedFolder) {
nestedUl.classList.remove("collapsed");
nestedUl.classList.add("expanded");
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
state["root"] = "block";
state[effectiveRoot] = "block";
} else {
nestedUl.classList.remove("expanded");
nestedUl.classList.add("collapsed");
this.textContent = "[+]";
state["root"] = "none";
state[effectiveRoot] = "none";
}
saveFolderTreeState(state);
}
@@ -433,12 +465,10 @@ export function loadFolderList(selectedFolder) {
loadFolderTree(selectedFolder);
}
// ----------------------
// Folder Management (Rename, Delete, Create)
// ----------------------
/* ----------------------
Folder Management (Rename, Delete, Create)
----------------------*/
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
function openRenameFolderModal() {
@@ -450,7 +480,6 @@ function openRenameFolderModal() {
const parts = selectedFolder.split("/");
document.getElementById("newRenameFolderName").value = parts[parts.length - 1];
document.getElementById("renameFolderModal").style.display = "block";
// Focus the input field after a short delay to ensure modal is visible.
setTimeout(() => {
const input = document.getElementById("newRenameFolderName");
input.focus();
@@ -601,8 +630,6 @@ document.getElementById("submitCreateFolder").addEventListener("click", function
});
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
// Function to display the custom context menu at (x, y) with given menu items.
function showFolderManagerContextMenu(x, y, menuItems) {
let menu = document.getElementById("folderManagerContextMenu");
if (!menu) {
@@ -614,8 +641,6 @@ function showFolderManagerContextMenu(x, y, menuItems) {
menu.style.zIndex = "9999";
document.body.appendChild(menu);
}
// Set styles based on dark mode.
if (document.body.classList.contains("dark-mode")) {
menu.style.backgroundColor = "#2c2c2c";
menu.style.border = "1px solid #555";
@@ -625,8 +650,6 @@ function showFolderManagerContextMenu(x, y, menuItems) {
menu.style.border = "1px solid #ccc";
menu.style.color = "#000";
}
// Clear previous items.
menu.innerHTML = "";
menuItems.forEach(item => {
const menuItem = document.createElement("div");
@@ -661,24 +684,16 @@ function hideFolderManagerContextMenu() {
}
}
// Context menu handler for folder tree and breadcrumb items.
function folderManagerContextMenuHandler(e) {
e.preventDefault();
e.stopPropagation();
// Get the closest folder element (either from the tree or breadcrumb).
const target = e.target.closest(".folder-option, .breadcrumb-link");
if (!target) return;
const folder = target.getAttribute("data-folder");
if (!folder) return;
// Update current folder and highlight the selected element.
window.currentFolder = folder;
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
target.classList.add("selected");
// Build context menu items.
const menuItems = [
{
label: "Create Folder",
@@ -696,20 +711,15 @@ function folderManagerContextMenuHandler(e) {
action: () => { openDeleteFolderModal(); }
}
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
}
// Bind contextmenu events to folder tree and breadcrumb elements.
function bindFolderManagerContextMenu() {
// Bind context menu to folder tree container.
const container = document.getElementById("folderTreeContainer");
if (container) {
container.removeEventListener("contextmenu", folderManagerContextMenuHandler);
container.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
}
// Bind context menu to breadcrumb links.
const breadcrumbNodes = document.querySelectorAll(".breadcrumb-link");
breadcrumbNodes.forEach(node => {
node.removeEventListener("contextmenu", folderManagerContextMenuHandler);
@@ -717,31 +727,23 @@ function bindFolderManagerContextMenu() {
});
}
// Hide context menu when clicking elsewhere.
document.addEventListener("click", function () {
hideFolderManagerContextMenu();
});
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("keydown", function (e) {
// Skip if the user is typing in an input, textarea, or contentEditable element.
const tag = e.target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) {
return;
}
// On macOS, "Delete" is typically reported as "Backspace" (keyCode 8)
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
// Ensure a folder is selected and it isn't the root folder.
if (window.currentFolder && window.currentFolder !== "root") {
// Prevent default (avoid navigating back on macOS).
e.preventDefault();
// Call your existing folder delete function.
openDeleteFolderModal();
}
}
});
});
// Call this binding function after rendering the folder tree and breadcrumbs.
bindFolderManagerContextMenu();

36
getConfig.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
$configFile = USERS_DIR . 'adminConfig.json';
if (file_exists($configFile)) {
$encryptedContent = file_get_contents($configFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
if ($decryptedContent === false) {
http_response_code(500);
echo json_encode(['error' => 'Failed to decrypt configuration.']);
exit;
}
// Decode the configuration and ensure globalOtpauthUrl is set
$config = json_decode($decryptedContent, true);
if (!isset($config['globalOtpauthUrl'])) {
$config['globalOtpauthUrl'] = "";
}
echo json_encode($config);
} else {
echo json_encode([
'oidc' => [
'providerUrl' => 'https://your-oidc-provider.com',
'clientId' => 'YOUR_CLIENT_ID',
'clientSecret' => 'YOUR_CLIENT_SECRET',
'redirectUri' => 'https://yourdomain.com/auth.php?oidc=callback'
],
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin' => false
],
'globalOtpauthUrl' => ""
]);
}
?>

View File

@@ -93,9 +93,14 @@ foreach ($files as $file) {
'modified' => $fileDateModified,
'uploaded' => $fileUploadedDate,
'size' => $fileSizeFormatted,
'uploader' => $fileUploader
'uploader' => $fileUploader,
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
];
}
echo json_encode(["files" => $fileList]);
// Load global tags from createdTags.json.
$globalTagsFile = META_DIR . "createdTags.json";
$globalTags = file_exists($globalTagsFile) ? json_decode(file_get_contents($globalTagsFile), true) : [];
echo json_encode(["files" => $fileList, "globalTags" => $globalTags]);
?>

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
// Ensure user is authenticated

47
getUserPermissions.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Check if the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$permissionsFile = USERS_DIR . "userPermissions.json";
$permissionsArray = [];
// Load permissions file if it exists.
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
// Attempt to decrypt the content.
$decryptedContent = decryptData($content, $encryptionKey);
if ($decryptedContent === false) {
// If decryption fails, assume the file is plain JSON.
$permissionsArray = json_decode($content, true);
} else {
$permissionsArray = json_decode($decryptedContent, true);
}
if (!is_array($permissionsArray)) {
$permissionsArray = [];
}
}
// If the user is an admin, return all permissions.
if (isset($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) {
echo json_encode($permissionsArray);
exit;
}
// Otherwise, return only the current user's permissions.
$username = $_SESSION['username'] ?? '';
foreach ($permissionsArray as $storedUsername => $data) {
if (strcasecmp($storedUsername, $username) === 0) {
echo json_encode($data);
exit;
}
}
// If no permissions are found for the current user, return an empty object.
echo json_encode(new stdClass());
?>

View File

@@ -1,24 +1,31 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
$usersFile = USERS_DIR . USERS_FILE;
$users = [];
if (file_exists($usersFile)) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 3) {
// Optionally, validate username format:
// Validate username format:
if (preg_match('/^[A-Za-z0-9_\- ]+$/', $parts[0])) {
$users[] = ["username" => $parts[0]];
$users[] = [
"username" => $parts[0],
"role" => trim($parts[2])
];
}
}
}
}
echo json_encode($users);
?>

View File

@@ -99,7 +99,7 @@
<button id="logoutBtn" title="Logout">
<i class="material-icons">exit_to_app</i>
</button>
<button id="changePasswordBtn" title="Change Password">
<button id="changePasswordBtn" title="Change Password" style="display: none;">
<i class="material-icons">vpn_key</i>
</button>
<div id="restoreFilesModal" class="modal centered-modal" style="display: none;">
@@ -123,10 +123,10 @@
</div>
</div>
</div>
<button id="addUserBtn" title="Add User">
<button id="addUserBtn" title="Add User" style="display: none;">
<i class="material-icons">person_add</i>
</button>
<button id="removeUserBtn" title="Remove User">
<button id="removeUserBtn" title="Remove User" style="display: none;">
<i class="material-icons">person_remove</i>
</button>
<button id="darkModeToggle" class="dark-mode-toggle">Dark Mode</button>
@@ -162,6 +162,14 @@
<label for="rememberMeCheckbox">Remember me</label>
</div>
</form>
<!-- OIDC Login Option -->
<div class="text-center mt-3">
<button id="oidcLoginBtn" class="btn btn-secondary">Login with OIDC</button>
</div>
<!-- Basic HTTP Login Option -->
<div class="text-center mt-3">
<a href="login_basic.php" class="btn btn-secondary">Use Basic HTTP Login</a>
</div>
</div>
</div>
@@ -383,7 +391,6 @@
</div>
</div>
<!-- JavaScript Files -->
<script type="module" src="main.js"></script>
</body>

106
login_basic.php Normal file
View File

@@ -0,0 +1,106 @@
<?php
require_once 'config.php';
$usersFile = USERS_DIR . USERS_FILE; // Make sure the users file path is defined
// Reuse the same authentication function
function authenticate($username, $password)
{
global $usersFile;
if (!file_exists($usersFile)) {
error_log("authenticate(): users file not found");
return false;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
list($storedUser, $storedPass, $storedRole) = explode(':', trim($line), 3);
if ($username === $storedUser && password_verify($password, $storedPass)) {
return $storedRole; // Return the user's role
}
}
error_log("authenticate(): authentication failed for '$username'");
return false;
}
// Define helper function to get a user's role from users.txt
function getUserRole($username) {
global $usersFile;
if (file_exists($usersFile)) {
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(":", trim($line));
if (count($parts) >= 3 && $parts[0] === $username) {
return trim($parts[2]);
}
}
}
return null;
}
// Add the loadFolderPermission function here:
function loadFolderPermission($username) {
global $encryptionKey;
$permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) {
$content = file_get_contents($permissionsFile);
// Try to decrypt the content.
$decryptedContent = decryptData($content, $encryptionKey);
if ($decryptedContent !== false) {
$permissions = json_decode($decryptedContent, true);
} else {
$permissions = json_decode($content, true);
}
if (is_array($permissions)) {
// Use case-insensitive comparison.
foreach ($permissions as $storedUsername => $data) {
if (strcasecmp($storedUsername, $username) === 0 && isset($data['folderOnly'])) {
return (bool)$data['folderOnly'];
}
}
}
}
return false; // Default if not set.
}
// Check if the user has sent HTTP Basic auth credentials.
if (!isset($_SERVER['PHP_AUTH_USER'])) {
header('WWW-Authenticate: Basic realm="FileRise Login"');
header('HTTP/1.0 401 Unauthorized');
echo 'Authorization Required';
exit;
} else {
$username = trim($_SERVER['PHP_AUTH_USER']);
$password = trim($_SERVER['PHP_AUTH_PW']);
// Validate username format (optional)
if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
header('WWW-Authenticate: Basic realm="FileRise Login"');
header('HTTP/1.0 401 Unauthorized');
echo 'Invalid username format';
exit;
}
// Attempt authentication
$roleFromAuth = authenticate($username, $password);
if ($roleFromAuth !== false) {
// Use getUserRole() to determine the user's role from the file
$actualRole = getUserRole($username);
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = ($actualRole === "1");
// Set the folderOnly flag based on userPermissions.json.
$_SESSION["folderOnly"] = loadFolderPermission($username);
// Redirect to the main page (or output JSON for testing)
header("Location: index.html");
exit;
} else {
// Invalid credentials; prompt again
header('WWW-Authenticate: Basic realm="FileRise Login"');
header('HTTP/1.0 401 Unauthorized');
echo 'Invalid credentials';
exit;
}
}
?>

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
// Retrieve headers and check CSRF token.
$headers = array_change_key_case(getallheaders(), CASE_LOWER);

View File

@@ -18,6 +18,7 @@ import { initUpload } from './upload.js';
import { initAuth, checkAuthentication } from './auth.js';
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
import { initDragAndDrop, loadSidebarOrder } from './dragAndDrop.js'
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
function loadCsrfToken() {
fetch('token.php', { credentials: 'include' })
@@ -129,6 +130,7 @@ document.addEventListener("DOMContentLoaded", function () {
checkAuthentication().then(authenticated => {
if (authenticated) {
window.currentFolder = "root";
initTagSearch();
loadFileList(window.currentFolder);
initDragAndDrop();
loadSidebarOrder();

View File

@@ -20,6 +20,16 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
exit;
}
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to move files."]);
exit();
}
}
$data = json_decode(file_get_contents("php://input"), true);
if (

View File

@@ -1,33 +1,31 @@
export function sendRequest(url, method = "GET", data = null) {
console.log("Sending request to:", url, "with method:", method);
export function sendRequest(url, method = "GET", data = null, customHeaders = {}) {
const options = {
method,
credentials: 'include', // include cookies in requests
credentials: 'include',
headers: {}
};
// Merge custom headers
Object.assign(options.headers, customHeaders);
// If data is provided and is not FormData, assume JSON.
if (data && !(data instanceof FormData)) {
if (!options.headers["Content-Type"]) {
options.headers["Content-Type"] = "application/json";
}
options.body = JSON.stringify(data);
} else if (data instanceof FormData) {
// For FormData, don't set the Content-Type header; the browser will handle it.
options.body = data;
}
return fetch(url, options)
.then(response => {
console.log("Response status:", response.status);
if (!response.ok) {
return response.text().then(text => {
throw new Error(`HTTP error ${response.status}: ${text}`);
});
}
// Clone the response so we can safely fall back if JSON parsing fails.
const clonedResponse = response.clone();
return response.json().catch(() => {
console.warn("Response is not JSON, returning as text");
return clonedResponse.text();
});
return response.json().catch(() => clonedResponse.text());
});
}

View File

@@ -18,7 +18,7 @@ if (!isset($_POST['folder'])) {
}
$folder = $_POST['folder'];
// Validate the folder name to allow only expected characters (adjust the regex as needed)
// Validate the folder name (only alphanumerics, dashes allowed)
if (!preg_match('/^resumable_[A-Za-z0-9\-]+$/', $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
http_response_code(400);
@@ -33,27 +33,28 @@ if (!is_dir($tempDir)) {
exit;
}
// Recursively delete directory and its contents
// Recursively delete directory using RecursiveDirectoryIterator
function rrmdir($dir) {
if (is_dir($dir)) {
$objects = scandir($dir);
foreach ($objects as $object) {
if ($object != "." && $object != "..") {
$path = $dir . DIRECTORY_SEPARATOR . $object;
if (is_dir($path) && !is_link($path)) {
rrmdir($path);
if (!is_dir($dir)) {
return;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file) {
if ($file->isDir()){
rmdir($file->getRealPath());
} else {
@unlink($path);
unlink($file->getRealPath());
}
}
}
@rmdir($dir);
}
rmdir($dir);
}
rrmdir($tempDir);
// check if folder still exists
// Verify removal
if (!is_dir($tempDir)) {
echo json_encode(["success" => true, "message" => "Temporary folder removed."]);
} else {

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
$usersFile = USERS_DIR . USERS_FILE;
@@ -72,5 +72,17 @@ if (!$userFound) {
// Write the updated list back to users.txt
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
// Also update the userPermissions.json file
$permissionsFile = USERS_DIR . "userPermissions.json";
if (file_exists($permissionsFile)) {
$permissionsJson = file_get_contents($permissionsFile);
$permissionsArray = json_decode($permissionsJson, true);
if (is_array($permissionsArray) && isset($permissionsArray[$usernameToRemove])) {
unset($permissionsArray[$usernameToRemove]);
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
}
}
echo json_encode(["success" => "User removed successfully"]);
?>

View File

@@ -21,6 +21,16 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
exit;
}
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to rename files."]);
exit();
}
}
$data = json_decode(file_get_contents("php://input"), true);
if (!$data || !isset($data['folder']) || !isset($data['oldName']) || !isset($data['newName'])) {

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache");
@@ -27,6 +27,16 @@ if ($receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
exit;
}
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to rename folders."]);
exit();
}
}
// Get the JSON input and decode it
$input = json_decode(file_get_contents('php://input'), true);

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

View File

@@ -18,6 +18,16 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
exit;
}
$userPermissions = loadUserPermissions($username);
// Check if the user is read-only. (Assuming that if readOnly is true, deletion is disallowed.)
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
echo json_encode(["error" => "Read-only users are not allowed to save files."]);
exit();
}
}
$data = json_decode(file_get_contents("php://input"), true);

138
saveFileTag.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
require_once 'config.php';
header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache");
header("Expires: 0");
header('Content-Type: application/json');
// Check authentication.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
http_response_code(401);
exit;
}
// CSRF Protection: validate token from header.
$headers = getallheaders();
if (!isset($headers['X-CSRF-Token']) || $headers['X-CSRF-Token'] !== $_SESSION['csrf_token']) {
echo json_encode(["error" => "Invalid CSRF token."]);
http_response_code(403);
exit;
}
// Retrieve and sanitize input.
$data = json_decode(file_get_contents('php://input'), true);
$file = isset($data['file']) ? trim($data['file']) : '';
$folder = isset($data['folder']) ? trim($data['folder']) : 'root';
$tags = isset($data['tags']) ? $data['tags'] : [];
// Basic validation.
if ($file === '') {
echo json_encode(["error" => "No file specified."]);
exit;
}
$globalTagsFile = META_DIR . "createdTags.json";
// If file is "global", update the global tags only.
if ($file === "global") {
if (!file_exists($globalTagsFile)) {
if (file_put_contents($globalTagsFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
echo json_encode(["error" => "Failed to create global tags file."]);
exit;
}
}
$globalTags = json_decode(file_get_contents($globalTagsFile), true);
if (!is_array($globalTags)) {
$globalTags = [];
}
// If deleteGlobal flag is set and tagToDelete is provided, remove it.
if (isset($data['deleteGlobal']) && $data['deleteGlobal'] === true && isset($data['tagToDelete'])) {
$tagToDelete = strtolower($data['tagToDelete']);
$globalTags = array_values(array_filter($globalTags, function($globalTag) use ($tagToDelete) {
return strtolower($globalTag['name']) !== $tagToDelete;
}));
} else {
// Otherwise, merge new tags.
foreach ($tags as $tag) {
$found = false;
foreach ($globalTags as &$globalTag) {
if (strtolower($globalTag['name']) === strtolower($tag['name'])) {
$globalTag['color'] = $tag['color'];
$found = true;
break;
}
}
if (!$found) {
$globalTags[] = $tag;
}
}
}
if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT)) === false) {
echo json_encode(["error" => "Failed to save global tags."]);
exit;
}
echo json_encode(["success" => "Global tags updated successfully.", "globalTags" => $globalTags]);
exit;
}
// Validate folder name.
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
function getMetadataFilePath($folder) {
if (strtolower($folder) === 'root' || $folder === '') {
return META_DIR . "root_metadata.json";
}
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
}
$metadataFile = getMetadataFilePath($folder);
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!isset($metadata[$file])) {
$metadata[$file] = [];
}
$metadata[$file]['tags'] = $tags;
if (file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT)) === false) {
echo json_encode(["error" => "Failed to save tag data."]);
exit;
}
// Now update the global tags file as well.
if (!file_exists($globalTagsFile)) {
if (file_put_contents($globalTagsFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
echo json_encode(["error" => "Failed to create global tags file."]);
exit;
}
}
$globalTags = json_decode(file_get_contents($globalTagsFile), true);
if (!is_array($globalTags)) {
$globalTags = [];
}
foreach ($tags as $tag) {
$found = false;
foreach ($globalTags as &$globalTag) {
if (strtolower($globalTag['name']) === strtolower($tag['name'])) {
$globalTag['color'] = $tag['color'];
$found = true;
break;
}
}
if (!$found) {
$globalTags[] = $tag;
}
}
if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT)) === false) {
echo json_encode(["error" => "Failed to save global tags."]);
exit;
}
echo json_encode(["success" => "Tag data saved successfully.", "tags" => $tags, "globalTags" => $globalTags]);
?>

View File

@@ -1053,7 +1053,7 @@ body.dark-mode .custom-prev-next-btn:hover:not(:disabled) {
#customToast {
position: fixed;
top: 20px;
bottom: 20px;
right: 20px;
background: #333;
color: #fff;
@@ -1068,7 +1068,7 @@ body.dark-mode .custom-prev-next-btn:hover:not(:disabled) {
}
#customToast.show {
opacity: 1;
opacity: 0.7;
}
.button-wrap {
@@ -2013,3 +2013,57 @@ body.dark-mode .card {
padding-top: 5px !important;
padding-right: 0 !important;
}
#addUserModal,
#removeUserModal {
z-index: 5000 !important;
}
#customConfirmModal {
z-index: 6000 !important;
}
/* Default (light mode) for admin panel content */
.admin-panel-content {
background: #fff;
color: #000;
}
/* Dark mode overrides for admin panel content */
body.dark-mode .admin-panel-content {
background: #2c2c2c; /* dark background */
color: #e0e0e0; /* light text */
border: 1px solid #444;
}
/* Optionally, adjust input, label, etc. for dark mode */
body.dark-mode .admin-panel-content input,
body.dark-mode .admin-panel-content select,
body.dark-mode .admin-panel-content textarea {
background: #3a3a3a;
color: #e0e0e0;
border: 1px solid #555;
}
body.dark-mode .admin-panel-content label {
color: #e0e0e0;
}
#openChangePasswordModalBtn {
width: auto;
padding: 5px 10px;
font-size: 14px;
margin-right: 300px;
}
#changePasswordModal {
z-index: 9999;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.spinning {
animation: spin 1s linear infinite;
}

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
echo json_encode([
"csrf_token" => $_SESSION['csrf_token'],

74
totp_disable.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
// disableTOTP.php
require_once 'vendor/autoload.php';
require_once 'config.php';
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(403);
echo json_encode(["error" => "Not authenticated"]);
exit;
}
// Verify CSRF token from request headers.
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
header('Content-Type: application/json');
$username = $_SESSION['username'] ?? '';
if (empty($username)) {
http_response_code(400);
echo json_encode(["error" => "Username not found in session"]);
exit;
}
/**
* Removes the TOTP secret for the given user in users.txt.
*
* @param string $username
* @return bool True on success, false otherwise.
*/
function removeUserTOTPSecret($username) {
global $encryptionKey;
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return false;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$modified = false;
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) < 3) {
$newLines[] = $line;
continue;
}
if ($parts[0] === $username) {
// Remove the TOTP secret by setting it to an empty string.
if (count($parts) >= 4) {
$parts[3] = "";
}
$modified = true;
$newLines[] = implode(":", $parts);
} else {
$newLines[] = $line;
}
}
if ($modified) {
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
}
return $modified;
}
if (removeUserTOTPSecret($username)) {
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
} else {
http_response_code(500);
echo json_encode(["error" => "Failed to disable TOTP."]);
}
?>

148
totp_setup.php Normal file
View File

@@ -0,0 +1,148 @@
<?php
// totp_setup.php
require_once 'vendor/autoload.php';
require_once 'config.php';
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
// For debugging purposes, you might enable error reporting temporarily:
// ini_set('display_errors', 1);
// error_reporting(E_ALL);
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(403);
exit;
}
// Verify CSRF token provided as a GET parameter.
if (!isset($_GET['csrf']) || $_GET['csrf'] !== $_SESSION['csrf_token']) {
http_response_code(403);
exit;
}
$username = $_SESSION['username'] ?? '';
if (!$username) {
http_response_code(400);
exit;
}
// Set header to output a PNG image.
header("Content-Type: image/png");
// Define the path to your users.txt file.
$usersFile = USERS_DIR . USERS_FILE;
/**
* Updates the TOTP secret for the given user in users.txt.
*
* @param string $username
* @param string $encryptedSecret The encrypted TOTP secret.
*/
function updateUserTOTPSecret($username, $encryptedSecret) {
global $usersFile;
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) < 3) {
$newLines[] = $line;
continue;
}
if ($parts[0] === $username) {
// If a fourth field exists, update it; otherwise, append it.
if (count($parts) >= 4) {
$parts[3] = $encryptedSecret;
} else {
$parts[] = $encryptedSecret;
}
$newLines[] = implode(':', $parts);
} else {
$newLines[] = $line;
}
}
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
}
/**
* Retrieves the current user's TOTP secret from users.txt (if present).
*
* @param string $username
* @return string|null The decrypted TOTP secret or null if not found.
*/
function getUserTOTPSecret($username) {
global $usersFile, $encryptionKey;
if (!file_exists($usersFile)) {
return null;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(':', trim($line));
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
return decryptData($parts[3], $encryptionKey);
}
}
return null;
}
/**
* Retrieves the global OTPAuth URL from admin configuration.
*
* @return string Global OTPAuth URL template or an empty string if not set.
*/
function getGlobalOtpauthUrl() {
global $encryptionKey;
$adminConfigFile = USERS_DIR . 'adminConfig.json';
if (file_exists($adminConfigFile)) {
$encryptedContent = file_get_contents($adminConfigFile);
$decryptedContent = decryptData($encryptedContent, $encryptionKey);
if ($decryptedContent !== false) {
$config = json_decode($decryptedContent, true);
if (isset($config['globalOtpauthUrl']) && !empty($config['globalOtpauthUrl'])) {
return $config['globalOtpauthUrl'];
}
}
}
return "";
}
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
// Retrieve the current TOTP secret for the user.
$totpSecret = getUserTOTPSecret($username);
if (!$totpSecret) {
// If no TOTP secret exists, generate a new one.
$totpSecret = $tfa->createSecret();
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
updateUserTOTPSecret($username, $encryptedSecret);
}
// Determine the otpauth URL to use.
// If a global OTPAuth URL template is defined, replace placeholders {label} and {secret}.
// Otherwise, use the default method.
$globalOtpauthUrl = getGlobalOtpauthUrl();
if (!empty($globalOtpauthUrl)) {
$label = "FileRise:" . $username;
$otpauthUrl = str_replace(
["{label}", "{secret}"],
[urlencode($label), $totpSecret],
$globalOtpauthUrl
);
} else {
$label = urlencode("FileRise:" . $username);
$issuer = urlencode("FileRise");
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
}
// Build the QR code using Endroid QR Code.
$result = Builder::create()
->writer(new PngWriter())
->data($otpauthUrl)
->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
->build();
header('Content-Type: ' . $result->getMimeType());
echo $result->getString();
?>

84
totp_verify.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
// verifyTOTPSetup.php
require_once 'vendor/autoload.php';
require_once 'config.php';
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(403);
echo json_encode(["error" => "Not authenticated"]);
exit;
}
// Verify CSRF token from request headers.
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!isset($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Ensure Content-Type is JSON.
header('Content-Type: application/json');
// Read and decode the JSON request body.
$input = json_decode(file_get_contents("php://input"), true);
if (!isset($input['totp_code']) || strlen(trim($input['totp_code'])) !== 6 || !ctype_digit(trim($input['totp_code']))) {
http_response_code(400);
echo json_encode(["error" => "A valid 6-digit TOTP code is required"]);
exit;
}
$totpCode = trim($input['totp_code']);
$username = $_SESSION['username'] ?? '';
if (empty($username)) {
http_response_code(400);
echo json_encode(["error" => "Username not found in session"]);
exit;
}
/**
* Retrieves the current user's TOTP secret from users.txt.
*
* @param string $username
* @return string|null The decrypted TOTP secret or null if not found.
*/
function getUserTOTPSecret($username) {
global $encryptionKey;
// Define the path to your users file.
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return null;
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$parts = explode(':', trim($line));
// Assuming format: username:hashedPassword:role:encryptedTOTPSecret
if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) {
return decryptData($parts[3], $encryptionKey);
}
}
return null;
}
// Retrieve the user's TOTP secret.
$totpSecret = getUserTOTPSecret($username);
if (!$totpSecret) {
http_response_code(500);
echo json_encode(["error" => "TOTP secret not found. Please try setting up TOTP again."]);
exit;
}
// Verify the provided TOTP code.
$tfa = new \RobThree\Auth\TwoFactorAuth('FileRise');
if (!$tfa->verifyCode($totpSecret, $totpCode)) {
http_response_code(400);
echo json_encode(["error" => "Invalid TOTP code."]);
exit;
}
// If needed, you could update a flag or store the confirmation in the user record here.
// Return a successful response.
echo json_encode(["success" => true, "message" => "TOTP successfully verified."]);
?>

100
updateConfig.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Verify that the user is authenticated and is an admin.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
// Validate CSRF token.
$receivedToken = '';
if (isset($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$receivedToken = trim($_SERVER['HTTP_X_CSRF_TOKEN']);
} else {
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
}
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
}
// Retrieve and decode JSON input.
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// Validate and sanitize OIDC configuration.
$oidc = isset($data['oidc']) ? $data['oidc'] : [];
$oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : '';
$oidcClientId = isset($oidc['clientId']) ? trim($oidc['clientId']) : '';
$oidcClientSecret = isset($oidc['clientSecret']) ? trim($oidc['clientSecret']) : '';
$oidcRedirectUri = isset($oidc['redirectUri']) ? filter_var($oidc['redirectUri'], FILTER_SANITIZE_URL) : '';
if (!$oidcProviderUrl || !$oidcClientId || !$oidcClientSecret || !$oidcRedirectUri) {
http_response_code(400);
echo json_encode(['error' => 'Incomplete OIDC configuration.']);
exit;
}
// Validate login option booleans.
$disableFormLogin = isset($data['disableFormLogin']) ? filter_var($data['disableFormLogin'], FILTER_VALIDATE_BOOLEAN) : false;
$disableBasicAuth = isset($data['disableBasicAuth']) ? filter_var($data['disableBasicAuth'], FILTER_VALIDATE_BOOLEAN) : false;
$disableOIDCLogin = isset($data['disableOIDCLogin']) ? filter_var($data['disableOIDCLogin'], FILTER_VALIDATE_BOOLEAN) : false;
// Retrieve the global OTPAuth URL (new field). If not provided, default to an empty string.
$globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : "";
// Prepare configuration array.
$configUpdate = [
'oidc' => [
'providerUrl' => $oidcProviderUrl,
'clientId' => $oidcClientId,
'clientSecret' => $oidcClientSecret,
'redirectUri' => $oidcRedirectUri,
],
'loginOptions' => [
'disableFormLogin' => $disableFormLogin,
'disableBasicAuth' => $disableBasicAuth,
'disableOIDCLogin' => $disableOIDCLogin,
],
'globalOtpauthUrl' => $globalOtpauthUrl
];
// Define the configuration file path.
$configFile = USERS_DIR . 'adminConfig.json';
// Convert and encrypt configuration.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
$encryptedContent = encryptData($plainTextConfig, $encryptionKey);
// Attempt to write the new configuration.
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
// Log the error.
error_log("updateConfig.php: Initial write failed, attempting to delete the old configuration file.");
// Delete the old file.
if (file_exists($configFile)) {
unlink($configFile);
}
// Try writing again.
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
error_log("updateConfig.php: Failed to write configuration even after deletion.");
http_response_code(500);
echo json_encode(['error' => 'Failed to update configuration even after cleanup.']);
exit;
}
}
echo json_encode(['success' => 'Configuration updated successfully.']);
?>

80
updateUserPanel.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
// updateUserPanel.php
require_once 'config.php';
header('Content-Type: application/json');
// Ensure the user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(403);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Verify the CSRF token from headers.
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
$data = json_decode(file_get_contents("php://input"), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(["error" => "Invalid input"]);
exit;
}
$username = $_SESSION['username'] ?? '';
if (!$username) {
http_response_code(400);
echo json_encode(["error" => "No username in session"]);
exit;
}
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
$usersFile = USERS_DIR . USERS_FILE;
/**
* Clears the TOTP secret for a given user by removing or emptying the fourth field.
*
* @param string $username
*/
function disableUserTOTP($username) {
global $usersFile;
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$newLines = [];
foreach ($lines as $line) {
$parts = explode(':', trim($line));
// If the line doesn't have at least three parts, leave it alone.
if (count($parts) < 3) {
$newLines[] = $line;
continue;
}
if ($parts[0] === $username) {
// If a fourth field exists, clear it; otherwise, append an empty field.
if (count($parts) >= 4) {
$parts[3] = "";
} else {
$parts[] = "";
}
$newLines[] = implode(':', $parts);
} else {
$newLines[] = $line;
}
}
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
}
// If TOTP is disabled, clear the user's TOTP secret.
if (!$totp_enabled) {
disableUserTOTP($username);
echo json_encode(["success" => "User panel updated: TOTP disabled"]);
exit;
} else {
// If TOTP is enabled, do not change the stored secret.
echo json_encode(["success" => "User panel updated: TOTP remains enabled"]);
exit;
}
?>

71
updateUserPermissions.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
require_once 'config.php';
header('Content-Type: application/json');
// Only admins should update user permissions.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Verify the CSRF token from headers.
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$csrfToken = isset($headers['x-csrf-token']) ? trim($headers['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $csrfToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Read the POST input.
$input = json_decode(file_get_contents("php://input"), true);
if (!isset($input['permissions']) || !is_array($input['permissions'])) {
echo json_encode(["error" => "Invalid input"]);
exit;
}
$permissions = $input['permissions'];
$permissionsFile = USERS_DIR . "userPermissions.json";
// Load existing permissions if available and decrypt.
if (file_exists($permissionsFile)) {
$encryptedContent = file_get_contents($permissionsFile);
$json = decryptData($encryptedContent, $encryptionKey);
$existingPermissions = json_decode($json, true);
if (!is_array($existingPermissions)) {
$existingPermissions = [];
}
} else {
$existingPermissions = [];
}
// Loop through each permission update.
foreach ($permissions as $perm) {
// Ensure username is provided.
if (!isset($perm['username'])) continue;
$username = $perm['username'];
// Skip updating permissions for admin users.
if (strtolower($username) === "admin") continue;
// Update permissions: default any missing value to false.
$existingPermissions[$username] = [
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
'readOnly' => isset($perm['readOnly']) ? (bool)$perm['readOnly'] : false,
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
];
}
// Convert the permissions array to JSON.
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
// Encrypt the JSON data.
$encryptedData = encryptData($plainText, $encryptionKey);
// Save encrypted permissions back to the JSON file.
$result = file_put_contents($permissionsFile, $encryptedData);
if ($result === false) {
echo json_encode(["error" => "Failed to save user permissions."]);
exit;
}
echo json_encode(["success" => "User permissions updated successfully."]);
?>

View File

@@ -407,7 +407,7 @@ function initResumableUpload() {
resumableInstance = new Resumable({
target: "upload.php",
query: { folder: window.currentFolder || "root", upload_token: window.csrfToken },
chunkSize: 3 * 1024 * 1024, // 3 MB chunks
chunkSize: 1.5 * 1024 * 1024, // 1.5 MB chunks
simultaneousUploads: 3,
testChunks: false,
throttleProgressCallbacks: 1,
@@ -457,18 +457,19 @@ function initResumableUpload() {
updateFileInfoCount();
});
resumableInstance.on("fileProgress", function (file) {
const percent = Math.floor(file.progress() * 100);
resumableInstance.on("fileProgress", function(file) {
const progress = file.progress(); // value between 0 and 1
const percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
// Calculate elapsed time since file entry was created.
// Calculate elapsed time and speed.
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
if (elapsed > 0) {
// Calculate total bytes uploaded so far using file.progress() * file.size
const bytesUploaded = file.progress() * file.size;
const bytesUploaded = progress * file.size;
const spd = bytesUploaded / elapsed;
if (spd < 1024) {
speed = spd.toFixed(0) + " B/s";
@@ -479,8 +480,13 @@ function initResumableUpload() {
}
}
li.progressBar.innerText = percent + "% (" + speed + ")";
} else {
// When progress reaches 99% or higher, show only a spinner icon.
li.progressBar.style.width = "100%";
li.progressBar.innerHTML = '<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
}
// Enable the pause/resume button once progress starts
// Enable the pause/resume button once progress starts.
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.disabled = false;
@@ -488,12 +494,14 @@ function initResumableUpload() {
}
});
resumableInstance.on("fileSuccess", function (file, message) {
resumableInstance.on("fileSuccess", function(file, message) {
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
if (li && li.progressBar) {
// Clear any merging indicators.
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
// Hide the pause/resume button when upload is complete.
// Optionally hide the pause/resume and remove buttons.
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.style.display = "none";

View File

@@ -18,8 +18,42 @@ if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
exit;
}
$userPermissions = loadUserPermissions($username);
$username = $_SESSION['username'] ?? '';
if ($username) {
$userPermissions = loadUserPermissions($username);
if (isset($userPermissions['disableUpload']) && $userPermissions['disableUpload'] === true) {
echo json_encode(["error" => "Disabled upload users are not allowed to upload."]);
exit();
}
}
// Check if this is a chunked upload (Resumable.js sends "resumableChunkNumber").
/*
* Handle test chunk requests.
* When testChunks is enabled in Resumable.js, the client sends GET requests with a "resumableTest" parameter.
*/
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['resumableTest'])) {
$chunkNumber = intval($_GET['resumableChunkNumber']);
$resumableIdentifier = $_GET['resumableIdentifier'];
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
// Determine the base upload directory.
$baseUploadDir = UPLOAD_DIR;
if ($folder !== 'root') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$chunkFile = $tempDir . $chunkNumber;
if (file_exists($chunkFile)) {
http_response_code(200);
} else {
http_response_code(404);
}
exit;
}
// ---------------------
// Chunked upload handling (POST requests)
// ---------------------
if (isset($_POST['resumableChunkNumber'])) {
// ------------- Chunked Upload Handling -------------
$chunkNumber = intval($_POST['resumableChunkNumber']); // current chunk (1-indexed)
@@ -61,8 +95,13 @@ if (isset($_POST['resumableChunkNumber'])) {
// Check if all chunks have been uploaded.
$uploadedChunks = glob($tempDir . "*");
if (count($uploadedChunks) >= $totalChunks) {
// All chunks uploaded. Merge chunks.
if (count($uploadedChunks) < $totalChunks) {
// More chunks remain respond and let the client continue.
echo json_encode(["status" => "chunk uploaded"]);
exit;
}
// All chunks are present. Merge chunks.
$targetPath = $baseUploadDir . $resumableFilename;
if (!$out = fopen($targetPath, "wb")) {
echo json_encode(["error" => "Failed to open target file for writing"]);
@@ -120,11 +159,6 @@ if (isset($_POST['resumableChunkNumber'])) {
echo json_encode(["success" => "File uploaded successfully"]);
exit;
} else {
// Chunk successfully uploaded, but more chunks remain.
echo json_encode(["status" => "chunk uploaded"]);
exit;
}
} else {
// ------------- Full Upload (Non-chunked) -------------