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 0000000..5e68e60
Binary files /dev/null and b/public/assets/default-avatar.png differ
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/fileListView.js b/public/js/fileListView.js
index 47a2877..745b62b 100644
--- a/public/js/fileListView.js
+++ b/public/js/fileListView.js
@@ -186,9 +186,6 @@ export function formatFolderName(folder) {
window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight;
-/**
- * --- FILE LIST & VIEW RENDERING ---
- */
export function loadFileList(folderParam) {
const folder = folderParam || "root";
const fileListContainer = document.getElementById("fileList");
@@ -196,77 +193,151 @@ export function loadFileList(folderParam) {
fileListContainer.style.visibility = "hidden";
fileListContainer.innerHTML = "Loading files...
";
- return fetch("/api/file/getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime())
- .then(response => {
- if (response.status === 401) {
- showToast("Session expired. Please log in again.");
- window.location.href = "/api/auth/logout.php";
- throw new Error("Unauthorized");
- }
- return response.json();
- })
- .then(data => {
- fileListContainer.innerHTML = ""; // Clear loading message.
- if (data.files && Object.keys(data.files).length > 0) {
- // If the returned "files" is an object instead of an array, transform it.
- if (!Array.isArray(data.files)) {
- data.files = Object.entries(data.files).map(([name, meta]) => {
- meta.name = name;
- return meta;
- });
- }
- // Process each file – add computed properties.
- data.files = data.files.map(file => {
- file.fullName = (file.path || file.name).trim().toLowerCase();
- file.editable = canEditFile(file.name);
- file.folder = folder;
- if (!file.type && /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
- file.type = "image";
- }
- // OPTIONAL: For text documents, preload content (if available from backend)
- // Example: if (/\.txt|html|md|js|css|json|xml$/i.test(file.name)) { file.content = file.content || ""; }
- return file;
- });
- fileData = data.files;
+ return fetch(
+ "/api/file/getFileList.php?folder=" +
+ encodeURIComponent(folder) +
+ "&recursive=1&t=" +
+ Date.now()
+ )
+ .then((res) =>
+ res.status === 401
+ ? (window.location.href = "/api/auth/logout.php" && Promise.reject("Unauthorized"))
+ : res.json()
+ )
+ .then((data) => {
+ fileListContainer.innerHTML = "";
- // Update file summary.
- const actionsContainer = document.getElementById("fileListActions");
- if (actionsContainer) {
- let summaryElem = document.getElementById("fileSummary");
- if (!summaryElem) {
- summaryElem = document.createElement("div");
- summaryElem.id = "fileSummary";
- summaryElem.style.float = "right";
- summaryElem.style.marginLeft = "auto";
- summaryElem.style.marginRight = "60px";
- summaryElem.style.fontSize = "0.9em";
- actionsContainer.appendChild(summaryElem);
- } else {
- summaryElem.style.display = "block";
- }
- summaryElem.innerHTML = buildFolderSummary(fileData);
- }
-
- // Render view based on the view mode.
- if (window.viewMode === "gallery") {
- renderGalleryView(folder);
- updateFileActionButtons();
- } else {
- renderFileTable(folder);
- }
- } else {
+ // No files case
+ if (!data.files || Object.keys(data.files).length === 0) {
fileListContainer.textContent = t("no_files_found");
+
+ // hide summary
const summaryElem = document.getElementById("fileSummary");
- if (summaryElem) {
- summaryElem.style.display = "none";
- }
+ if (summaryElem) summaryElem.style.display = "none";
+
+ // hide slider
+ const sliderContainer = document.getElementById("viewSliderContainer");
+ if (sliderContainer) sliderContainer.style.display = "none";
+
updateFileActionButtons();
+ return [];
}
- return data.files || [];
+
+ // Normalize to array
+ if (!Array.isArray(data.files)) {
+ data.files = Object.entries(data.files).map(([name, meta]) => {
+ meta.name = name;
+ return meta;
+ });
+ }
+ // Enrich each file
+ data.files = data.files.map((f) => {
+ f.fullName = (f.path || f.name).trim().toLowerCase();
+ f.editable = canEditFile(f.name);
+ f.folder = folder;
+ return f;
+ });
+ fileData = data.files;
+
+ // --- folder summary + slider injection ---
+ const actionsContainer = document.getElementById("fileListActions");
+ if (actionsContainer) {
+ // 1) summary
+ let summaryElem = document.getElementById("fileSummary");
+ if (!summaryElem) {
+ summaryElem = document.createElement("div");
+ summaryElem.id = "fileSummary";
+ summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;";
+ actionsContainer.appendChild(summaryElem);
+ }
+ summaryElem.style.display = "block";
+ summaryElem.innerHTML = buildFolderSummary(fileData);
+
+ // 2) view‐mode slider
+ const viewMode = window.viewMode || "table";
+ let sliderContainer = document.getElementById("viewSliderContainer");
+ if (!sliderContainer) {
+ sliderContainer = document.createElement("div");
+ sliderContainer.id = "viewSliderContainer";
+ sliderContainer.style.cssText = "display: inline-flex; align-items: center; vertical-align: middle; margin-right: auto; font-size: 0.9em;";
+ actionsContainer.insertBefore(sliderContainer, summaryElem);
+ } else {
+ sliderContainer.style.display = "inline-flex";
+ }
+
+ if (viewMode === "gallery") {
+ // determine responsive caps:
+ const w = window.innerWidth;
+ let maxCols;
+ if (w < 600) maxCols = 1;
+ else if (w < 900) maxCols = 2;
+ else if (w < 1200) maxCols = 4;
+ else maxCols = 6;
+
+ const currentCols = Math.min(
+ parseInt(localStorage.getItem("galleryColumns") || "3", 10),
+ maxCols
+ );
+
+ sliderContainer.innerHTML = `
+
+ ${t("columns")}:
+
+
+ ${currentCols}
+ `;
+ // hookup gallery slider
+ const gallerySlider = document.getElementById("galleryColumnsSlider");
+ const galleryValue = document.getElementById("galleryColumnsValue");
+ gallerySlider.oninput = (e) => {
+ const v = +e.target.value;
+ localStorage.setItem("galleryColumns", v);
+ galleryValue.textContent = v;
+ // update grid if already rendered
+ const grid = document.querySelector(".gallery-container");
+ if (grid) grid.style.gridTemplateColumns = `repeat(${v},1fr)`;
+ };
+ } else {
+ const currentHeight = parseInt(localStorage.getItem("rowHeight") ?? "48", 10);
+ sliderContainer.innerHTML = `
+
+ ${t("row_height")}:
+
+
+ ${currentHeight}px
+ `;
+ // hookup row‐height slider
+ const rowSlider = document.getElementById("rowHeightSlider");
+ const rowValue = document.getElementById("rowHeightValue");
+ rowSlider.oninput = (e) => {
+ const v = e.target.value;
+ document.documentElement.style.setProperty("--file-row-height", v + "px");
+ localStorage.setItem("rowHeight", v);
+ rowValue.textContent = v + "px";
+ };
+ }
+ }
+
+ // 3) Render based on viewMode
+ if (window.viewMode === "gallery") {
+ renderGalleryView(folder);
+ } else {
+ renderFileTable(folder);
+ }
+
+ updateFileActionButtons();
+ return data.files;
})
- .catch(error => {
- console.error("Error loading file list:", error);
- if (error.message !== "Unauthorized") {
+ .catch((err) => {
+ console.error("Error loading file list:", err);
+ if (err !== "Unauthorized") {
fileListContainer.textContent = "Error loading files.";
}
return [];
@@ -327,9 +398,6 @@ export function renderFileTable(folder, container) {
rowHTML = rowHTML.replace(/()(.*?)(<\/td>)/, (match, p1, p2, p3) => {
return p1 + p2 + tagBadgesHTML + p3;
});
- rowHTML = rowHTML.replace(/(<\/div>\s*<\/td>\s*<\/tr>)/, `
- share
- $1`);
rowsHTML += rowHTML;
});
} else {
@@ -414,7 +482,7 @@ export function renderFileTable(folder, container) {
});
});
- // 5) Preview buttons (if you still have a .preview-btn)
+ // 5) Preview buttons
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
@@ -441,6 +509,17 @@ export function renderFileTable(folder, container) {
}, 0);
}, 300));
}
+ const slider = document.getElementById('rowHeightSlider');
+ const valueDisplay = document.getElementById('rowHeightValue');
+ if (slider) {
+ slider.addEventListener('input', e => {
+ const v = +e.target.value; // slider value in px
+ document.documentElement.style.setProperty('--file-row-height', v + 'px');
+ localStorage.setItem('rowHeight', v);
+ valueDisplay.textContent = v + 'px';
+ });
+ }
+
document.querySelectorAll("table.table thead th[data-column]").forEach(cell => {
cell.addEventListener("click", function () {
const column = this.getAttribute("data-column");
@@ -530,18 +609,17 @@ export function renderGalleryView(folder, container) {
}
}, 0);
- // --- Column slider ---
+ // --- Column slider with responsive max ---
const numColumns = window.galleryColumns || 3;
- galleryHTML += `
-
-
- ${t('columns')}:
-
-
- ${numColumns}
-
- `;
+ // clamp slider max to 1 on small (<600px), 2 on medium (<900px), else up to 6
+ const w = window.innerWidth;
+ let maxCols = 6;
+ if (w < 600) maxCols = 1;
+ else if (w < 900) maxCols = 2;
+
+ // ensure current value doesn’t exceed the new max
+ const startCols = Math.min(numColumns, maxCols);
+ window.galleryColumns = startCols;
// --- Start gallery grid ---
galleryHTML += `
@@ -627,32 +705,52 @@ export function renderGalleryView(folder, container) {
${tagBadgesHTML}
-
-
- file_download
-
- ${file.editable ? `
-
- edit
- ` : ""}
-
- drive_file_rename_outline
-
-
- share
-
-
+
+
+ file_download
+
+
+ ${file.editable ? `
+
+ edit
+ ` : ""}
+
+
+ drive_file_rename_outline
+
+
+
+ share
+
+
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];
+ }
+}