Compare commits

...

19 Commits

Author SHA1 Message Date
Ryan
9209f7a582 Center folder strip name, fix file share url, keep fileList wrapping tight (closes #38) 2025-05-22 07:32:38 -04:00
Ryan
4a736b0224 Enable drag-and-drop to folder strip & fix restore toast messaging 2025-05-21 00:54:20 -04:00
Ryan
f162a7d0d7 updateFileActionButtons to hide or show depending on action 2025-05-20 09:55:40 -04:00
Ryan
3fc526df7f Add folder strip and “Create File” functionality (closes #36) 2025-05-19 00:39:10 -04:00
Ryan
20422cf5a7 Drag‐and‐Drop Upload extended to File List 2025-05-15 02:24:26 -04:00
Ryan
492bab36ca Fix duplicated Upload & Folder cards if they were added to header and page was refreshed 2025-05-14 08:08:18 -04:00
Ryan
f2f7697994 Link updated in readme 2025-05-14 07:09:55 -04:00
Ryan
13aa011632 #nosec to silence false positive 2025-05-14 07:05:35 -04:00
Ryan
1add160f5d setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts 2025-05-14 07:00:04 -04:00
Ryan
87368143b5 Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked 2025-05-14 06:51:16 -04:00
Ryan
939aa032f0 ui: polish header and user panel with dropdown + profile pic support & file list adjustments 2025-05-14 05:20:22 -04:00
Ryan
fbd21a035b Ensure /var/www/config exists and is owned by www-data so that start.sh sed -i updates work reliably 2025-05-08 17:20:36 -04:00
Ryan
2f391d11db fix(admin-api): omit clientSecret from getConfig response for security & add OIDC scope. 2025-05-08 11:39:44 -04:00
Ryan
8c70783d5a fix(upload): relax filename validation regex to allow broader Unicode and special chars (closes #29) 2025-05-08 04:58:57 -04:00
Ryan
b4d6f01432 feat(admin): add proxy-only auth bypass and configurable auth header (closes #28) 2025-05-08 04:43:33 -04:00
Ryan
d48b15a5f4 screenshots updated 2025-05-05 08:49:27 -04:00
Ryan
d1726f0160 Refactor auth flow: add loading overlay, separate login, extract initializeApp 2025-05-05 07:28:28 -04:00
Ryan
bd1841b788 Unify modals: shared close button, truncate long filenames, fix sizing & overflow 2025-05-04 15:44:43 -04:00
Ryan
bde35d1d31 Extend clean up expired shared entries 2025-05-04 02:28:33 -04:00
48 changed files with 2725 additions and 1126 deletions

View File

@@ -1,5 +1,312 @@
# Changelog # Changelog
## Changes 5/22/2025 v1.3.7
- `.folder-strip-container .folder-name` css added to center text below folder material icon.
- Override file share_url to always use current origin
- Update `fileList` css to keep file name wrapping tight.
---
## Changes 5/21/2025
- **Drag & Drop to Folder Strip**
- Enabled dragging files from the file list directly onto the folder-strip items.
- Hooked up `folderDragOverHandler`, `folderDragLeaveHandler`, and `folderDropHandler` to `.folder-strip-container .folder-item`.
- On drop, files are moved via `/api/file/moveFiles.php` and the file list is refreshed.
- **Restore files from trash Toast Message**
- Changed the restore handlers so that the toast always reports the actual file(s) restored (e.g. “Restored file: foo.txt”) instead of “No trash record found.”
- Removed reliance on backend message payload and now generate the confirmation text client-side based on selected items.
---
## Changes 5/20/2025 v1.3.6
- **domUtils.js**
- `updateFileActionButtons`
- Hide selection buttons (`Delete Files`, `Copy Files`, `Move Files` & `Download ZIP`) until file is selected.
- Hide `Extract ZIP` until selecting zip files
- Hide `Create File` button when file list items are selected.
---
## Changes 5/19/2025 v1.3.5
### Added Folder strip & Create File
- **Folder strip in file list**
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
- Filters to only *direct* children of the current folder, hiding `profile_pics` and `trash`.
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
- Clicking a folder in the strip updates:
- the breadcrumb (via `updateBreadcrumbTitle`)
- the tree selection highlight
- reloads `loadFileList` for the chosen folder.
- **Create File feature**
- New “Create New File” button added to the file-actions toolbar and context menu.
- New endpoint `public/api/file/createFile.php` (handled by `FileController`/`FileModel`):
- Creates an empty file if it doesnt already exist.
- Appends an entry to `<folder>_metadata.json` with `uploaded` timestamp and `uploader`.
- `fileActions.js`:
- Implemented `handleCreateFile()` to show a modal, POST to the new endpoint, and refresh the list.
- Added translations for `create_new_file` and `newfile_placeholder`.
---
## Changees 5/15/2025
### DragandDrop Upload extended to File List
- **Forward filelist drops**
Dropping files onto the filelist area (`#fileListContainer`) now redispatches the same `drop` event to the upload cards drop zone (`#uploadDropArea`)
- **Visual feedback**
Added a `.drop-hover` class on `#fileListContainer` during dragover for a dashedborder + lightbackground hover state to indicate it accepts file drops.
---
## 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 “ViewMode” Slider**
Added a single slider panel (`#viewSliderContainer`) in the filelist 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 grids `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 rerender.
- **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.
### 9. Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked
**openUserPanel**
- **Rewritten entirely with DOM APIs** instead of `innerHTML` for any user-supplied text to eliminates “DOM text reinterpreted as HTML” warnings.
- **Default avatar fallback**: now uses `'/assets/default-avatar.png'` whenever `profile_picture` is empty.
- **TOTP checkbox initial state** is now set from the `totp_enabled` value returned by the server.
- **Modal title sync** on reopen now updates the `(username)` correctly (no more “undefined” until refresh).
- **Re-sync on reopen**: background color, avatar, TOTP checkbox and language selector all update when reopen the panel.
**updateAuthenticatedUI**
- **Username fix**: dropdown toggle now always uses `data.username` so the name never becomes `undefined` after uploading a picture.
- **Profile URL update** via `fetchProfilePicture()` always writes into `localStorage` before rebuilding the header, ensuring avatar+name stay in sync instantly.
- **Dropdown rebuild logic** tweaked to update the toggles innerHTML with both avatar and username on every call.
**UserModel::getUser**
- Switched to `explode(':', $line, 4)` to the fourth “profile_picture” field without clobbering the TOTP secret.
- **Strip trailing colons** from the stored URL (`rtrim($parts[3], ':')`) so we never send `…png:` back to the client.
- Returns an array with both `'username'` and `'profile_picture'`, matching what `getCurrentUser.php` needs.
### 10. setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
### 11. Fix duplicated Upload & Folder cards if they were added to header and page was refreshed
---
## Changes 5/8/2025
### Docker 🐳
- Ensure `/var/www/config` exists and is owned by `www-data` (chmod 750) so that `start.sh`s `sed -i` updates to `config.php` work reliably
---
## Changes 5/8/2025 v1.3.3
### Enhancements
- **Admin API** (`updateConfig.php`):
- Now merges incoming payload onto existing on-disk settings instead of overwriting blanks.
- Preserves `clientId`, `clientSecret`, `providerUrl` and `redirectUri` when those fields are omitted or empty in the request.
- **Admin API** (`getConfig.php`):
- Returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
- **Frontend** (`auth.js`):
- Update UI based on merged loginOptions from the server, ensuring blank or missing fields no longer revert your existing config.
- **Auth API** (`auth.php`):
- Added `$oidc->addScope(['openid','profile','email']);` to OIDC flow. (This should resolve authentik issue)
---
## Changes 5/8/2025 v1.3.2
### config/config.php
- Added a default `define('AUTH_BYPASS', false)` at the top so the constant always exists.
- Removed the static `AUTH_HEADER` fallback; instead read the adminConfig.json at the end of the file and:
- Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk.
- Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`.
- Inserted a **proxy-only auto-login** block *before* the usual session/auth checks:
If `AUTH_BYPASS` is true and the trusted header (`$_SERVER['HTTP_' . AUTH_HEADER]`) is present, bump the session, mark the user authenticated/admin, load their permissions, and skip straight to JSON output.
- Relax filename validation regex to allow broader Unicode and special chars
### src/controllers/AdminController.php
- Ensured the returned `loginOptions` object always contains:
- `authBypass` (boolean, default false)
- `authHeaderName` (string, default `"X-Remote-User"`)
- Read `authBypass` and `authHeaderName` from the nested `loginOptions` in the request payload.
- Validated them (`authBypass` → bool; `authHeaderName` → non-empty string, fallback to `"X-Remote-User"`).
- Included them when building the `$configUpdate` array to pass to the model.
### src/models/AdminModel.php
- Normalized `loginOptions.authBypass` to a boolean (default false).
- Validated/truncated `loginOptions.authHeaderName` to a non-empty trimmed string (default `"X-Remote-User"`).
- JSON-encoded and encrypted the full config, now including the two new fields.
- After decrypting & decoding, normalized the loaded `loginOptions` to always include:
- `authBypass` (bool)
- `authHeaderName` (string, default `"X-Remote-User"`)
- Left all existing defaults & validations for the original flags intact.
### public/js/adminPanel.js
- **Login Options** section:
- Added a checkbox for **Disable All Built-in Logins (proxy only)** (`authBypass`).
- Added a text input for **Auth Header Name** (`authHeaderName`).
- In `handleSave()`:
- Included the new `authBypass` and `authHeaderName` values in the payload sent to `updateConfig.php`.
- In `openAdminPanel()`:
- Initialized those inputs from `config.loginOptions.authBypass` and `config.loginOptions.authHeaderName`.
### public/js/auth.js
- In `loadAdminConfigFunc()`:
- Stored `authBypass` and `authHeaderName` in `localStorage`.
- In `checkAuthentication()`:
- After a successful login check, called a new helper (`applyProxyBypassUI()`) which reads `localStorage.authBypass` and conditionally hides the entire login form/UI.
- In the “not authenticated” branch, only shows the login form if `authBypass` is false.
- No other core fetch/token logic changed; all existing flows remain intact.
### Security
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
---
## Changes 5/4/2025 v1.3.1
### Modals
- **Added** a shared `.editor-close-btn` component for all modals:
- File Tags
- User Panel
- TOTP Login & Setup
- Change Password
- **Truncated** long filenames in the File Tags modal header using CSS `text-overflow: ellipsis`.
- **Resized** File Tags modal from 400px to 450px wide (with `max-width: 90vw` fallback).
- **Capped** User Panel height at 381px and hidden scrollbars to eliminate layout jumps on hover.
### HTML
- **Moved** `<div id="loginForm">…</div>` out of `.main-wrapper` so the login form can show independently of the app shell.
- **Added** `<div id="loadingOverlay"></div>` immediately inside `<body>` to cover the UI during auth checks.
- **Inserted** inline `<style>` in `<head>` to:
- Hide `.main-wrapper` by default.
- Style `#loadingOverlay` as a full-viewport white overlay.
- **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"`
### `main.js`
- **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config).
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.
- **Extended** `updateAuthenticatedUI()` to call `initializeApp()` after a fresh login so all UI modules re-hydrate.
- **Enhanced** setup-mode in `checkAuthentication()`:
- Show `#addUserModal` as a flex overlay (`style.display = 'flex'`).
- Keep `.main-wrapper` hidden until setup completes.
- **Added** post-setup handler in the Add-User modals save button:
- Hide setup modal.
- Show login form.
- Keep app shell hidden.
- Pre-fill and focus the new username in the login inputs.
### `auth.js` / Auth Logic
- **Refactored** `checkAuthentication()` to handle three states:
1. **`data.setup`** remove overlay, hide main UI, show setup modal.
2. **`data.authenticated`** remove overlay, call `updateAuthenticatedUI()`.
3. **not authenticated** remove overlay, show login form, keep main UI hidden.
- **Refined** `updateAuthenticatedUI()` to:
- Remove loading overlay.
- Show `.main-wrapper` and main operations.
- Hide `#loginForm`.
- Reveal header buttons.
- Initialize dynamic header buttons (restore, admin, user-panel).
- Call `initializeApp()` to load all modules after login.
---
## Changes 5/3/2025 v1.3.0 ## Changes 5/3/2025 v1.3.0
**Admin Panel Refactor & Enhancements** **Admin Panel Refactor & Enhancements**
@@ -48,6 +355,10 @@
- Adjusted endpoint paths to match controller filenames - Adjusted endpoint paths to match controller filenames
- Fix FolderController readOnly create folder permission - Fix FolderController readOnly create folder permission
### Additional changes
- Extend clean up expired shared entries
--- ---
## Changes 4/30/2025 v1.2.8 ## Changes 4/30/2025 v1.2.8

View File

@@ -51,6 +51,11 @@ COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
COPY --from=appsource /var/www /var/www COPY --from=appsource /var/www /var/www
COPY --from=composer /app/vendor /var/www/vendor COPY --from=composer /app/vendor /var/www/vendor
# ── ensure config/ is writable by www-data so sed -i can work ──
RUN mkdir -p /var/www/config \
&& chown -R www-data:www-data /var/www/config \
&& chmod 750 /var/www/config
# Secure permissions: code read-only, only data dirs writable # Secure permissions: code read-only, only data dirs writable
RUN chown -R root:www-data /var/www && \ RUN chown -R root:www-data /var/www && \
find /var/www -type d -exec chmod 755 {} \; && \ find /var/www -type d -exec chmod 755 {} \; && \

View File

@@ -218,7 +218,7 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
## Community and Support ## Community and Support
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1jl01pi/introducing_filerise_a_modern_selfhosted_file/) (Announcement and user feedback thread). - **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) (Announcement and user feedback thread).
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific support or issues. - **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific support or issues.
- **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements. - **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements.

View File

@@ -30,11 +30,12 @@ define('DATE_TIME_FORMAT','m/d/y h:iA');
define('TOTAL_UPLOAD_SIZE','5G'); define('TOTAL_UPLOAD_SIZE','5G');
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u'); define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u');
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+'); define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
define('REGEX_FILE_NAME', '/^[\p{L}\p{N}\p{M}%\-\.\(\) _]+$/u'); define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u'); define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
date_default_timezone_set(TIMEZONE); date_default_timezone_set(TIMEZONE);
// Encryption helpers // Encryption helpers
function encryptData($data, $encryptionKey) function encryptData($data, $encryptionKey)
{ {
@@ -114,6 +115,7 @@ if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} }
// Autologin via persistent token // Autologin via persistent token
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) { if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
$tokFile = USERS_DIR . 'persistent_tokens.json'; $tokFile = USERS_DIR . 'persistent_tokens.json';
@@ -140,6 +142,60 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
} }
} }
$adminConfigFile = USERS_DIR . 'adminConfig.json';
// sane defaults:
$cfgAuthBypass = false;
$cfgAuthHeader = 'X_REMOTE_USER';
if (file_exists($adminConfigFile)) {
$encrypted = file_get_contents($adminConfigFile);
$decrypted = decryptData($encrypted, $encryptionKey);
$adminCfg = json_decode($decrypted, true) ?: [];
$loginOpts = $adminCfg['loginOptions'] ?? [];
// proxy-only bypass flag
$cfgAuthBypass = ! empty($loginOpts['authBypass']);
// header name (e.g. “X-Remote-User” → HTTP_X_REMOTE_USER)
$hdr = trim($loginOpts['authHeaderName'] ?? '');
if ($hdr === '') {
$hdr = 'X-Remote-User';
}
// normalize to PHPs $_SERVER key format:
$cfgAuthHeader = 'HTTP_' . strtoupper(str_replace('-', '_', $hdr));
}
define('AUTH_BYPASS', $cfgAuthBypass);
define('AUTH_HEADER', $cfgAuthHeader);
// ─────────────────────────────────────────────────────────────────────────────
// PROXY-ONLY AUTOLOGIN now uses those constants:
if (AUTH_BYPASS) {
$hdrKey = AUTH_HEADER; // e.g. "HTTP_X_REMOTE_USER"
if (!empty($_SERVER[$hdrKey])) {
// regenerate once per session
if (empty($_SESSION['authenticated'])) {
session_regenerate_id(true);
}
$username = $_SERVER[$hdrKey];
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
// ◾ lookup actual role instead of forcing admin
require_once PROJECT_ROOT . '/src/models/AuthModel.php';
$role = AuthModel::getUserRole($username);
$_SESSION['isAdmin'] = ($role === '1');
// carry over any folder/read/upload perms
$perms = loadUserPermissions($username) ?: [];
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
}
}
// Share URL fallback // Share URL fallback
define('BASE_URL', 'http://yourwebsite/uploads/'); define('BASE_URL', 'http://yourwebsite/uploads/');
if (strpos(BASE_URL, 'yourwebsite') !== false) { if (strpos(BASE_URL, 'yourwebsite') !== false) {

View File

@@ -3,42 +3,61 @@
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
// Simple authcheck: only admins may read these // Only admins may read these
if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) { if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
http_response_code(403); http_response_code(403);
echo json_encode(['error'=>'Forbidden']); echo json_encode(['error' => 'Forbidden']);
exit; exit;
} }
// Expect a ?file=share_links.json or share_folder_links.json // Must supply ?file=share_links.json or share_folder_links.json
if (empty($_GET['file'])) { if (empty($_GET['file'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['error'=>'Missing `file` parameter']); echo json_encode(['error' => 'Missing `file` parameter']);
exit; exit;
} }
$file = basename($_GET['file']); $file = basename($_GET['file']);
$allowed = ['share_links.json','share_folder_links.json']; $allowed = ['share_links.json', 'share_folder_links.json'];
if (!in_array($file, $allowed, true)) { if (!in_array($file, $allowed, true)) {
http_response_code(403); http_response_code(403);
echo json_encode(['error'=>'Invalid file requested']); echo json_encode(['error' => 'Invalid file requested']);
exit; exit;
} }
$path = META_DIR . $file; $path = META_DIR . $file;
if (!file_exists($path)) { if (!file_exists($path)) {
http_response_code(404); // Return empty object so JS sees `{}` not an error
echo json_encode((object)[]); // return empty object http_response_code(200);
header('Content-Type: application/json');
echo json_encode((object)[]);
exit; exit;
} }
$data = file_get_contents($path); $jsonData = file_get_contents($path);
$json = json_decode($data, true); $data = json_decode($jsonData, true);
if (json_last_error() !== JSON_ERROR_NONE) { if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
http_response_code(500); http_response_code(500);
echo json_encode(['error'=>'Corrupted JSON']); echo json_encode(['error' => 'Corrupted JSON']);
exit; exit;
} }
// ——— Clean up expired entries ———
$now = time();
$changed = false;
foreach ($data as $token => $entry) {
if (!empty($entry['expires']) && $entry['expires'] < $now) {
unset($data[$token]);
$changed = true;
}
}
if ($changed) {
// overwrite file with cleaned data
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));
}
// ——— Send cleaned data back ———
http_response_code(200);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($json); echo json_encode($data);
exit;

