ui: polish header and user panel with dropdown + profile pic support & file list adjustments
This commit is contained in:
79
CHANGELOG.md
79
CHANGELOG.md
@@ -1,5 +1,84 @@
|
|||||||
# Changelog
|
# 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 `<tr>` 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
|
## Changes 5/8/2025
|
||||||
|
|
||||||
### Docker 🐳
|
### Docker 🐳
|
||||||
|
|||||||
15
public/api/profile/getCurrentUser.php
Normal file
15
public/api/profile/getCurrentUser.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if (empty($_SESSION['authenticated'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error'=>'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $_SESSION['username'];
|
||||||
|
$data = UserModel::getUser($user);
|
||||||
|
echo json_encode($data);
|
||||||
17
public/api/profile/uploadPicture.php
Normal file
17
public/api/profile/uploadPicture.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
// Always JSON, even on PHP notices
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userController = new UserController();
|
||||||
|
$userController->uploadPicture();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Exception: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
BIN
public/assets/default-avatar.png
Normal file
BIN
public/assets/default-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -134,17 +134,27 @@ body.dark-mode header {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 9px;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
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 {
|
.header-buttons button:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
header {
|
header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -955,6 +965,23 @@ body.dark-mode #fileList table tr {
|
|||||||
padding: 8px 10px !important;
|
padding: 8px 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--file-row-height: 48px; /* default, will be overwritten by your slider */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force each <tr> to be exactly the var() height */
|
||||||
|
#fileList table.table tbody tr {
|
||||||
|
height: var(--file-row-height) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* And force each <td> 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
|
HEADINGS & FORM LABELS
|
||||||
@@ -1328,26 +1355,6 @@ body.dark-mode .image-preview-modal-content {
|
|||||||
border-color: #444;
|
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 {
|
.image-modal-img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@@ -2102,13 +2109,23 @@ body.dark-mode .header-drop-zone.drag-active {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
#fileSummary {
|
#fileSummary,
|
||||||
float: none !important;
|
#rowHeightSliderContainer,
|
||||||
margin: 0 auto !important;
|
#viewSliderContainer {
|
||||||
text-align: center !important;
|
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 {
|
body.dark-mode #fileSummary {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -2165,4 +2182,64 @@ body.dark-mode #searchIcon .material-icons {
|
|||||||
body.dark-mode .btn-icon:hover,
|
body.dark-mode .btn-icon:hover,
|
||||||
body.dark-mode .btn-icon:focus {
|
body.dark-mode .btn-icon:focus {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
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;
|
||||||
}
|
}
|
||||||
@@ -135,9 +135,6 @@
|
|||||||
<!-- Your header drop zone -->
|
<!-- Your header drop zone -->
|
||||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<button id="logoutBtn" data-i18n-title="logout">
|
|
||||||
<i class="material-icons">exit_to_app</i>
|
|
||||||
</button>
|
|
||||||
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
||||||
<i class="material-icons">vpn_key</i>
|
<i class="material-icons">vpn_key</i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { loadAdminConfigFunc } from './auth.js';
|
|||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
|
|
||||||
const version = "v1.3.3";
|
const version = "v1.3.4";
|
||||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||||
|
|
||||||
// ————— Inject updated styles —————
|
// ————— Inject updated styles —————
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import {
|
|||||||
openUserPanel,
|
openUserPanel,
|
||||||
openTOTPModal,
|
openTOTPModal,
|
||||||
closeTOTPModal,
|
closeTOTPModal,
|
||||||
setLastLoginData
|
setLastLoginData,
|
||||||
|
openApiModal
|
||||||
} from './authModals.js';
|
} from './authModals.js';
|
||||||
import { openAdminPanel } from './adminPanel.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)
|
// Production OIDC configuration (override via API as needed)
|
||||||
const currentOIDCConfig = {
|
const currentOIDCConfig = {
|
||||||
@@ -154,7 +155,7 @@ function updateLoginOptionsUIFromStorage() {
|
|||||||
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
||||||
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
||||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "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);
|
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAuthenticatedUI(data) {
|
async function fetchProfilePicture() {
|
||||||
document.getElementById('loadingOverlay').remove();
|
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)
|
export async function updateAuthenticatedUI(data) {
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
// 1) Remove loading overlay safely
|
||||||
document.getElementById('loginForm').style.display = 'none';
|
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("loginForm", false);
|
||||||
toggleVisibility("mainOperations", true);
|
toggleVisibility("mainOperations", true);
|
||||||
toggleVisibility("uploadFileForm", true);
|
toggleVisibility("uploadFileForm", true);
|
||||||
toggleVisibility("fileListContainer", true);
|
toggleVisibility("fileListContainer", true);
|
||||||
//attachEnterKeyListener("addUserModal", "saveUserBtn");
|
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
|
||||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
|
||||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||||
|
|
||||||
|
// 3) Persist auth flags (unchanged)
|
||||||
if (typeof data.totp_enabled !== "undefined") {
|
if (typeof data.totp_enabled !== "undefined") {
|
||||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||||
}
|
}
|
||||||
@@ -221,64 +246,143 @@ function updateAuthenticatedUI(data) {
|
|||||||
localStorage.setItem("username", data.username);
|
localStorage.setItem("username", data.username);
|
||||||
}
|
}
|
||||||
if (typeof data.folderOnly !== "undefined") {
|
if (typeof data.folderOnly !== "undefined") {
|
||||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||||
localStorage.setItem("disableUpload", data.disableUpload ? "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 headerButtons = document.querySelector(".header-buttons");
|
||||||
const firstButton = headerButtons.firstElementChild;
|
const firstButton = headerButtons.firstElementChild;
|
||||||
|
|
||||||
|
// a) restore-from-trash for admins
|
||||||
if (data.isAdmin) {
|
if (data.isAdmin) {
|
||||||
let restoreBtn = document.getElementById("restoreFilesBtn");
|
let r = document.getElementById("restoreFilesBtn");
|
||||||
if (!restoreBtn) {
|
if (!r) {
|
||||||
restoreBtn = document.createElement("button");
|
r = document.createElement("button");
|
||||||
restoreBtn.id = "restoreFilesBtn";
|
r.id = "restoreFilesBtn";
|
||||||
restoreBtn.classList.add("btn", "btn-warning");
|
r.classList.add("btn","btn-warning");
|
||||||
restoreBtn.setAttribute("data-i18n-title", "trash_restore_delete");
|
r.setAttribute("data-i18n-title","trash_restore_delete");
|
||||||
restoreBtn.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||||
if (firstButton) insertAfter(restoreBtn, firstButton);
|
if (firstButton) insertAfter(r, firstButton);
|
||||||
else headerButtons.appendChild(restoreBtn);
|
else headerButtons.appendChild(r);
|
||||||
}
|
|
||||||
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 = '<i class="material-icons">admin_panel_settings</i>';
|
|
||||||
insertAfter(adminPanelBtn, restoreBtn);
|
|
||||||
adminPanelBtn.addEventListener("click", openAdminPanel);
|
|
||||||
} else {
|
|
||||||
adminPanelBtn.style.display = "block";
|
|
||||||
}
|
}
|
||||||
|
r.style.display = "block";
|
||||||
} else {
|
} else {
|
||||||
const restoreBtn = document.getElementById("restoreFilesBtn");
|
const r = document.getElementById("restoreFilesBtn");
|
||||||
if (restoreBtn) restoreBtn.style.display = "none";
|
if (r) r.style.display = "none";
|
||||||
const adminPanelBtn = document.getElementById("adminPanelBtn");
|
|
||||||
if (adminPanelBtn) adminPanelBtn.style.display = "none";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.hostname !== "demo.filerise.net") {
|
// b) admin panel button only on demo.filerise.net
|
||||||
let userPanelBtn = document.getElementById("userPanelBtn");
|
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
||||||
if (!userPanelBtn) {
|
let a = document.getElementById("adminPanelBtn");
|
||||||
userPanelBtn = document.createElement("button");
|
if (!a) {
|
||||||
userPanelBtn.id = "userPanelBtn";
|
a = document.createElement("button");
|
||||||
userPanelBtn.classList.add("btn", "btn-user");
|
a.id = "adminPanelBtn";
|
||||||
userPanelBtn.setAttribute("data-i18n-title", "user_panel");
|
a.classList.add("btn","btn-info");
|
||||||
userPanelBtn.innerHTML = '<i class="material-icons">account_circle</i>';
|
a.setAttribute("data-i18n-title","admin_panel");
|
||||||
|
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||||
|
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
|
||||||
|
? `<img src="${profilePicUrl}" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;">`
|
||||||
|
: `<i class="material-icons">account_circle</i>`;
|
||||||
|
|
||||||
|
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}<span class="dropdown-username">${data.username}</span><span class="dropdown-caret"></span>`;
|
||||||
|
dd.append(toggle);
|
||||||
|
|
||||||
|
// menu
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.classList.add("user-menu");
|
||||||
|
menu.innerHTML = `
|
||||||
|
<div class="item" id="menuUserPanel">
|
||||||
|
<i class="material-icons folder-icon">person</i> ${t("user_panel")}
|
||||||
|
</div>
|
||||||
|
${data.isAdmin ? `
|
||||||
|
<div class="item" id="menuAdminPanel">
|
||||||
|
<i class="material-icons folder-icon">admin_panel_settings</i> ${t("admin_panel")}
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="item" id="menuApiDocs">
|
||||||
|
<i class="material-icons folder-icon">description</i> ${t("api_docs")}
|
||||||
|
</div>
|
||||||
|
<div class="item" id="menuLogout">
|
||||||
|
<i class="material-icons folder-icon">logout</i> ${t("logout")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 {
|
} else {
|
||||||
userPanelBtn.style.display = "block";
|
// update avatar only
|
||||||
|
const tog = dd.querySelector("#userDropdownToggle");
|
||||||
|
tog.innerHTML = `${avatarHTML}<span class="dropdown-username">${data.username}</span><span class="dropdown-caret"></span>`;
|
||||||
|
dd.style.display = "inline-block";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6) Finalize
|
||||||
initializeApp();
|
initializeApp();
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
updateItemsPerPageSelect();
|
updateItemsPerPageSelect();
|
||||||
@@ -289,7 +393,8 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
return sendRequest("/api/auth/checkAuth.php")
|
return sendRequest("/api/auth/checkAuth.php")
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.setup) {
|
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)
|
// show the wrapper (so the login form can be visible)
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
@@ -322,13 +427,14 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
updateAuthenticatedUI(data);
|
updateAuthenticatedUI(data);
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
|
||||||
// show the wrapper (so the login form can be visible)
|
// show the wrapper (so the login form can be visible)
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
document.getElementById('loginForm').style.display = '';
|
document.getElementById('loginForm').style.display = '';
|
||||||
if (showLoginToast) showToast("Please log in to continue.");
|
if (showLoginToast) showToast("Please log in to continue.");
|
||||||
toggleVisibility("loginForm", ! (localStorage.getItem("authBypass")==="true"));
|
toggleVisibility("loginForm", !(localStorage.getItem("authBypass") === "true"));
|
||||||
toggleVisibility("mainOperations", false);
|
toggleVisibility("mainOperations", false);
|
||||||
toggleVisibility("uploadFileForm", false);
|
toggleVisibility("uploadFileForm", false);
|
||||||
toggleVisibility("fileListContainer", false);
|
toggleVisibility("fileListContainer", false);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||||
import { sendRequest } from './networkUtils.js';
|
import { sendRequest } from './networkUtils.js';
|
||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
import { loadAdminConfigFunc } from './auth.js';
|
import { loadAdminConfigFunc, updateAuthenticatedUI } from './auth.js';
|
||||||
|
|
||||||
|
|
||||||
let lastLoginData = null;
|
let lastLoginData = null;
|
||||||
export function setLastLoginData(data) {
|
export function setLastLoginData(data) {
|
||||||
@@ -60,14 +59,11 @@ export function openTOTPLoginModal() {
|
|||||||
const totpSection = document.getElementById("totpSection");
|
const totpSection = document.getElementById("totpSection");
|
||||||
const recoverySection = document.getElementById("recoverySection");
|
const recoverySection = document.getElementById("recoverySection");
|
||||||
const toggleLink = this;
|
const toggleLink = this;
|
||||||
|
|
||||||
if (recoverySection.style.display === "none") {
|
if (recoverySection.style.display === "none") {
|
||||||
// Switch to recovery
|
|
||||||
totpSection.style.display = "none";
|
totpSection.style.display = "none";
|
||||||
recoverySection.style.display = "block";
|
recoverySection.style.display = "block";
|
||||||
toggleLink.textContent = t("use_totp_code_instead");
|
toggleLink.textContent = t("use_totp_code_instead");
|
||||||
} else {
|
} else {
|
||||||
// Switch back to TOTP
|
|
||||||
recoverySection.style.display = "none";
|
recoverySection.style.display = "none";
|
||||||
totpSection.style.display = "block";
|
totpSection.style.display = "block";
|
||||||
toggleLink.textContent = t("use_recovery_code_instead");
|
toggleLink.textContent = t("use_recovery_code_instead");
|
||||||
@@ -93,7 +89,6 @@ export function openTOTPLoginModal() {
|
|||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(json => {
|
.then(json => {
|
||||||
if (json.status === "ok") {
|
if (json.status === "ok") {
|
||||||
// recovery succeeded → finalize login
|
|
||||||
window.location.href = "/index.html";
|
window.location.href = "/index.html";
|
||||||
} else {
|
} else {
|
||||||
showToast(json.message || t("recovery_code_verification_failed"));
|
showToast(json.message || t("recovery_code_verification_failed"));
|
||||||
@@ -107,17 +102,11 @@ export function openTOTPLoginModal() {
|
|||||||
// TOTP submission
|
// TOTP submission
|
||||||
const totpInput = document.getElementById("totpLoginInput");
|
const totpInput = document.getElementById("totpLoginInput");
|
||||||
totpInput.focus();
|
totpInput.focus();
|
||||||
|
|
||||||
totpInput.addEventListener("input", async function () {
|
totpInput.addEventListener("input", async function () {
|
||||||
const code = this.value.trim();
|
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) {
|
if (!tokenRes.ok) {
|
||||||
showToast(t("totp_verification_failed"));
|
showToast(t("totp_verification_failed"));
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +133,6 @@ export function openTOTPLoginModal() {
|
|||||||
} else {
|
} else {
|
||||||
showToast(t("totp_verification_failed"));
|
showToast(t("totp_verification_failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.value = "";
|
this.value = "";
|
||||||
totpLoginModal.style.display = "flex";
|
totpLoginModal.style.display = "flex";
|
||||||
this.focus();
|
this.focus();
|
||||||
@@ -160,153 +148,209 @@ export function openTOTPLoginModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openUserPanel() {
|
/**
|
||||||
const username = localStorage.getItem("username") || "User";
|
* Fetch current user info (username, profile_picture, totp_enabled)
|
||||||
let userPanelModal = document.getElementById("userPanelModal");
|
*/
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
async function fetchCurrentUser() {
|
||||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
try {
|
||||||
const modalContentStyles = `
|
const res = await fetch('/api/profile/getCurrentUser.php', {
|
||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
credentials: 'include'
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
});
|
||||||
padding: 20px;
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
max-width: 600px;
|
return await res.json();
|
||||||
width: 90%;
|
} catch (e) {
|
||||||
border-radius: 8px;
|
console.warn('fetchCurrentUser failed:', e);
|
||||||
overflow-y: auto;
|
return {};
|
||||||
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";
|
|
||||||
|
|
||||||
if (!userPanelModal) {
|
/**
|
||||||
userPanelModal = document.createElement("div");
|
* Normalize any profile‐picture URL:
|
||||||
userPanelModal.id = "userPanelModal";
|
* - strip leading colons
|
||||||
userPanelModal.style.cssText = `
|
* - ensure exactly one leading slash
|
||||||
position: fixed;
|
*/
|
||||||
top: 0; right: 0; bottom: 0; left: 0;
|
function normalizePicUrl(raw) {
|
||||||
background-color: ${overlayBackground};
|
if (!raw) return '';
|
||||||
display: flex;
|
// take only what's after the last colon
|
||||||
justify-content: center;
|
const parts = raw.split(':');
|
||||||
align-items: center;
|
let pic = parts[parts.length - 1];
|
||||||
z-index: 1000;
|
// strip any stray colons
|
||||||
overflow: hidden;
|
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 = `
|
|
||||||
<div class="modal-content user-panel-content" style="${modalContentStyles}">
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="${contentCss}">
|
||||||
<span id="closeUserPanel" class="editor-close-btn">×</span>
|
<span id="closeUserPanel" class="editor-close-btn">×</span>
|
||||||
<h3>${t("user_panel")} (${username})</h3>
|
<div style="text-align:center; margin-bottom:20px;">
|
||||||
|
<div style="position:relative; width:80px; height:80px; margin:0 auto;">
|
||||||
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;">
|
<img id="profilePicPreview"
|
||||||
${t("change_password")}
|
src="${picUrl || '/assets/default-avatar.png'}"
|
||||||
</button>
|
style="width:100%; height:100%; border-radius:50%; object-fit:cover;">
|
||||||
|
<label for="profilePicInput"
|
||||||
<fieldset style="margin-bottom: 15px;">
|
style="
|
||||||
<legend>${t("totp_settings")}</legend>
|
position:absolute; bottom:0; right:0;
|
||||||
<div class="form-group">
|
width:24px; height:24px; background:rgba(0,0,0,0.6);
|
||||||
<label for="userTOTPEnabled">${t("enable_totp")}:</label>
|
border-radius:50%; display:flex; align-items:center;
|
||||||
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" />
|
justify-content:center; cursor:pointer;">
|
||||||
|
<i class="material-icons" style="color:#fff; font-size:16px;">edit</i>
|
||||||
|
</label>
|
||||||
|
<input type="file" id="profilePicInput" accept="image/*" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset style="margin-bottom: 15px;">
|
|
||||||
<legend>${t("language")}</legend>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="languageSelector">${t("select_language")}:</label>
|
|
||||||
<select id="languageSelector">
|
|
||||||
<option value="en">${t("english")}</option>
|
|
||||||
<option value="es">${t("spanish")}</option>
|
|
||||||
<option value="fr">${t("french")}</option>
|
|
||||||
<option value="de">${t("german")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- New API Docs link -->
|
|
||||||
<div style="margin-bottom: 15px;">
|
|
||||||
<button type="button" id="openApiModalBtn" class="btn btn-secondary">
|
|
||||||
${t("api_docs") || "API Docs"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 style="text-align:center; margin-bottom:20px;">
|
||||||
|
${t('user_panel')} (${username})
|
||||||
|
</h3>
|
||||||
|
<button id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom:15px;">
|
||||||
|
${t('change_password')}
|
||||||
|
</button>
|
||||||
|
<fieldset style="margin-bottom:15px;">
|
||||||
|
<legend>${t('totp_settings')}</legend>
|
||||||
|
<label style="cursor:pointer;">
|
||||||
|
<input type="checkbox" id="userTOTPEnabled" style="vertical-align:middle;">
|
||||||
|
${t('enable_totp')}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset style="margin-bottom:15px;">
|
||||||
|
<legend>${t('language')}</legend>
|
||||||
|
<select id="languageSelector" class="form-select">
|
||||||
|
<option value="en">${t('english')}</option>
|
||||||
|
<option value="es">${t('spanish')}</option>
|
||||||
|
<option value="fr">${t('french')}</option>
|
||||||
|
<option value="de">${t('german')}</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(userPanelModal);
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
const apiModal = document.createElement("div");
|
// --- wire up handlers ---
|
||||||
apiModal.id = "apiModal";
|
|
||||||
apiModal.style.cssText = `
|
|
||||||
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
|
||||||
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// api.php
|
modal.querySelector('#closeUserPanel')
|
||||||
apiModal.innerHTML = `
|
.addEventListener('click', () => modal.style.display = 'none');
|
||||||
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
|
||||||
<div class="editor-close-btn" id="closeApiModal">×</div>
|
|
||||||
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(apiModal);
|
modal.querySelector('#openChangePasswordModalBtn')
|
||||||
|
.addEventListener('click', () => {
|
||||||
|
document.getElementById('changePasswordModal').style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("openApiModalBtn").addEventListener("click", () => {
|
// TOTP
|
||||||
apiModal.style.display = "flex";
|
const totpCb = modal.querySelector('#userTOTPEnabled');
|
||||||
});
|
totpCb.addEventListener('change', async function () {
|
||||||
document.getElementById("closeApiModal").addEventListener("click", () => {
|
const resp = await fetch('/api/updateUserPanel.php', {
|
||||||
apiModal.style.display = "none";
|
method: 'POST',
|
||||||
});
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
// Handlers…
|
'Content-Type': 'application/json',
|
||||||
document.getElementById("closeUserPanel").addEventListener("click", () => {
|
'X-CSRF-Token': window.csrfToken
|
||||||
userPanelModal.style.display = "none";
|
},
|
||||||
});
|
|
||||||
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => {
|
|
||||||
document.getElementById("changePasswordModal").style.display = "block";
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// TOTP checkbox
|
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
|
||||||
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true";
|
|
||||||
totpCheckbox.addEventListener("change", function () {
|
|
||||||
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false");
|
|
||||||
fetch("/api/updateUserPanel.php", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
|
||||||
body: JSON.stringify({ totp_enabled: this.checked })
|
body: JSON.stringify({ totp_enabled: this.checked })
|
||||||
})
|
});
|
||||||
.then(r => r.json())
|
const js = await resp.json();
|
||||||
.then(result => {
|
if (!js.success) showToast(js.error || t('error_updating_totp_setting'));
|
||||||
if (!result.success) showToast(t("error_updating_totp_setting") + ": " + result.error);
|
else if (this.checked) openTOTPModal();
|
||||||
else if (this.checked) openTOTPModal();
|
|
||||||
})
|
|
||||||
.catch(() => showToast(t("error_updating_totp_setting")));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Language selector
|
// Language
|
||||||
const languageSelector = document.getElementById("languageSelector");
|
const langSel = modal.querySelector('#languageSelector');
|
||||||
languageSelector.value = savedLanguage;
|
langSel.addEventListener('change', function () {
|
||||||
languageSelector.addEventListener("change", function () {
|
localStorage.setItem('language', this.value);
|
||||||
localStorage.setItem("language", this.value);
|
|
||||||
setLocale(this.value);
|
setLocale(this.value);
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto‐upload on file select
|
||||||
|
const fileInput = modal.querySelector('#profilePicInput');
|
||||||
|
fileInput.addEventListener('change', async function () {
|
||||||
|
const file = this.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// preview immediately
|
||||||
|
const img = modal.querySelector('#profilePicPreview');
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
// upload
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('profile_picture', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/profile/uploadPicture.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const js = JSON.parse(text || '{}');
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast(js.error || t('error_updating_picture'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newUrl = normalizePicUrl(js.url);
|
||||||
|
img.src = newUrl;
|
||||||
|
localStorage.setItem('profilePicUrl', newUrl);
|
||||||
|
// refresh the header immediately
|
||||||
|
updateAuthenticatedUI(window.__lastAuthData || {});
|
||||||
|
showToast(t('profile_picture_updated'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast(t('error_updating_picture'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Update colors if already exists
|
|
||||||
userPanelModal.style.backgroundColor = overlayBackground;
|
modal.style.background = overlayBg;
|
||||||
const modalContent = userPanelModal.querySelector(".modal-content");
|
const contentEl = modal.querySelector('.modal-content');
|
||||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
contentEl.style.cssText = contentCss;
|
||||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
// re-open: sync current values
|
||||||
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
modal.querySelector('#profilePicPreview').src = picUrl || '/images/default-avatar.png';
|
||||||
|
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
|
||||||
|
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
userPanelModal.style.display = "flex";
|
// show
|
||||||
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRecoveryCodeModal(recoveryCode) {
|
function showRecoveryCodeModal(recoveryCode) {
|
||||||
@@ -314,26 +358,21 @@ function showRecoveryCodeModal(recoveryCode) {
|
|||||||
recoveryModal.id = "recoveryModal";
|
recoveryModal.id = "recoveryModal";
|
||||||
recoveryModal.style.cssText = `
|
recoveryModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0; left: 0;
|
||||||
left: 0;
|
width: 100vw; height: 100vh;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: rgba(0,0,0,0.3);
|
background-color: rgba(0,0,0,0.3);
|
||||||
display: flex;
|
display: flex; justify-content: center; align-items: center;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3200;
|
z-index: 3200;
|
||||||
`;
|
`;
|
||||||
recoveryModal.innerHTML = `
|
recoveryModal.innerHTML = `
|
||||||
<div style="background: #fff; color: #000; padding: 20px; max-width: 400px; width: 90%; border-radius: 8px; text-align: center;">
|
<div style="background:#fff; color:#000; padding:20px; max-width:400px; width:90%; border-radius:8px; text-align:center;">
|
||||||
<h3>${t("your_recovery_code")}</h3>
|
<h3>${t("your_recovery_code")}</h3>
|
||||||
<p>${t("please_save_recovery_code")}</p>
|
<p>${t("please_save_recovery_code")}</p>
|
||||||
<code style="display: block; margin: 10px 0; font-size: 20px;">${recoveryCode}</code>
|
<code style="display:block; margin:10px 0; font-size:20px;">${recoveryCode}</code>
|
||||||
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
<button type="button" id="closeRecoveryModal" class="btn btn-primary">${t("ok")}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(recoveryModal);
|
document.body.appendChild(recoveryModal);
|
||||||
|
|
||||||
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
document.getElementById("closeRecoveryModal").addEventListener("click", () => {
|
||||||
recoveryModal.remove();
|
recoveryModal.remove();
|
||||||
});
|
});
|
||||||
@@ -346,109 +385,57 @@ export function openTOTPModal() {
|
|||||||
const modalContentStyles = `
|
const modalContentStyles = `
|
||||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||||
padding: 20px;
|
padding: 20px; max-width:400px; width:90%; border-radius:8px; position:relative;
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
border-radius: 8px;
|
|
||||||
position: relative;
|
|
||||||
`;
|
`;
|
||||||
if (!totpModal) {
|
if (!totpModal) {
|
||||||
totpModal = document.createElement("div");
|
totpModal = document.createElement("div");
|
||||||
totpModal.id = "totpModal";
|
totpModal.id = "totpModal";
|
||||||
totpModal.style.cssText = `
|
totpModal.style.cssText = `
|
||||||
position: fixed;
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
top: 0;
|
background-color:${overlayBackground}; display:flex; justify-content:center; align-items:center;
|
||||||
left: 0;
|
z-index:3100;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: ${overlayBackground};
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3100;
|
|
||||||
`;
|
`;
|
||||||
totpModal.innerHTML = `
|
totpModal.innerHTML = `
|
||||||
<div class="modal-content" style="${modalContentStyles}">
|
<div class="modal-content" style="${modalContentStyles}">
|
||||||
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
<span id="closeTOTPModal" class="editor-close-btn">×</span>
|
||||||
<h3>${t("totp_setup")}</h3>
|
<h3>${t("totp_setup")}</h3>
|
||||||
<p>${t("scan_qr_code")}</p>
|
<p>${t("scan_qr_code")}</p>
|
||||||
<!-- Create an image placeholder without the CSRF token in the src -->
|
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width:100%; height:auto; display:block; margin:0 auto;" />
|
||||||
<img id="totpQRCodeImage" src="" alt="TOTP QR Code" style="max-width: 100%; height: auto; display: block; margin: 0 auto;">
|
<br/>
|
||||||
<br/>
|
<p>${t("enter_totp_confirmation")}</p>
|
||||||
<p>${t("enter_totp_confirmation")}</p>
|
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
||||||
<input type="text" id="totpConfirmInput" maxlength="6" style="font-size:24px; text-align:center; width:100%; padding:10px;" placeholder="6-digit code" />
|
<br/><br/>
|
||||||
<br/><br/>
|
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
||||||
<button type="button" id="confirmTOTPBtn" class="btn btn-primary">${t("confirm")}</button>
|
</div>
|
||||||
</div>
|
`;
|
||||||
`;
|
|
||||||
document.body.appendChild(totpModal);
|
document.body.appendChild(totpModal);
|
||||||
loadTOTPQRCode();
|
loadTOTPQRCode();
|
||||||
|
document.getElementById("closeTOTPModal").addEventListener("click", () => closeTOTPModal(true));
|
||||||
document.getElementById("closeTOTPModal").addEventListener("click", () => {
|
|
||||||
closeTOTPModal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
document.getElementById("confirmTOTPBtn").addEventListener("click", async function () {
|
||||||
const code = document.getElementById("totpConfirmInput").value.trim();
|
const code = document.getElementById("totpConfirmInput").value.trim();
|
||||||
if (code.length !== 6) {
|
if (code.length !== 6) { showToast(t("please_enter_valid_code")); return; }
|
||||||
showToast(t("please_enter_valid_code"));
|
const tokenRes = await fetch("/api/auth/token.php", { credentials: "include" });
|
||||||
return;
|
if (!tokenRes.ok) { showToast(t("error_verifying_totp_code")); return; }
|
||||||
}
|
window.csrfToken = (await tokenRes.json()).csrf_token;
|
||||||
|
|
||||||
const tokenRes = await fetch("/api/auth/token.php", {
|
|
||||||
credentials: "include"
|
|
||||||
});
|
|
||||||
if (!tokenRes.ok) {
|
|
||||||
showToast(t("error_verifying_totp_code"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { csrf_token } = await tokenRes.json();
|
|
||||||
window.csrfToken = csrf_token;
|
|
||||||
|
|
||||||
const verifyRes = await fetch("/api/totp_verify.php", {
|
const verifyRes = await fetch("/api/totp_verify.php", {
|
||||||
method: "POST",
|
method: "POST", credentials: "include",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ totp_code: code })
|
body: JSON.stringify({ totp_code: code })
|
||||||
});
|
});
|
||||||
|
if (!verifyRes.ok) { showToast(t("totp_verification_failed")); return; }
|
||||||
if (!verifyRes.ok) {
|
|
||||||
showToast(t("totp_verification_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await verifyRes.json();
|
const result = await verifyRes.json();
|
||||||
if (result.status !== "ok") {
|
if (result.status !== "ok") { showToast(result.message || t("totp_verification_failed")); return; }
|
||||||
showToast(result.message || t("totp_verification_failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(t("totp_enabled_successfully"));
|
showToast(t("totp_enabled_successfully"));
|
||||||
|
|
||||||
const saveRes = await fetch("/api/totp_saveCode.php", {
|
const saveRes = await fetch("/api/totp_saveCode.php", {
|
||||||
method: "POST",
|
method: "POST", credentials: "include", headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!saveRes.ok) {
|
if (!saveRes.ok) { showToast(t("error_generating_recovery_code")); closeTOTPModal(false); return; }
|
||||||
showToast(t("error_generating_recovery_code"));
|
|
||||||
closeTOTPModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await saveRes.json();
|
const data = await saveRes.json();
|
||||||
if (data.status === "ok" && data.recoveryCode) {
|
if (data.status === "ok" && data.recoveryCode) showRecoveryCodeModal(data.recoveryCode);
|
||||||
showRecoveryCodeModal(data.recoveryCode);
|
else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
||||||
} else {
|
|
||||||
showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
|
|
||||||
}
|
|
||||||
|
|
||||||
closeTOTPModal(false);
|
closeTOTPModal(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus the input and attach enter key listener
|
// Focus the input and attach enter key listener
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
||||||
if (totpConfirmInput) {
|
if (totpConfirmInput) {
|
||||||
@@ -458,29 +445,18 @@ export function openTOTPModal() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
totpModal.style.display = "flex";
|
totpModal.style.display = "flex";
|
||||||
totpModal.style.backgroundColor = overlayBackground;
|
totpModal.style.backgroundColor = overlayBackground;
|
||||||
const modalContent = totpModal.querySelector(".modal-content");
|
const modalContent = totpModal.querySelector(".modal-content");
|
||||||
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff";
|
||||||
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000";
|
||||||
|
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc";
|
||||||
// Clear any previous QR code src if needed and then load it:
|
|
||||||
const qrImg = document.getElementById("totpQRCodeImage");
|
|
||||||
if (qrImg) {
|
|
||||||
qrImg.src = "";
|
|
||||||
}
|
|
||||||
loadTOTPQRCode();
|
loadTOTPQRCode();
|
||||||
|
const totpInput = document.getElementById("totpConfirmInput");
|
||||||
// Focus the input and attach enter key listener
|
if (totpInput) {
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
totpInput.value = "";
|
||||||
if (totpConfirmInput) {
|
setTimeout(() => totpInput.focus(), 100);
|
||||||
totpConfirmInput.value = "";
|
|
||||||
setTimeout(() => {
|
|
||||||
const totpConfirmInput = document.getElementById("totpConfirmInput");
|
|
||||||
if (totpConfirmInput) totpConfirmInput.focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
attachEnterKeyListener("totpModal", "confirmTOTPBtn");
|
||||||
}
|
}
|
||||||
@@ -490,42 +466,31 @@ function loadTOTPQRCode() {
|
|||||||
fetch("/api/totp_setup.php", {
|
fetch("/api/totp_setup.php", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
"X-CSRF-Token": window.csrfToken // Send your CSRF token here
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(res => {
|
||||||
if (!response.ok) {
|
if (!res.ok) throw new Error("Failed to fetch QR code: " + res.status);
|
||||||
throw new Error("Failed to fetch QR code. Status: " + response.status);
|
return res.blob();
|
||||||
}
|
|
||||||
return response.blob();
|
|
||||||
})
|
})
|
||||||
.then(blob => {
|
.then(blob => {
|
||||||
const imageURL = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const qrImg = document.getElementById("totpQRCodeImage");
|
document.getElementById("totpQRCodeImage").src = url;
|
||||||
if (qrImg) {
|
|
||||||
qrImg.src = imageURL;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(err => {
|
||||||
console.error("Error loading TOTP QR code:", error);
|
console.error(err);
|
||||||
showToast(t("error_loading_qr_code"));
|
showToast(t("error_loading_qr_code"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated closeTOTPModal function with a disable parameter
|
|
||||||
export function closeTOTPModal(disable = true) {
|
export function closeTOTPModal(disable = true) {
|
||||||
const totpModal = document.getElementById("totpModal");
|
const totpModal = document.getElementById("totpModal");
|
||||||
if (totpModal) totpModal.style.display = "none";
|
if (totpModal) totpModal.style.display = "none";
|
||||||
|
|
||||||
if (disable) {
|
if (disable) {
|
||||||
// Uncheck the Enable TOTP checkbox
|
|
||||||
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
const totpCheckbox = document.getElementById("userTOTPEnabled");
|
||||||
if (totpCheckbox) {
|
if (totpCheckbox) {
|
||||||
totpCheckbox.checked = false;
|
totpCheckbox.checked = false;
|
||||||
localStorage.setItem("userTOTPEnabled", "false");
|
localStorage.setItem("userTOTPEnabled", "false");
|
||||||
}
|
}
|
||||||
// Call endpoint to remove the TOTP secret from the user's record
|
|
||||||
fetch("/api/totp_disable.php", {
|
fetch("/api/totp_disable.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -536,10 +501,36 @@ export function closeTOTPModal(disable = true) {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success) {
|
if (!result.success) showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
||||||
showToast(t("error_disabling_totp_setting") + ": " + result.error);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => { showToast(t("error_disabling_totp_setting")); });
|
.catch(() => showToast(t("error_disabling_totp_setting")));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openApiModal() {
|
||||||
|
let apiModal = document.getElementById("apiModal");
|
||||||
|
if (!apiModal) {
|
||||||
|
// create the container exactly as you do now inside openUserPanel
|
||||||
|
apiModal = document.createElement("div");
|
||||||
|
apiModal.id = "apiModal";
|
||||||
|
apiModal.style.cssText = `
|
||||||
|
position: fixed; top:0; left:0; width:100vw; height:100vh;
|
||||||
|
background: rgba(0,0,0,0.8); z-index: 4000; display:none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
`;
|
||||||
|
apiModal.innerHTML = `
|
||||||
|
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
|
||||||
|
<div class="editor-close-btn" id="closeApiModal">×</div>
|
||||||
|
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(apiModal);
|
||||||
|
|
||||||
|
// wire up its close button
|
||||||
|
document.getElementById("closeApiModal").addEventListener("click", () => {
|
||||||
|
apiModal.style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// finally, show it
|
||||||
|
apiModal.style.display = "flex";
|
||||||
}
|
}
|
||||||
@@ -178,9 +178,14 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||||
}
|
}
|
||||||
previewButton = `<button class="btn btn-sm btn-info preview-btn" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-name="${safeFileName}">
|
previewButton = `<button
|
||||||
${previewIcon}
|
type="button"
|
||||||
</button>`;
|
class="btn btn-sm btn-info preview-btn"
|
||||||
|
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||||
|
data-preview-name="${safeFileName}"
|
||||||
|
title="${t('preview')}">
|
||||||
|
${previewIcon}
|
||||||
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -194,19 +199,44 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
<td class="hide-small nowrap">${safeSize}</td>
|
<td class="hide-small nowrap">${safeSize}</td>
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
|
||||||
<button type="button" class="btn btn-sm btn-success download-btn" data-download-name="${file.name}" data-download-folder="${file.folder || 'root'}" title="${t('download')}">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-success download-btn"
|
||||||
|
data-download-name="${file.name}"
|
||||||
|
data-download-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('download')}">
|
||||||
<i class="material-icons">file_download</i>
|
<i class="material-icons">file_download</i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
${file.editable ? `
|
${file.editable ? `
|
||||||
<button class="btn btn-sm edit-btn" data-edit-name="${file.name}" data-edit-folder="${file.folder || 'root'}" title="${t('edit')}">
|
<button
|
||||||
<i class="material-icons">edit</i>
|
type="button"
|
||||||
</button>
|
class="btn btn-sm btn-secondary edit-btn"
|
||||||
` : ""}
|
data-edit-name="${file.name}"
|
||||||
|
data-edit-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('edit')}">
|
||||||
|
<i class="material-icons">edit</i>
|
||||||
|
</button>` : ""}
|
||||||
|
|
||||||
${previewButton}
|
${previewButton}
|
||||||
<button class="btn btn-sm btn-warning rename-btn" data-rename-name="${file.name}" data-rename-folder="${file.folder || 'root'}" title="${t('rename')}">
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-warning rename-btn"
|
||||||
|
data-rename-name="${file.name}"
|
||||||
|
data-rename-folder="${file.folder || 'root'}"
|
||||||
|
title="${t('rename')}">
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- share -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||||
|
data-file="${safeFileName}"
|
||||||
|
title="${t('share')}">
|
||||||
|
<i class="material-icons">share</i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -186,9 +186,6 @@ export function formatFolderName(folder) {
|
|||||||
window.toggleRowSelection = toggleRowSelection;
|
window.toggleRowSelection = toggleRowSelection;
|
||||||
window.updateRowHighlight = updateRowHighlight;
|
window.updateRowHighlight = updateRowHighlight;
|
||||||
|
|
||||||
/**
|
|
||||||
* --- FILE LIST & VIEW RENDERING ---
|
|
||||||
*/
|
|
||||||
export function loadFileList(folderParam) {
|
export function loadFileList(folderParam) {
|
||||||
const folder = folderParam || "root";
|
const folder = folderParam || "root";
|
||||||
const fileListContainer = document.getElementById("fileList");
|
const fileListContainer = document.getElementById("fileList");
|
||||||
@@ -196,77 +193,151 @@ export function loadFileList(folderParam) {
|
|||||||
fileListContainer.style.visibility = "hidden";
|
fileListContainer.style.visibility = "hidden";
|
||||||
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
||||||
|
|
||||||
return fetch("/api/file/getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime())
|
return fetch(
|
||||||
.then(response => {
|
"/api/file/getFileList.php?folder=" +
|
||||||
if (response.status === 401) {
|
encodeURIComponent(folder) +
|
||||||
showToast("Session expired. Please log in again.");
|
"&recursive=1&t=" +
|
||||||
window.location.href = "/api/auth/logout.php";
|
Date.now()
|
||||||
throw new Error("Unauthorized");
|
)
|
||||||
}
|
.then((res) =>
|
||||||
return response.json();
|
res.status === 401
|
||||||
})
|
? (window.location.href = "/api/auth/logout.php" && Promise.reject("Unauthorized"))
|
||||||
.then(data => {
|
: res.json()
|
||||||
fileListContainer.innerHTML = ""; // Clear loading message.
|
)
|
||||||
if (data.files && Object.keys(data.files).length > 0) {
|
.then((data) => {
|
||||||
// If the returned "files" is an object instead of an array, transform it.
|
fileListContainer.innerHTML = "";
|
||||||
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;
|
|
||||||
|
|
||||||
// Update file summary.
|
// No files case
|
||||||
const actionsContainer = document.getElementById("fileListActions");
|
if (!data.files || Object.keys(data.files).length === 0) {
|
||||||
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 {
|
|
||||||
fileListContainer.textContent = t("no_files_found");
|
fileListContainer.textContent = t("no_files_found");
|
||||||
|
|
||||||
|
// hide summary
|
||||||
const summaryElem = document.getElementById("fileSummary");
|
const summaryElem = document.getElementById("fileSummary");
|
||||||
if (summaryElem) {
|
if (summaryElem) summaryElem.style.display = "none";
|
||||||
summaryElem.style.display = "none";
|
|
||||||
}
|
// hide slider
|
||||||
|
const sliderContainer = document.getElementById("viewSliderContainer");
|
||||||
|
if (sliderContainer) sliderContainer.style.display = "none";
|
||||||
|
|
||||||
updateFileActionButtons();
|
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 = `
|
||||||
|
<label for="galleryColumnsSlider" style="margin-right:8px; white-space:nowrap; line-height:1;">
|
||||||
|
${t("columns")}:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="galleryColumnsSlider"
|
||||||
|
min="1"
|
||||||
|
max="${maxCols}"
|
||||||
|
value="${currentCols}"
|
||||||
|
style="vertical-align:middle;"
|
||||||
|
>
|
||||||
|
<span id="galleryColumnsValue" style="margin-left:6px; line-height:1;">${currentCols}</span>
|
||||||
|
`;
|
||||||
|
// 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 = `
|
||||||
|
<label for="rowHeightSlider" style="margin-right:8px; white-space:nowrap; line-height:1;">
|
||||||
|
${t("row_height")}:
|
||||||
|
</label>
|
||||||
|
<input type="range" id="rowHeightSlider" min="31" max="60" value="${currentHeight}" style="vertical-align:middle;">
|
||||||
|
<span id="rowHeightValue" style="margin-left:6px; line-height:1;">${currentHeight}px</span>
|
||||||
|
`;
|
||||||
|
// 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 => {
|
.catch((err) => {
|
||||||
console.error("Error loading file list:", error);
|
console.error("Error loading file list:", err);
|
||||||
if (error.message !== "Unauthorized") {
|
if (err !== "Unauthorized") {
|
||||||
fileListContainer.textContent = "Error loading files.";
|
fileListContainer.textContent = "Error loading files.";
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -327,9 +398,6 @@ export function renderFileTable(folder, container) {
|
|||||||
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
||||||
return p1 + p2 + tagBadgesHTML + p3;
|
return p1 + p2 + tagBadgesHTML + p3;
|
||||||
});
|
});
|
||||||
rowHTML = rowHTML.replace(/(<\/div>\s*<\/td>\s*<\/tr>)/, `<button class="share-btn btn btn-sm btn-secondary" data-file="${escapeHTML(file.name)}" title="${t('share')}">
|
|
||||||
<i class="material-icons">share</i>
|
|
||||||
</button>$1`);
|
|
||||||
rowsHTML += rowHTML;
|
rowsHTML += rowHTML;
|
||||||
});
|
});
|
||||||
} else {
|
} 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 => {
|
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", e => {
|
btn.addEventListener("click", e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -441,6 +509,17 @@ export function renderFileTable(folder, container) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, 300));
|
}, 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 => {
|
document.querySelectorAll("table.table thead th[data-column]").forEach(cell => {
|
||||||
cell.addEventListener("click", function () {
|
cell.addEventListener("click", function () {
|
||||||
const column = this.getAttribute("data-column");
|
const column = this.getAttribute("data-column");
|
||||||
@@ -530,18 +609,17 @@ export function renderGalleryView(folder, container) {
|
|||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// --- Column slider ---
|
// --- Column slider with responsive max ---
|
||||||
const numColumns = window.galleryColumns || 3;
|
const numColumns = window.galleryColumns || 3;
|
||||||
galleryHTML += `
|
// clamp slider max to 1 on small (<600px), 2 on medium (<900px), else up to 6
|
||||||
<div class="gallery-slider" style="margin:10px; text-align:center;">
|
const w = window.innerWidth;
|
||||||
<label for="galleryColumnsSlider" style="margin-right:5px;">
|
let maxCols = 6;
|
||||||
${t('columns')}:
|
if (w < 600) maxCols = 1;
|
||||||
</label>
|
else if (w < 900) maxCols = 2;
|
||||||
<input type="range" id="galleryColumnsSlider" min="1" max="6"
|
|
||||||
value="${numColumns}" style="vertical-align:middle;">
|
// ensure current value doesn’t exceed the new max
|
||||||
<span id="galleryColumnsValue">${numColumns}</span>
|
const startCols = Math.min(numColumns, maxCols);
|
||||||
</div>
|
window.galleryColumns = startCols;
|
||||||
`;
|
|
||||||
|
|
||||||
// --- Start gallery grid ---
|
// --- Start gallery grid ---
|
||||||
galleryHTML += `
|
galleryHTML += `
|
||||||
@@ -627,32 +705,52 @@ export function renderGalleryView(folder, container) {
|
|||||||
</span>
|
</span>
|
||||||
${tagBadgesHTML}
|
${tagBadgesHTML}
|
||||||
|
|
||||||
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
|
<div
|
||||||
<button type="button" class="btn btn-sm btn-success download-btn"
|
class="btn-group btn-group-sm btn-group-hover"
|
||||||
data-download-name="${escapeHTML(file.name)}"
|
role="group"
|
||||||
data-download-folder="${file.folder || "root"}"
|
aria-label="File actions"
|
||||||
title="${t('download')}">
|
style="margin-top:5px;"
|
||||||
<i class="material-icons">file_download</i>
|
>
|
||||||
</button>
|
<button
|
||||||
${file.editable ? `
|
type="button"
|
||||||
<button type="button" class="btn btn-sm edit-btn"
|
class="btn btn-success py-1 download-btn"
|
||||||
data-edit-name="${escapeHTML(file.name)}"
|
data-download-name="${escapeHTML(file.name)}"
|
||||||
data-edit-folder="${file.folder || "root"}"
|
data-download-folder="${file.folder || "root"}"
|
||||||
title="${t('edit')}">
|
title="${t('download')}"
|
||||||
<i class="material-icons">edit</i>
|
>
|
||||||
</button>` : ""}
|
<i class="material-icons">file_download</i>
|
||||||
<button type="button" class="btn btn-sm btn-warning rename-btn"
|
</button>
|
||||||
data-rename-name="${escapeHTML(file.name)}"
|
|
||||||
data-rename-folder="${file.folder || "root"}"
|
${file.editable ? `
|
||||||
title="${t('rename')}">
|
<button
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
type="button"
|
||||||
</button>
|
class="btn btn-secondary py-1 edit-btn"
|
||||||
<button type="button" class="btn btn-sm btn-secondary share-btn"
|
data-edit-name="${escapeHTML(file.name)}"
|
||||||
data-file="${escapeHTML(file.name)}"
|
data-edit-folder="${file.folder || "root"}"
|
||||||
title="${t('share')}">
|
title="${t('edit')}"
|
||||||
<i class="material-icons">share</i>
|
>
|
||||||
</button>
|
<i class="material-icons">edit</i>
|
||||||
</div>
|
</button>` : ""}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning py-1 rename-btn"
|
||||||
|
data-rename-name="${escapeHTML(file.name)}"
|
||||||
|
data-rename-folder="${file.folder || "root"}"
|
||||||
|
title="${t('rename')}"
|
||||||
|
>
|
||||||
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary py-1 share-btn"
|
||||||
|
data-file="${escapeHTML(file.name)}"
|
||||||
|
title="${t('share')}"
|
||||||
|
>
|
||||||
|
<i class="material-icons">share</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -236,7 +236,8 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
|||||||
const state = loadFolderTreeState();
|
const state = loadFolderTreeState();
|
||||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
||||||
for (const folder in tree) {
|
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 fullPath = parentPath ? parentPath + "/" + folder : folder;
|
||||||
const hasChildren = Object.keys(tree[folder]).length > 0;
|
const hasChildren = Object.keys(tree[folder]).length > 0;
|
||||||
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
||||||
|
|||||||
@@ -202,6 +202,11 @@ const translations = {
|
|||||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||||
"admin_panel": "Admin Panel",
|
"admin_panel": "Admin Panel",
|
||||||
"user_panel": "User 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",
|
"trash_restore_delete": "Trash Restore/Delete",
|
||||||
"totp_settings": "TOTP Settings",
|
"totp_settings": "TOTP Settings",
|
||||||
"enable_totp": "Enable TOTP",
|
"enable_totp": "Enable TOTP",
|
||||||
@@ -260,6 +265,7 @@ const translations = {
|
|||||||
"show": "Show",
|
"show": "Show",
|
||||||
"items_per_page": "items per page",
|
"items_per_page": "items per page",
|
||||||
"columns": "Columns",
|
"columns": "Columns",
|
||||||
|
"row_height": "Row Height",
|
||||||
"api_docs": "API Docs"
|
"api_docs": "API Docs"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { editFile, saveFile } from './fileEditor.js';
|
|||||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||||
|
|
||||||
export function initializeApp() {
|
export function initializeApp() {
|
||||||
|
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||||
|
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
@@ -77,18 +79,14 @@ if (params.get('logout') === '1') {
|
|||||||
localStorage.removeItem("userTOTPEnabled");
|
localStorage.removeItem("userTOTPEnabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Wire up logoutBtn right away
|
export function triggerLogout() {
|
||||||
const logoutBtn = document.getElementById("logoutBtn");
|
fetch("/api/auth/logout.php", {
|
||||||
if (logoutBtn) {
|
method: "POST",
|
||||||
logoutBtn.addEventListener("click", () => {
|
credentials: "include",
|
||||||
fetch("/api/auth/logout.php", {
|
headers: { "X-CSRF-Token": window.csrfToken }
|
||||||
method: "POST",
|
})
|
||||||
credentials: "include",
|
.then(() => window.location.reload(true))
|
||||||
headers: { "X-CSRF-Token": window.csrfToken }
|
.catch(()=>{});
|
||||||
})
|
|
||||||
.then(() => window.location.reload(true))
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -122,7 +120,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
// Continue with initializations that rely on a valid CSRF token:
|
// Continue with initializations that rely on a valid CSRF token:
|
||||||
checkAuthentication().then(authenticated => {
|
checkAuthentication().then(authenticated => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
document.getElementById('loadingOverlay').remove();
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
initializeApp();
|
initializeApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -201,7 +200,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Auto-scroll During Drag ---
|
// --- Auto-scroll During Drag ---
|
||||||
// Adjust these values as needed:
|
|
||||||
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
||||||
const SCROLL_SPEED = 20; // pixels to scroll per event
|
const SCROLL_SPEED = 20; // pixels to scroll per event
|
||||||
|
|
||||||
|
|||||||
@@ -867,123 +867,126 @@ class UserController
|
|||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public function verifyTOTP()
|
public function verifyTOTP()
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';");
|
||||||
|
|
||||||
// Rate-limit
|
// Rate-limit
|
||||||
if (!isset($_SESSION['totp_failures'])) {
|
if (!isset($_SESSION['totp_failures'])) {
|
||||||
$_SESSION['totp_failures'] = 0;
|
$_SESSION['totp_failures'] = 0;
|
||||||
}
|
}
|
||||||
if ($_SESSION['totp_failures'] >= 5) {
|
if ($_SESSION['totp_failures'] >= 5) {
|
||||||
http_response_code(429);
|
http_response_code(429);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
|
echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be authenticated OR pending login
|
// Must be authenticated OR pending login
|
||||||
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
|
if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
echo json_encode(['status' => 'error', 'message' => 'Not authenticated']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF check
|
// CSRF check
|
||||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
$csrfHeader = $headersArr['x-csrf-token'] ?? '';
|
||||||
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
if (empty($_SESSION['csrf_token']) || $csrfHeader !== $_SESSION['csrf_token']) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
echo json_encode(['status' => 'error', 'message' => 'Invalid CSRF token']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse & validate input
|
// Parse & validate input
|
||||||
$inputData = json_decode(file_get_contents("php://input"), true);
|
$inputData = json_decode(file_get_contents("php://input"), true);
|
||||||
$code = trim($inputData['totp_code'] ?? '');
|
$code = trim($inputData['totp_code'] ?? '');
|
||||||
if (!preg_match('/^\d{6}$/', $code)) {
|
if (!preg_match('/^\d{6}$/', $code)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
|
echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TFA helper
|
// TFA helper
|
||||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(),
|
||||||
'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1
|
'FileRise',
|
||||||
);
|
6,
|
||||||
|
30,
|
||||||
// === Pending-login flow (we just came from auth and need to finish login) ===
|
\RobThree\Auth\Algorithm::Sha1
|
||||||
if (isset($_SESSION['pending_login_user'])) {
|
);
|
||||||
$username = $_SESSION['pending_login_user'];
|
|
||||||
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
// === Pending-login flow (we just came from auth and need to finish login) ===
|
||||||
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
if (isset($_SESSION['pending_login_user'])) {
|
||||||
|
$username = $_SESSION['pending_login_user'];
|
||||||
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
$pendingSecret = $_SESSION['pending_login_secret'] ?? null;
|
||||||
$_SESSION['totp_failures']++;
|
$rememberMe = $_SESSION['pending_login_remember_me'] ?? false;
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) {
|
||||||
exit;
|
$_SESSION['totp_failures']++;
|
||||||
}
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']);
|
||||||
// Issue “remember me” token if requested
|
exit;
|
||||||
if ($rememberMe) {
|
}
|
||||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
|
||||||
$token = bin2hex(random_bytes(32));
|
// Issue “remember me” token if requested
|
||||||
$expiry = time() + 30 * 24 * 60 * 60;
|
if ($rememberMe) {
|
||||||
$all = [];
|
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||||
if (file_exists($tokFile)) {
|
$token = bin2hex(random_bytes(32));
|
||||||
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
$expiry = time() + 30 * 24 * 60 * 60;
|
||||||
$all = json_decode($dec, true) ?: [];
|
$all = [];
|
||||||
}
|
if (file_exists($tokFile)) {
|
||||||
$all[$token] = [
|
$dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']);
|
||||||
'username' => $username,
|
$all = json_decode($dec, true) ?: [];
|
||||||
'expiry' => $expiry,
|
}
|
||||||
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
|
$all[$token] = [
|
||||||
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
'username' => $username,
|
||||||
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
'expiry' => $expiry,
|
||||||
'disableUpload'=> loadUserPermissions($username)['disableUpload']?? false
|
'isAdmin' => ((int)userModel::getUserRole($username) === 1),
|
||||||
];
|
'folderOnly' => loadUserPermissions($username)['folderOnly'] ?? false,
|
||||||
file_put_contents(
|
'readOnly' => loadUserPermissions($username)['readOnly'] ?? false,
|
||||||
$tokFile,
|
'disableUpload' => loadUserPermissions($username)['disableUpload'] ?? false
|
||||||
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
];
|
||||||
LOCK_EX
|
file_put_contents(
|
||||||
);
|
$tokFile,
|
||||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']),
|
||||||
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
LOCK_EX
|
||||||
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
);
|
||||||
}
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true);
|
||||||
// === Finalize login into session exactly as finalizeLogin() would ===
|
setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true);
|
||||||
session_regenerate_id(true);
|
}
|
||||||
$_SESSION['authenticated'] = true;
|
|
||||||
$_SESSION['username'] = $username;
|
// === Finalize login into session exactly as finalizeLogin() would ===
|
||||||
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
session_regenerate_id(true);
|
||||||
$perms = loadUserPermissions($username);
|
$_SESSION['authenticated'] = true;
|
||||||
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
$_SESSION['username'] = $username;
|
||||||
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
$_SESSION['isAdmin'] = ((int)userModel::getUserRole($username) === 1);
|
||||||
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
$perms = loadUserPermissions($username);
|
||||||
|
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
|
||||||
// Clean up pending markers
|
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
|
||||||
unset(
|
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
|
||||||
$_SESSION['pending_login_user'],
|
|
||||||
$_SESSION['pending_login_secret'],
|
// Clean up pending markers
|
||||||
$_SESSION['pending_login_remember_me'],
|
unset(
|
||||||
$_SESSION['totp_failures']
|
$_SESSION['pending_login_user'],
|
||||||
);
|
$_SESSION['pending_login_secret'],
|
||||||
|
$_SESSION['pending_login_remember_me'],
|
||||||
// Send back full login payload
|
$_SESSION['totp_failures']
|
||||||
echo json_encode([
|
);
|
||||||
'status' => 'ok',
|
|
||||||
'success' => 'Login successful',
|
// Send back full login payload
|
||||||
'isAdmin' => $_SESSION['isAdmin'],
|
echo json_encode([
|
||||||
'folderOnly' => $_SESSION['folderOnly'],
|
'status' => 'ok',
|
||||||
'readOnly' => $_SESSION['readOnly'],
|
'success' => 'Login successful',
|
||||||
'disableUpload' => $_SESSION['disableUpload'],
|
'isAdmin' => $_SESSION['isAdmin'],
|
||||||
'username' => $_SESSION['username']
|
'folderOnly' => $_SESSION['folderOnly'],
|
||||||
]);
|
'readOnly' => $_SESSION['readOnly'],
|
||||||
exit;
|
'disableUpload' => $_SESSION['disableUpload'],
|
||||||
}
|
'username' => $_SESSION['username']
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Setup/verification flow (not pending)
|
// Setup/verification flow (not pending)
|
||||||
$username = $_SESSION['username'] ?? '';
|
$username = $_SESSION['username'] ?? '';
|
||||||
@@ -1011,4 +1014,91 @@ class UserController
|
|||||||
unset($_SESSION['totp_failures']);
|
unset($_SESSION['totp_failures']);
|
||||||
echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
|
|
||||||
require_once PROJECT_ROOT . '/config/config.php';
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
|
||||||
class userModel {
|
class userModel
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Retrieves all users from the users file.
|
* Retrieves all users from the users file.
|
||||||
*
|
*
|
||||||
* @return array Returns an array of users.
|
* @return array Returns an array of users.
|
||||||
*/
|
*/
|
||||||
public static function getAllUsers() {
|
public static function getAllUsers()
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
$users = [];
|
$users = [];
|
||||||
if (file_exists($usersFile)) {
|
if (file_exists($usersFile)) {
|
||||||
@@ -26,7 +28,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new user.
|
* Adds a new user.
|
||||||
*
|
*
|
||||||
@@ -36,14 +38,15 @@ class userModel {
|
|||||||
* @param bool $setupMode If true, overwrite the users file.
|
* @param bool $setupMode If true, overwrite the users file.
|
||||||
* @return array Response containing either an error or a success message.
|
* @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;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
// Ensure users.txt exists.
|
// Ensure users.txt exists.
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
file_put_contents($usersFile, '');
|
file_put_contents($usersFile, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if username already exists.
|
// Check if username already exists.
|
||||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
foreach ($existingUsers as $line) {
|
foreach ($existingUsers as $line) {
|
||||||
@@ -52,40 +55,41 @@ class userModel {
|
|||||||
return ["error" => "User already exists"];
|
return ["error" => "User already exists"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the password.
|
// Hash the password.
|
||||||
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
|
||||||
|
|
||||||
// Prepare the new line.
|
// Prepare the new line.
|
||||||
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
$newUserLine = $username . ":" . $hashedPassword . ":" . $isAdmin . PHP_EOL;
|
||||||
|
|
||||||
// If setup mode, overwrite the file; otherwise, append.
|
// If setup mode, overwrite the file; otherwise, append.
|
||||||
if ($setupMode) {
|
if ($setupMode) {
|
||||||
file_put_contents($usersFile, $newUserLine);
|
file_put_contents($usersFile, $newUserLine);
|
||||||
} else {
|
} else {
|
||||||
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
|
file_put_contents($usersFile, $newUserLine, FILE_APPEND);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["success" => "User added successfully"];
|
return ["success" => "User added successfully"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the specified user from the users file and updates the userPermissions file.
|
* Removes the specified user from the users file and updates the userPermissions file.
|
||||||
*
|
*
|
||||||
* @param string $usernameToRemove The username to remove.
|
* @param string $usernameToRemove The username to remove.
|
||||||
* @return array An array with either an error message or a success message.
|
* @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;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$existingUsers = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$newUsers = [];
|
$newUsers = [];
|
||||||
$userFound = false;
|
$userFound = false;
|
||||||
|
|
||||||
// Loop through users; skip (remove) the specified user.
|
// Loop through users; skip (remove) the specified user.
|
||||||
foreach ($existingUsers as $line) {
|
foreach ($existingUsers as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
@@ -98,14 +102,14 @@ class userModel {
|
|||||||
}
|
}
|
||||||
$newUsers[] = $line;
|
$newUsers[] = $line;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$userFound) {
|
if (!$userFound) {
|
||||||
return ["error" => "User not found"];
|
return ["error" => "User not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the updated user list back to the file.
|
// Write the updated user list back to the file.
|
||||||
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
|
file_put_contents($usersFile, implode(PHP_EOL, $newUsers) . PHP_EOL);
|
||||||
|
|
||||||
// Update the userPermissions.json file.
|
// Update the userPermissions.json file.
|
||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
if (file_exists($permissionsFile)) {
|
if (file_exists($permissionsFile)) {
|
||||||
@@ -116,18 +120,19 @@ class userModel {
|
|||||||
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
|
file_put_contents($permissionsFile, json_encode($permissionsArray, JSON_PRETTY_PRINT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["success" => "User removed successfully"];
|
return ["success" => "User removed successfully"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves permissions from the userPermissions.json file.
|
* Retrieves permissions from the userPermissions.json file.
|
||||||
* If the current user is an admin, returns all permissions.
|
* If the current user is an admin, returns all permissions.
|
||||||
* Otherwise, returns only the permissions for the current user.
|
* 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.
|
* @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;
|
global $encryptionKey;
|
||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
$permissionsArray = [];
|
$permissionsArray = [];
|
||||||
@@ -165,13 +170,14 @@ class userModel {
|
|||||||
return new stdClass();
|
return new stdClass();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates user permissions in the userPermissions.json file.
|
* Updates user permissions in the userPermissions.json file.
|
||||||
*
|
*
|
||||||
* @param array $permissions An array of permission updates.
|
* @param array $permissions An array of permission updates.
|
||||||
* @return array An associative array with a success or error message.
|
* @return array An associative array with a success or error message.
|
||||||
*/
|
*/
|
||||||
public static function updateUserPermissions($permissions) {
|
public static function updateUserPermissions($permissions)
|
||||||
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$permissionsFile = USERS_DIR . "userPermissions.json";
|
$permissionsFile = USERS_DIR . "userPermissions.json";
|
||||||
$existingPermissions = [];
|
$existingPermissions = [];
|
||||||
@@ -185,7 +191,7 @@ class userModel {
|
|||||||
$existingPermissions = [];
|
$existingPermissions = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user roles from the users file.
|
// Load user roles from the users file.
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
$userRoles = [];
|
$userRoles = [];
|
||||||
@@ -199,7 +205,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each permission update.
|
// Process each permission update.
|
||||||
foreach ($permissions as $perm) {
|
foreach ($permissions as $perm) {
|
||||||
if (!isset($perm['username'])) {
|
if (!isset($perm['username'])) {
|
||||||
@@ -208,12 +214,12 @@ class userModel {
|
|||||||
$username = $perm['username'];
|
$username = $perm['username'];
|
||||||
// Look up the user's role.
|
// Look up the user's role.
|
||||||
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
|
$role = isset($userRoles[strtolower($username)]) ? $userRoles[strtolower($username)] : null;
|
||||||
|
|
||||||
// Skip updating permissions for admin users.
|
// Skip updating permissions for admin users.
|
||||||
if ($role === "1") {
|
if ($role === "1") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update permissions: default any missing value to false.
|
// Update permissions: default any missing value to false.
|
||||||
$existingPermissions[strtolower($username)] = [
|
$existingPermissions[strtolower($username)] = [
|
||||||
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
|
'folderOnly' => isset($perm['folderOnly']) ? (bool)$perm['folderOnly'] : false,
|
||||||
@@ -221,7 +227,7 @@ class userModel {
|
|||||||
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
|
'disableUpload' => isset($perm['disableUpload']) ? (bool)$perm['disableUpload'] : false
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the updated permissions array to JSON.
|
// Convert the updated permissions array to JSON.
|
||||||
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
$plainText = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
||||||
// Encrypt the JSON.
|
// Encrypt the JSON.
|
||||||
@@ -231,11 +237,11 @@ class userModel {
|
|||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return ["error" => "Failed to save user permissions."];
|
return ["error" => "Failed to save user permissions."];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["success" => "User permissions updated successfully."];
|
return ["success" => "User permissions updated successfully."];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes the password for the given user.
|
* Changes the password for the given user.
|
||||||
*
|
*
|
||||||
* @param string $username The username whose password is to be changed.
|
* @param string $username The username whose password is to be changed.
|
||||||
@@ -243,17 +249,18 @@ class userModel {
|
|||||||
* @param string $newPassword The new password.
|
* @param string $newPassword The new password.
|
||||||
* @return array An array with either a success or error message.
|
* @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;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$userFound = false;
|
$userFound = false;
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
// Expect at least 3 parts: username, hashed password, and role.
|
// Expect at least 3 parts: username, hashed password, and role.
|
||||||
@@ -266,7 +273,7 @@ class userModel {
|
|||||||
$storedRole = $parts[2];
|
$storedRole = $parts[2];
|
||||||
// Preserve TOTP secret if it exists.
|
// Preserve TOTP secret if it exists.
|
||||||
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
|
$totpSecret = (count($parts) >= 4) ? $parts[3] : "";
|
||||||
|
|
||||||
if ($storedUser === $username) {
|
if ($storedUser === $username) {
|
||||||
$userFound = true;
|
$userFound = true;
|
||||||
// Verify the old password.
|
// Verify the old password.
|
||||||
@@ -275,7 +282,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
// Hash the new password.
|
// Hash the new password.
|
||||||
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
$newHashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
|
||||||
|
|
||||||
// Rebuild the line, preserving TOTP secret if it exists.
|
// Rebuild the line, preserving TOTP secret if it exists.
|
||||||
if ($totpSecret !== "") {
|
if ($totpSecret !== "") {
|
||||||
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
|
$newLines[] = $username . ":" . $newHashedPassword . ":" . $storedRole . ":" . $totpSecret;
|
||||||
@@ -286,11 +293,11 @@ class userModel {
|
|||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$userFound) {
|
if (!$userFound) {
|
||||||
return ["error" => "User not found."];
|
return ["error" => "User not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the updated users file.
|
// Save the updated users file.
|
||||||
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
|
if (file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL)) {
|
||||||
return ["success" => "Password updated successfully."];
|
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.
|
* 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 string $username The username whose panel settings are being updated.
|
||||||
* @param bool $totp_enabled Whether TOTP is enabled.
|
* @param bool $totp_enabled Whether TOTP is enabled.
|
||||||
* @return array An array indicating success or failure.
|
* @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;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ["error" => "Users file not found"];
|
return ["error" => "Users file not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TOTP is disabled, update the file to clear the TOTP secret.
|
// If TOTP is disabled, update the file to clear the TOTP secret.
|
||||||
if (!$totp_enabled) {
|
if (!$totp_enabled) {
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$parts = explode(':', trim($line));
|
$parts = explode(':', trim($line));
|
||||||
// Leave lines with fewer than three parts unchanged.
|
// Leave lines with fewer than three parts unchanged.
|
||||||
@@ -325,7 +333,7 @@ class userModel {
|
|||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($parts[0] === $username) {
|
if ($parts[0] === $username) {
|
||||||
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field.
|
// If a fourth field (TOTP secret) exists, clear it; otherwise, append an empty field.
|
||||||
if (count($parts) >= 4) {
|
if (count($parts) >= 4) {
|
||||||
@@ -338,25 +346,26 @@ class userModel {
|
|||||||
$newLines[] = $line;
|
$newLines[] = $line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
$result = file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return ["error" => "Failed to disable TOTP secret"];
|
return ["error" => "Failed to disable TOTP secret"];
|
||||||
}
|
}
|
||||||
return ["success" => "User panel updated: TOTP disabled"];
|
return ["success" => "User panel updated: TOTP disabled"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TOTP is enabled, do nothing.
|
// If TOTP is enabled, do nothing.
|
||||||
return ["success" => "User panel updated: TOTP remains enabled"];
|
return ["success" => "User panel updated: TOTP remains enabled"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disables the TOTP secret for the specified user.
|
* Disables the TOTP secret for the specified user.
|
||||||
*
|
*
|
||||||
* @param string $username The user for whom TOTP should be disabled.
|
* @param string $username The user for whom TOTP should be disabled.
|
||||||
* @return bool True if the secret was cleared; false otherwise.
|
* @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.
|
global $encryptionKey; // In case it's used in this model context.
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
@@ -391,14 +400,15 @@ class userModel {
|
|||||||
return $modified;
|
return $modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to recover TOTP for a user using the supplied recovery code.
|
* Attempts to recover TOTP for a user using the supplied recovery code.
|
||||||
*
|
*
|
||||||
* @param string $userId The user identifier.
|
* @param string $userId The user identifier.
|
||||||
* @param string $recoveryCode The recovery code provided by the user.
|
* @param string $recoveryCode The recovery code provided by the user.
|
||||||
* @return array An associative array with keys 'status' and 'message'.
|
* @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 ---
|
// --- Rate‑limit recovery attempts ---
|
||||||
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
$attemptsFile = rtrim(USERS_DIR, '/\\') . '/recovery_attempts.json';
|
||||||
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
|
$attempts = is_file($attemptsFile) ? json_decode(file_get_contents($attemptsFile), true) : [];
|
||||||
@@ -406,36 +416,36 @@ class userModel {
|
|||||||
$now = time();
|
$now = time();
|
||||||
if (isset($attempts[$key])) {
|
if (isset($attempts[$key])) {
|
||||||
// Prune attempts older than 15 minutes.
|
// 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;
|
return $ts > $now - 900;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (count($attempts[$key] ?? []) >= 5) {
|
if (count($attempts[$key] ?? []) >= 5) {
|
||||||
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
|
return ['status' => 'error', 'message' => 'Too many attempts. Try again later.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Load user metadata file ---
|
// --- Load user metadata file ---
|
||||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||||
if (!file_exists($userFile)) {
|
if (!file_exists($userFile)) {
|
||||||
return ['status' => 'error', 'message' => 'User not found'];
|
return ['status' => 'error', 'message' => 'User not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Open and lock file ---
|
// --- Open and lock file ---
|
||||||
$fp = fopen($userFile, 'c+');
|
$fp = fopen($userFile, 'c+');
|
||||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||||
return ['status' => 'error', 'message' => 'Server error'];
|
return ['status' => 'error', 'message' => 'Server error'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileContents = stream_get_contents($fp);
|
$fileContents = stream_get_contents($fp);
|
||||||
$data = json_decode($fileContents, true) ?: [];
|
$data = json_decode($fileContents, true) ?: [];
|
||||||
|
|
||||||
// --- Check recovery code ---
|
// --- Check recovery code ---
|
||||||
if (empty($recoveryCode)) {
|
if (empty($recoveryCode)) {
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
return ['status' => 'error', 'message' => 'Recovery code required'];
|
return ['status' => 'error', 'message' => 'Recovery code required'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$storedHash = $data['totp_recovery_code'] ?? null;
|
$storedHash = $data['totp_recovery_code'] ?? null;
|
||||||
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
|
if (!$storedHash || !password_verify($recoveryCode, $storedHash)) {
|
||||||
// Record failed attempt.
|
// Record failed attempt.
|
||||||
@@ -445,7 +455,7 @@ class userModel {
|
|||||||
fclose($fp);
|
fclose($fp);
|
||||||
return ['status' => 'error', 'message' => 'Invalid recovery code'];
|
return ['status' => 'error', 'message' => 'Invalid recovery code'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Invalidate recovery code ---
|
// --- Invalidate recovery code ---
|
||||||
$data['totp_recovery_code'] = null;
|
$data['totp_recovery_code'] = null;
|
||||||
rewind($fp);
|
rewind($fp);
|
||||||
@@ -454,17 +464,18 @@ class userModel {
|
|||||||
fflush($fp);
|
fflush($fp);
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
|
|
||||||
return ['status' => 'ok'];
|
return ['status' => 'ok'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random recovery code.
|
* Generates a random recovery code.
|
||||||
*
|
*
|
||||||
* @param int $length Length of the recovery code.
|
* @param int $length Length of the recovery code.
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private static function generateRecoveryCode($length = 12) {
|
private static function generateRecoveryCode($length = 12)
|
||||||
|
{
|
||||||
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
$max = strlen($chars) - 1;
|
$max = strlen($chars) - 1;
|
||||||
$code = '';
|
$code = '';
|
||||||
@@ -480,10 +491,11 @@ class userModel {
|
|||||||
* @param string $userId The username of the user.
|
* @param string $userId The username of the user.
|
||||||
* @return array An associative array with the status and recovery code (if successful).
|
* @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.
|
// Determine the user file path.
|
||||||
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
$userFile = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . $userId . '.json';
|
||||||
|
|
||||||
// Ensure the file exists; if not, create it with default data.
|
// Ensure the file exists; if not, create it with default data.
|
||||||
if (!file_exists($userFile)) {
|
if (!file_exists($userFile)) {
|
||||||
$defaultData = [];
|
$defaultData = [];
|
||||||
@@ -491,24 +503,24 @@ class userModel {
|
|||||||
return ['status' => 'error', 'message' => 'Server error: could not create user file'];
|
return ['status' => 'error', 'message' => 'Server error: could not create user file'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new recovery code.
|
// Generate a new recovery code.
|
||||||
$recoveryCode = self::generateRecoveryCode();
|
$recoveryCode = self::generateRecoveryCode();
|
||||||
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
|
$recoveryHash = password_hash($recoveryCode, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
// Open the file, lock it, and update the totp_recovery_code field.
|
// Open the file, lock it, and update the totp_recovery_code field.
|
||||||
$fp = fopen($userFile, 'c+');
|
$fp = fopen($userFile, 'c+');
|
||||||
if (!$fp || !flock($fp, LOCK_EX)) {
|
if (!$fp || !flock($fp, LOCK_EX)) {
|
||||||
return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
|
return ['status' => 'error', 'message' => 'Server error: could not lock user file'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and decode the existing JSON.
|
// Read and decode the existing JSON.
|
||||||
$contents = stream_get_contents($fp);
|
$contents = stream_get_contents($fp);
|
||||||
$data = json_decode($contents, true) ?: [];
|
$data = json_decode($contents, true) ?: [];
|
||||||
|
|
||||||
// Update the totp_recovery_code field.
|
// Update the totp_recovery_code field.
|
||||||
$data['totp_recovery_code'] = $recoveryHash;
|
$data['totp_recovery_code'] = $recoveryHash;
|
||||||
|
|
||||||
// Write the new data.
|
// Write the new data.
|
||||||
rewind($fp);
|
rewind($fp);
|
||||||
ftruncate($fp, 0);
|
ftruncate($fp, 0);
|
||||||
@@ -516,25 +528,26 @@ class userModel {
|
|||||||
fflush($fp);
|
fflush($fp);
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
|
|
||||||
return ['status' => 'ok', 'recoveryCode' => $recoveryCode];
|
return ['status' => 'ok', 'recoveryCode' => $recoveryCode];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up TOTP for the specified user by retrieving or generating a TOTP secret,
|
* 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.
|
* then builds and returns a QR code image for the OTPAuth URL.
|
||||||
*
|
*
|
||||||
* @param string $username The username for which to set up TOTP.
|
* @param string $username The username for which to set up TOTP.
|
||||||
* @return array An associative array with keys 'imageData' and 'mimeType', or 'error'.
|
* @return array An associative array with keys 'imageData' and 'mimeType', or 'error'.
|
||||||
*/
|
*/
|
||||||
public static function setupTOTP($username) {
|
public static function setupTOTP($username)
|
||||||
|
{
|
||||||
global $encryptionKey;
|
global $encryptionKey;
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
|
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return ['error' => 'Users file not found'];
|
return ['error' => 'Users file not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for an existing TOTP secret.
|
// Look for an existing TOTP secret.
|
||||||
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
$totpSecret = null;
|
$totpSecret = null;
|
||||||
@@ -545,7 +558,7 @@ class userModel {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the TwoFactorAuth library to create a new secret if none found.
|
// Use the TwoFactorAuth library to create a new secret if none found.
|
||||||
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
$tfa = new \RobThree\Auth\TwoFactorAuth(
|
||||||
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider
|
new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), // QR code provider
|
||||||
@@ -557,7 +570,7 @@ class userModel {
|
|||||||
if (!$totpSecret) {
|
if (!$totpSecret) {
|
||||||
$totpSecret = $tfa->createSecret();
|
$totpSecret = $tfa->createSecret();
|
||||||
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
|
$encryptedSecret = encryptData($totpSecret, $encryptionKey);
|
||||||
|
|
||||||
// Update the user’s line with the new encrypted secret.
|
// Update the user’s line with the new encrypted secret.
|
||||||
$newLines = [];
|
$newLines = [];
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
@@ -575,7 +588,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
file_put_contents($usersFile, implode(PHP_EOL, $newLines) . PHP_EOL, LOCK_EX);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the OTPAuth URL.
|
// Determine the OTPAuth URL.
|
||||||
// Try to load a global OTPAuth URL template from admin configuration.
|
// Try to load a global OTPAuth URL template from admin configuration.
|
||||||
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
$adminConfigFile = USERS_DIR . 'adminConfig.json';
|
||||||
@@ -590,7 +603,7 @@ class userModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($globalOtpauthUrl)) {
|
if (!empty($globalOtpauthUrl)) {
|
||||||
$label = "FileRise:" . $username;
|
$label = "FileRise:" . $username;
|
||||||
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
|
$otpauthUrl = str_replace(["{label}", "{secret}"], [urlencode($label), $totpSecret], $globalOtpauthUrl);
|
||||||
@@ -599,26 +612,27 @@ class userModel {
|
|||||||
$issuer = urlencode("FileRise");
|
$issuer = urlencode("FileRise");
|
||||||
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
|
$otpauthUrl = "otpauth://totp/{$label}?secret={$totpSecret}&issuer={$issuer}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the QR code image using the Endroid QR Code Builder.
|
// Build the QR code image using the Endroid QR Code Builder.
|
||||||
$result = \Endroid\QrCode\Builder\Builder::create()
|
$result = \Endroid\QrCode\Builder\Builder::create()
|
||||||
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
||||||
->data($otpauthUrl)
|
->data($otpauthUrl)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'imageData' => $result->getString(),
|
'imageData' => $result->getString(),
|
||||||
'mimeType' => $result->getMimeType()
|
'mimeType' => $result->getMimeType()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the decrypted TOTP secret for a given user.
|
* Retrieves the decrypted TOTP secret for a given user.
|
||||||
*
|
*
|
||||||
* @param string $username
|
* @param string $username
|
||||||
* @return string|null Returns the TOTP secret if found, or null if not.
|
* @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;
|
global $encryptionKey;
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
@@ -634,14 +648,15 @@ class userModel {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to get a user's role from users.txt.
|
* Helper to get a user's role from users.txt.
|
||||||
*
|
*
|
||||||
* @param string $username
|
* @param string $username
|
||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
public static function getUserRole($username) {
|
public static function getUserRole($username)
|
||||||
|
{
|
||||||
$usersFile = USERS_DIR . USERS_FILE;
|
$usersFile = USERS_DIR . USERS_FILE;
|
||||||
if (!file_exists($usersFile)) {
|
if (!file_exists($usersFile)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -654,4 +669,79 @@ class userModel {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user