Compare commits

..

8 Commits

45 changed files with 2907 additions and 856 deletions

View File

@@ -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,28 +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.
- Basic Auth supported for login.
- 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:**
@@ -115,42 +129,51 @@ 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 & OpenID Connect (OIDC) Integration
## 🔒 Admin Panel, TOTP & OpenID Connect (OIDC) Integration
- **Flexible Authentication:**
- Supports multiple authentication methods including Form-based Login, Basic Auth, and OpenID Connect (OIDC). Allow disable of only two login options.
- 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:**
- Integrates seamlessly with OIDC providers (e.g., Keycloak, Okta).
- Admin-configurable OIDC settings, including Provider URL, Client ID, Client Secret, and Redirect URI.
- All sensitive configurations are securely stored in an encrypted JSON file.
- Seamlessly integrates with OIDC providers (e.g., Keycloak, Okta).
- Provides admin-configurable OIDC settingsincluding 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:**
- Intuitive Admin Panel with Material Icons for quick recognition and access.
- Allows administrators to easily manage authentication settings, user management, and login methods.
- Real-time validation prevents disabling all authentication methods simultaneously, ensuring continuous secure access.
- 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
---

View File

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

673
auth.js
View File

@@ -2,43 +2,49 @@ 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';
// Default OIDC configuration (can be overridden via API in production)
// 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"
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";
}
}
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
const authForm = document.getElementById("authForm");
if (authForm) {
authForm.style.display = disableFormLogin ? "none" : "block";
}
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";
}
if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block";
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
if (oidcLoginBtn) {
oidcLoginBtn.style.display = disableOIDCLogin ? "none" : "inline-block";
}
if (oidcLoginBtn) oidcLoginBtn.style.display = disableOIDCLogin ? "none" : "inline-block";
}
function updateLoginOptionsUIFromStorage() {
const disableFormLogin = localStorage.getItem("disableFormLogin") === "true";
const disableBasicAuth = localStorage.getItem("disableBasicAuth") === "true";
const disableOIDCLogin = localStorage.getItem("disableOIDCLogin") === "true";
updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
updateLoginOptionsUI({
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true"
});
}
function loadAdminConfigFunc() {
@@ -48,16 +54,22 @@ function loadAdminConfigFunc() {
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);
@@ -68,6 +80,20 @@ function updateAuthenticatedUI(data) {
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
document.querySelector(".header-buttons").style.visibility = "visible";
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) {
let restoreBtn = document.getElementById("restoreFilesBtn");
if (!restoreBtn) {
@@ -75,15 +101,12 @@ function updateAuthenticatedUI(data) {
restoreBtn.id = "restoreFilesBtn";
restoreBtn.classList.add("btn", "btn-warning");
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 >= 3) {
headerButtons.insertBefore(restoreBtn, headerButtons.children[3]);
if (firstButton) {
insertAfter(restoreBtn, firstButton);
} else {
headerButtons.appendChild(restoreBtn);
}
}
}
restoreBtn.style.display = "block";
let adminPanelBtn = document.getElementById("adminPanelBtn");
@@ -91,17 +114,8 @@ function updateAuthenticatedUI(data) {
adminPanelBtn = document.createElement("button");
adminPanelBtn.id = "adminPanelBtn";
adminPanelBtn.classList.add("btn", "btn-info");
// Use material icon for the admin panel button.
adminPanelBtn.innerHTML = '<i class="material-icons" title="Admin Panel">admin_panel_settings</i>';
const headerButtons = document.querySelector(".header-buttons");
if (headerButtons) {
// Insert the adminPanelBtn immediately after the restoreBtn.
if (restoreBtn.nextSibling) {
headerButtons.insertBefore(adminPanelBtn, restoreBtn.nextSibling);
} else {
headerButtons.appendChild(adminPanelBtn);
}
}
insertAfter(adminPanelBtn, restoreBtn);
adminPanelBtn.addEventListener("click", openAdminPanel);
} else {
adminPanelBtn.style.display = "block";
@@ -112,6 +126,29 @@ function updateAuthenticatedUI(data) {
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();
}
@@ -131,6 +168,9 @@ function checkAuthentication(showLoginToast = true) {
}
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 {
@@ -146,31 +186,19 @@ function checkAuthentication(showLoginToast = true) {
.catch(() => false);
}
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
};
sendRequest("auth.php", "POST", formData, { "X-CSRF-Token": window.csrfToken })
.then(data => {
if (data.success) {
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(() => {
@@ -179,398 +207,21 @@ function initAuth() {
}, 30 * 60 * 1000);
}
} else {
showToast("Login failed: " + (data.error || "Unknown error"));
}
}
})
.catch(() => {});
});
}
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(() => {});
});
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
if (oidcLoginBtn) {
oidcLoginBtn.addEventListener("click", function () {
window.location.href = "auth.php?oidc";
});
}
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."));
showToast("Login failed: " + (response.error || "Unknown error"));
}
})
.catch(() => {
showToast("Error changing password.");
});
showToast("Login failed: Unknown error");
});
}
window.submitLogin = submitLogin;
function loadOIDCConfig() {
return fetch("getConfig.php", { credentials: "include" })
.then(response => response.json())
.then(config => {
if (config.oidc) {
Object.assign(currentOIDCConfig, config.oidc);
}
return currentOIDCConfig;
})
.catch(() => currentOIDCConfig);
}
function openAdminPanel() {
fetch("getConfig.php", { credentials: "include" })
.then(response => response.json())
.then(config => {
if (config.oidc) {
Object.assign(currentOIDCConfig, config.oidc);
}
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;
`;
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>Admin Panel</h3>
<form id="adminPanelForm">
<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="${currentOIDCConfig.providerUrl}" />
</div>
<div class="form-group">
<label for="oidcClientId">OIDC Client ID:</label>
<input type="text" id="oidcClientId" class="form-control" value="${currentOIDCConfig.clientId}" />
</div>
<div class="form-group">
<label for="oidcClientSecret">OIDC Client Secret:</label>
<input type="text" id="oidcClientSecret" class="form-control" value="${currentOIDCConfig.clientSecret}" />
</div>
<div class="form-group">
<label for="oidcRedirectUri">OIDC Redirect URI:</label>
<input type="text" id="oidcRedirectUri" class="form-control" value="${currentOIDCConfig.redirectUri}" />
</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>
<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>
</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", function (e) {
if (e.target === adminModal) {
closeAdminPanel();
}
});
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
document.getElementById("adminOpenAddUser").addEventListener("click", function () {
toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus();
});
document.getElementById("adminOpenRemoveUser").addEventListener("click", function () {
loadUserList();
toggleVisibility("removeUserModal", true);
});
document.getElementById("saveAdminSettings").addEventListener("click", function () {
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");
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;
sendRequest("updateConfig.php", "POST", {
oidc: newOIDCConfig,
disableFormLogin,
disableBasicAuth,
disableOIDCLogin
}, { "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);
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 = localStorage.getItem("disableFormLogin") === "true";
document.getElementById("disableBasicAuth").checked = localStorage.getItem("disableBasicAuth") === "true";
document.getElementById("disableOIDCLogin").checked = localStorage.getItem("disableOIDCLogin") === "true";
} else {
const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0, 0, 0, 0.7)" : "rgba(0, 0, 0, 0.3)";
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 = currentOIDCConfig.providerUrl;
document.getElementById("oidcClientId").value = currentOIDCConfig.clientId;
document.getElementById("oidcClientSecret").value = currentOIDCConfig.clientSecret;
document.getElementById("oidcRedirectUri").value = currentOIDCConfig.redirectUri;
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";
}
})
.catch(() => {
let adminModal = document.getElementById("adminPanelModal");
if (adminModal) {
document.getElementById("oidcProviderUrl").value = currentOIDCConfig.providerUrl;
document.getElementById("oidcClientId").value = currentOIDCConfig.clientId;
document.getElementById("oidcClientSecret").value = currentOIDCConfig.clientSecret;
document.getElementById("oidcRedirectUri").value = currentOIDCConfig.redirectUri;
adminModal.style.display = "flex";
} else {
openAdminPanel();
}
});
}
function closeAdminPanel() {
const adminModal = document.getElementById("adminPanelModal");
if (adminModal) {
adminModal.style.display = "none";
}
}
/* ----------------- 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();
const disableFormLogin = localStorage.getItem("disableFormLogin") === "true";
const disableBasicAuth = localStorage.getItem("disableBasicAuth") === "true";
const disableOIDCLogin = localStorage.getItem("disableOIDCLogin") === "true";
updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin });
});
function resetUserForm() {
document.getElementById("newUsername").value = "";
document.getElementById("addUserPassword").value = "";
@@ -604,7 +255,151 @@ function loadUserList() {
closeRemoveUserModal();
}
})
.catch(() => {});
.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 };

View File

@@ -1,18 +1,36 @@
<?php
require_once 'vendor/autoload.php';
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
// --- OIDC Authentication Flow ---
if (isset($_GET['oidc'])) {
/**
* 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) {
echo json_encode(['error' => 'Failed to decrypt admin configuration.']);
// 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);
@@ -42,8 +60,6 @@ if (isset($_GET['oidc'])) {
);
$oidc->setRedirectURL($oidc_redirect_uri);
// Since PKCE is disabled in Keycloak, we do not set any PKCE parameters.
if ($_GET['oidc'] === 'callback') {
try {
$oidc->authenticate();
@@ -51,11 +67,16 @@ if (isset($_GET['oidc'])) {
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = false;
// 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) {
echo json_encode(["error" => "Authentication failed: " . $e->getMessage()]);
error_log("OIDC authentication error: " . $e->getMessage());
echo json_encode(["error" => "Authentication failed."]);
exit();
}
} else {
@@ -63,14 +84,15 @@ if (isset($_GET['oidc'])) {
$oidc->authenticate();
exit();
} catch (Exception $e) {
echo json_encode(["error" => "Authentication initiation failed: " . $e->getMessage()]);
error_log("OIDC initiation error: " . $e->getMessage());
echo json_encode(["error" => "Authentication initiation failed."]);
exit();
}
}
}
// --- Fallback: Form-based Authentication ---
/* --- Fallback: Form-based Authentication --- */
// (Form-based branch code remains unchanged. It calls loadUserPermissions() in its basic auth branch.)
$usersFile = USERS_DIR . USERS_FILE;
$maxAttempts = 5;
$lockoutTime = 30 * 60;
@@ -105,15 +127,22 @@ if (isset($failedAttempts[$ip])) {
}
function authenticate($username, $password) {
global $usersFile;
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;
$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;
@@ -134,8 +163,24 @@ if (!preg_match('/^[A-Za-z0-9_\- ]+$/', $username)) {
exit();
}
$userRole = authenticate($username, $password);
if ($userRole !== false) {
$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);
@@ -143,7 +188,8 @@ if ($userRole !== false) {
session_regenerate_id(true);
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $username;
$_SESSION["isAdmin"] = ($userRole === "1");
$_SESSION["isAdmin"] = ($user['role'] === "1");
$_SESSION["folderOnly"] = loadUserPermissions($username);
if ($rememberMe) {
$token = bin2hex(random_bytes(32));
@@ -160,14 +206,19 @@ if ($userRole !== false) {
$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);
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 {
if (isset($failedAttempts[$ip])) {
$failedAttempts[$ip]['count']++;

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

@@ -21,7 +21,8 @@ date_default_timezone_set(TIMEZONE);
* @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);
@@ -36,7 +37,8 @@ function encryptData($data, $encryptionKey) {
* @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);
@@ -51,6 +53,40 @@ if (!$encryptionKey) {
die('Encryption key for persistent tokens is not set.');
}
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) {
@@ -67,9 +103,12 @@ $cookieParams = [
'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();
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
@@ -92,6 +131,8 @@ if (!isset($_SESSION["authenticated"]) && isset($_COOKIE['remember_me_token']))
if ($tokenData['expiry'] >= time()) {
$_SESSION["authenticated"] = true;
$_SESSION["username"] = $tokenData["username"];
// IMPORTANT: Set the folderOnly flag here for auto-login.
$_SESSION["folderOnly"] = loadFolderPermission($tokenData["username"]);
} else {
unset($persistentTokens[$_COOKIE['remember_me_token']]);
$newEncryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $encryptionKey);
@@ -111,4 +152,3 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
$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">
@@ -157,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>

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

@@ -15,6 +15,8 @@ import {
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"
@@ -178,15 +180,18 @@ function previewFile(fileUrl, fileName) {
document.body.appendChild(modal);
function closeModal() {
// Pause and reset any video or audio elements within the modal
// 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";
}
@@ -263,7 +268,29 @@ function previewFile(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;
@@ -419,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;
@@ -442,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 {
@@ -472,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);
}
@@ -519,17 +567,22 @@ 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;">`;
@@ -538,12 +591,24 @@ export function renderGalleryView(folder) {
} 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)}"
@@ -565,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();
}
@@ -1454,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) {
@@ -1483,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: () => {
@@ -1499,7 +1562,6 @@ function fileListContextMenuHandler(e) {
}
});
// Only show Edit option if file is editable.
if (canEditFile(file.name)) {
menuItems.push({
label: "Edit",
@@ -1507,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();

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
$configFile = USERS_DIR . 'adminConfig.json';
@@ -11,7 +11,12 @@ if (file_exists($configFile)) {
echo json_encode(['error' => 'Failed to decrypt configuration.']);
exit;
}
echo $decryptedContent;
// 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' => [
@@ -24,7 +29,8 @@ if (file_exists($configFile)) {
'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;">
@@ -391,7 +391,6 @@
</div>
</div>
<!-- JavaScript Files -->
<script type="module" src="main.js"></script>
</body>

View File

@@ -1,14 +1,14 @@
<?php
require 'config.php';
session_start();
require_once 'config.php';
$usersFile = USERS_DIR . USERS_FILE;
$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);
@@ -18,9 +18,50 @@ function authenticate($username, $password)
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"');
@@ -40,15 +81,18 @@ if (!isset($_SERVER['PHP_AUTH_USER'])) {
}
// Attempt authentication
$userRole = authenticate($username, $password);
if ($userRole !== false) {
// Successful login
$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"] = ($userRole === "1"); // Assuming "1" means admin
$_SESSION["isAdmin"] = ($actualRole === "1");
// Set the folderOnly flag based on userPermissions.json.
$_SESSION["folderOnly"] = loadFolderPermission($username);
// Redirect to the main page
// Redirect to the main page (or output JSON for testing)
header("Location: index.html");
exit;
} else {

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

@@ -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);

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 {
@@ -2048,3 +2048,22 @@ body.dark-mode .admin-panel-content textarea {
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."]);
?>

View File

@@ -1,5 +1,5 @@
<?php
require 'config.php';
require_once 'config.php';
header('Content-Type: application/json');
// Verify that the user is authenticated and is an admin.
@@ -51,6 +51,9 @@ $disableFormLogin = isset($data['disableFormLogin']) ? filter_var($data['disable
$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' => [
@@ -63,7 +66,8 @@ $configUpdate = [
'disableFormLogin' => $disableFormLogin,
'disableBasicAuth' => $disableBasicAuth,
'disableOIDCLogin' => $disableOIDCLogin,
]
],
'globalOtpauthUrl' => $globalOtpauthUrl
];
// Define the configuration file path.
@@ -72,10 +76,24 @@ $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.']);
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) -------------