View File

@@ -0,0 +1,15 @@
<?php
// public/api/file/createFile.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
header('Content-Type: application/json');
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['success'=>false,'error'=>'Unauthorized']);
exit;
}
$fc = new FileController();
$fc->createFile();

View File

@@ -1,2 +0,0 @@
cd /var/www/public
ln -s ../uploads uploads

View File

@@ -0,0 +1,15 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/UserModel.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);

View 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()
]);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -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;
@@ -838,6 +848,11 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
background-color: #00796B; background-color: #00796B;
} }
#createFileBtn {
background-color: #007bff;
color: white;
}
#fileList button.edit-btn { #fileList button.edit-btn {
background-color: #007bff; background-color: #007bff;
color: white; color: white;
@@ -955,6 +970,29 @@ body.dark-mode #fileList table tr {
padding: 8px 10px !important; padding: 8px 10px !important;
} }
:root {
--file-row-height: 48px;
}
#fileList table.table tbody tr {
height: auto !important;
min-height: var(--file-row-height) !important;
}
#fileList table.table tbody td:not(.file-name-cell) {
height: var(--file-row-height) !important;
line-height: var(--file-row-height) !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
vertical-align: middle;
}
#fileList table.table tbody td.file-name-cell {
white-space: normal;
word-break: break-word;
line-height: 1.2em !important;
height: auto !important;
}
/* =========================================================== /* ===========================================================
HEADINGS & FORM LABELS HEADINGS & FORM LABELS
@@ -1328,26 +1366,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 +2120,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 +2193,100 @@ 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;
}
.folder-strip-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 8px 0;
}
.folder-strip-container .folder-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
width: 80px;
color: inherit;
font-size: 0.85em;
}
.folder-strip-container .folder-item i.material-icons {
font-size: 28px;
margin-bottom: 4px;
}
.folder-strip-container .folder-name {
text-align: center;
white-space: normal;
word-break: break-word;
max-width: 80px;
margin-top: 4px;
}
.folder-strip-container .folder-item i.material-icons {
color: currentColor;
}
.folder-strip-container .folder-item:hover {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
} }

View File

@@ -9,6 +9,26 @@
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg"> <link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<meta name="csrf-token" content=""> <meta name="csrf-token" content="">
<meta name="share-url" content=""> <meta name="share-url" content="">
<style>
/* hide the app shell until JS says otherwise */
.main-wrapper {
display: none;
}
/* full-screen white overlay while we check auth */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-color, #fff);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
</style>
<!-- Google Fonts and Material Icons --> <!-- Google Fonts and Material Icons -->
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
@@ -120,9 +140,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>
@@ -165,10 +182,42 @@
</div> </div>
</header> </header>
<div id="loadingOverlay"></div>
<!-- Custom Toast Container --> <!-- Custom Toast Container -->
<div id="customToast"></div> <div id="customToast"></div>
<div id="hiddenCardsContainer" style="display:none;"></div> <div id="hiddenCardsContainer" style="display:none;"></div>
<div class="row mt-4" id="loginForm">
<div class="col-12">
<form id="authForm" method="post">
<div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label>
<input type="text" class="form-control" id="loginUsername" name="username" required autofocus />
</div>
<div class="form-group">
<label for="loginPassword" data-i18n-key="password">Password:</label>
<input type="password" class="form-control" id="loginPassword" name="password" required />
</div>
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button>
<div class="form-group remember-me-container">
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
</div>
</form>
<!-- OIDC Login Option -->
<div class="text-center mt-3">
<button id="oidcLoginBtn" class="btn btn-secondary" data-i18n-key="login_oidc">Login with OIDC</button>
</div>
<!-- Basic HTTP Login Option -->
<div class="text-center mt-3">
<a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic
HTTP
Login</a>
</div>
</div>
</div>
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login --> <!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
<div class="main-wrapper"> <div class="main-wrapper">
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) --> <!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
@@ -176,37 +225,6 @@
<!-- Main Column --> <!-- Main Column -->
<div id="mainColumn" class="main-column"> <div id="mainColumn" class="main-column">
<div class="container-fluid"> <div class="container-fluid">
<!-- Login Form (unchanged) -->
<div class="row" id="loginForm">
<div class="col-12">
<form id="authForm" method="post">
<div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label>
<input type="text" class="form-control" id="loginUsername" name="username" required autofocus />
</div>
<div class="form-group">
<label for="loginPassword" data-i18n-key="password">Password:</label>
<input type="password" class="form-control" id="loginPassword" name="password" required />
</div>
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button>
<div class="form-group remember-me-container">
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
</div>
</form>
<!-- OIDC Login Option -->
<div class="text-center mt-3">
<button id="oidcLoginBtn" class="btn btn-secondary" data-i18n-key="login_oidc">Login with OIDC</button>
</div>
<!-- Basic HTTP Login Option -->
<div class="text-center mt-3">
<a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic
HTTP
Login</a>
</div>
</div>
</div>
<!-- Main Operations: Upload and Folder Management --> <!-- Main Operations: Upload and Folder Management -->
<div id="mainOperations"> <div id="mainOperations">
<div class="container" style="max-width: 1400px; margin: 0 auto;"> <div class="container" style="max-width: 1400px; margin: 0 auto;">
@@ -371,8 +389,28 @@
</div> </div>
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled <button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
data-i18n-key="download_zip">Download ZIP</button> data-i18n-key="download_zip">Download ZIP</button>
<button id="extractZipBtn" class="btn btn-sm btn-info" data-i18n-title="extract_zip" <button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
data-i18n-key="extract_zip_button">Extract Zip</button> data-i18n-key="extract_zip_button">Extract Zip</button>
<button id="createFileBtn" class="btn action-btn" data-i18n-key="create_file">
${t('create_file')}
</button>
<!-- Create File Modal -->
<div id="createFileModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="create_new_file">Create New File</h4>
<input
type="text"
id="createFileNameInput"
class="form-control"
placeholder="Enter filename…"
data-i18n-placeholder="newfile_placeholder"
/>
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
</div>
</div>
</div>
<div id="downloadZipModal" class="modal" style="display:none;"> <div id="downloadZipModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4> <h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
@@ -427,8 +465,7 @@
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) --> <!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
<div id="changePasswordModal" class="modal" style="display:none;"> <div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;"> <div class="modal-content" style="max-width:400px; margin:auto;">
<span id="closeChangePasswordModal" <span id="closeChangePasswordModal" class="editor-close-btn">&times;</span>
style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</span>
<h3 data-i18n-key="change_password_title">Change Password</h3> <h3 data-i18n-key="change_password_title">Change Password</h3>
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password" <input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
placeholder="Old Password" style="width:100%; margin: 5px 0;" /> placeholder="Old Password" style="width:100%; margin: 5px 0;" />
@@ -439,22 +476,22 @@
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button> <button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button>
</div> </div>
</div> </div>
<div id="addUserModal" class="modal"> <div id="addUserModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h3 data-i18n-key="create_new_user_title">Create New User</h3> <h3 data-i18n-key="create_new_user_title">Create New User</h3>
<!-- 1) Add a form around these fields --> <!-- 1) Add a form around these fields -->
<form id="addUserForm"> <form id="addUserForm">
<label for="newUsername" data-i18n-key="username">Username:</label> <label for="newUsername" data-i18n-key="username">Username:</label>
<input type="text" id="newUsername" class="form-control" required /> <input type="text" id="newUsername" class="form-control" required />
<label for="addUserPassword" data-i18n-key="password">Password:</label> <label for="addUserPassword" data-i18n-key="password">Password:</label>
<input type="password" id="addUserPassword" class="form-control" required /> <input type="password" id="addUserPassword" class="form-control" required />
<div id="adminCheckboxContainer"> <div id="adminCheckboxContainer">
<input type="checkbox" id="isAdmin" /> <input type="checkbox" id="isAdmin" />
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label> <label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
</div> </div>
<div class="button-container"> <div class="button-container">
<!-- Cancel stays type="button" --> <!-- Cancel stays type="button" -->
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel"> <button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
@@ -468,7 +505,7 @@
</form> </form>
</div> </div>
</div> </div>
<div id="removeUserModal" class="modal"> <div id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3> <h3 data-i18n-key="remove_user_title">Remove User</h3>
<label for="removeUsernameSelect" data-i18n-key="select_user_remove">Select a user to remove:</label> <label for="removeUsernameSelect" data-i18n-key="select_user_remove">Select a user to remove:</label>
@@ -479,7 +516,7 @@
</div> </div>
</div> </div>
</div> </div>
<div id="renameFileModal" class="modal"> <div id="renameFileModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h4 data-i18n-key="rename_file_title">Rename File</h4> <h4 data-i18n-key="rename_file_title">Rename File</h4>
<input type="text" id="newFileName" class="form-control" data-i18n-placeholder="rename_file_placeholder" <input type="text" id="newFileName" class="form-control" data-i18n-placeholder="rename_file_placeholder"

