From 939aa032f04d34f74d64712a6ff56917eae7fb38 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 14 May 2025 05:20:22 -0400 Subject: [PATCH] ui: polish header and user panel with dropdown + profile pic support & file list adjustments --- CHANGELOG.md | 79 ++++ public/api/profile/getCurrentUser.php | 15 + public/api/profile/uploadPicture.php | 17 + public/assets/default-avatar.png | Bin 0 -> 21852 bytes public/css/styles.css | 129 +++++-- public/index.html | 3 - public/js/adminPanel.js | 2 +- public/js/auth.js | 224 ++++++++--- public/js/authModals.js | 537 +++++++++++++------------- public/js/domUtils.js | 50 ++- public/js/fileListView.js | 316 +++++++++------ public/js/folderManager.js | 3 +- public/js/i18n.js | 6 + public/js/main.js | 26 +- src/controllers/UserController.php | 324 ++++++++++------ src/models/UserModel.php | 254 ++++++++---- 16 files changed, 1290 insertions(+), 695 deletions(-) create mode 100644 public/api/profile/getCurrentUser.php create mode 100644 public/api/profile/uploadPicture.php create mode 100644 public/assets/default-avatar.png diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b7af8..2d41d97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## Changes 5/14/2025 v1.3.4 + +### 1. Button Grouping (Bootstrap) + +- Converted individual action buttons (`download`, `edit`, `rename`, `share`) in both **table view** and **gallery view** into a single Bootstrap button group for a cleaner, more compact UI. +- Applied `btn-group` and `btn-sm` classes for consistent sizing and spacing. + +### 2. Header Dropdown Replacement + +- Replaced the standalone “User Panel” icon button with a **dropdown wrapper** (`.user-dropdown`) in the header. +- Dropdown toggle now shows: + - **Profile picture** (if set) or the Material “account_circle” icon + - **Username** text (between avatar and caret) + - Down-arrow caret span. + +### 3. Menu Items Moved to Dropdown + +- Moved previously standalone header buttons into the dropdown menu: + - **User Panel** opens the modal + - **Admin Panel** only shown when `data.isAdmin` *and* on `demo.filerise.net` + - **API Docs** calls `openApiModal()` + - **Logout** calls `triggerLogout()` +- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`). + +### 4. Profile Picture Support + +- Added a new `/api/profile/uploadPicture.php` endpoint + `UserController::uploadPicture()` + corresponding `UserModel::setProfilePicture()`. +- On **Open User Panel**, display: + - Default avatar if none set + - Current profile picture if available +- In the **User Panel** modal: + - Stylish “edit” overlay icon on the avatar to launch file picker + - Auto-upload on file selection (no “Save” button click needed) + - Preview updates immediately and header avatar refreshes live + - Persisted in `users.txt` and re-fetched via `getCurrentUser.php` + +### 5. API Docs & Logout Relocation + +- Removed API Docs from User Panel +- Removed “Logout” buttons from the header toolbar. +- Both are now menu entries in the **User Dropdown**. + +### 6. Admin Panel Conditional + +- The **Admin Panel** button was: + - Kept in the dropdown only when `data.isAdmin` + - Removed entirely elsewhere. + +### 7. Utility & Styling Tweaks + +- Introduced a small `normalizePicUrl()` helper to strip stray colons and ensure a leading slash. +- Hidden the scrollbar in the User Panel modal via: + - Inline CSS (`scrollbar-width: none; -ms-overflow-style: none;`) + - Global/WebKit rule for `::-webkit-scrollbar { display: none; }` +- Made the User Panel modal fully responsive and vertically centered, with smooth dark-mode support. + +### 8. File/List View & Gallery View Sliders + +- **Unified “View‐Mode” Slider** + Added a single slider panel (`#viewSliderContainer`) in the file‐list actions toolbar that switches behavior based on the current view mode: + - **Table View**: shows a **Row Height** slider (min 31px, max 60px). + - Adjusts the CSS variable `--file-row-height` to resize all `` heights. + - Persists the chosen height in `localStorage`. + - **Gallery View**: shows a **Columns** slider (min 1, max 6). + - Updates the grid’s `grid-template-columns: repeat(N, 1fr)`. + - Persists the chosen column count in `localStorage`. + +- **Injection Point** + The slider container is dynamically inserted (or updated) just before the folder summary (`#fileSummary`) in `loadFileList()`, ensuring a consistent position across both view modes. + +- **Live Updates** + Moving the slider thumb immediately updates the visible table row heights or gallery column layout without a full re‐render. + +- **Styling & Alignment** + - `#viewSliderContainer` uses `inline-flex` and `align-items: center` so that label, slider, and value text are vertically aligned with the other toolbar elements. + - Reset margins/padding on the label and value span within `#viewSliderContainer` to eliminate any vertical misalignment. + +--- + ## Changes 5/8/2025 ### Docker 🐳 diff --git a/public/api/profile/getCurrentUser.php b/public/api/profile/getCurrentUser.php new file mode 100644 index 0000000..c60fd6c --- /dev/null +++ b/public/api/profile/getCurrentUser.php @@ -0,0 +1,15 @@ +'Unauthorized']); + exit; +} + +$user = $_SESSION['username']; +$data = UserModel::getUser($user); +echo json_encode($data); \ No newline at end of file diff --git a/public/api/profile/uploadPicture.php b/public/api/profile/uploadPicture.php new file mode 100644 index 0000000..7ecee04 --- /dev/null +++ b/public/api/profile/uploadPicture.php @@ -0,0 +1,17 @@ +uploadPicture(); +} catch (\Throwable $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'Exception: ' . $e->getMessage() + ]); +} \ No newline at end of file diff --git a/public/assets/default-avatar.png b/public/assets/default-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..5e68e6062c375bd8a09b59e9cfbb3bef8528c352 GIT binary patch literal 21852 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 C7GHq? literal 0 HcmV?d00001 diff --git a/public/css/styles.css b/public/css/styles.css index 86b2e4b..20130d1 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -134,17 +134,27 @@ body.dark-mode header { background: none; border: none; cursor: pointer; - padding: 9px; - border-radius: 50%; color: #fff; transition: background-color 0.2s ease, box-shadow 0.2s ease; } +.header-buttons button:not(#userDropdownToggle) { + border-radius: 50%; + padding: 9px; +} + +#userDropdownToggle { + border-radius: 4px !important; + padding: 6px 10px !important; +} + .header-buttons button:hover { background-color: rgba(255, 255, 255, 0.2); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + color: #fff; } + @media (max-width: 600px) { header { flex-direction: column; @@ -955,6 +965,23 @@ body.dark-mode #fileList table tr { padding: 8px 10px !important; } +:root { + --file-row-height: 48px; /* default, will be overwritten by your slider */ +} + +/* Force each to be exactly the var() height */ +#fileList table.table tbody tr { + height: var(--file-row-height) !important; +} + +/* And force each to match, with no extra padding or line-height */ +#fileList table.table tbody td { + height: var(--file-row-height) !important; + line-height: var(--file-row-height) !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + vertical-align: middle; +} /* =========================================================== HEADINGS & FORM LABELS @@ -1328,26 +1355,6 @@ body.dark-mode .image-preview-modal-content { border-color: #444; } -.preview-btn, -.download-btn, -.rename-btn, -.share-btn, -.edit-btn { - display: flex; - align-items: center; - padding: 8px 12px; - justify-content: center; -} - -.share-btn { - border: none; - color: white; - padding: 8px 12px; - cursor: pointer; - margin-left: 0px; - transition: background 0.3s; -} - .image-modal-img { max-width: 100%; max-height: 80vh; @@ -2102,13 +2109,23 @@ body.dark-mode .header-drop-zone.drag-active { color: black; } @media only screen and (max-width: 600px) { - #fileSummary { - float: none !important; - margin: 0 auto !important; - text-align: center !important; + #fileSummary, + #rowHeightSliderContainer, + #viewSliderContainer { + float: none !important; + margin: 0 auto !important; + text-align: center !important; + display: block !important; } } +#viewSliderContainer label, +#viewSliderContainer span { + line-height: 1; + margin: 0; + padding: 0; +} + body.dark-mode #fileSummary { color: white; } @@ -2165,4 +2182,64 @@ body.dark-mode #searchIcon .material-icons { body.dark-mode .btn-icon:hover, body.dark-mode .btn-icon:focus { background: rgba(255, 255, 255, 0.1); +} + +.user-dropdown { + position: relative; + display: inline-block; +} + +.user-dropdown .user-menu { + display: none; + position: absolute; + right: 0; + margin-top: 0.25rem; + background: var(--bs-body-bg, #fff); + border: 1px solid #ccc; + border-radius: 4px; + min-width: 150px; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + z-index: 1000; +} + +.user-dropdown .user-menu.show { + display: block; +} + +.user-dropdown .user-menu .item { + padding: 0.5rem 0.75rem; + cursor: pointer; + white-space: nowrap; +} +.user-dropdown .user-menu .item:hover { + background: #f5f5f5; +} + +.user-dropdown .dropdown-caret { + border-top: 5px solid currentColor; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + display: inline-block; + vertical-align: middle; + margin-left: 0.25rem; +} + +body.dark-mode .user-dropdown .user-menu { + background: #2c2c2c; + border-color: #444; +} + +body.dark-mode .user-dropdown .user-menu .item { + color: #e0e0e0; +} + +body.dark-mode .user-dropdown .user-menu .item:hover { + background: rgba(255,255,255,0.1); +} + +.user-dropdown .dropdown-username { + margin: 0 8px; + font-weight: 500; + vertical-align: middle; + white-space: nowrap; } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 1335911..6655316 100644 --- a/public/index.html +++ b/public/index.html @@ -135,9 +135,6 @@
- diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 12b1804..c86ae7a 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -3,7 +3,7 @@ import { loadAdminConfigFunc } from './auth.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { sendRequest } from './networkUtils.js'; -const version = "v1.3.3"; +const version = "v1.3.4"; const adminTitle = `${t("admin_panel")} ${version}`; // ————— Inject updated styles ————— diff --git a/public/js/auth.js b/public/js/auth.js index b505f32..2ad7f6b 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -15,10 +15,11 @@ import { openUserPanel, openTOTPModal, closeTOTPModal, - setLastLoginData + setLastLoginData, + openApiModal } from './authModals.js'; import { openAdminPanel } from './adminPanel.js'; -import { initializeApp } from './main.js'; +import { initializeApp, triggerLogout } from './main.js'; // Production OIDC configuration (override via API as needed) const currentOIDCConfig = { @@ -154,7 +155,7 @@ function updateLoginOptionsUIFromStorage() { disableFormLogin: localStorage.getItem("disableFormLogin") === "true", disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true", disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true", - authBypass: localStorage.getItem("authBypass") === "true" + authBypass: localStorage.getItem("authBypass") === "true" }); } @@ -199,21 +200,45 @@ function insertAfter(newNode, referenceNode) { referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); } -function updateAuthenticatedUI(data) { - document.getElementById('loadingOverlay').remove(); +async function fetchProfilePicture() { + try { + const res = await fetch('/api/profile/getCurrentUser.php', { + credentials: 'include' + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const info = await res.json(); + let pic = info.profile_picture || ''; + // --- take only what's after the *last* colon --- + const parts = pic.split(':'); + pic = parts[parts.length - 1] || ''; + // strip any stray leading colons + pic = pic.replace(/^:+/, ''); + // ensure exactly one leading slash + if (pic && !pic.startsWith('/')) pic = '/' + pic; + return pic; + } catch (e) { + console.warn('fetchProfilePicture failed:', e); + return ''; + } +} - // show the wrapper (so the login form can be visible) - document.querySelector('.main-wrapper').style.display = ''; - document.getElementById('loginForm').style.display = 'none'; +export async function updateAuthenticatedUI(data) { + // 1) Remove loading overlay safely + const loading = document.getElementById('loadingOverlay'); + if (loading) loading.remove(); + + // 2) Show main UI + document.querySelector('.main-wrapper').style.display = ''; + document.getElementById('loginForm').style.display = 'none'; toggleVisibility("loginForm", false); toggleVisibility("mainOperations", true); toggleVisibility("uploadFileForm", true); toggleVisibility("fileListContainer", true); - //attachEnterKeyListener("addUserModal", "saveUserBtn"); - attachEnterKeyListener("removeUserModal", "deleteUserBtn"); - attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn"); + attachEnterKeyListener("removeUserModal", "deleteUserBtn"); + attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn"); document.querySelector(".header-buttons").style.visibility = "visible"; + // 3) Persist auth flags (unchanged) if (typeof data.totp_enabled !== "undefined") { localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false"); } @@ -221,64 +246,143 @@ function updateAuthenticatedUI(data) { localStorage.setItem("username", data.username); } if (typeof data.folderOnly !== "undefined") { - localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false"); - localStorage.setItem("readOnly", data.readOnly ? "true" : "false"); - localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false"); + localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false"); + localStorage.setItem("readOnly", data.readOnly ? "true" : "false"); + localStorage.setItem("disableUpload",data.disableUpload? "true" : "false"); } + // 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage + const profilePicUrl = await fetchProfilePicture(); + localStorage.setItem("profilePicUrl", profilePicUrl); + + // 5) Build / update header buttons const headerButtons = document.querySelector(".header-buttons"); - const firstButton = headerButtons.firstElementChild; + const firstButton = headerButtons.firstElementChild; + // a) restore-from-trash for admins if (data.isAdmin) { - let restoreBtn = document.getElementById("restoreFilesBtn"); - if (!restoreBtn) { - restoreBtn = document.createElement("button"); - restoreBtn.id = "restoreFilesBtn"; - restoreBtn.classList.add("btn", "btn-warning"); - restoreBtn.setAttribute("data-i18n-title", "trash_restore_delete"); - restoreBtn.innerHTML = 'restore_from_trash'; - if (firstButton) insertAfter(restoreBtn, firstButton); - else headerButtons.appendChild(restoreBtn); - } - restoreBtn.style.display = "block"; - - let adminPanelBtn = document.getElementById("adminPanelBtn"); - if (!adminPanelBtn) { - adminPanelBtn = document.createElement("button"); - adminPanelBtn.id = "adminPanelBtn"; - adminPanelBtn.classList.add("btn", "btn-info"); - adminPanelBtn.setAttribute("data-i18n-title", "admin_panel"); - adminPanelBtn.innerHTML = 'admin_panel_settings'; - insertAfter(adminPanelBtn, restoreBtn); - adminPanelBtn.addEventListener("click", openAdminPanel); - } else { - adminPanelBtn.style.display = "block"; + let r = document.getElementById("restoreFilesBtn"); + if (!r) { + r = document.createElement("button"); + r.id = "restoreFilesBtn"; + r.classList.add("btn","btn-warning"); + r.setAttribute("data-i18n-title","trash_restore_delete"); + r.innerHTML = 'restore_from_trash'; + if (firstButton) insertAfter(r, firstButton); + else headerButtons.appendChild(r); } + r.style.display = "block"; } else { - const restoreBtn = document.getElementById("restoreFilesBtn"); - if (restoreBtn) restoreBtn.style.display = "none"; - const adminPanelBtn = document.getElementById("adminPanelBtn"); - if (adminPanelBtn) adminPanelBtn.style.display = "none"; + const r = document.getElementById("restoreFilesBtn"); + if (r) r.style.display = "none"; } - if (window.location.hostname !== "demo.filerise.net") { - let userPanelBtn = document.getElementById("userPanelBtn"); - if (!userPanelBtn) { - userPanelBtn = document.createElement("button"); - userPanelBtn.id = "userPanelBtn"; - userPanelBtn.classList.add("btn", "btn-user"); - userPanelBtn.setAttribute("data-i18n-title", "user_panel"); - userPanelBtn.innerHTML = 'account_circle'; + // b) admin panel button only on demo.filerise.net + if (data.isAdmin && window.location.hostname === "demo.filerise.net") { + let a = document.getElementById("adminPanelBtn"); + if (!a) { + a = document.createElement("button"); + a.id = "adminPanelBtn"; + a.classList.add("btn","btn-info"); + a.setAttribute("data-i18n-title","admin_panel"); + a.innerHTML = 'admin_panel_settings'; + insertAfter(a, document.getElementById("restoreFilesBtn")); + a.addEventListener("click", openAdminPanel); + } + a.style.display = "block"; + } else { + const a = document.getElementById("adminPanelBtn"); + if (a) a.style.display = "none"; + } + + // c) user dropdown on non-demo + if (window.location.hostname !== "demo.filerise.net") { + let dd = document.getElementById("userDropdown"); + + // choose icon *or* img + const avatarHTML = profilePicUrl + ? `` + : `account_circle`; + + if (!dd) { + dd = document.createElement("div"); + dd.id = "userDropdown"; + dd.classList.add("user-dropdown"); + + // toggle button + const toggle = document.createElement("button"); + toggle.id = "userDropdownToggle"; + toggle.classList.add("btn","btn-user"); + toggle.setAttribute("title", t("user_settings")); + toggle.innerHTML = `${avatarHTML}${data.username}`; + dd.append(toggle); + + // menu + const menu = document.createElement("div"); + menu.classList.add("user-menu"); + menu.innerHTML = ` + + ${data.isAdmin ? ` + ` : ''} + + + `; + dd.append(menu); + + // insert + const dm = document.getElementById("darkModeToggle"); + if (dm) insertAfter(dd, dm); + else if (firstButton) insertAfter(dd, firstButton); + else headerButtons.appendChild(dd); + + // open/close + toggle.addEventListener("click", e => { + e.stopPropagation(); + menu.classList.toggle("show"); + }); + document.addEventListener("click", () => menu.classList.remove("show")); + + // actions + document.getElementById("menuUserPanel") + .addEventListener("click", () => { + menu.classList.remove("show"); + openUserPanel(); + }); + if (data.isAdmin) { + document.getElementById("menuAdminPanel") + .addEventListener("click", () => { + menu.classList.remove("show"); + openAdminPanel(); + }); + } + document.getElementById("menuApiDocs") + .addEventListener("click", () => { + menu.classList.remove("show"); + openApiModal(); + }); + document.getElementById("menuLogout") + .addEventListener("click", () => { + menu.classList.remove("show"); + triggerLogout(); + }); - const adminBtn = document.getElementById("adminPanelBtn"); - if (adminBtn) insertAfter(userPanelBtn, adminBtn); - else if (firstButton) insertAfter(userPanelBtn, firstButton); - else headerButtons.appendChild(userPanelBtn); - userPanelBtn.addEventListener("click", openUserPanel); } else { - userPanelBtn.style.display = "block"; + // update avatar only + const tog = dd.querySelector("#userDropdownToggle"); + tog.innerHTML = `${avatarHTML}${data.username}`; + dd.style.display = "inline-block"; } } + + // 6) Finalize initializeApp(); applyTranslations(); updateItemsPerPageSelect(); @@ -289,7 +393,8 @@ function checkAuthentication(showLoginToast = true) { return sendRequest("/api/auth/checkAuth.php") .then(data => { if (data.setup) { - document.getElementById('loadingOverlay').remove(); + const overlay = document.getElementById('loadingOverlay'); + if (overlay) overlay.remove(); // show the wrapper (so the login form can be visible) document.querySelector('.main-wrapper').style.display = ''; @@ -322,13 +427,14 @@ function checkAuthentication(showLoginToast = true) { updateAuthenticatedUI(data); return data; } else { - document.getElementById('loadingOverlay').remove(); + const overlay = document.getElementById('loadingOverlay'); + if (overlay) overlay.remove(); // show the wrapper (so the login form can be visible) document.querySelector('.main-wrapper').style.display = ''; document.getElementById('loginForm').style.display = ''; if (showLoginToast) showToast("Please log in to continue."); - toggleVisibility("loginForm", ! (localStorage.getItem("authBypass")==="true")); + toggleVisibility("loginForm", !(localStorage.getItem("authBypass") === "true")); toggleVisibility("mainOperations", false); toggleVisibility("uploadFileForm", false); toggleVisibility("fileListContainer", false); diff --git a/public/js/authModals.js b/public/js/authModals.js index c862f04..642ba36 100644 --- a/public/js/authModals.js +++ b/public/js/authModals.js @@ -1,8 +1,7 @@ import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { sendRequest } from './networkUtils.js'; import { t, applyTranslations, setLocale } from './i18n.js'; -import { loadAdminConfigFunc } from './auth.js'; - +import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js'; let lastLoginData = null; export function setLastLoginData(data) { @@ -60,14 +59,11 @@ export function openTOTPLoginModal() { const totpSection = document.getElementById("totpSection"); const recoverySection = document.getElementById("recoverySection"); const toggleLink = this; - if (recoverySection.style.display === "none") { - // Switch to recovery totpSection.style.display = "none"; recoverySection.style.display = "block"; toggleLink.textContent = t("use_totp_code_instead"); } else { - // Switch back to TOTP recoverySection.style.display = "none"; totpSection.style.display = "block"; toggleLink.textContent = t("use_recovery_code_instead"); @@ -93,7 +89,6 @@ export function openTOTPLoginModal() { .then(res => res.json()) .then(json => { if (json.status === "ok") { - // recovery succeeded → finalize login window.location.href = "/index.html"; } else { showToast(json.message || t("recovery_code_verification_failed")); @@ -107,17 +102,11 @@ export function openTOTPLoginModal() { // TOTP submission const totpInput = document.getElementById("totpLoginInput"); totpInput.focus(); - totpInput.addEventListener("input", async function () { const code = this.value.trim(); - if (code.length !== 6) { + if (code.length !== 6) return; - return; - } - - const tokenRes = await fetch("/api/auth/token.php", { - credentials: "include" - }); + const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" }); if (!tokenRes.ok) { showToast(t("totp_verification_failed")); return; @@ -144,7 +133,6 @@ export function openTOTPLoginModal() { } else { showToast(t("totp_verification_failed")); } - this.value = ""; totpLoginModal.style.display = "flex"; this.focus(); @@ -160,153 +148,209 @@ export function openTOTPLoginModal() { } } -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; - overflow-y: auto; - overflow-x: hidden; - max-height: 383px !important; - flex-shrink: 0 !important; - scrollbar-gutter: stable both-edges; - border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"}; - box-sizing: border-box; - transition: none; - `; - const savedLanguage = localStorage.getItem("language") || "en"; +/** + * Fetch current user info (username, profile_picture, totp_enabled) + */ +async function fetchCurrentUser() { + try { + const res = await fetch('/api/profile/getCurrentUser.php', { + credentials: 'include' + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (e) { + console.warn('fetchCurrentUser failed:', e); + return {}; + } +} - if (!userPanelModal) { - userPanelModal = document.createElement("div"); - userPanelModal.id = "userPanelModal"; - userPanelModal.style.cssText = ` - position: fixed; - top: 0; right: 0; bottom: 0; left: 0; - background-color: ${overlayBackground}; - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - overflow: hidden; +/** + * Normalize any profile‐picture URL: + * - strip leading colons + * - ensure exactly one leading slash + */ +function normalizePicUrl(raw) { + if (!raw) return ''; + // take only what's after the last colon + const parts = raw.split(':'); + let pic = parts[parts.length - 1]; + // strip any stray colons + pic = pic.replace(/^:+/, ''); + // ensure leading slash + if (pic && !pic.startsWith('/')) pic = '/' + pic; + return pic; +} + +export async function openUserPanel() { + // 1) load data + const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser(); + const raw = profile_picture; + const picUrl = normalizePicUrl(raw); + + // 2) dark‐mode helpers + const isDark = document.body.classList.contains('dark-mode'); + const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; + const contentCss = ` + background: ${isDark ? '#2c2c2c' : '#fff'}; + color: ${isDark ? '#e0e0e0' : '#000'}; + padding: 20px; + max-width: 600px; + width: 90%; + border-radius: 8px; + overflow-y: auto; + max-height: 415px; + border: ${isDark ? '1px solid #444' : '1px solid #ccc'}; + box-sizing: border-box; + + /* hide scrollbar in Firefox */ + scrollbar-width: none; + /* hide scrollbar in IE 10+ */ + -ms-overflow-style: none; +`; + + // 3) build or re-use modal + let modal = document.getElementById('userPanelModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'userPanelModal'; + modal.style.cssText = ` + position:fixed; top:0; left:0; right:0; bottom:0; + background:${overlayBg}; + display:flex; align-items:center; justify-content:center; + z-index:1000; `; - userPanelModal.innerHTML = ` - diff --git a/public/js/folderManager.js b/public/js/folderManager.js index ad377c2..cca3409 100644 --- a/public/js/folderManager.js +++ b/public/js/folderManager.js @@ -236,7 +236,8 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") { const state = loadFolderTreeState(); let html = `
    `; for (const folder in tree) { - if (folder.toLowerCase() === "trash") continue; + const name = folder.toLowerCase(); + if (name === "trash" || name === "profile_pics") continue; const fullPath = parentPath ? parentPath + "/" + folder : folder; const hasChildren = Object.keys(tree[folder]).length > 0; const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay; diff --git a/public/js/i18n.js b/public/js/i18n.js index 1e97be7..2fa98e1 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -202,6 +202,11 @@ const translations = { // NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS: "admin_panel": "Admin Panel", "user_panel": "User Panel", + "user_settings": "User Settings", + "save_profile_picture": "Save Profile Picture", + "please_select_picture": "Please select a picture", + "profile_picture_updated": "Profile picture updated", + "error_updating_picture": "Error updating profile picture", "trash_restore_delete": "Trash Restore/Delete", "totp_settings": "TOTP Settings", "enable_totp": "Enable TOTP", @@ -260,6 +265,7 @@ const translations = { "show": "Show", "items_per_page": "items per page", "columns": "Columns", + "row_height": "Row Height", "api_docs": "API Docs" }, es: { diff --git a/public/js/main.js b/public/js/main.js index f9562f7..c2aaa4e 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -15,6 +15,8 @@ import { editFile, saveFile } from './fileEditor.js'; import { t, applyTranslations, setLocale } from './i18n.js'; export function initializeApp() { + const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10); + document.documentElement.style.setProperty('--file-row-height', saved + 'px'); window.currentFolder = "root"; initTagSearch(); loadFileList(window.currentFolder); @@ -77,18 +79,14 @@ if (params.get('logout') === '1') { localStorage.removeItem("userTOTPEnabled"); } -// 2) Wire up logoutBtn right away -const logoutBtn = document.getElementById("logoutBtn"); -if (logoutBtn) { - logoutBtn.addEventListener("click", () => { - fetch("/api/auth/logout.php", { - method: "POST", - credentials: "include", - headers: { "X-CSRF-Token": window.csrfToken } - }) - .then(() => window.location.reload(true)) - .catch(() => {}); - }); +export function triggerLogout() { + fetch("/api/auth/logout.php", { + method: "POST", + credentials: "include", + headers: { "X-CSRF-Token": window.csrfToken } + }) + .then(() => window.location.reload(true)) + .catch(()=>{}); } @@ -122,7 +120,8 @@ document.addEventListener("DOMContentLoaded", function () { // Continue with initializations that rely on a valid CSRF token: checkAuthentication().then(authenticated => { if (authenticated) { - document.getElementById('loadingOverlay').remove(); + const overlay = document.getElementById('loadingOverlay'); + if (overlay) overlay.remove(); initializeApp(); } }); @@ -201,7 +200,6 @@ document.addEventListener("DOMContentLoaded", function () { }); // --- Auto-scroll During Drag --- - // Adjust these values as needed: const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling const SCROLL_SPEED = 20; // pixels to scroll per event diff --git a/src/controllers/UserController.php b/src/controllers/UserController.php index 89e2b8d..6113086 100644 --- a/src/controllers/UserController.php +++ b/src/controllers/UserController.php @@ -867,123 +867,126 @@ class UserController * ) */ - public function verifyTOTP() - { - header('Content-Type: application/json'); - header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); - - // Rate-limit - if (!isset($_SESSION['totp_failures'])) { - $_SESSION['totp_failures'] = 0; - } - if ($_SESSION['totp_failures'] >= 5) { - http_response_code(429); - echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); - exit; - } - - // Must be authenticated OR pending login - if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) { - http_response_code(403); - echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); - exit; - } - - // CSRF check - $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); - $csrfHeader = $headersArr['x-csrf-token'] ?? ''; - if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { - http_response_code(403); - echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']); - exit; - } - - // Parse & validate input - $inputData = json_decode(file_get_contents("php://input"), true); - $code = trim($inputData['totp_code'] ?? ''); - if (!preg_match('/^\d{6}$/', $code)) { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); - exit; - } - - // TFA helper - $tfa = new \RobThree\Auth\TwoFactorAuth( - new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), - 'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1 - ); - - // === Pending-login flow (we just came from auth and need to finish login) === - if (isset($_SESSION['pending_login_user'])) { - $username = $_SESSION['pending_login_user']; - $pendingSecret = $_SESSION['pending_login_secret'] ?? null; - $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; - - if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { - $_SESSION['totp_failures']++; - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); - exit; - } - - // Issue “remember me” token if requested - if ($rememberMe) { - $tokFile = USERS_DIR . 'persistent_tokens.json'; - $token = bin2hex(random_bytes(32)); - $expiry = time() + 30 * 24 * 60 * 60; - $all = []; - if (file_exists($tokFile)) { - $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); - $all = json_decode($dec, true) ?: []; - } - $all[$token] = [ - 'username' => $username, - 'expiry' => $expiry, - 'isAdmin' => ((int)userModel::getUserRole($username) === 1), - 'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false, - 'readOnly' => loadUserPermissions($username)['readOnly'] ?? false, - 'disableUpload'=> loadUserPermissions($username)['disableUpload']?? false - ]; - file_put_contents( - $tokFile, - encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), - LOCK_EX - ); - $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); - setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); - setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true); - } - - // === Finalize login into session exactly as finalizeLogin() would === - session_regenerate_id(true); - $_SESSION['authenticated'] = true; - $_SESSION['username'] = $username; - $_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1); - $perms = loadUserPermissions($username); - $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; - $_SESSION['readOnly'] = $perms['readOnly'] ?? false; - $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; - - // Clean up pending markers - unset( - $_SESSION['pending_login_user'], - $_SESSION['pending_login_secret'], - $_SESSION['pending_login_remember_me'], - $_SESSION['totp_failures'] - ); - - // Send back full login payload - echo json_encode([ - 'status' => 'ok', - 'success' => 'Login successful', - 'isAdmin' => $_SESSION['isAdmin'], - 'folderOnly' => $_SESSION['folderOnly'], - 'readOnly' => $_SESSION['readOnly'], - 'disableUpload' => $_SESSION['disableUpload'], - 'username' => $_SESSION['username'] - ]); - exit; - } + public function verifyTOTP() + { + header('Content-Type: application/json'); + header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); + + // Rate-limit + if (!isset($_SESSION['totp_failures'])) { + $_SESSION['totp_failures'] = 0; + } + if ($_SESSION['totp_failures'] >= 5) { + http_response_code(429); + echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); + exit; + } + + // Must be authenticated OR pending login + if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) { + http_response_code(403); + echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); + exit; + } + + // CSRF check + $headersArr = array_change_key_case(getallheaders(), CASE_LOWER); + $csrfHeader = $headersArr['x-csrf-token'] ?? ''; + if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) { + http_response_code(403); + echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']); + exit; + } + + // Parse & validate input + $inputData = json_decode(file_get_contents("php://input"), true); + $code = trim($inputData['totp_code'] ?? ''); + if (!preg_match('/^\d{6}$/', $code)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); + exit; + } + + // TFA helper + $tfa = new \RobThree\Auth\TwoFactorAuth( + new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), + 'FileRise', + 6, + 30, + \RobThree\Auth\Algorithm::Sha1 + ); + + // === Pending-login flow (we just came from auth and need to finish login) === + if (isset($_SESSION['pending_login_user'])) { + $username = $_SESSION['pending_login_user']; + $pendingSecret = $_SESSION['pending_login_secret'] ?? null; + $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; + + if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { + $_SESSION['totp_failures']++; + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); + exit; + } + + // Issue “remember me” token if requested + if ($rememberMe) { + $tokFile = USERS_DIR . 'persistent_tokens.json'; + $token = bin2hex(random_bytes(32)); + $expiry = time() + 30 * 24 * 60 * 60; + $all = []; + if (file_exists($tokFile)) { + $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); + $all = json_decode($dec, true) ?: []; + } + $all[$token] = [ + 'username' => $username, + 'expiry' => $expiry, + 'isAdmin' => ((int)userModel::getUserRole($username) === 1), + 'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false, + 'readOnly' => loadUserPermissions($username)['readOnly'] ?? false, + 'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false + ]; + file_put_contents( + $tokFile, + encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), + LOCK_EX + ); + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); + setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true); + } + + // === Finalize login into session exactly as finalizeLogin() would === + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $username; + $_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1); + $perms = loadUserPermissions($username); + $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; + $_SESSION['readOnly'] = $perms['readOnly'] ?? false; + $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; + + // Clean up pending markers + unset( + $_SESSION['pending_login_user'], + $_SESSION['pending_login_secret'], + $_SESSION['pending_login_remember_me'], + $_SESSION['totp_failures'] + ); + + // Send back full login payload + echo json_encode([ + 'status' => 'ok', + 'success' => 'Login successful', + 'isAdmin' => $_SESSION['isAdmin'], + 'folderOnly' => $_SESSION['folderOnly'], + 'readOnly' => $_SESSION['readOnly'], + 'disableUpload' => $_SESSION['disableUpload'], + 'username' => $_SESSION['username'] + ]); + exit; + } // Setup/verification flow (not pending) $username = $_SESSION['username'] ?? ''; @@ -1011,4 +1014,91 @@ class UserController unset($_SESSION['totp_failures']); echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']); } + + public function uploadPicture() + { + header('Content-Type: application/json'); + + // 1) Auth check + if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { + http_response_code(401); + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; + } + + // 2) CSRF check + $headers = function_exists('getallheaders') + ? array_change_key_case(getallheaders(), CASE_LOWER) + : []; + $csrf = $headers['x-csrf-token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + exit; + } + + // 3) File presence + if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No file uploaded or error']); + exit; + } + $file = $_FILES['profile_picture']; + + // 4) Validate MIME & size + $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + if (!isset($allowed[$mime])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid file type']); + exit; + } + if ($file['size'] > 2 * 1024 * 1024) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'File too large']); + exit; + } + + // 5) Destination under public/uploads/profile_pics + $uploadDir = UPLOAD_DIR . '/profile_pics'; + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']); + exit; + } + + // 6) Move file + $ext = $allowed[$mime]; + $user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']); + $filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext; + $dest = "$uploadDir/$filename"; + if (!move_uploaded_file($file['tmp_name'], $dest)) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to save file']); + exit; + } + + // 7) Build public URL + $url = '/uploads/profile_pics/' . $filename; + + // ─── THIS IS WHERE WE PERSIST INTO users.txt ─── + $result = UserModel::setProfilePicture($_SESSION['username'], $url); + if (!$result['success']) { + // on failure, remove the file we just wrote + @unlink($dest); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'Failed to save profile picture setting' + ]); + exit; + } + // ───────────────────────────────────────────────── + + // 8) Return success + echo json_encode(['success' => true, 'url' => $url]); + exit; + } } diff --git a/src/models/UserModel.php b/src/models/UserModel.php index ac34033..34c76d2 100644 --- a/src/models/UserModel.php +++ b/src/models/UserModel.php @@ -3,13 +3,15 @@ require_once PROJECT_ROOT . '/config/config.php'; -class userModel { +class userModel +{ /** * Retrieves all users from the users file. * * @return array Returns an array of users. */ - public static function getAllUsers() { + public static function getAllUsers() + { $usersFile = USERS_DIR . USERS_FILE; $users = []; if (file_exists($usersFile)) { @@ -26,7 +28,7 @@ class userModel { } return $users; } - + /** * Adds a new user. * @@ -36,14 +38,15 @@ class userModel { * @param bool $setupMode If true, overwrite the users file. * @return array Response containing either an error or a success message. */ - public static function addUser($username, $password, $isAdmin, $setupMode) { + public static function addUser($username, $password, $isAdmin, $setupMode) + { $usersFile = USERS_DIR . USERS_FILE; // Ensure users.txt exists. if (!file_exists($usersFile)) { file_put_contents($usersFile, ''); } - + // Check if username already exists. $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); foreach ($existingUsers as $line) { @@ -52,40 +55,41 @@ class userModel { return ["error" => "User already exists"]; } } - + // Hash the password. $hashedPassword = password_hash($password, PASSWORD_BCRYPT); - + // Prepare the new line. $newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL; - + // If setup mode, overwrite the file; otherwise, append. if ($setupMode) { file_put_contents($usersFile, $newUserLine); } else { file_put_contents($usersFile, $newUserLine, FILE_APPEND); } - + return ["success" => "User added successfully"]; } - /** + /** * Removes the specified user from the users file and updates the userPermissions file. * * @param string $usernameToRemove The username to remove. * @return array An array with either an error message or a success message. */ - public static function removeUser($usernameToRemove) { + public static function removeUser($usernameToRemove) + { $usersFile = USERS_DIR . USERS_FILE; - + if (!file_exists($usersFile)) { return ["error" => "Users file not found"]; } - + $existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $newUsers = []; $userFound = false; - + // Loop through users; skip (remove) the specified user. foreach ($existingUsers as $line) { $parts = explode(':', trim($line)); @@ -98,14 +102,14 @@ class userModel { } $newUsers[] = $line; } - + if (!$userFound) { return ["error" => "User not found"]; } - + // Write the updated user list back to the file. file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL); - + // Update the userPermissions.json file. $permissionsFile = USERS_DIR . "userPermissions.json"; if (file_exists($permissionsFile)) { @@ -116,18 +120,19 @@ class userModel { file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT)); } } - + return ["success" => "User removed successfully"]; } - /** + /** * Retrieves permissions from the userPermissions.json file. * If the current user is an admin, returns all permissions. * Otherwise, returns only the permissions for the current user. * * @return array|object Returns an associative array of permissions or an empty object if none are found. */ - public static function getUserPermissions() { + public static function getUserPermissions() + { global $encryptionKey; $permissionsFile = USERS_DIR . "userPermissions.json"; $permissionsArray = []; @@ -165,13 +170,14 @@ class userModel { return new stdClass(); } - /** + /** * Updates user permissions in the userPermissions.json file. * * @param array $permissions An array of permission updates. * @return array An associative array with a success or error message. */ - public static function updateUserPermissions($permissions) { + public static function updateUserPermissions($permissions) + { global $encryptionKey; $permissionsFile = USERS_DIR . "userPermissions.json"; $existingPermissions = []; @@ -185,7 +191,7 @@ class userModel { $existingPermissions = []; } } - + // Load user roles from the users file. $usersFile = USERS_DIR . USERS_FILE; $userRoles = []; @@ -199,7 +205,7 @@ class userModel { } } } - + // Process each permission update. foreach ($permissions as $perm) { if (!isset($perm['username'])) { @@ -208,12 +214,12 @@ class userModel { $username = $perm['username']; // Look up the user's role. $role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null; - + // Skip updating permissions for admin users. if ($role === "1") { continue; } - + // Update permissions: default any missing value to false. $existingPermissions[strtolower($username)] = [ 'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false, @@ -221,7 +227,7 @@ class userModel { 'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false ]; } - + // Convert the updated permissions array to JSON. $plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT); // Encrypt the JSON. @@ -231,11 +237,11 @@ class userModel { if ($result === false) { return ["error" => "Failed to save user permissions."]; } - + return ["success" => "User permissions updated successfully."]; } - /** + /** * Changes the password for the given user. * * @param string $username The username whose password is to be changed. @@ -243,17 +249,18 @@ class userModel { * @param string $newPassword The new password. * @return array An array with either a success or error message. */ - public static function changePassword($username, $oldPassword, $newPassword) { + public static function changePassword($username, $oldPassword, $newPassword) + { $usersFile = USERS_DIR . USERS_FILE; - + if (!file_exists($usersFile)) { return ["error" => "Users file not found"]; } - + $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $userFound = false; $newLines = []; - + foreach ($lines as $line) { $parts = explode(':', trim($line)); // Expect at least 3 parts: username, hashed password, and role. @@ -266,7 +273,7 @@ class userModel { $storedRole = $parts[2]; // Preserve TOTP secret if it exists. $totpSecret = (count($parts) >= 4) ? $parts[3] : ""; - + if ($storedUser === $username) { $userFound = true; // Verify the old password. @@ -275,7 +282,7 @@ class userModel { } // Hash the new password. $newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT); - + // Rebuild the line, preserving TOTP secret if it exists. if ($totpSecret !== "") { $newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret; @@ -286,11 +293,11 @@ class userModel { $newLines[] = $line; } } - + if (!$userFound) { return ["error" => "User not found."]; } - + // Save the updated users file. if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) { return ["success" => "Password updated successfully."]; @@ -299,25 +306,26 @@ class userModel { } } - /** + /** * Updates the user panel settings by disabling the TOTP secret if TOTP is not enabled. * * @param string $username The username whose panel settings are being updated. * @param bool $totp_enabled Whether TOTP is enabled. * @return array An array indicating success or failure. */ - public static function updateUserPanel($username, $totp_enabled) { + public static function updateUserPanel($username, $totp_enabled) + { $usersFile = USERS_DIR . USERS_FILE; - + if (!file_exists($usersFile)) { return ["error" => "Users file not found"]; } - + // If TOTP is disabled, update the file to clear the TOTP secret. if (!$totp_enabled) { $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $newLines = []; - + foreach ($lines as $line) { $parts = explode(':', trim($line)); // Leave lines with fewer than three parts unchanged. @@ -325,7 +333,7 @@ class userModel { $newLines[] = $line; continue; } - + if ($parts[0] === $username) { // If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field. if (count($parts) >= 4) { @@ -338,25 +346,26 @@ class userModel { $newLines[] = $line; } } - + $result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); if ($result === false) { return ["error" => "Failed to disable TOTP secret"]; } return ["success" => "User panel updated: TOTP disabled"]; } - + // If TOTP is enabled, do nothing. return ["success" => "User panel updated: TOTP remains enabled"]; } - /** + /** * Disables the TOTP secret for the specified user. * * @param string $username The user for whom TOTP should be disabled. * @return bool True if the secret was cleared; false otherwise. */ - public static function disableTOTPSecret($username) { + public static function disableTOTPSecret($username) + { global $encryptionKey; // In case it's used in this model context. $usersFile = USERS_DIR . USERS_FILE; if (!file_exists($usersFile)) { @@ -391,14 +400,15 @@ class userModel { return $modified; } - /** + /** * Attempts to recover TOTP for a user using the supplied recovery code. * * @param string $userId The user identifier. * @param string $recoveryCode The recovery code provided by the user. * @return array An associative array with keys 'status' and 'message'. */ - public static function recoverTOTP($userId, $recoveryCode) { + public static function recoverTOTP($userId, $recoveryCode) + { // --- Rate‑limit recovery attempts --- $attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json'; $attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : []; @@ -406,36 +416,36 @@ class userModel { $now = time(); if (isset($attempts[$key])) { // Prune attempts older than 15 minutes. - $attempts[$key] = array_filter($attempts[$key], function($ts) use ($now) { + $attempts[$key] = array_filter($attempts[$key], function ($ts) use ($now) { return $ts > $now - 900; }); } if (count($attempts[$key] ?? []) >= 5) { return ['status' => 'error', 'message' => 'Too many attempts. Try again later.']; } - + // --- Load user metadata file --- $userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; if (!file_exists($userFile)) { return ['status' => 'error', 'message' => 'User not found']; } - + // --- Open and lock file --- $fp = fopen($userFile, 'c+'); if (!$fp || !flock($fp, LOCK_EX)) { return ['status' => 'error', 'message' => 'Server error']; } - + $fileContents = stream_get_contents($fp); $data = json_decode($fileContents, true) ?: []; - + // --- Check recovery code --- if (empty($recoveryCode)) { flock($fp, LOCK_UN); fclose($fp); return ['status' => 'error', 'message' => 'Recovery code required']; } - + $storedHash = $data['totp_recovery_code'] ?? null; if (!$storedHash || !password_verify($recoveryCode, $storedHash)) { // Record failed attempt. @@ -445,7 +455,7 @@ class userModel { fclose($fp); return ['status' => 'error', 'message' => 'Invalid recovery code']; } - + // --- Invalidate recovery code --- $data['totp_recovery_code'] = null; rewind($fp); @@ -454,17 +464,18 @@ class userModel { fflush($fp); flock($fp, LOCK_UN); fclose($fp); - + return ['status' => 'ok']; } - /** + /** * Generates a random recovery code. * * @param int $length Length of the recovery code. * @return string */ - private static function generateRecoveryCode($length = 12) { + private static function generateRecoveryCode($length = 12) + { $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; $max = strlen($chars) - 1; $code = ''; @@ -480,10 +491,11 @@ class userModel { * @param string $userId The username of the user. * @return array An associative array with the status and recovery code (if successful). */ - public static function saveTOTPRecoveryCode($userId) { + public static function saveTOTPRecoveryCode($userId) + { // Determine the user file path. $userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json'; - + // Ensure the file exists; if not, create it with default data. if (!file_exists($userFile)) { $defaultData = []; @@ -491,24 +503,24 @@ class userModel { return ['status' => 'error', 'message' => 'Server error: could not create user file']; } } - + // Generate a new recovery code. $recoveryCode = self::generateRecoveryCode(); $recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT); - + // Open the file, lock it, and update the totp_recovery_code field. $fp = fopen($userFile, 'c+'); if (!$fp || !flock($fp, LOCK_EX)) { return ['status' => 'error', 'message' => 'Server error: could not lock user file']; } - + // Read and decode the existing JSON. $contents = stream_get_contents($fp); $data = json_decode($contents, true) ?: []; - + // Update the totp_recovery_code field. $data['totp_recovery_code'] = $recoveryHash; - + // Write the new data. rewind($fp); ftruncate($fp, 0); @@ -516,25 +528,26 @@ class userModel { fflush($fp); flock($fp, LOCK_UN); fclose($fp); - + return ['status' => 'ok', 'recoveryCode' => $recoveryCode]; } - /** + /** * Sets up TOTP for the specified user by retrieving or generating a TOTP secret, * then builds and returns a QR code image for the OTPAuth URL. * * @param string $username The username for which to set up TOTP. * @return array An associative array with keys 'imageData' and 'mimeType', or 'error'. */ - public static function setupTOTP($username) { + public static function setupTOTP($username) + { global $encryptionKey; $usersFile = USERS_DIR . USERS_FILE; - + if (!file_exists($usersFile)) { return ['error' => 'Users file not found']; } - + // Look for an existing TOTP secret. $lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $totpSecret = null; @@ -545,7 +558,7 @@ class userModel { break; } } - + // Use the TwoFactorAuth library to create a new secret if none found. $tfa = new \RobThree\Auth\TwoFactorAuth( new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider @@ -557,7 +570,7 @@ class userModel { if (!$totpSecret) { $totpSecret = $tfa->createSecret(); $encryptedSecret = encryptData($totpSecret, $encryptionKey); - + // Update the user’s line with the new encrypted secret. $newLines = []; foreach ($lines as $line) { @@ -575,7 +588,7 @@ class userModel { } file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX); } - + // Determine the OTPAuth URL. // Try to load a global OTPAuth URL template from admin configuration. $adminConfigFile = USERS_DIR . 'adminConfig.json'; @@ -590,7 +603,7 @@ class userModel { } } } - + if (!empty($globalOtpauthUrl)) { $label = "FileRise:" . $username; $otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl); @@ -599,26 +612,27 @@ class userModel { $issuer = urlencode("FileRise"); $otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}"; } - + // Build the QR code image using the Endroid QR Code Builder. $result = \Endroid\QrCode\Builder\Builder::create() ->writer(new \Endroid\QrCode\Writer\PngWriter()) ->data($otpauthUrl) ->build(); - + return [ 'imageData' => $result->getString(), 'mimeType' => $result->getMimeType() ]; } - /** + /** * Retrieves the decrypted TOTP secret for a given user. * * @param string $username * @return string|null Returns the TOTP secret if found, or null if not. */ - public static function getTOTPSecret($username) { + public static function getTOTPSecret($username) + { global $encryptionKey; $usersFile = USERS_DIR . USERS_FILE; if (!file_exists($usersFile)) { @@ -634,14 +648,15 @@ class userModel { } return null; } - + /** * Helper to get a user's role from users.txt. * * @param string $username * @return string|null */ - public static function getUserRole($username) { + public static function getUserRole($username) + { $usersFile = USERS_DIR . USERS_FILE; if (!file_exists($usersFile)) { return null; @@ -654,4 +669,79 @@ class userModel { } return null; } -} \ No newline at end of file + + public static function getUser(string $username): array + { + $usersFile = USERS_DIR . USERS_FILE; + if (! file_exists($usersFile)) { + return []; + } + + foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + // explode into at most 4 parts: [0]=username, [1]=hash, [2]=isAdmin, [3]=profileUrl (might include a trailing colon) + $parts = explode(':', $line, 4); + if ($parts[0] !== $username) { + continue; + } + // strip any trailing colon(s) from the URL field + $pic = isset($parts[3]) ? rtrim($parts[3], ':') : ''; + + return [ + 'username' => $parts[0], + 'profile_picture' => $pic, + ]; + } + + return []; // user not found + } + + /** + * Persistently set the profile picture URL for a given user, + * storing it in the 5th field so we leave the 4th (TOTP secret) untouched. + * + * users.txt format: + * username:hash:isAdmin:totp_secret:profile_picture + * + * @param string $username + * @param string $url The public URL (e.g. "/uploads/profile_pics/…") + * @return array ['success'=>true] or ['success'=>false,'error'=>'…'] + */ + public static function setProfilePicture(string $username, string $url): array + { + $usersFile = USERS_DIR . USERS_FILE; + if (! file_exists($usersFile)) { + return ['success' => false, 'error' => 'Users file not found']; + } + + $lines = file($usersFile, FILE_IGNORE_NEW_LINES); + $out = []; + $found = false; + + foreach ($lines as $line) { + $parts = explode(':', $line); + if ($parts[0] === $username) { + $found = true; + // Ensure we have at least 5 fields + while (count($parts) < 5) { + $parts[] = ''; + } + // Write profile_picture into the 5th field (index 4) + $parts[4] = ltrim($url, '/'); // or $url if leading slash is desired + // Re-assemble (this preserves parts[3] completely) + $line = implode(':', $parts); + } + $out[] = $line; + } + + if (! $found) { + return ['success' => false, 'error' => 'User not found']; + } + + $newContent = implode(PHP_EOL, $out) . PHP_EOL; + if (file_put_contents($usersFile, $newContent, LOCK_EX) === false) { + return ['success' => false, 'error' => 'Failed to write users file']; + } + + return ['success' => true]; + } +}