View File

@@ -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.0"; const version = "v1.3.7";
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 —————
@@ -184,49 +184,63 @@ function loadShareLinksSection() {
const container = document.getElementById("shareLinksContent"); const container = document.getElementById("shareLinksContent");
container.textContent = t("loading") + "..."; container.textContent = t("loading") + "...";
// Helper to fetch a metadata file or return {} on any error // helper: fetch one metadata file, but never throw —
const fetchMeta = file => // on non-2xx (including 404) or network error, resolve to {}
fetch(`/api/admin/readMetadata.php?file=${file}`, { credentials: "include" }) function fetchMeta(fileName) {
.then(r => r.ok ? r.json() : {}) // non-2xx → treat as empty return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, {
.catch(() => ({})); credentials: "include"
})
.then(resp => {
if (!resp.ok) {
// 404 or any other non-OK → treat as empty
return {};
}
return resp.json();
})
.catch(() => {
// network failure, parse error, etc → also empty
return {};
});
}
Promise.all([ Promise.all([
fetchMeta("share_folder_links.json"), fetchMeta("share_folder_links.json"),
fetchMeta("share_links.json") fetchMeta("share_links.json")
]) ])
.then(([folders, files]) => { .then(([folders, files]) => {
// If nothing at all, show a friendly message // if *both* are empty, show "no shared links"
if (Object.keys(folders).length === 0 && Object.keys(files).length === 0) { const hasAny = Object.keys(folders).length || Object.keys(files).length;
container.textContent = t("no_shared_links_available"); if (!hasAny) {
container.innerHTML = `<p>${t("no_shared_links_available")}</p>`;
return; return;
} }
let html = `<h5>${t("folder_shares")}</h5><ul>`; let html = `<h5>${t("folder_shares")}</h5><ul>`;
Object.entries(folders).forEach(([token, o]) => { Object.entries(folders).forEach(([token, o]) => {
const lock = o.password ? `🔒 ` : ""; const lock = o.password ? "🔒 " : "";
html += ` html += `
<li> <li>
${lock}<strong>${o.folder}</strong> ${lock}<strong>${o.folder}</strong>
<small>(${new Date(o.expires * 1000).toLocaleString()})</small> <small>(${new Date(o.expires * 1000).toLocaleString()})</small>
<button type="button" <button type="button"
data-key="${token}" data-key="${token}"
data-type="folder" data-type="folder"
class="btn btn-sm btn-link delete-share">🗑️</button> class="btn btn-sm btn-link delete-share">🗑️</button>
</li>`; </li>`;
}); });
html += `</ul><h5 style="margin-top:1em;">${t("file_shares")}</h5><ul>`; html += `</ul><h5 style="margin-top:1em;">${t("file_shares")}</h5><ul>`;
Object.entries(files).forEach(([token, o]) => { Object.entries(files).forEach(([token, o]) => {
const lock = o.password ? `🔒 ` : ""; const lock = o.password ? "🔒 " : "";
html += ` html += `
<li> <li>
${lock}<strong>${o.folder}/${o.file}</strong> ${lock}<strong>${o.folder}/${o.file}</strong>
<small>(${new Date(o.expires * 1000).toLocaleString()})</small> <small>(${new Date(o.expires * 1000).toLocaleString()})</small>
<button type="button" <button type="button"
data-key="${token}" data-key="${token}"
data-type="file" data-type="file"
class="btn btn-sm btn-link delete-share">🗑️</button> class="btn btn-sm btn-link delete-share">🗑️</button>
</li>`; </li>`;
}); });
html += `</ul>`; html += `</ul>`;
@@ -375,7 +389,7 @@ export function openAdminPanel() {
// — Header Settings — // — Header Settings —
document.getElementById("headerSettingsContent").innerHTML = ` document.getElementById("headerSettingsContent").innerHTML = `
<div class="form-group"> <div class="form-group">
<label for="headerTitle">${t("header_title")}:</label> <label for="headerTitle">${t("header_title_text")}:</label>
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle}" /> <input type="text" id="headerTitle" class="form-control" value="${window.headerTitle}" />
</div> </div>
`; `;
@@ -385,6 +399,14 @@ export function openAdminPanel() {
<div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div> <div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div>
<div class="form-group"><input type="checkbox" id="disableBasicAuth" /> <label for="disableBasicAuth">${t("disable_basic_http_auth")}</label></div> <div class="form-group"><input type="checkbox" id="disableBasicAuth" /> <label for="disableBasicAuth">${t("disable_basic_http_auth")}</label></div>
<div class="form-group"><input type="checkbox" id="disableOIDCLogin" /> <label for="disableOIDCLogin">${t("disable_oidc_login")}</label></div> <div class="form-group"><input type="checkbox" id="disableOIDCLogin" /> <label for="disableOIDCLogin">${t("disable_oidc_login")}</label></div>
<div class="form-group">
<input type="checkbox" id="authBypass" />
<label for="authBypass">Disable all built-in logins (proxy only)</label>
</div>
<div class="form-group">
<label for="authHeaderName">Auth header name:</label>
<input type="text" id="authHeaderName" class="form-control" placeholder="e.g. X-Remote-User" />
</div>
`; `;
// — WebDAV — // — WebDAV —
@@ -403,6 +425,9 @@ export function openAdminPanel() {
// — OIDC & TOTP — // — OIDC & TOTP —
document.getElementById("oidcContent").innerHTML = ` document.getElementById("oidcContent").innerHTML = `
<div class="form-text text-muted" style="margin-top:8px;">
<small>Note: OIDC credentials (Client ID/Secret) will show blank here after saving, but remain unchanged until you explicitly edit and save them.</small>
</div>
<div class="form-group"><label for="oidcProviderUrl">${t("oidc_provider_url")}:</label><input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig.providerUrl}" /></div> <div class="form-group"><label for="oidcProviderUrl">${t("oidc_provider_url")}:</label><input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig.providerUrl}" /></div>
<div class="form-group"><label for="oidcClientId">${t("oidc_client_id")}:</label><input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig.clientId}" /></div> <div class="form-group"><label for="oidcClientId">${t("oidc_client_id")}:</label><input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig.clientId}" /></div>
<div class="form-group"><label for="oidcClientSecret">${t("oidc_client_secret")}:</label><input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig.clientSecret}" /></div> <div class="form-group"><label for="oidcClientSecret">${t("oidc_client_secret")}:</label><input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig.clientSecret}" /></div>
@@ -427,11 +452,20 @@ export function openAdminPanel() {
} }
}); });
}); });
// If authBypass is checked, clear the other three
document.getElementById("authBypass").addEventListener("change", e => {
if (e.target.checked) {
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"]
.forEach(i => document.getElementById(i).checked = false);
}
});
// Initialize inputs from config + capture // Initialize inputs from config + capture
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
document.getElementById("authBypass").checked = !!config.loginOptions.authBypass;
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
captureInitialAdminConfig(); captureInitialAdminConfig();
@@ -443,6 +477,8 @@ export function openAdminPanel() {
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
document.getElementById("authBypass").checked = !!config.loginOptions.authBypass;
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl; document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl;
@@ -457,19 +493,21 @@ export function openAdminPanel() {
} }
function handleSave() { function handleSave() {
const dFL = document.getElementById("disableFormLogin").checked; const dFL = document.getElementById("disableFormLogin").checked;
const dBA = document.getElementById("disableBasicAuth").checked; const dBA = document.getElementById("disableBasicAuth").checked;
const dOIDC = document.getElementById("disableOIDCLogin").checked; const dOIDC = document.getElementById("disableOIDCLogin").checked;
const eWD = document.getElementById("enableWebDAV").checked; const aBypass= document.getElementById("authBypass").checked;
const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0; const aHeader= document.getElementById("authHeaderName").value.trim() || "X-Remote-User";
const nHT = document.getElementById("headerTitle").value.trim(); const eWD = document.getElementById("enableWebDAV").checked;
const nOIDC = { const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0;
const nHT = document.getElementById("headerTitle").value.trim();
const nOIDC = {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(), providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
clientId: document.getElementById("oidcClientId").value.trim(), clientId: document.getElementById("oidcClientId").value.trim(),
clientSecret: document.getElementById("oidcClientSecret").value.trim(), clientSecret:document.getElementById("oidcClientSecret").value.trim(),
redirectUri: document.getElementById("oidcRedirectUri").value.trim() redirectUri: document.getElementById("oidcRedirectUri").value.trim()
}; };
const gURL = document.getElementById("globalOtpauthUrl").value.trim(); const gURL = document.getElementById("globalOtpauthUrl").value.trim();
if ([dFL, dBA, dOIDC].filter(x => x).length === 3) { if ([dFL, dBA, dOIDC].filter(x => x).length === 3) {
showToast(t("at_least_one_login_method")); showToast(t("at_least_one_login_method"));
@@ -477,12 +515,22 @@ function handleSave() {
} }
sendRequest("/api/admin/updateConfig.php", "POST", { sendRequest("/api/admin/updateConfig.php", "POST", {
header_title: nHT, oidc: nOIDC, header_title: nHT,
disableFormLogin: dFL, disableBasicAuth: dBA, disableOIDCLogin: dOIDC, oidc: nOIDC,
enableWebDAV: eWD, sharedMaxUploadSize: sMax, globalOtpauthUrl: gURL loginOptions: {
disableFormLogin: dFL,
disableBasicAuth: dBA,
disableOIDCLogin: dOIDC,
authBypass: aBypass,
authHeaderName: aHeader
},
enableWebDAV: eWD,
sharedMaxUploadSize: sMax,
globalOtpauthUrl: gURL
}, { }, {
"X-CSRF-Token": window.csrfToken "X-CSRF-Token": window.csrfToken
}).then(res => { })
.then(res => {
if (res.success) { if (res.success) {
showToast(t("settings_updated_successfully"), "success"); showToast(t("settings_updated_successfully"), "success");
captureInitialAdminConfig(); captureInitialAdminConfig();
@@ -491,7 +539,7 @@ function handleSave() {
} else { } else {
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error"); showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error");
} }
}).catch(() => {/*noop*/ }); }).catch(() => {/*noop*/});
} }
export async function closeAdminPanel() { export async function closeAdminPanel() {
@@ -534,7 +582,7 @@ export function openUserPermissionsModal() {
`; `;
userPermissionsModal.innerHTML = ` userPermissionsModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}"> <div class="modal-content" style="${modalContentStyles}">
<span id="closeUserPermissionsModal" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span> <span id="closeUserPermissionsModal" class="editor-close-btn">&times;</span>
<h3>${t("user_permissions")}</h3> <h3>${t("user_permissions")}</h3>
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;"> <div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">
<!-- User rows will be loaded here --> <!-- User rows will be loaded here -->

View File

@@ -15,15 +15,17 @@ 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, triggerLogout } from './main.js';
// Production OIDC configuration (override via API as needed) // Production OIDC configuration (override via API as needed)
const currentOIDCConfig = { const currentOIDCConfig = {
providerUrl: "https://your-oidc-provider.com", providerUrl: "https://your-oidc-provider.com",
clientId: "YOUR_CLIENT_ID", clientId: "",
clientSecret: "YOUR_CLIENT_SECRET", clientSecret: "",
redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback", redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback",
globalOtpauthUrl: "" globalOtpauthUrl: ""
}; };
@@ -124,16 +126,23 @@ function updateItemsPerPageSelect() {
} }
} }
function applyProxyBypassUI() {
const bypass = localStorage.getItem("authBypass") === "true";
const loginContainer = document.getElementById("loginForm");
if (loginContainer) {
loginContainer.style.display = bypass ? "none" : "";
}
}
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) { function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
const authForm = document.getElementById("authForm"); const authForm = document.getElementById("authForm");
if if
(authForm) { (authForm) {
authForm.style.display = disableFormLogin ? "none" : "block"; authForm.style.display = disableFormLogin ? "none" : "block";
setTimeout(() => { setTimeout(() => {
const loginInput = document.getElementById('loginUsername'); const loginInput = document.getElementById('loginUsername');
if (loginInput) loginInput.focus(); if (loginInput) loginInput.focus();
}, 0); }, 0);
} }
const basicAuthLink = document.querySelector("a[href='/api/auth/login_basic.php']"); const basicAuthLink = document.querySelector("a[href='/api/auth/login_basic.php']");
if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block"; if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block";
@@ -145,7 +154,8 @@ function updateLoginOptionsUIFromStorage() {
updateLoginOptionsUI({ updateLoginOptionsUI({
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"
}); });
} }
@@ -160,6 +170,8 @@ export function loadAdminConfigFunc() {
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth); localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin); localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise"); localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
localStorage.setItem("authBypass", String(!!config.loginOptions.authBypass));
localStorage.setItem("authHeaderName", config.loginOptions.authHeaderName || "X-Remote-User");
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
@@ -188,16 +200,48 @@ function insertAfter(newNode, referenceNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
} }
function updateAuthenticatedUI(data) { async function fetchProfilePicture() {
try {
const res = await fetch('/api/profile/getCurrentUser.php', {
credentials: 'include'
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const info = await res.json();
let pic = info.profile_picture || '';
// --- take only what's after the *last* colon ---
const parts = pic.split(':');
pic = parts[parts.length - 1] || '';
// strip any stray leading colons
pic = pic.replace(/^:+/, '');
// ensure exactly one leading slash
if (pic && !pic.startsWith('/')) pic = '/' + pic;
return pic;
} catch (e) {
console.warn('fetchProfilePicture failed:', e);
return '';
}
}
export async function updateAuthenticatedUI(data) {
// Save latest auth data for later reuse
window.__lastAuthData = data;
// 1) Remove loading overlay safely
const loading = document.getElementById('loadingOverlay');
if (loading) loading.remove();
// 2) Show main UI
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = 'none';
toggleVisibility("loginForm", false); toggleVisibility("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");
} }
@@ -205,64 +249,157 @@ 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>`;
// fallback username if missing
const usernameText = data.username
|| localStorage.getItem("username")
|| "";
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">${usernameText}</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 & username only
const tog = dd.querySelector("#userDropdownToggle");
tog.innerHTML = `
${avatarHTML}
<span class="dropdown-username">${usernameText}</span>
<span class="dropdown-caret"></span>
`;
dd.style.display = "inline-block";
} }
} }
// 6) Finalize
initializeApp();
applyTranslations(); applyTranslations();
updateItemsPerPageSelect(); updateItemsPerPageSelect();
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
@@ -272,6 +409,12 @@ 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) {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
// show the wrapper (so the login form can be visible)
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = 'none';
window.setupMode = true; window.setupMode = true;
if (showLoginToast) showToast("Setup mode: No users found. Please add an admin user."); if (showLoginToast) showToast("Setup mode: No users found. Please add an admin user.");
toggleVisibility("loginForm", false); toggleVisibility("loginForm", false);
@@ -283,11 +426,13 @@ function checkAuthentication(showLoginToast = true) {
} }
window.setupMode = false; window.setupMode = false;
if (data.authenticated) { if (data.authenticated) {
localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false'); localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false');
localStorage.setItem("folderOnly", data.folderOnly); localStorage.setItem("folderOnly", data.folderOnly);
localStorage.setItem("readOnly", data.readOnly); localStorage.setItem("readOnly", data.readOnly);
localStorage.setItem("disableUpload", data.disableUpload); localStorage.setItem("disableUpload", data.disableUpload);
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
applyProxyBypassUI();
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");
} }
@@ -298,8 +443,14 @@ function checkAuthentication(showLoginToast = true) {
updateAuthenticatedUI(data); updateAuthenticatedUI(data);
return data; return data;
} else { } else {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
// show the wrapper (so the login form can be visible)
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = '';
if (showLoginToast) showToast("Please log in to continue."); if (showLoginToast) showToast("Please log in to continue.");
toggleVisibility("loginForm", 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);
@@ -443,52 +594,55 @@ function initAuth() {
submitLogin(formData); submitLogin(formData);
}); });
} }
document.getElementById("addUserBtn").addEventListener("click", function () { document.getElementById("addUserBtn").addEventListener("click", function () {
resetUserForm(); resetUserForm();
toggleVisibility("addUserModal", true); toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus(); document.getElementById("newUsername").focus();
}); });
// remove your old saveUserBtn click-handler…
// instead: // remove your old saveUserBtn click-handler…
const addUserForm = document.getElementById("addUserForm");
addUserForm.addEventListener("submit", function (e) {
e.preventDefault(); // stop the browser from reloading the page
const newUsername = document.getElementById("newUsername").value.trim(); // instead:
const newPassword = document.getElementById("addUserPassword").value.trim(); const addUserForm = document.getElementById("addUserForm");
const isAdmin = document.getElementById("isAdmin").checked; addUserForm.addEventListener("submit", function (e) {
e.preventDefault(); // stop the browser from reloading the page
if (!newUsername || !newPassword) { const newUsername = document.getElementById("newUsername").value.trim();
showToast("Username and password are required!"); const newPassword = document.getElementById("addUserPassword").value.trim();
return; const isAdmin = document.getElementById("isAdmin").checked;
}
let url = "/api/addUser.php"; if (!newUsername || !newPassword) {
if (window.setupMode) url += "?setup=1"; showToast("Username and password are required!");
return;
}
fetchWithCsrf(url, { let url = "/api/addUser.php";
method: "POST", if (window.setupMode) url += "?setup=1";
credentials: "include",
headers: { "Content-Type": "application/json" }, fetchWithCsrf(url, {
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin }) method: "POST",
}) credentials: "include",
.then(r => r.json()) headers: { "Content-Type": "application/json" },
.then(data => { body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
if (data.success) {
showToast("User added successfully!");
closeAddUserModal();
checkAuthentication(false);
} else {
showToast("Error: " + (data.error || "Could not add user"));
}
}) })
.catch(() => { .then(r => r.json())
showToast("Error: Could not add user"); .then(data => {
}); if (data.success) {
}); showToast("User added successfully!");
closeAddUserModal();
checkAuthentication(false);
if (window.setupMode) {
toggleVisibility("loginForm", true);
}
} else {
showToast("Error: " + (data.error || "Could not add user"));
}
})
.catch(() => {
showToast("Error: Could not add user");
});
});
document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal); document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal);
document.getElementById("removeUserBtn").addEventListener("click", function () { document.getElementById("removeUserBtn").addEventListener("click", function () {

View File

@@ -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) {
@@ -30,7 +29,7 @@ export function openTOTPLoginModal() {
`; `;
totpLoginModal.innerHTML = ` totpLoginModal.innerHTML = `
<div style="background: ${modalBg}; padding:20px; border-radius:8px; text-align:center; position:relative; color:${textColor};"> <div style="background: ${modalBg}; padding:20px; border-radius:8px; text-align:center; position:relative; color:${textColor};">
<span id="closeTOTPLoginModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</span> <span id="closeTOTPLoginModal" class="editor-close-btn">&times;</span>
<div id="totpSection"> <div id="totpSection">
<h3>${t("enter_totp_code")}</h3> <h3>${t("enter_totp_code")}</h3>
<input type="text" id="totpLoginInput" maxlength="6" <input type="text" id="totpLoginInput" maxlength="6"
@@ -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,279 @@ 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"}; });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.warn('fetchCurrentUser failed:', e);
return {};
}
}
/**
* Normalize any profilepicture URL:
* - strip leading colons
* - ensure exactly one leading slash
*/
function normalizePicUrl(raw) {
if (!raw) return '';
// take only what's after the last colon
const parts = raw.split(':');
let pic = parts[parts.length - 1];
// strip any stray colons
pic = pic.replace(/^:+/, '');
// ensure leading slash
if (pic && !pic.startsWith('/')) pic = '/' + pic;
return pic;
}
export async function openUserPanel() {
// 1) load data
const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser();
const raw = profile_picture;
const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png';
// 2) darkmode 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 contentStyle = `
background: ${isDark ? '#2c2c2c' : '#fff'};
color: ${isDark ? '#e0e0e0' : '#000'};
padding: 20px; padding: 20px;
max-width: 600px; max-width: 600px; width:90%;
width: 90%;
border-radius: 8px; border-radius: 8px;
position: fixed; overflow-y: auto; max-height: 500px;
overflow-y: auto; border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
max-height: 400px !important; box-sizing: border-box;
border: ${isDarkMode ? "1px solid #444" : "1px solid #ccc"}; scrollbar-width: none;
transform: none; -ms-overflow-style: none;
transition: none;
`; `;
const savedLanguage = localStorage.getItem("language") || "en";
if (!userPanelModal) { // 3) create or reuse modal
userPanelModal = document.createElement("div"); let modal = document.getElementById('userPanelModal');
userPanelModal.id = "userPanelModal"; if (!modal) {
userPanelModal.style.cssText = ` // overlay
position: fixed; modal = document.createElement('div');
top: 0; modal.id = 'userPanelModal';
left: 0; Object.assign(modal.style, {
width: 100vw; position: 'fixed',
height: 100vh; top: '0',
background-color: ${overlayBackground}; left: '0',
display: flex; right: '0',
justify-content: center; bottom: '0',
align-items: center; background: overlayBg,
z-index: 3000; display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '1000',
});
// content container
const content = document.createElement('div');
content.className = 'modal-content';
content.style.cssText = contentStyle;
// close button
const closeBtn = document.createElement('span');
closeBtn.id = 'closeUserPanel';
closeBtn.className = 'editor-close-btn';
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => modal.style.display = 'none');
content.appendChild(closeBtn);
// avatar + picker
const avatarWrapper = document.createElement('div');
avatarWrapper.style.cssText = 'text-align:center; margin-bottom:20px;';
const avatarInner = document.createElement('div');
avatarInner.style.cssText = 'position:relative; width:80px; height:80px; margin:0 auto;';
const img = document.createElement('img');
img.id = 'profilePicPreview';
img.src = picUrl;
img.alt = 'Profile Picture';
img.style.cssText = 'width:100%; height:100%; border-radius:50%; object-fit:cover;';
avatarInner.appendChild(img);
const label = document.createElement('label');
label.htmlFor = 'profilePicInput';
label.style.cssText = `
position:absolute; bottom:0; right:0;
width:24px; height:24px;
background:rgba(0,0,0,0.6);
border-radius:50%; display:flex;
align-items:center; justify-content:center;
cursor:pointer;
`; `;
userPanelModal.innerHTML = ` const editIcon = document.createElement('i');
<div class="modal-content user-panel-content" style="${modalContentStyles}"> editIcon.className = 'material-icons';
<span id="closeUserPanel" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span> editIcon.style.cssText = 'color:#fff; font-size:16px;';
<h3>${t("user_panel")} (${username})</h3> editIcon.textContent = 'edit';
label.appendChild(editIcon);
avatarInner.appendChild(label);
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'profilePicInput';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
avatarInner.appendChild(fileInput);
avatarWrapper.appendChild(avatarInner);
content.appendChild(avatarWrapper);
<button type="button" id="openChangePasswordModalBtn" class="btn btn-primary" style="margin-bottom: 15px;"> // title
${t("change_password")} const title = document.createElement('h3');
</button> title.style.cssText = 'text-align:center; margin-bottom:20px;';
title.textContent = `${t('user_panel')} (${username})`;
content.appendChild(title);
<fieldset style="margin-bottom: 15px;"> // change password btn
<legend>${t("totp_settings")}</legend> const pwdBtn = document.createElement('button');
<div class="form-group"> pwdBtn.id = 'openChangePasswordModalBtn';
<label for="userTOTPEnabled">${t("enable_totp")}:</label> pwdBtn.className = 'btn btn-primary';
<input type="checkbox" id="userTOTPEnabled" style="vertical-align: middle;" /> pwdBtn.style.marginBottom = '15px';
</div> pwdBtn.textContent = t('change_password');
</fieldset> pwdBtn.addEventListener('click', () => {
document.getElementById('changePasswordModal').style.display = 'block';
<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>
`;
document.body.appendChild(userPanelModal);
const 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;
`;
// api.php
apiModal.innerHTML = `
<div style="position:relative; width:90vw; height:90vh; background:#fff; border-radius:8px; overflow:hidden;">
<div class="editor-close-btn" id="closeApiModal">&times;</div>
<iframe src="api.php" style="width:100%;height:100%;border:none;"></iframe>
</div>
`;
document.body.appendChild(apiModal);
document.getElementById("openApiModalBtn").addEventListener("click", () => {
apiModal.style.display = "flex";
});
document.getElementById("closeApiModal").addEventListener("click", () => {
apiModal.style.display = "none";
}); });
content.appendChild(pwdBtn);
// Handlers… // TOTP fieldset
document.getElementById("closeUserPanel").addEventListener("click", () => { const totpFs = document.createElement('fieldset');
userPanelModal.style.display = "none"; totpFs.style.marginBottom = '15px';
}); const totpLegend = document.createElement('legend');
document.getElementById("openChangePasswordModalBtn").addEventListener("click", () => { totpLegend.textContent = t('totp_settings');
document.getElementById("changePasswordModal").style.display = "block"; totpFs.appendChild(totpLegend);
}); const totpLabel = document.createElement('label');
totpLabel.style.cursor = 'pointer';
const totpCb = document.createElement('input');
// TOTP checkbox totpCb.type = 'checkbox';
const totpCheckbox = document.getElementById("userTOTPEnabled"); totpCb.id = 'userTOTPEnabled';
totpCheckbox.checked = localStorage.getItem("userTOTPEnabled") === "true"; totpCb.style.verticalAlign = 'middle';
totpCheckbox.addEventListener("change", function () { totpCb.checked = totp_enabled;
localStorage.setItem("userTOTPEnabled", this.checked ? "true" : "false"); totpCb.addEventListener('change', async function () {
fetch("/api/updateUserPanel.php", { const resp = await fetch('/api/updateUserPanel.php', {
method: "POST", method: 'POST', credentials: 'include',
credentials: "include", headers: {
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, '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")));
}); });
totpLabel.appendChild(totpCb);
totpLabel.append(` ${t('enable_totp')}`);
totpFs.appendChild(totpLabel);
content.appendChild(totpFs);
// Language selector // language fieldset
const languageSelector = document.getElementById("languageSelector"); const langFs = document.createElement('fieldset');
languageSelector.value = savedLanguage; langFs.style.marginBottom = '15px';
languageSelector.addEventListener("change", function () { const langLegend = document.createElement('legend');
localStorage.setItem("language", this.value); langLegend.textContent = t('language');
langFs.appendChild(langLegend);
const langSel = document.createElement('select');
langSel.id = 'languageSelector';
langSel.className = 'form-select';
['en', 'es', 'fr', 'de'].forEach(code => {
const opt = document.createElement('option');
opt.value = code;
opt.textContent = t(code === 'en' ? 'english' : code === 'es' ? 'spanish' : code === 'fr' ? 'french' : 'german');
langSel.appendChild(opt);
});
langSel.value = localStorage.getItem('language') || 'en';
langSel.addEventListener('change', function () {
localStorage.setItem('language', this.value);
setLocale(this.value); setLocale(this.value);
applyTranslations(); applyTranslations();
}); });
langFs.appendChild(langSel);
content.appendChild(langFs);
// --- Display fieldset: “Show folders above files” ---
const dispFs = document.createElement('fieldset');
dispFs.style.marginBottom = '15px';
const dispLegend = document.createElement('legend');
dispLegend.textContent = t('display');
dispFs.appendChild(dispLegend);
const dispLabel = document.createElement('label');
dispLabel.style.cursor = 'pointer';
const dispCb = document.createElement('input');
dispCb.type = 'checkbox';
dispCb.id = 'showFoldersInList';
dispCb.style.verticalAlign = 'middle';
const stored = localStorage.getItem('showFoldersInList');
dispCb.checked = stored === null ? true : stored === 'true';
dispLabel.appendChild(dispCb);
dispLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(dispLabel);
content.appendChild(dispFs);
dispCb.addEventListener('change', () => {
window.showFoldersInList = dispCb.checked;
localStorage.setItem('showFoldersInList', dispCb.checked);
// reload the entire file list (and strip) in one go:
loadFileList(window.currentFolder);
});
// wire up imageinput change
fileInput.addEventListener('change', async function () {
const f = this.files[0];
if (!f) return;
// preview immediately
// #nosec
img.src = URL.createObjectURL(f);
const blobUrl = URL.createObjectURL(f);
// use setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
img.setAttribute('src', encodeURI(blobUrl));
// upload
const fd = new FormData();
fd.append('profile_picture', f);
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);
updateAuthenticatedUI(window.__lastAuthData || {});
showToast(t('profile_picture_updated'));
} catch (e) {
console.error(e);
showToast(t('error_updating_picture'));
}
});
// finalize
modal.appendChild(content);
document.body.appendChild(modal);
} else { } else {
// Update colors if already exists // reuse on reopen
userPanelModal.style.backgroundColor = overlayBackground; Object.assign(modal.style, { background: overlayBg });
const modalContent = userPanelModal.querySelector(".modal-content"); const content = modal.querySelector('.modal-content');
modalContent.style.background = isDarkMode ? "#2c2c2c" : "#fff"; content.style.cssText = contentStyle;
modalContent.style.color = isDarkMode ? "#e0e0e0" : "#000"; modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
modalContent.style.border = isDarkMode ? "1px solid #444" : "1px solid #ccc"; modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
} }
userPanelModal.style.display = "flex"; // show
modal.style.display = 'flex';
} }
function showRecoveryCodeModal(recoveryCode) { function showRecoveryCodeModal(recoveryCode) {
@@ -314,26 +428,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,106 +455,54 @@ 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" style="position: absolute; top: 10px; right: 10px; cursor: pointer; font-size: 24px;">&times;</span> <span id="closeTOTPModal" class="editor-close-btn">&times;</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);
}); });
@@ -458,29 +515,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 +536,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 +571,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">&times;</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";
} }

View File

@@ -33,54 +33,66 @@ export function toggleAllCheckboxes(masterCheckbox) {
export function updateFileActionButtons() { export function updateFileActionButtons() {
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox"); const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked"); const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
const deleteBtn = document.getElementById("deleteSelectedBtn");
const copyBtn = document.getElementById("copySelectedBtn"); const copyBtn = document.getElementById("copySelectedBtn");
const moveBtn = document.getElementById("moveSelectedBtn"); const moveBtn = document.getElementById("moveSelectedBtn");
const deleteBtn = document.getElementById("deleteSelectedBtn");
const zipBtn = document.getElementById("downloadZipBtn"); const zipBtn = document.getElementById("downloadZipBtn");
const extractZipBtn = document.getElementById("extractZipBtn"); const extractZipBtn = document.getElementById("extractZipBtn");
const createBtn = document.getElementById("createFileBtn");
// keep the “select all” in sync —— const anyFiles = fileCheckboxes.length > 0;
const master = document.getElementById("selectAll"); const anySelected = selectedCheckboxes.length > 0;
if (master) { const anyZip = Array.from(selectedCheckboxes)
if (selectedCheckboxes.length === fileCheckboxes.length) { .some(cb => cb.value.toLowerCase().endsWith(".zip"));
master.checked = true;
master.indeterminate = false;
} else if (selectedCheckboxes.length === 0) {
master.checked = false;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
if (fileCheckboxes.length === 0) { // — Select All checkbox sync (unchanged) —
if (copyBtn) copyBtn.style.display = "none"; const master = document.getElementById("selectAll");
if (moveBtn) moveBtn.style.display = "none"; if (master) {
if (deleteBtn) deleteBtn.style.display = "none"; if (selectedCheckboxes.length === fileCheckboxes.length) {
if (zipBtn) zipBtn.style.display = "none"; master.checked = true;
if (extractZipBtn) extractZipBtn.style.display = "none"; master.indeterminate = false;
} else { } else if (selectedCheckboxes.length === 0) {
if (copyBtn) copyBtn.style.display = "inline-block"; master.checked = false;
if (moveBtn) moveBtn.style.display = "inline-block"; master.indeterminate = false;
if (deleteBtn) deleteBtn.style.display = "inline-block"; } else {
if (zipBtn) zipBtn.style.display = "inline-block"; master.checked = false;
if (extractZipBtn) extractZipBtn.style.display = "inline-block"; master.indeterminate = true;
const anySelected = selectedCheckboxes.length > 0;
if (copyBtn) copyBtn.disabled = !anySelected;
if (moveBtn) moveBtn.disabled = !anySelected;
if (deleteBtn) deleteBtn.disabled = !anySelected;
if (zipBtn) zipBtn.disabled = !anySelected;
if (extractZipBtn) {
// Enable only if at least one selected file ends with .zip (case-insensitive).
const anyZipSelected = Array.from(selectedCheckboxes).some(chk =>
chk.value.toLowerCase().endsWith(".zip")
);
extractZipBtn.disabled = !anyZipSelected;
} }
} }
// Delete / Copy / Move: only show when something is selected
if (deleteBtn) {
deleteBtn.style.display = anySelected ? "" : "none";
}
if (copyBtn) {
copyBtn.style.display = anySelected ? "" : "none";
}
if (moveBtn) {
moveBtn.style.display = anySelected ? "" : "none";
}
// Download ZIP: only show when something is selected
if (zipBtn) {
zipBtn.style.display = anySelected ? "" : "none";
}
// Extract ZIP: only show when a selected file is a .zip
if (extractZipBtn) {
extractZipBtn.style.display = anyZip ? "" : "none";
}
// Create File: only show when nothing is selected
if (createBtn) {
createBtn.style.display = anySelected ? "none" : "";
}
// Finally disable the ones that are shown but shouldnt be clickable
if (deleteBtn) deleteBtn.disabled = !anySelected;
if (copyBtn) copyBtn.disabled = !anySelected;
if (moveBtn) moveBtn.disabled = !anySelected;
if (zipBtn) zipBtn.disabled = !anySelected;
if (extractZipBtn) extractZipBtn.disabled = !anyZip;
} }
export function showToast(message, duration = 3000) { export function showToast(message, duration = 3000) {
@@ -178,9 +190,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 +211,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>

View File

@@ -32,23 +32,33 @@ export function loadSidebarOrder() {
updateSidebarVisibility(); updateSidebarVisibility();
} }
// NEW: Load header order from localStorage.
export function loadHeaderOrder() { export function loadHeaderOrder() {
const headerDropArea = document.getElementById('headerDropArea'); const headerDropArea = document.getElementById('headerDropArea');
if (!headerDropArea) return; if (!headerDropArea) return;
const orderStr = localStorage.getItem('headerOrder');
if (orderStr) { // 1) Clear out any icons that might already be in the drop area
const order = JSON.parse(orderStr); headerDropArea.innerHTML = '';
if (order.length > 0) {
order.forEach(id => { // 2) Read the saved array (or empty array if invalid/missing)
const card = document.getElementById(id); let stored;
// Only load if card is not already in header drop zone. try {
if (card && card.parentNode.id !== 'headerDropArea') { stored = JSON.parse(localStorage.getItem('headerOrder') || '[]');
insertCardInHeader(card, null); } catch {
} stored = [];
});
}
} }
// 3) Deduplicate IDs
const uniqueIds = Array.from(new Set(stored));
// 4) Re-insert exactly one icon per saved card ID
uniqueIds.forEach(id => {
const card = document.getElementById(id);
if (card) insertCardInHeader(card, null);
});
// 5) Persist the cleaned, deduped list back to storage
localStorage.setItem('headerOrder', JSON.stringify(uniqueIds));
} }
// Internal helper: update sidebar visibility based on its content. // Internal helper: update sidebar visibility based on its content.

View File

@@ -76,6 +76,72 @@ export function handleDownloadZipSelected(e) {
}, 100); }, 100);
}; };
export function handleCreateFileSelected(e) {
e.preventDefault(); e.stopImmediatePropagation();
const modal = document.getElementById('createFileModal');
modal.style.display = 'block';
setTimeout(() => {
const inp = document.getElementById('newFileCreateName');
if (inp) inp.focus();
}, 100);
}
/**
* Open the “New File” modal
*/
export function openCreateFileModal() {
const modal = document.getElementById('createFileModal');
const input = document.getElementById('createFileNameInput');
if (!modal || !input) {
console.error('Create-file modal or input not found');
return;
}
input.value = '';
modal.style.display = 'block';
setTimeout(() => input.focus(), 0);
}
export async function handleCreateFile(e) {
e.preventDefault();
const input = document.getElementById('createFileNameInput');
if (!input) return console.error('Create-file input missing');
const name = input.value.trim();
if (!name) {
showToast(t('newfile_placeholder')); // or a more explicit error
return;
}
const folder = window.currentFolder || 'root';
try {
const res = await fetch('/api/file/createFile.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type':'application/json',
'X-CSRF-Token': window.csrfToken
},
// ⚠️ must send `name`, not `filename`
body: JSON.stringify({ folder, name })
});
const js = await res.json();
if (!js.success) throw new Error(js.error);
showToast(t('file_created'));
loadFileList(folder);
} catch (err) {
showToast(err.message || t('error_creating_file'));
} finally {
document.getElementById('createFileModal').style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', () => {
const cancel = document.getElementById('cancelCreateFile');
const confirm = document.getElementById('confirmCreateFile');
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
if (confirm) confirm.addEventListener('click', handleCreateFile);
});
export function openDownloadModal(fileName, folder) { export function openDownloadModal(fileName, folder) {
// Store file details globally for the download confirmation function. // Store file details globally for the download confirmation function.
window.singleFileToDownload = fileName; window.singleFileToDownload = fileName;
@@ -197,6 +263,49 @@ document.addEventListener("DOMContentLoaded", () => {
const progressModal = document.getElementById("downloadProgressModal"); const progressModal = document.getElementById("downloadProgressModal");
const cancelZipBtn = document.getElementById("cancelDownloadZip"); const cancelZipBtn = document.getElementById("cancelDownloadZip");
const confirmZipBtn = document.getElementById("confirmDownloadZip"); const confirmZipBtn = document.getElementById("confirmDownloadZip");
const cancelCreate = document.getElementById('cancelCreateFile');
if (cancelCreate) {
cancelCreate.addEventListener('click', () => {
document.getElementById('createFileModal').style.display = 'none';
});
}
const confirmCreate = document.getElementById('confirmCreateFile');
if (confirmCreate) {
confirmCreate.addEventListener('click', async () => {
const name = document.getElementById('newFileCreateName').value.trim();
if (!name) {
showToast(t('please_enter_filename'));
return;
}
document.getElementById('createFileModal').style.display = 'none';
try {
const res = await fetch('/api/file/createFile.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken
},
body: JSON.stringify({
folder: window.currentFolder || 'root',
filename: name
})
});
const js = await res.json();
if (!res.ok || !js.success) {
throw new Error(js.error || t('error_creating_file'));
}
showToast(t('file_created_successfully'));
loadFileList(window.currentFolder);
} catch (err) {
console.error(err);
showToast(err.message || t('error_creating_file'));
}
});
attachEnterKeyListener('createFileModal','confirmCreateFile');
}
// 1) Cancel button hides the name modal // 1) Cancel button hides the name modal
if (cancelZipBtn) { if (cancelZipBtn) {
@@ -553,8 +662,14 @@ export function initFileActions() {
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true)); extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected); document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
} }
const createBtn = document.getElementById('createFileBtn');
if (createBtn) {
createBtn.replaceWith(createBtn.cloneNode(true));
document.getElementById('createFileBtn').addEventListener('click', openCreateFileModal);
}
} }
// Hook up the singlefile download modal buttons // Hook up the singlefile download modal buttons
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile"); const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");

View File

@@ -16,6 +16,12 @@ import { t } from './i18n.js';
import { bindFileListContextMenu } from './fileMenu.js'; import { bindFileListContextMenu } from './fileMenu.js';
import { openDownloadModal } from './fileActions.js'; import { openDownloadModal } from './fileActions.js';
import { openTagModal, openMultiTagModal } from './fileTags.js'; import { openTagModal, openMultiTagModal } from './fileTags.js';
import { getParentFolder, updateBreadcrumbTitle, setupBreadcrumbDelegation } from './folderManager.js';
import {
folderDragOverHandler,
folderDragLeaveHandler,
folderDropHandler
} from './fileDragDrop.js';
export let fileData = []; export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true }; export let sortOrder = { column: "uploaded", ascending: true };
@@ -186,100 +192,231 @@ export function formatFolderName(folder) {
window.toggleRowSelection = toggleRowSelection; window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight; window.updateRowHighlight = updateRowHighlight;
/** export async function loadFileList(folderParam) {
* --- FILE LIST & VIEW RENDERING ---
*/
export function loadFileList(folderParam) {
const folder = folderParam || "root"; const folder = folderParam || "root";
const fileListContainer = document.getElementById("fileList"); const fileListContainer = document.getElementById("fileList");
const actionsContainer = document.getElementById("fileListActions");
// 1) show loader
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()) try {
.then(response => { // 2) fetch files + folders in parallel
if (response.status === 401) { const [filesRes, foldersRes] = await Promise.all([
showToast("Session expired. Please log in again."); fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`),
window.location.href = "/api/auth/logout.php"; fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`)
throw new Error("Unauthorized"); ]);
}
return response.json();
})
.then(data => {
fileListContainer.innerHTML = ""; // Clear loading message.
if (data.files && Object.keys(data.files).length > 0) {
// If the returned "files" is an object instead of an array, transform it.
if (!Array.isArray(data.files)) {
data.files = Object.entries(data.files).map(([name, meta]) => {
meta.name = name;
return meta;
});
}
// Process each file add computed properties.
data.files = data.files.map(file => {
file.fullName = (file.path || file.name).trim().toLowerCase();
file.editable = canEditFile(file.name);
file.folder = folder;
if (!file.type && /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
file.type = "image";
}
// OPTIONAL: For text documents, preload content (if available from backend)
// Example: if (/\.txt|html|md|js|css|json|xml$/i.test(file.name)) { file.content = file.content || ""; }
return file;
});
fileData = data.files;
// Update file summary. if (filesRes.status === 401) {
const actionsContainer = document.getElementById("fileListActions"); window.location.href = "/api/auth/logout.php";
if (actionsContainer) { throw new Error("Unauthorized");
let summaryElem = document.getElementById("fileSummary"); }
if (!summaryElem) { const data = await filesRes.json();
summaryElem = document.createElement("div"); const folderRaw = await foldersRes.json();
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. // --- build ONLY the *direct* children of current folder ---
if (window.viewMode === "gallery") { let subfolders = [];
renderGalleryView(folder); const hidden = new Set(["profile_pics", "trash"]);
updateFileActionButtons(); if (Array.isArray(folderRaw)) {
} else { const allPaths = folderRaw.map(item => item.folder ?? item);
renderFileTable(folder); const depth = folder === "root" ? 1 : folder.split("/").length + 1;
} subfolders = allPaths
} else { .filter(p => {
fileListContainer.textContent = t("no_files_found"); if (folder === "root") {
const summaryElem = document.getElementById("fileSummary"); return p.indexOf("/") === -1;
if (summaryElem) { }
summaryElem.style.display = "none"; if (!p.startsWith(folder + "/")) return false;
} return p.split("/").length === depth;
updateFileActionButtons(); })
} .map(p => ({ name: p.split("/").pop(), full: p }));
return data.files || []; }
}) subfolders = subfolders.filter(sf => !hidden.has(sf.name));
.catch(error => {
console.error("Error loading file list:", error); // 3) clear loader
if (error.message !== "Unauthorized") { fileListContainer.innerHTML = "";
fileListContainer.textContent = "Error loading files.";
} // 4) handle “no files” case
if (!data.files || Object.keys(data.files).length === 0) {
fileListContainer.textContent = t("no_files_found");
// hide summary
const summaryElem = document.getElementById("fileSummary");
if (summaryElem) summaryElem.style.display = "none";
// hide slider
const sliderContainer = document.getElementById("viewSliderContainer");
if (sliderContainer) sliderContainer.style.display = "none";
// hide folder strip
const strip = document.getElementById("folderStripContainer");
if (strip) strip.style.display = "none";
updateFileActionButtons();
return []; return [];
}) }
.finally(() => {
fileListContainer.style.visibility = "visible"; // 5) normalize files array
if (!Array.isArray(data.files)) {
data.files = Object.entries(data.files).map(([name, meta]) => {
meta.name = name;
return meta;
});
}
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;
// 6) inject summary + slider
if (actionsContainer) {
// a) 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);
// b) 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; margin-right:auto; font-size:0.9em;";
actionsContainer.insertBefore(sliderContainer, summaryElem);
} else {
sliderContainer.style.display = "inline-flex";
}
if (viewMode === "gallery") {
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;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>
`;
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;
document.querySelector(".gallery-container")
?.style.setProperty("grid-template-columns", `repeat(${v},1fr)`);
};
} else {
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10);
sliderContainer.innerHTML = `
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
${t("row_height")}:
</label>
<input type="range" id="rowHeightSlider" min="30" max="60" value="${currentHeight}" style="vertical-align:middle;">
<span id="rowHeightValue" style="margin-left:6px;line-height:1;">${currentHeight}px</span>
`;
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";
};
}
}
// 7) inject folder strip below actions, above file list
let strip = document.getElementById("folderStripContainer");
if (!strip) {
strip = document.createElement("div");
strip.id = "folderStripContainer";
strip.className = "folder-strip-container";
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
}
if (window.showFoldersInList && subfolders.length) {
strip.innerHTML = subfolders.map(sf => `
<div class="folder-item" data-folder="${sf.full}" draggable="true">
<i class="material-icons">folder</i>
<div class="folder-name">${escapeHTML(sf.name)}</div>
</div>
`).join("");
strip.style.display = "flex";
strip.querySelectorAll(".folder-item").forEach(el => {
// clicktonavigate
el.addEventListener("click", () => {
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
updateBreadcrumbTitle(dest);
document.querySelectorAll(".folder-option.selected")
.forEach(o => o.classList.remove("selected"));
document.querySelector(`.folder-option[data-folder="${dest}"]`)
?.classList.add("selected");
loadFileList(dest);
});
// drag & drop handlers
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
});
} else {
strip.style.display = "none";
}
// 8) render files
if (window.viewMode === "gallery") {
renderGalleryView(folder);
} else {
renderFileTable(folder);
}
updateFileActionButtons();
return data.files;
} catch (err) {
console.error("Error loading file list:", err);
if (err.message !== "Unauthorized") {
fileListContainer.textContent = "Error loading files.";
}
return [];
} finally {
fileListContainer.style.visibility = "visible";
}
} }
/** /**
* Update renderFileTable so it writes its content into the provided container. * Update renderFileTable so it writes its content into the provided container.
*/ */
export function renderFileTable(folder, container) { export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList"); const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase(); const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10); const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
@@ -327,9 +464,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 {
@@ -340,6 +474,10 @@ export function renderFileTable(folder, container) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
fileListContent.querySelectorAll('.folder-item').forEach(el => {
el.addEventListener('click', () => loadFileList(el.dataset.folder));
});
// pagination clicks // pagination clicks
const prevBtn = document.getElementById("prevPageBtn"); const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => { if (prevBtn) prevBtn.addEventListener("click", () => {
@@ -414,7 +552,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 +579,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 +679,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 doesnt 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 +775,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>

View File

@@ -1,6 +1,6 @@
// fileMenu.js // fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js'; import { updateRowHighlight, showToast } from './domUtils.js';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile } from './fileActions.js'; import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
import { previewFile } from './filePreview.js'; import { previewFile } from './filePreview.js';
import { editFile } from './fileEditor.js'; import { editFile } from './fileEditor.js';
import { canEditFile, fileData } from './fileListView.js'; import { canEditFile, fileData } from './fileListView.js';
@@ -75,6 +75,7 @@ export function fileListContextMenuHandler(e) {
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value); const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
let menuItems = [ let menuItems = [
{ label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } }, { label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } }, { label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } }, { label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },

View File

@@ -13,10 +13,19 @@ export function openTagModal(file) {
modal.id = 'tagModal'; modal.id = 'tagModal';
modal.className = 'modal'; modal.className = 'modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-width:90vw;"> <div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">${t("tag_file")}: ${file.name}</h3> <h3 style="
<span id="closeTagModal" style="cursor:pointer; font-size:24px;">&times;</span> margin:0;
display:inline-block;
max-width: calc(100% - 40px);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
">
${t("tag_file")}: ${escapeHTML(file.name)}
</h3>
<span id="closeTagModal" class="editor-close-btn">&times;</span>
</div> </div>
<div class="modal-body" style="margin-top:10px;"> <div class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">${t("tag_name")}</label> <label for="tagNameInput">${t("tag_name")}</label>
@@ -83,10 +92,10 @@ export function openMultiTagModal(files) {
modal.id = 'multiTagModal'; modal.id = 'multiTagModal';
modal.className = 'modal'; modal.className = 'modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-width:90vw;"> <div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3> <h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
<span id="closeMultiTagModal" style="cursor:pointer; font-size:24px;">&times;</span> <span id="closeMultiTagModal" class="editor-close-btn">&times;</span>
</div> </div>
<div class="modal-body" style="margin-top:10px;"> <div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">Tag Name:</label> <label for="multiTagNameInput">Tag Name:</label>

View File

@@ -56,7 +56,7 @@ function saveFolderTreeState(state) {
} }
// Helper for getting the parent folder. // Helper for getting the parent folder.
function getParentFolder(folder) { export function getParentFolder(folder) {
if (folder === "root") return "root"; if (folder === "root") return "root";
const lastSlash = folder.lastIndexOf("/"); const lastSlash = folder.lastIndexOf("/");
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash); return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
@@ -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;
@@ -360,7 +361,7 @@ function renderBreadcrumbFragment(folderPath) {
return frag; return frag;
} }
function updateBreadcrumbTitle(folder) { export function updateBreadcrumbTitle(folder) {
const titleEl = document.getElementById("fileListTitle"); const titleEl = document.getElementById("fileListTitle");
titleEl.textContent = ""; titleEl.textContent = "";
titleEl.appendChild(document.createTextNode(t("files_in") + " (")); titleEl.appendChild(document.createTextNode(t("files_in") + " ("));

View File

@@ -55,6 +55,7 @@ const translations = {
// Additional keys for HTML translations: // Additional keys for HTML translations:
"title": "FileRise", "title": "FileRise",
"header_title": "FileRise", "header_title": "FileRise",
"header_title_text": "Header Title",
"logout": "Logout", "logout": "Logout",
"change_password": "Change Password", "change_password": "Change Password",
"restore_text": "Restore or", "restore_text": "Restore or",
@@ -201,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",
@@ -259,7 +265,17 @@ const translations = {
"show": "Show", "show": "Show",
"items_per_page": "items per page", "items_per_page": "items per page",
"columns": "Columns", "columns": "Columns",
"api_docs": "API Docs" "row_height": "Row Height",
"api_docs": "API Docs",
"show_folders_above_files": "Show folders above files",
"display": "Display",
"create_file": "Create File",
"create_new_file": "Create New File",
"enter_file_name": "Enter file name",
"newfile_placeholder": "New file name",
"file_created_successfully": "File created successfully!",
"error_creating_file": "Error creating file",
"file_created": "File created successfully!"
}, },
es: { es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.", "please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
@@ -316,6 +332,7 @@ const translations = {
// Additional keys for HTML translations: // Additional keys for HTML translations:
"title": "FileRise", "title": "FileRise",
"header_title": "FileRise", "header_title": "FileRise",
"header_title_text": "Header Title",
"logout": "Cerrar sesión", "logout": "Cerrar sesión",
"change_password": "Cambiar contraseña", "change_password": "Cambiar contraseña",
"restore_text": "Restaurar o", "restore_text": "Restaurar o",

View File

@@ -14,37 +14,76 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
import { editFile, saveFile } from './fileEditor.js'; import { editFile, saveFile } from './fileEditor.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
export function initializeApp() {
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
window.currentFolder = "root";
initTagSearch();
loadFileList(window.currentFolder);
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) {
fileListArea.addEventListener('dragover', e => {
e.preventDefault();
fileListArea.classList.add('drop-hover');
});
fileListArea.addEventListener('dragleave', () => {
fileListArea.classList.remove('drop-hover');
});
fileListArea.addEventListener('drop', e => {
e.preventDefault();
fileListArea.classList.remove('drop-hover');
// re-dispatch the same drop into the real upload card
uploadArea.dispatchEvent(new DragEvent('drop', {
dataTransfer: e.dataTransfer,
bubbles: true,
cancelable: true
}));
});
}
initDragAndDrop();
loadSidebarOrder();
loadHeaderOrder();
initFileActions();
initUpload();
loadFolderTree();
setupTrashRestoreDelete();
loadAdminConfigFunc();
const helpBtn = document.getElementById("folderHelpBtn");
const helpTooltip = document.getElementById("folderHelpTooltip");
if (helpBtn && helpTooltip) {
helpBtn.addEventListener("click", () => {
helpTooltip.style.display =
helpTooltip.style.display === "block" ? "none" : "block";
});
}
}
export function loadCsrfToken() { export function loadCsrfToken() {
return fetchWithCsrf('/api/auth/token.php', { return fetchWithCsrf('/api/auth/token.php', { method: 'GET' })
method: 'GET'
})
.then(res => { .then(res => {
if (!res.ok) { if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`);
throw new Error(`Token fetch failed with status ${res.status}`);
}
return res.json(); return res.json();
}) })
.then(({ csrf_token, share_url }) => { .then(({ csrf_token, share_url }) => {
// Update global and <meta>
window.csrfToken = csrf_token; window.csrfToken = csrf_token;
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) { // update CSRF meta
meta = document.createElement('meta'); let meta = document.querySelector('meta[name="csrf-token"]') ||
meta.name = 'csrf-token'; Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' });
document.head.appendChild(meta);
}
meta.content = csrf_token; meta.content = csrf_token;
let shareMeta = document.querySelector('meta[name="share-url"]'); // force share_url to match wherever we're browsing
if (!shareMeta) { const actualShare = window.location.origin;
shareMeta = document.createElement('meta'); let shareMeta = document.querySelector('meta[name="share-url"]') ||
shareMeta.name = 'share-url'; Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' });
document.head.appendChild(shareMeta); shareMeta.content = actualShare;
}
shareMeta.content = share_url;
return { csrf_token, share_url }; return { csrf_token, share_url: actualShare };
}); });
} }
@@ -55,18 +94,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(() => {});
});
} }
@@ -100,30 +135,9 @@ 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) {
window.currentFolder = "root"; const overlay = document.getElementById('loadingOverlay');
initTagSearch(); if (overlay) overlay.remove();
loadFileList(window.currentFolder); initializeApp();
initDragAndDrop();
loadSidebarOrder();
loadHeaderOrder();
initFileActions();
initUpload();
loadFolderTree();
setupTrashRestoreDelete();
loadAdminConfigFunc();
const helpBtn = document.getElementById("folderHelpBtn");
const helpTooltip = document.getElementById("folderHelpTooltip");
helpBtn.addEventListener("click", function () {
// Toggle display of the tooltip.
if (helpTooltip.style.display === "none" || helpTooltip.style.display === "") {
helpTooltip.style.display = "block";
} else {
helpTooltip.style.display = "none";
}
});
} else {
console.warn("User not authenticated. Data loading deferred.");
} }
}); });
@@ -201,7 +215,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

View File

@@ -79,15 +79,16 @@ export function setupTrashRestoreDelete() {
body: JSON.stringify({ files }) body: JSON.stringify({ files })
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(() => {
if (data.success) { // Always report what we actually restored
showToast(data.success); if (files.length === 1) {
toggleVisibility("restoreFilesModal", false); showToast(`Restored file: ${files[0]}`);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
} else { } else {
showToast(data.error); showToast(`Restored files: ${files.join(", ")}`);
} }
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
}) })
.catch(err => { .catch(err => {
console.error("Error restoring files:", err); console.error("Error restoring files:", err);
@@ -119,16 +120,15 @@ export function setupTrashRestoreDelete() {
body: JSON.stringify({ files }) body: JSON.stringify({ files })
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(() => {
if (data.success) { if (files.length === 1) {
showToast(data.success); showToast(`Restored file: ${files[0]}`);
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
} else { } else {
showToast(data.error); showToast(`Restored files: ${files.join(", ")}`);
} }
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
}) })
.catch(err => { .catch(err => {
console.error("Error restoring files:", err); console.error("Error restoring files:", err);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

View File

@@ -54,12 +54,27 @@ class AdminController
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
$config = AdminModel::getConfig(); $config = AdminModel::getConfig();
// If an error was encountered, send a 500 status.
if (isset($config['error'])) { if (isset($config['error'])) {
http_response_code(500); http_response_code(500);
echo json_encode(['error' => $config['error']]);
exit;
} }
echo json_encode($config);
// Build a safe subset for the front-end
$safe = [
'header_title' => $config['header_title'],
'loginOptions' => $config['loginOptions'],
'globalOtpauthUrl' => $config['globalOtpauthUrl'],
'enableWebDAV' => $config['enableWebDAV'],
'sharedMaxUploadSize' => $config['sharedMaxUploadSize'],
'oidc' => [
'providerUrl' => $config['oidc']['providerUrl'],
'redirectUri' => $config['oidc']['redirectUri'],
// clientSecret and clientId never exposed here
],
];
echo json_encode($safe);
exit; exit;
} }
@@ -122,111 +137,106 @@ class AdminController
* @return void Outputs a JSON response indicating success or failure. * @return void Outputs a JSON response indicating success or failure.
*/ */
public function updateConfig(): void public function updateConfig(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
// Ensure the user is authenticated and is an admin. // —– auth & CSRF checks —–
if ( if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true || !isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin'] !isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
) { ) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']); echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
// Validate CSRF token.
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
}
// Retrieve and decode JSON input.
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// Prepare existing settings
$headerTitle = isset($data['header_title']) ? trim($data['header_title']) : "";
$oidc = isset($data['oidc']) ? $data['oidc'] : [];
$oidcProviderUrl = isset($oidc['providerUrl']) ? filter_var($oidc['providerUrl'], FILTER_SANITIZE_URL) : '';
$oidcClientId = isset($oidc['clientId']) ? trim($oidc['clientId']) : '';
$oidcClientSecret = isset($oidc['clientSecret']) ? trim($oidc['clientSecret']) : '';
$oidcRedirectUri = isset($oidc['redirectUri']) ? filter_var($oidc['redirectUri'], FILTER_SANITIZE_URL) : '';
if (!$oidcProviderUrl || !$oidcClientId || !$oidcClientSecret || !$oidcRedirectUri) {
http_response_code(400);
echo json_encode(['error' => 'Incomplete OIDC configuration.']);
exit;
}
$disableFormLogin = false;
if (isset($data['loginOptions']['disableFormLogin'])) {
$disableFormLogin = filter_var($data['loginOptions']['disableFormLogin'], FILTER_VALIDATE_BOOLEAN);
} elseif (isset($data['disableFormLogin'])) {
$disableFormLogin = filter_var($data['disableFormLogin'], FILTER_VALIDATE_BOOLEAN);
}
$disableBasicAuth = false;
if (isset($data['loginOptions']['disableBasicAuth'])) {
$disableBasicAuth = filter_var($data['loginOptions']['disableBasicAuth'], FILTER_VALIDATE_BOOLEAN);
} elseif (isset($data['disableBasicAuth'])) {
$disableBasicAuth = filter_var($data['disableBasicAuth'], FILTER_VALIDATE_BOOLEAN);
}
$disableOIDCLogin = false;
if (isset($data['loginOptions']['disableOIDCLogin'])) {
$disableOIDCLogin = filter_var($data['loginOptions']['disableOIDCLogin'], FILTER_VALIDATE_BOOLEAN);
} elseif (isset($data['disableOIDCLogin'])) {
$disableOIDCLogin = filter_var($data['disableOIDCLogin'], FILTER_VALIDATE_BOOLEAN);
}
$globalOtpauthUrl = isset($data['globalOtpauthUrl']) ? trim($data['globalOtpauthUrl']) : "";
// ── NEW: enableWebDAV flag ──────────────────────────────────────
$enableWebDAV = false;
if (array_key_exists('enableWebDAV', $data)) {
$enableWebDAV = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
} elseif (isset($data['features']['enableWebDAV'])) {
$enableWebDAV = filter_var($data['features']['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
}
// ── NEW: sharedMaxUploadSize ──────────────────────────────────────
$sharedMaxUploadSize = null;
if (array_key_exists('sharedMaxUploadSize', $data)) {
$sharedMaxUploadSize = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
} elseif (isset($data['features']['sharedMaxUploadSize'])) {
$sharedMaxUploadSize = filter_var($data['features']['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
}
$configUpdate = [
'header_title' => $headerTitle,
'oidc' => [
'providerUrl' => $oidcProviderUrl,
'clientId' => $oidcClientId,
'clientSecret' => $oidcClientSecret,
'redirectUri' => $oidcRedirectUri,
],
'loginOptions' => [
'disableFormLogin' => $disableFormLogin,
'disableBasicAuth' => $disableBasicAuth,
'disableOIDCLogin' => $disableOIDCLogin,
],
'globalOtpauthUrl' => $globalOtpauthUrl,
'enableWebDAV' => $enableWebDAV,
'sharedMaxUploadSize' => $sharedMaxUploadSize // ← NEW
];
// Delegate to the model.
$result = AdminModel::updateConfig($configUpdate);
if (isset($result['error'])) {
http_response_code(500);
}
echo json_encode($result);
exit; exit;
} }
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = trim($headersArr['x-csrf-token'] ?? '');
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
}
// —– fetch payload —–
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// —– load existing on-disk config —–
$existing = AdminModel::getConfig();
// —– start merge with existing as base —–
$merged = $existing;
// header_title
if (array_key_exists('header_title', $data)) {
$merged['header_title'] = trim($data['header_title']);
}
// loginOptions: inherit existing then override if provided
$merged['loginOptions'] = $existing['loginOptions'] ?? [
'disableFormLogin' => false,
'disableBasicAuth' => false,
'disableOIDCLogin'=> false,
'authBypass' => false,
'authHeaderName' => 'X-Remote-User'
];
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
if (isset($data['loginOptions'][$flag])) {
$merged['loginOptions'][$flag] = filter_var(
$data['loginOptions'][$flag],
FILTER_VALIDATE_BOOLEAN
);
}
}
if (isset($data['loginOptions']['authHeaderName'])) {
$hdr = trim($data['loginOptions']['authHeaderName']);
if ($hdr !== '') {
$merged['loginOptions']['authHeaderName'] = $hdr;
}
}
// globalOtpauthUrl
if (array_key_exists('globalOtpauthUrl', $data)) {
$merged['globalOtpauthUrl'] = trim($data['globalOtpauthUrl']);
}
// enableWebDAV
if (array_key_exists('enableWebDAV', $data)) {
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
}
// sharedMaxUploadSize
if (array_key_exists('sharedMaxUploadSize', $data)) {
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
if ($sms !== false) {
$merged['sharedMaxUploadSize'] = $sms;
}
}
// oidc: only overwrite non-empty inputs
$merged['oidc'] = $existing['oidc'] ?? [
'providerUrl'=>'','clientId'=>'','clientSecret'=>'','redirectUri'=>''
];
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
if (!empty($data['oidc'][$f])) {
$val = trim($data['oidc'][$f]);
if ($f === 'providerUrl' || $f === 'redirectUri') {
$val = filter_var($val, FILTER_SANITIZE_URL);
}
$merged['oidc'][$f] = $val;
}
}
// —– persist merged config —–
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {
http_response_code(500);
}
echo json_encode($result);
exit;
}
} }

View File

@@ -111,6 +111,8 @@ class AuthController
$cfg['oidc']['clientSecret'] $cfg['oidc']['clientSecret']
); );
$oidc->setRedirectURL($cfg['oidc']['redirectUri']); $oidc->setRedirectURL($cfg['oidc']['redirectUri']);
$oidc->addScope(['openid','profile','email']);
if ($oidcAction === 'callback') { if ($oidcAction === 'callback') {
try { try {
@@ -342,48 +344,48 @@ class AuthController
public function checkAuth(): void public function checkAuth(): void
{ {
// 1) Remember-me re-login // 1) Remember-me re-login
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) { if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']); $payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
if ($payload) { if ($payload) {
$old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32)); $old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['csrf_token'] = $old; $_SESSION['csrf_token'] = $old;
$_SESSION['authenticated'] = true; $_SESSION['authenticated'] = true;
$_SESSION['username'] = $payload['username']; $_SESSION['username'] = $payload['username'];
$_SESSION['isAdmin'] = !empty($payload['isAdmin']); $_SESSION['isAdmin'] = !empty($payload['isAdmin']);
$_SESSION['folderOnly'] = $payload['folderOnly'] ?? false; $_SESSION['folderOnly'] = $payload['folderOnly'] ?? false;
$_SESSION['readOnly'] = $payload['readOnly'] ?? false; $_SESSION['readOnly'] = $payload['readOnly'] ?? false;
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false; $_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
// regenerate CSRF if you use one // regenerate CSRF if you use one
// TOTP enabled? (same logic as below)
$usersFile = USERS_DIR . USERS_FILE; // TOTP enabled? (same logic as below)
$totp = false; $usersFile = USERS_DIR . USERS_FILE;
if (file_exists($usersFile)) { $totp = false;
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { if (file_exists($usersFile)) {
$parts = explode(':', trim($line)); foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) { $parts = explode(':', trim($line));
$totp = true; if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
break; $totp = true;
break;
}
} }
} }
}
echo json_encode([ echo json_encode([
'authenticated' => true, 'authenticated' => true,
'csrf_token' => $_SESSION['csrf_token'], 'csrf_token' => $_SESSION['csrf_token'],
'isAdmin' => $_SESSION['isAdmin'], 'isAdmin' => $_SESSION['isAdmin'],
'totp_enabled' => $totp, 'totp_enabled' => $totp,
'username' => $_SESSION['username'], 'username' => $_SESSION['username'],
'folderOnly' => $_SESSION['folderOnly'], 'folderOnly' => $_SESSION['folderOnly'],
'readOnly' => $_SESSION['readOnly'], 'readOnly' => $_SESSION['readOnly'],
'disableUpload' => $_SESSION['disableUpload'] 'disableUpload' => $_SESSION['disableUpload']
]); ]);
exit(); exit();
}
} }
}
$usersFile = USERS_DIR . USERS_FILE; $usersFile = USERS_DIR . USERS_FILE;
@@ -453,11 +455,11 @@ class AuthController
if (empty($_SESSION['csrf_token'])) { if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} }
// 2) Emit headers // 2) Emit headers
header('Content-Type: application/json'); header('Content-Type: application/json');
header('X-CSRF-Token: ' . $_SESSION['csrf_token']); header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
// 3) Return JSON payload // 3) Return JSON payload
echo json_encode([ echo json_encode([
'csrf_token' => $_SESSION['csrf_token'], 'csrf_token' => $_SESSION['csrf_token'],

View File

@@ -1582,6 +1582,31 @@ class FileController
echo json_encode($shareFile, JSON_PRETTY_PRINT); echo json_encode($shareFile, JSON_PRETTY_PRINT);
} }
public function getAllShareLinks(): void
{
header('Content-Type: application/json');
$shareFile = META_DIR . 'share_links.json';
$links = file_exists($shareFile)
? json_decode(file_get_contents($shareFile), true) ?? []
: [];
$now = time();
$cleaned = [];
// remove expired
foreach ($links as $token => $record) {
if (!empty($record['expires']) && $record['expires'] < $now) {
continue;
}
$cleaned[$token] = $record;
}
if (count($cleaned) !== count($links)) {
file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT));
}
echo json_encode($cleaned);
}
/** /**
* POST /api/file/deleteShareLink.php * POST /api/file/deleteShareLink.php
*/ */
@@ -1601,4 +1626,31 @@ class FileController
echo json_encode(['success' => false, 'error' => 'Not found']); echo json_encode(['success' => false, 'error' => 'Not found']);
} }
} }
/**
* POST /api/file/createFile.php
*/
public function createFile(): void
{
// Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if (!empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to create files."]);
exit;
}
$body = json_decode(file_get_contents('php://input'), true);
$folder = $body['folder'] ?? 'root';
$filename = $body['name'] ?? '';
$result = FileModel::createFile($folder, $filename, $_SESSION['username'] ?? 'Unknown');
if (!$result['success']) {
http_response_code($result['code'] ?? 400);
echo json_encode(['success'=>false,'error'=>$result['error']]);
} else {
echo json_encode(['success'=>true]);
}
}
} }

View File

@@ -340,16 +340,14 @@ class FolderController
public function getFolderList(): void public function getFolderList(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
if (empty($_SESSION['authenticated'])) {
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401); http_response_code(401);
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
// Optionally, you might add further input validation if necessary. $parent = $_GET['folder'] ?? null;
$folderList = FolderModel::getFolderList(); $folderList = FolderModel::getFolderList($parent);
echo json_encode($folderList); echo json_encode($folderList);
exit; exit;
} }
@@ -1082,11 +1080,30 @@ class FolderController
/** /**
* GET /api/folder/getShareFolderLinks.php * GET /api/folder/getShareFolderLinks.php
*/ */
public function getShareFolderLinks() public function getAllShareFolderLinks(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
$links = FolderModel::getAllShareFolderLinks(); $shareFile = META_DIR . 'share_folder_links.json';
echo json_encode($links, JSON_PRETTY_PRINT); $links = file_exists($shareFile)
? json_decode(file_get_contents($shareFile), true) ?? []
: [];
$now = time();
$cleaned = [];
// 1) Remove expired
foreach ($links as $token => $record) {
if (!empty($record['expires']) && $record['expires'] < $now) {
continue;
}
$cleaned[$token] = $record;
}
// 2) Persist back if anything was pruned
if (count($cleaned) !== count($links)) {
file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT));
}
echo json_encode($cleaned);
} }
/** /**

View File

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

View File

@@ -16,10 +16,14 @@ class AdminModel
$unit = strtolower(substr($val, -1)); $unit = strtolower(substr($val, -1));
$num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY'); $num = (int) rtrim($val, 'bkmgtpezyBKMGTPESY');
switch ($unit) { switch ($unit) {
case 'g': return $num * 1024 ** 3; case 'g':
case 'm': return $num * 1024 ** 2; return $num * 1024 ** 3;
case 'k': return $num * 1024; case 'm':
default: return $num; return $num * 1024 ** 2;
case 'k':
return $num * 1024;
default:
return $num;
} }
} }
@@ -63,6 +67,24 @@ class AdminModel
$configUpdate['sharedMaxUploadSize'] = $sms; $configUpdate['sharedMaxUploadSize'] = $sms;
} }
// ── NEW: normalize authBypass & authHeaderName ─────────────────────────
if (!isset($configUpdate['loginOptions']['authBypass'])) {
$configUpdate['loginOptions']['authBypass'] = false;
}
$configUpdate['loginOptions']['authBypass'] = (bool)$configUpdate['loginOptions']['authBypass'];
if (
!isset($configUpdate['loginOptions']['authHeaderName'])
|| !is_string($configUpdate['loginOptions']['authHeaderName'])
|| trim($configUpdate['loginOptions']['authHeaderName']) === ''
) {
$configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User';
} else {
$configUpdate['loginOptions']['authHeaderName'] =
trim($configUpdate['loginOptions']['authHeaderName']);
}
// ───────────────────────────────────────────────────────────────────────────
// Convert configuration to JSON. // Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT); $plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
if ($plainTextConfig === false) { if ($plainTextConfig === false) {
@@ -128,6 +150,19 @@ class AdminModel
$config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin']; $config['loginOptions']['disableOIDCLogin'] = (bool)$config['loginOptions']['disableOIDCLogin'];
} }
if (!array_key_exists('authBypass', $config['loginOptions'])) {
$config['loginOptions']['authBypass'] = false;
} else {
$config['loginOptions']['authBypass'] = (bool)$config['loginOptions']['authBypass'];
}
if (
!array_key_exists('authHeaderName', $config['loginOptions'])
|| !is_string($config['loginOptions']['authHeaderName'])
|| trim($config['loginOptions']['authHeaderName']) === ''
) {
$config['loginOptions']['authHeaderName'] = 'X-Remote-User';
}
// Default values for other keys // Default values for other keys
if (!isset($config['globalOtpauthUrl'])) { if (!isset($config['globalOtpauthUrl'])) {
$config['globalOtpauthUrl'] = ""; $config['globalOtpauthUrl'] = "";
@@ -151,8 +186,8 @@ class AdminModel
'header_title' => "FileRise", 'header_title' => "FileRise",
'oidc' => [ 'oidc' => [
'providerUrl' => 'https://your-oidc-provider.com', 'providerUrl' => 'https://your-oidc-provider.com',
'clientId' => 'YOUR_CLIENT_ID', 'clientId' => '',
'clientSecret' => 'YOUR_CLIENT_SECRET', 'clientSecret' => '',
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback' 'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
], ],
'loginOptions' => [ 'loginOptions' => [
@@ -166,4 +201,4 @@ class AdminModel
]; ];
} }
} }
} }

View File

@@ -1278,4 +1278,64 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)); file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
return true; return true;
} }
/**
* Create an empty file plus metadata entry.
*
* @param string $folder
* @param string $filename
* @param string $uploader
* @return array ['success'=>bool, 'error'=>string, 'code'=>int]
*/
public static function createFile(string $folder, string $filename, string $uploader): array
{
// 1) basic validation
if (!preg_match('/^[\w\-. ]+$/', $filename)) {
return ['success'=>false,'error'=>'Invalid filename','code'=>400];
}
// 2) build target path
$base = UPLOAD_DIR;
if ($folder !== 'root') {
$base = rtrim(UPLOAD_DIR, '/\\')
. DIRECTORY_SEPARATOR . $folder
. DIRECTORY_SEPARATOR;
}
if (!is_dir($base) && !mkdir($base, 0775, true)) {
return ['success'=>false,'error'=>'Cannot create folder','code'=>500];
}
$path = $base . $filename;
// 3) no overwrite
if (file_exists($path)) {
return ['success'=>false,'error'=>'File already exists','code'=>400];
}
// 4) touch the file
if (false === @file_put_contents($path, '')) {
return ['success'=>false,'error'=>'Could not create file','code'=>500];
}
// 5) write metadata
$metaKey = ($folder === 'root') ? 'root' : $folder;
$metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json';
$metaPath = META_DIR . $metaName;
$collection = [];
if (file_exists($metaPath)) {
$json = file_get_contents($metaPath);
$collection = json_decode($json, true) ?: [];
}
$collection[$filename] = [
'uploaded' => date(DATE_TIME_FORMAT),
'uploader' => $uploader
];
if (false === file_put_contents($metaPath, json_encode($collection, JSON_PRETTY_PRINT))) {
return ['success'=>false,'error'=>'Failed to update metadata','code'=>500];
}
return ['success'=>true];
}
} }

View File

@@ -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)
{
// --- Ratelimit recovery attempts --- // --- Ratelimit 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 users line with the new encrypted secret. // Update the users 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,86 @@ 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) {
// split *all* the fields
$parts = explode(':', $line);
if ($parts[0] !== $username) {
continue;
}
// determine admin & totp
$isAdmin = (isset($parts[2]) && $parts[2] === '1');
$totpEnabled = !empty($parts[3]);
// profile_picture is the 5th field if present
$pic = isset($parts[4]) ? $parts[4] : '';
return [
'username' => $parts[0],
'isAdmin' => $isAdmin,
'totp_enabled' => $totpEnabled,
'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];
}
}