Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e3aaa475 | ||
|
|
494be05801 | ||
|
|
ceb651894e | ||
|
|
ad72ef74d1 | ||
|
|
680c82638f | ||
|
|
31f54afc74 | ||
|
|
4f39b3a41e | ||
|
|
40cecc10ad | ||
|
|
aee78c9750 | ||
|
|
16ccb66d55 | ||
|
|
9209f7a582 | ||
|
|
4a736b0224 | ||
|
|
f162a7d0d7 | ||
|
|
3fc526df7f | ||
|
|
20422cf5a7 | ||
|
|
492bab36ca | ||
|
|
f2f7697994 | ||
|
|
13aa011632 | ||
|
|
1add160f5d | ||
|
|
87368143b5 | ||
|
|
939aa032f0 | ||
|
|
fbd21a035b | ||
|
|
2f391d11db | ||
|
|
8c70783d5a | ||
|
|
b4d6f01432 | ||
|
|
d48b15a5f4 | ||
|
|
d1726f0160 | ||
|
|
bd1841b788 | ||
|
|
bde35d1d31 | ||
|
|
8d6a1be777 | ||
|
|
56f34ba362 | ||
|
|
4d329e046f | ||
|
|
f3977153fb | ||
|
|
274bedd186 | ||
|
|
2e4dbe7f7f | ||
|
|
0334e443eb | ||
|
|
76f5ed5c96 | ||
|
|
18f588dc24 | ||
|
|
491c686762 | ||
|
|
25303df677 | ||
|
|
ae0d63b86f | ||
|
|
41ade2e205 | ||
|
|
0a9d332d60 | ||
|
|
1983f7705f | ||
|
|
6b2bf0ba70 | ||
|
|
6d9715169c | ||
|
|
0645a3712a | ||
|
|
ebc32ea965 | ||
|
|
078db33458 | ||
|
|
04f5cbe31f | ||
|
|
b5a7d8d559 | ||
|
|
58f8485b02 | ||
|
|
3e1da9c335 | ||
|
|
6bf6206e1c | ||
|
|
f9c60951c9 |
633
CHANGELOG.md
@@ -1,5 +1,619 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 10/4/2025 v1.3.13
|
||||
|
||||
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
||||
fix(scanner): rebuild per-folder metadata to match File/Folder models
|
||||
chore(scanner): skip profile_pics subtree during scans
|
||||
|
||||
- scan_uploads.php now falls back to UPLOAD_DIR/META_DIR from config.php
|
||||
- prevents double slashes in metadata paths; respects app timezone
|
||||
- unblocks SCAN_ON_START so externally added files are indexed at boot
|
||||
- Writes per-folder metadata files (root_metadata.json / folder_metadata.json) using the same naming rule as the models
|
||||
- Adds missing entries for files (uploaded, modified using DATE_TIME_FORMAT, uploader=Imported)
|
||||
- Prunes stale entries for files that no longer exist
|
||||
- Skips uploads/trash and symlinks
|
||||
- Resolves paths from CLI flags, env vars, or config constants (UPLOAD_DIR/META_DIR)
|
||||
- Idempotent; safe to run at startup via SCAN_ON_START
|
||||
- Avoids indexing internal avatar images (folder already hidden in UI)
|
||||
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
||||
|
||||
## Changes 10/4/2025 v1.3.12
|
||||
|
||||
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||
|
||||
- Remap www-data to PUID/PGID when running as root; skip with helpful log if non-root
|
||||
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
||||
- SCAN_ON_START unchanged, with non-root fallback
|
||||
|
||||
## Changes 10/4/2025 v1.3.11
|
||||
|
||||
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||
|
||||
- Remove no-op sed of SHARE_URL from start.sh (env already used)
|
||||
- Build default share link with correct scheme (http/https, proxy-aware)
|
||||
|
||||
## Changes 10/4/2025 v1.3.10
|
||||
|
||||
Fix: index externally added files on startup; harden start.sh (#46)
|
||||
|
||||
- Run metadata scan before Apache when SCAN_ON_START=true (was unreachable after exec)
|
||||
- Execute scan as www-data; continue on failure so startup isn’t blocked
|
||||
- Guard env reads for set -u; add umask 002 for consistent 775/664
|
||||
- Make ServerName idempotent; avoid duplicate entries
|
||||
- Ensure sessions/metadata/log dirs exist with correct ownership and perms
|
||||
|
||||
No behavior change unless SCAN_ON_START=true.
|
||||
|
||||
## Changes 5/27/2025 v1.3.9
|
||||
|
||||
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
||||
- New `scripts/scan_uploads.php` script to generate metadata for imported files and folders
|
||||
- `SCAN_ON_START` environment variable to trigger automatic scanning on container startup
|
||||
- Documentation for configuring CIFS share mounting and scanning
|
||||
|
||||
- Clipboard Paste Upload Support (single image):
|
||||
- Users can now paste images directly into the FileRise web interface.
|
||||
- Pasted images are renamed to `image<TIMESTAMP>.png` and added to the upload queue using the existing drag-and-drop logic.
|
||||
- Implemented using a `.isClipboard` flag and a delayed UI cleanup inside `xhr.addEventListener("load", ...)`.
|
||||
|
||||
---
|
||||
|
||||
## Changes 5/26/2025
|
||||
|
||||
- Updated `REGEX_FOLDER_NAME` in `config.php` to forbids < > : " | ? * characters in folder names.
|
||||
- Ensures the whole name can’t end in a space or period.
|
||||
- Blocks Windows device names.
|
||||
|
||||
- Updated `FolderController.php` when `createFolder` issues invalid folder name to return `http_response_code(400);`
|
||||
|
||||
---
|
||||
|
||||
## Changes 5/23/2025 v1.3.8
|
||||
|
||||
- **Folder-strip context menu**
|
||||
- Enabled right-click on items in the new folder strip (above file list) to open the same “Create / Rename / Share / Delete Folder” menu as in the main folder tree.
|
||||
- Bound `contextmenu` event on each `.folder-item` in `loadFileList` to:
|
||||
- Prevent the default browser menu
|
||||
- Highlight the clicked folder-strip item
|
||||
- Invoke `showFolderManagerContextMenu` with menu entries:
|
||||
- Create Folder
|
||||
- Rename Folder
|
||||
- Share Folder (passes the strip’s `data-folder` value)
|
||||
- Delete Folder
|
||||
- Ensured menu actions are wrapped in arrow functions (`() => …`) so they fire only on menu-item click, not on render.
|
||||
|
||||
- Refactored folder-strip injection in `fileListView.js` to:
|
||||
- Mark each strip item as `draggable="true"` (for drag-and-drop)
|
||||
- Add `el.addEventListener("contextmenu", …)` alongside existing click/drag handlers
|
||||
- Clean up global click listener for hiding the context menu
|
||||
|
||||
- Prevented premature invocation of `openFolderShareModal` by switching to `action: () => openFolderShareModal(dest)` instead of calling it directly.
|
||||
|
||||
- **Create File/Folder dropdown**
|
||||
- Replaced standalone “Create File” button with a combined dropdown button in the actions toolbar.
|
||||
- New markup
|
||||
- Wired up JS handlers in `fileActions.js`:
|
||||
- `#createFileOption` → `openCreateFileModal()`
|
||||
- `#createFolderOption` → `document.getElementById('createFolderModal').style.display = 'block'`
|
||||
- Toggled `.dropdown-menu` visibility on button click, and closed on outside click.
|
||||
- Applied dark-mode support: dropdown background and text colors switch with `.dark-mode` class.
|
||||
|
||||
---
|
||||
|
||||
## 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 doesn’t 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
|
||||
|
||||
### Drag‐and‐Drop Upload extended to File List
|
||||
|
||||
- **Forward file‐list drops**
|
||||
Dropping files onto the file‐list area (`#fileListContainer`) now re‐dispatches the same `drop` event to the upload card’s drop zone (`#uploadDropArea`)
|
||||
- **Visual feedback**
|
||||
Added a `.drop-hover` class on `#fileListContainer` during drag‐over for a dashed‐border + light‐background 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 “View‐Mode” Slider**
|
||||
Added a single slider panel (`#viewSliderContainer`) in the file‐list actions toolbar that switches behavior based on the current view mode:
|
||||
- **Table View**: shows a **Row Height** slider (min 31px, max 60px).
|
||||
- Adjusts the CSS variable `--file-row-height` to resize all `<tr>` heights.
|
||||
- Persists the chosen height in `localStorage`.
|
||||
- **Gallery View**: shows a **Columns** slider (min 1, max 6).
|
||||
- Updates the grid’s `grid-template-columns: repeat(N, 1fr)`.
|
||||
- Persists the chosen column count in `localStorage`.
|
||||
|
||||
- **Injection Point**
|
||||
The slider container is dynamically inserted (or updated) just before the folder summary (`#fileSummary`) in `loadFileList()`, ensuring a consistent position across both view modes.
|
||||
|
||||
- **Live Updates**
|
||||
Moving the slider thumb immediately updates the visible table row heights or gallery column layout without a full re‐render.
|
||||
|
||||
- **Styling & Alignment**
|
||||
- `#viewSliderContainer` uses `inline-flex` and `align-items: center` so that label, slider, and value text are vertically aligned with the other toolbar elements.
|
||||
- Reset margins/padding on the label and value span within `#viewSliderContainer` to eliminate any vertical misalignment.
|
||||
|
||||
### 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 toggle’s 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 modal’s 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
|
||||
|
||||
**Admin Panel Refactor & Enhancements**
|
||||
|
||||
### Moved from `authModals.js` to `adminPanel.js`
|
||||
|
||||
- Extracted all admin-related UI and logic out of `authModals.js`
|
||||
- Created a standalone `adminPanel.js` module
|
||||
- Initialized `openAdminPanel()` and `closeAdminPanel()` exports
|
||||
|
||||
### Responsive, Collapsible Sections
|
||||
|
||||
- Injected new CSS via JS (`adminPanelStyles`)
|
||||
- Default modal width: 50%
|
||||
- Small-screen override (`@media (max-width: 600px)`) to 90% width
|
||||
- Introduced `.section-header` / `.section-content` pattern
|
||||
- Click header to expand/collapse its content
|
||||
- Animated arrow via Material Icons
|
||||
- Indented and padded expanded content
|
||||
|
||||
### “Manage Shared Links” Feature
|
||||
|
||||
- Added new **Manage Shared Links** section to Admin Panel
|
||||
- Endpoint **GET** `/api/admin/readMetadata.php?file=…`
|
||||
- Reads `share_folder_links.json` & `share_links.json` under `META_DIR`
|
||||
- Endpoint **POST**
|
||||
- `/api/folder/deleteShareFolderLink.php`
|
||||
- `/api/file/deleteShareLink.php`
|
||||
- `loadShareLinksSection()` AJAX loader
|
||||
- Displays folder & file shares, expiry dates, upload-allowed, and 🔒 if password-protected
|
||||
- “🗑️” delete buttons refresh the list on success
|
||||
|
||||
### Dark-Mode & Theming Fixes
|
||||
|
||||
- Dark-mode CSS overrides for:
|
||||
- Modal border
|
||||
- `.btn-primary`, `.btn-secondary`
|
||||
- `.form-control` backgrounds & placeholders
|
||||
- Section headers & icons
|
||||
- Close button restyled to use shared **.editor-close-btn** look
|
||||
|
||||
### API and Controller changes
|
||||
|
||||
- Updated all endpoints to use correct controller casing
|
||||
- Renamed controller files to PascalCase (e.g. `adminController.php` to `AdminController.php`, `fileController.php` to `FileController.php`, `folderController.php` to `FolderController.php`)
|
||||
- Adjusted endpoint paths to match controller filenames
|
||||
- Fix FolderController readOnly create folder permission
|
||||
|
||||
### Additional changes
|
||||
|
||||
- Extend clean up expired shared entries
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/30/2025 v1.2.8
|
||||
|
||||
- **Added** PDF preview in `filePreview.js` (the `extension === "pdf"` block): replaced in-modal `<embed>` with `window.open(urlWithTs, "_blank")` and closed the modal to avoid CSP `frame-ancestors 'none'` restrictions.
|
||||
- **Added** `autofocus` attribute to the login form’s username input (`#loginUsername`) so the cursor is ready for typing on page load.
|
||||
- **Enhanced** login initialization with a `DOMContentLoaded` fallback that calls `loginUsername.focus()` (via `setTimeout`) if needed.
|
||||
- **Set** focus to the “New Username” field (`#newUsername`) when entering setup mode, hiding the login form and showing the Add-User modal.
|
||||
- **Implemented** Enter-key support in setup mode by attaching `attachEnterKeyListener("addUserModal", "saveUserBtn")`, allowing users to press Enter to submit the Add-User form.
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/28/2025
|
||||
|
||||
**Added**
|
||||
|
||||
- **Custom expiration** option to File Share modal
|
||||
- Users can specify a value + unit (seconds, minutes, hours, days)
|
||||
- Displays a warning when a custom duration is selected
|
||||
- **Custom expiration** option to Folder Share modal (same value+unit picker and warning)
|
||||
|
||||
**Changed**
|
||||
|
||||
- **API parameters** for both endpoints:
|
||||
- Replaced `expirationMinutes` with `expirationValue` + `expirationUnit`
|
||||
- Front-end now sends `{ expirationValue, expirationUnit }`
|
||||
- Back-end converts those into total seconds before saving
|
||||
- **UI**
|
||||
- FileShare and FolderShare modals updated to handle “Custom…” selection
|
||||
|
||||
**Updated Models & Controllers**
|
||||
|
||||
- **FileModel::createShareLink** now accepts expiration in seconds
|
||||
- **FolderModel::createShareFolderLink** now accepts expiration in seconds
|
||||
- **createShareLink.php** & **createShareFolderLink.php** updated to parse and convert new parameters
|
||||
|
||||
**Documentation**
|
||||
|
||||
- OpenAPI annotations for both endpoints updated to require `expirationValue` + `expirationUnit` (enum: seconds, minutes, hours, days)
|
||||
|
||||
## Changes 4/27/2025 v1.2.7
|
||||
|
||||
- **Select-All** checkbox now correctly toggles all `.file-checkbox` inputs
|
||||
- Updated `toggleAllCheckboxes(masterCheckbox)` to call `updateRowHighlight()` on each row so selections get the `.row-selected` highlight
|
||||
- **Master checkbox sync** in toolbar
|
||||
- Enhanced `updateFileActionButtons()` to set the header checkbox to checked, unchecked, or indeterminate based on how many files are selected
|
||||
- Fixed Pagination controls & Items-per-page dropdown
|
||||
- Fixed `#advancedSearchToggle` in both `renderFileTable()` and `renderGalleryView()`
|
||||
- **Shared folder gallery view logic**
|
||||
- Introduced new `public/js/sharedFolderView.js` containing all DOMContentLoaded wiring, `toggleViewMode()`, gallery rendering, and event listeners
|
||||
- Embedded a non-executing JSON payload in `shareFolder.php`
|
||||
- **`FolderController::shareFolder()` / `shareFolder.php`**
|
||||
- Removed all inline `onclick="…"` attributes and inline `<script>` blocks
|
||||
- Added `<script type="application/json" id="shared-data">…</script>` to export `$token` and `$files`
|
||||
- Added `<script src="/js/sharedFolderView.js" defer></script>` to load the external view logic
|
||||
- **Styling updates**
|
||||
- Added `.toggle-btn` CSS for blue header-style toggle button and applied it in JS
|
||||
- Added `.pagination a:hover { background-color: #0056b3; }` to match button hover
|
||||
- Tweaked `body` padding and `header h1` margins to reduce whitespace above header
|
||||
- Refactored `sharedFolderView.js:renderGalleryView()` to eliminate `innerHTML` usage; now uses `document.createElement` and `textContent` so filenames and URLs are fully escaped and CSP-safe
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/26/2025 1.2.6
|
||||
|
||||
**Apache / Dockerfile (CSP)**
|
||||
|
||||
- Enabled Apache’s `mod_headers` in the Dockerfile (`a2enmod headers ssl deflate expires proxy proxy_fcgi rewrite`)
|
||||
- Added a strong `Content-Security-Policy` header in the vhost configs to lock down allowed sources for scripts, styles, fonts, images, and connections
|
||||
|
||||
**index.html & CDN Includes**
|
||||
|
||||
- Applied Subresource Integrity (`integrity` + `crossorigin="anonymous"`) to all static CDN assets (Bootstrap CSS, CodeMirror CSS/JS, Resumable.js, DOMPurify, Fuse.js)
|
||||
- Omitted SRI on Google Fonts & Material Icons links (dynamic per-browser CSS)
|
||||
- Removed all inline `<script>` and `onclick` attributes; now all behaviors live in external JS modules
|
||||
|
||||
**auth.js (Logout Handling)**
|
||||
|
||||
- Moved the logout-on-`?logout=1` snippet from inline HTML into `auth.js`
|
||||
- In `DOMContentLoaded`, attached a `click` listener to `#logoutBtn` that POSTs to `/api/auth/logout.php` and reloads
|
||||
|
||||
**fileActions.js (Modal Button Handlers)**
|
||||
|
||||
- Externalized the cancel/download buttons for single-file and ZIP-download modals by adding `click` listeners in `fileActions.js`
|
||||
- Removed the inline `onclick` attributes from `#cancelDownloadFile` and `#confirmSingleDownloadButton` in the HTML
|
||||
- Ensured all file-action modals (delete, download, extract, copy, move, rename) now use JS event handlers instead of inline code
|
||||
|
||||
**domUtils.js**
|
||||
|
||||
- **Removed** all inline `onclick` and `onchange` attributes from:
|
||||
- `buildSearchAndPaginationControls` (advanced search toggle, prev/next buttons, items-per-page selector)
|
||||
- `buildFileTableHeader` (select-all checkbox)
|
||||
- `buildFileTableRow` (download, edit, preview, rename buttons)
|
||||
- **Retained** all original logic (file-type icon detection, shift-select, debounce, custom confirm modal, etc.)
|
||||
|
||||
**fileListView.js**
|
||||
|
||||
- **Stopped** generating inline `onclick` handlers in both table and gallery views.
|
||||
- **Added** `data-` attributes on actionable elements:
|
||||
- `data-download-name`, `data-download-folder`
|
||||
- `data-edit-name`, `data-edit-folder`
|
||||
- `data-rename-name`, `data-rename-folder`
|
||||
- `data-preview-url`, `data-preview-name`
|
||||
- IDs on controls: `#advancedSearchToggle`, `#searchInput`, `#prevPageBtn`, `#nextPageBtn`, `#selectAll`, `#itemsPerPageSelect`
|
||||
- **Introduced** `attachListControlListeners()` to bind all events via `addEventListener` immediately after rendering, preserving every interaction without inline code.
|
||||
|
||||
**Additional changes**
|
||||
|
||||
- **Security**: Added `frame-src 'self'` to the Content-Security-Policy header so that the embedded API docs iframe can load from our own origin without relaxing JS restrictions.
|
||||
- **Controller**: Updated `FolderController::shareFolder()` (folderController) to include the gallery-view toggle script block intact, ensuring the “Switch to Gallery View” button works when sharing folders.
|
||||
- **UI (fileListView.js)**: Refactored `renderGalleryView` to remove all inline `onclick=` handlers; switched to using data-attributes and `addEventListener()` for preview, download, edit and rename buttons, fully CSP-compliant.
|
||||
- Moved logout button handler out of inline `<script>` in `index.html` and into the `DOMContentLoaded` init in **main.js** (via `auth.js`), so it now attaches reliably after the CSRF token is loaded and DOM is ready.
|
||||
- Added Content-Security-Policy for `<Files "api.php">` block to allow embedding the ReDoc iframe.
|
||||
- Extracted inline ReDoc init into `public/js/redoc-init.js` and updated `public/api.php` to use deferred `<script>` tags.
|
||||
|
||||
---
|
||||
|
||||
## Changes 4/25/2025
|
||||
|
||||
- Switch single‐file download to native `<a>` link (no JS buffering)
|
||||
- Keep spinner modal during ZIP creation and download blob on POST response
|
||||
- Replace text toggle with a single button showing sun/moon icons and hover tooltip
|
||||
|
||||
## Changes 4/24/2025 1.2.5
|
||||
|
||||
- Enhance README and wiki with expanded installation instructions
|
||||
- Adjusted Dockerfile’s Apache vhost to:
|
||||
- Alias `/uploads/` to `/var/www/uploads/` with PHP engine disabled and directory indexes off
|
||||
- Disable HTTP TRACE and tune keep-alive (On, max 100 requests, 5s timeout) and server Timeout (60s)
|
||||
- Add security headers (`X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`, `Referrer-Policy`)
|
||||
- Enable `mod_deflate` compression for HTML, plain text, CSS, JS and JSON
|
||||
- Configure `mod_expires` caching for images (1 month), CSS (1 week) and JS (3 hour)
|
||||
- Deny access to hidden files (dot-files)
|
||||
~~- Add access control in public/.htaccess for api.html & openapi.json; update Nginx example in wiki~~
|
||||
- Remove obsolete folders from repo root
|
||||
- Embed API documentation (`api.php`) directly in the FileRise UI as a full-screen modal
|
||||
- Introduced `openApiModalBtn` in the user panel to launch the API modal
|
||||
- Added `#apiModal` container with a same-origin `<iframe src="api.php">` so session cookies authenticate automatically
|
||||
- Close control uses the existing `.editor-close-btn` for consistent styling and hover effects
|
||||
|
||||
- public/api.html has been replaced by the new api.php wrapper
|
||||
- **`public/api.php`**
|
||||
- Single PHP endpoint for both UI and spec
|
||||
- Enforces `$_SESSION['authenticated']`
|
||||
- Renders the Redoc API docs when accessed normally
|
||||
- Streams the JSON spec from `openapi.json.dist` when called as `api.php?spec=1`
|
||||
- Redirects unauthenticated users to `index.html?redirect=/api.php`
|
||||
- **Moved** `public/openapi.json` → `openapi.json.dist` (moved outside of `public/`) to prevent direct static access
|
||||
- **Dockerfile**: enabled required Apache modules for rewrite, security headers, proxying, caching and compression:
|
||||
|
||||
```dockerfile
|
||||
RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate
|
||||
```
|
||||
|
||||
## Changes 4/23/2025 1.2.4
|
||||
|
||||
**AuthModel**
|
||||
@@ -30,6 +644,21 @@
|
||||
- **start.sh**
|
||||
- Session directory setup
|
||||
|
||||
- Always sends `credentials: 'include'` and `X-CSRF-Token: window.csrfToken` s
|
||||
- On HTTP 403, automatically fetches a fresh CSRF token (from the response header or `/api/auth/token.php`) and retries the request once
|
||||
- Always returns the real `Response` object (no more “clone.json” on every 200)
|
||||
- Now calls `fetchWithCsrf('/api/auth/token.php')` to guarantee a fresh token
|
||||
- Checks `res.ok`, then parses JSON to extract `csrf_token` and `share_url`
|
||||
- Updates both `window.csrfToken` and the `<meta name="csrf-token">` & `<meta name="share-url">` tags
|
||||
- Removed Old CSRF logic that cloned every successful response and parsed its JSON body
|
||||
- Removed Any “soft-failure” JSON peek on non-403 responses
|
||||
- Add missing permissions in `UserModel.php` for TOTP login.
|
||||
- **Prevent XSS in breadcrumbs**
|
||||
- Replaced `innerHTML` calls in `fileListTitle` with a new `updateBreadcrumbTitle()` helper that uses `textContent` + `DocumentFragment`.
|
||||
- Introduced `renderBreadcrumbFragment()` to build each breadcrumb segment as a `<span class="breadcrumb-link" data-folder="…">` node.
|
||||
- Added `setupBreadcrumbDelegation()` to handle clicks via event delegation on the container, eliminating per-element listeners.
|
||||
- Removed any raw HTML concatenation to satisfy CodeQL and ensure all breadcrumb text is safely escaped.
|
||||
|
||||
## Changes 4/22/2025 v1.2.3
|
||||
|
||||
- Support for custom PUID/PGID via `PUID`/`PGID` environment variables, replacing the need to run the container with `--user`
|
||||
@@ -104,7 +733,7 @@
|
||||
Refactored to:
|
||||
1. Fetch CSRF
|
||||
2. POST credentials to `/api/auth/auth.php`
|
||||
3. On `totp_required`, re‑fetch CSRF *again* before calling `openTOTPLoginModal()`
|
||||
3. On `totp_required`, re‑fetch CSRF again before calling `openTOTPLoginModal()`
|
||||
4. Handle full logins vs. TOTP flows cleanly.
|
||||
|
||||
- **TOTP handlers update**
|
||||
@@ -1050,7 +1679,7 @@ The enhancements extend the existing drag-and-drop functionality by adding a hea
|
||||
- Adjusted file preview and icon styling for better alignment.
|
||||
- Centered the header and optimized the layout for a clean, modern appearance.
|
||||
|
||||
*This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.*
|
||||
This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.
|
||||
|
||||
---
|
||||
|
||||
|
||||
59
Dockerfile
@@ -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=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
|
||||
RUN chown -R root:www-data /var/www && \
|
||||
find /var/www -type d -exec chmod 755 {} \; && \
|
||||
@@ -62,26 +67,76 @@ RUN chown -R root:www-data /var/www && \
|
||||
# Apache site configuration
|
||||
RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
|
||||
<VirtualHost *:80>
|
||||
# Global settings
|
||||
TraceEnable off
|
||||
KeepAlive On
|
||||
MaxKeepAliveRequests 100
|
||||
KeepAliveTimeout 5
|
||||
Timeout 60
|
||||
|
||||
ServerAdmin webmaster@localhost
|
||||
DocumentRoot /var/www/public
|
||||
|
||||
# Security headers for all responses
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||
</IfModule>
|
||||
|
||||
# Compression
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json
|
||||
</IfModule>
|
||||
|
||||
# Cache static assets
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive on
|
||||
ExpiresByType image/jpeg "access plus 1 month"
|
||||
ExpiresByType image/png "access plus 1 month"
|
||||
ExpiresByType text/css "access plus 1 week"
|
||||
ExpiresByType application/javascript "access plus 3 hour"
|
||||
</IfModule>
|
||||
|
||||
# Protect uploads directory
|
||||
Alias /uploads/ /var/www/uploads/
|
||||
<Directory "/var/www/uploads/">
|
||||
Options -Indexes
|
||||
AllowOverride None
|
||||
<IfModule mod_php7.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# Public directory
|
||||
<Directory "/var/www/public">
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
DirectoryIndex index.html
|
||||
DirectoryIndex index.html index.php
|
||||
</Directory>
|
||||
|
||||
# Deny access to hidden files
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<Files "api.php">
|
||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.redoc.ly; style-src 'self' 'unsafe-inline'; worker-src 'self' https://cdn.redoc.ly blob:; connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
|
||||
</Files>
|
||||
|
||||
ErrorLog /var/www/metadata/log/error.log
|
||||
CustomLog /var/www/metadata/log/access.log combined
|
||||
</VirtualHost>
|
||||
EOF
|
||||
|
||||
# Enable required modules
|
||||
RUN a2enmod rewrite headers
|
||||
RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate ssl
|
||||
|
||||
EXPOSE 80 443
|
||||
COPY start.sh /usr/local/bin/start.sh
|
||||
|
||||
132
README.md
@@ -1,7 +1,7 @@
|
||||
# FileRise
|
||||
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
Upload, organize, and share files through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
||||
Upload, organize, and share files or folders through a sleek web interface. **FileRise** is lightweight yet powerful: think of it as your personal cloud drive that you control. With drag-and-drop uploads, in-browser editing, secure user logins (with SSO and 2FA support), and one-click sharing, **FileRise** makes file management on your server a breeze.
|
||||
|
||||
**4/3/2025 Video demo:**
|
||||
|
||||
@@ -52,38 +52,61 @@ Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (
|
||||
|
||||
You can deploy FileRise either by running the **Docker container** (quickest way) or by a **manual installation** on a PHP web server. Both methods are outlined below.
|
||||
|
||||
### 1. Running with Docker (Recommended)
|
||||
---
|
||||
|
||||
If you have Docker installed, you can get FileRise up and running in minutes:
|
||||
### 1) Running with Docker (Recommended)
|
||||
|
||||
- **Pull the image from Docker Hub:**
|
||||
#### Pull the image
|
||||
|
||||
``` bash
|
||||
```bash
|
||||
docker pull error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
- **Run a container:**
|
||||
#### Run a container
|
||||
|
||||
``` bash
|
||||
```bash
|
||||
docker run -d \
|
||||
--name filerise \
|
||||
-p 8080:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||
-e SECURE="false" \
|
||||
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
|
||||
-e PUID="1000" \
|
||||
-e PGID="1000" \
|
||||
-e CHOWN_ON_START="true" \
|
||||
-e SCAN_ON_START="true" \
|
||||
-e SHARE_URL="" \
|
||||
-v ~/filerise/uploads:/var/www/uploads \
|
||||
-v ~/filerise/users:/var/www/users \
|
||||
-v ~/filerise/metadata:/var/www/metadata \
|
||||
--name filerise \
|
||||
error311/filerise-docker:latest
|
||||
```
|
||||
```
|
||||
|
||||
This will start FileRise on port 8080. Visit `http://your-server-ip:8080` to access it. Environment variables shown above are optional – for instance, set `SECURE="true"` to enforce HTTPS (assuming you have SSL at proxy level) and adjust `TIMEZONE` as needed. The volume mounts ensure your files and user data persist outside the container.
|
||||
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
||||
|
||||
- **Using Docker Compose:**
|
||||
Alternatively, use **docker-compose**. Save the snippet below as docker-compose.yml and run `docker-compose up -d`:
|
||||
**Notes**
|
||||
|
||||
``` yaml
|
||||
version: '3'
|
||||
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
||||
- `CHOWN_ON_START=true` is recommended on **first run** to normalize ownership of existing trees. Set to **false** later for faster restarts.
|
||||
- `SCAN_ON_START=true` runs a one-time index of files added outside the UI so their metadata appears.
|
||||
- `SHARE_URL` is optional; leave blank to auto-detect from the current host/scheme. You can set it to your site root (e.g., `https://files.example.com`) or directly to the full endpoint.
|
||||
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
|
||||
|
||||
**Verify ownership mapping (optional)**
|
||||
|
||||
```bash
|
||||
docker exec -it filerise id www-data
|
||||
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
|
||||
```
|
||||
|
||||
#### Using Docker Compose
|
||||
|
||||
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
filerise:
|
||||
image: error311/filerise-docker:latest
|
||||
@@ -91,59 +114,88 @@ services:
|
||||
- "8080:80"
|
||||
environment:
|
||||
TIMEZONE: "UTC"
|
||||
DATE_TIME_FORMAT: "m/d/y h:iA"
|
||||
TOTAL_UPLOAD_SIZE: "10G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "please_change_this_@@"
|
||||
# Ownership & indexing
|
||||
PUID: "1000" # Unraid users often use 99
|
||||
PGID: "1000" # Unraid users often use 100
|
||||
CHOWN_ON_START: "true" # first run; set to "false" afterwards
|
||||
SCAN_ON_START: "true" # index files added outside the UI at boot
|
||||
# Sharing URL (optional): leave blank to auto-detect from host/scheme
|
||||
SHARE_URL: ""
|
||||
volumes:
|
||||
- ./uploads:/var/www/uploads
|
||||
- ./users:/var/www/users
|
||||
- ./metadata:/var/www/metadata
|
||||
```
|
||||
|
||||
FileRise will be accessible at `http://localhost:8080` (or your server’s IP). The above example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “remember me” tokens) – be sure to change it to a random string for security.
|
||||
FileRise will be accessible at `http://localhost:8080` (or your server’s IP).
|
||||
The example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “Remember Me” tokens)—change it to a strong random string.
|
||||
|
||||
**First-time Setup:** On first launch, FileRise will detect no users and prompt you to create an **Admin account**. Choose your admin username & password, and you’re in! You can then head to the **User Management** section to add additional users if needed.
|
||||
**First-time Setup**
|
||||
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. After logging in, use **User Management** to add more users.
|
||||
|
||||
### 2. Manual Installation (PHP/Apache)
|
||||
---
|
||||
|
||||
### 2) Manual Installation (PHP/Apache)
|
||||
|
||||
If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
|
||||
|
||||
- **Requirements:** PHP 8.1 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed.
|
||||
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
|
||||
**Requirements**
|
||||
|
||||
``` bash
|
||||
git clone https://github.com/error311/FileRise.git
|
||||
- PHP **8.3+**
|
||||
- Apache (mod_php) or another web server configured for PHP
|
||||
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
|
||||
|
||||
**Download Files**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/error311/FileRise.git
|
||||
```
|
||||
|
||||
Place the files into your web server’s directory (e.g., `/var/www/html/filerise`). It can be in a subfolder (just adjust the `BASE_URL` in config as below).
|
||||
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
||||
|
||||
- **Composer Dependencies:** If you plan to use OIDC (SSO login), install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.)
|
||||
**Composer (if applicable)**
|
||||
If you use optional features requiring Composer libraries, run:
|
||||
|
||||
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they don’t exist):
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
``` bash
|
||||
**Folders & Permissions**
|
||||
|
||||
```bash
|
||||
mkdir -p uploads users metadata
|
||||
chown -R www-data:www-data uploads users metadata # www-data is Apache user; use appropriate user
|
||||
chown -R www-data:www-data uploads users metadata # use your web user
|
||||
chmod -R 775 uploads users metadata
|
||||
```
|
||||
|
||||
The uploads/ folder is where files go, users/ stores the user credentials file, and metadata/ holds metadata like tags and share links.
|
||||
- `uploads/`: actual files
|
||||
- `users/`: credentials & token storage
|
||||
- `metadata/`: file metadata (tags, share links, etc.)
|
||||
|
||||
- **Configuration:** Open the `config.php` file in a text editor. You may want to adjust:
|
||||
**Configuration**
|
||||
|
||||
- `BASE_URL` – the URL where you will access FileRise (e.g., `“https://files.mydomain.com/”`). This is used for generating share links.
|
||||
|
||||
- `TIMEZONE` and `DATE_TIME_FORMAT` – match your locale (for correct timestamps).
|
||||
|
||||
- `TOTAL_UPLOAD_SIZE` – max aggregate upload size (default 5G). Also adjust PHP’s `upload_max_filesize` and `post_max_size` to at least this value (the Docker start script auto-adjusts PHP limits).
|
||||
|
||||
- `PERSISTENT_TOKENS_KEY` – set a unique secret if you use “Remember Me” logins, to encrypt the tokens.
|
||||
|
||||
- Other settings like `UPLOAD_DIR`, `USERS_FILE` etc. generally don’t need changes unless you move those folders. Defaults are set for the directories mentioned above.
|
||||
Open `config.php` and consider:
|
||||
|
||||
- **Web Server Config:** If using Apache, ensure `.htaccess` files are allowed or manually add the rules from `.htaccess` to your Apache config – these disable directory listings and prevent access to certain files. For Nginx or others, you’ll need to replicate those protections (see Wiki: [Nginx Setup for examples](https://github.com/error311/FileRise/wiki/Nginx-Setup)). Also enable mod_rewrite if not already, as FileRise may use pretty URLs for share links.
|
||||
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
||||
- `TOTAL_UPLOAD_SIZE` (also ensure your PHP `upload_max_filesize` & `post_max_size` meet/exceed this).
|
||||
- `PERSISTENT_TOKENS_KEY` set to a unique secret if using “Remember Me”.
|
||||
|
||||
Now navigate to the FileRise URL in your browser. On first load, you’ll be prompted to create the Admin user (same as Docker setup). After that, the application is ready to use!
|
||||
**Share links base URL**
|
||||
|
||||
- You can set **`SHARE_URL`** via your web server environment variables (preferred),
|
||||
**or** keep using `BASE_URL` in `config.php` as a fallback for manual installs.
|
||||
- If neither is set, FileRise auto-detects from the current host/scheme.
|
||||
|
||||
**Web Server Config**
|
||||
|
||||
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
||||
- Nginx/other: replicate the basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
||||
|
||||
Now browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
||||
|
||||
---
|
||||
|
||||
@@ -218,7 +270,7 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
|
||||
|
||||
## 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.
|
||||
- **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements.
|
||||
|
||||
|
||||
@@ -28,13 +28,14 @@ define('TRASH_DIR', UPLOAD_DIR . 'trash/');
|
||||
define('TIMEZONE', 'America/New_York');
|
||||
define('DATE_TIME_FORMAT','m/d/y h:iA');
|
||||
define('TOTAL_UPLOAD_SIZE','5G');
|
||||
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u');
|
||||
define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[. ]$)(?:[^<>:"\/\\\\|?*\x00-\x1F]{1,255})(?:[\/\\\\][^<>:"\/\\\\|?*\x00-\x1F]{1,255})*$/xu');
|
||||
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');
|
||||
|
||||
date_default_timezone_set(TIMEZONE);
|
||||
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
{
|
||||
@@ -114,6 +115,7 @@ if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
|
||||
// Auto‑login via persistent token
|
||||
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
|
||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||
@@ -140,13 +142,75 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
|
||||
}
|
||||
}
|
||||
|
||||
// Share URL fallback
|
||||
$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 PHP’s $_SERVER key format:
|
||||
$cfgAuthHeader = 'HTTP_' . strtoupper(str_replace('-', '_', $hdr));
|
||||
}
|
||||
|
||||
define('AUTH_BYPASS', $cfgAuthBypass);
|
||||
define('AUTH_HEADER', $cfgAuthHeader);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PROXY-ONLY AUTO–LOGIN 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 (keep BASE_URL behavior)
|
||||
define('BASE_URL', 'http://yourwebsite/uploads/');
|
||||
|
||||
// Detect scheme correctly (works behind proxies too)
|
||||
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? (
|
||||
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
|
||||
);
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
|
||||
if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||
$defaultShare = isset($_SERVER['HTTP_HOST'])
|
||||
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
|
||||
: "http://localhost/api/file/share.php";
|
||||
$defaultShare = "{$proto}://{$host}/api/file/share.php";
|
||||
} else {
|
||||
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
|
||||
}
|
||||
|
||||
// Final: env var wins, else fallback
|
||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||
@@ -1,20 +0,0 @@
|
||||
<!-- public/api.html -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>FileRise API Docs</title>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="openapi.json"></redoc>
|
||||
<div id="redoc-container"></div>
|
||||
<script>
|
||||
// If the <redoc> tag didn’t render, fall back to init()
|
||||
if (!customElements.get('redoc')) {
|
||||
Redoc.init('openapi.json', {}, document.getElementById('redoc-container'));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
31
public/api.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
// public/api.php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
if (empty($_SESSION['authenticated'])) {
|
||||
header('Location: /index.html?redirect=/api.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_GET['spec'])) {
|
||||
header('Content-Type: application/json');
|
||||
readfile(__DIR__ . '/../openapi.json.dist');
|
||||
exit;
|
||||
}
|
||||
|
||||
?><!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>FileRise API Docs</title>
|
||||
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
||||
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
|
||||
crossorigin="anonymous"></script>
|
||||
<script defer src="/js/redoc-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="api.php?spec=1"></redoc>
|
||||
<div id="redoc-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/addUser.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->addUser();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/admin/getConfig.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/adminController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$adminController = new AdminController();
|
||||
$adminController->getConfig();
|
||||
63
public/api/admin/readMetadata.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
// public/api/admin/readMetadata.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
// Only admins may read these
|
||||
if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Must supply ?file=share_links.json or share_folder_links.json
|
||||
if (empty($_GET['file'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Missing `file` parameter']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = basename($_GET['file']);
|
||||
$allowed = ['share_links.json', 'share_folder_links.json'];
|
||||
if (!in_array($file, $allowed, true)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Invalid file requested']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$path = META_DIR . $file;
|
||||
if (!file_exists($path)) {
|
||||
// Return empty object so JS sees `{}` not an error
|
||||
http_response_code(200);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode((object)[]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$jsonData = file_get_contents($path);
|
||||
$data = json_decode($jsonData, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Corrupted JSON']);
|
||||
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');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/admin/updateConfig.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/adminController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$adminController = new AdminController();
|
||||
$adminController->updateConfig();
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/authController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->auth();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/auth/checkAuth.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/authController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->checkAuth();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/auth/login_basic.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/authController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->loginBasic();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/auth/logout.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/authController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->logout();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/auth/token.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/authController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
|
||||
|
||||
$authController = new AuthController();
|
||||
$authController->getToken();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/changePassword.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->changePassword();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/copyFiles.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->copyFiles();
|
||||
15
public/api/file/createFile.php
Normal 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();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/createShareLink.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->createShareLink();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/deleteFiles.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->deleteFiles();
|
||||
6
public/api/file/deleteShareLink.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->deleteShareLink();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/deleteTrashFiles.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->deleteTrashFiles();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/download.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->downloadFile();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/downloadZip.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->downloadZip();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/extractZip.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->extractZip();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/getFileList.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->getFileList();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/getFileTag.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->getFileTags();
|
||||
6
public/api/file/getShareLinks.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->getShareLinks();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/getTrashItems.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->getTrashItems();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/moveFiles.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->moveFiles();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/renameFile.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->renameFile();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/restoreFiles.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->restoreFiles();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/saveFile.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->saveFile();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/saveFileTag.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->saveFileTag();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/file/share.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/fileController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||
|
||||
$fileController = new FileController();
|
||||
$fileController->shareFile();
|
||||
@@ -1,2 +0,0 @@
|
||||
cd /var/www/public
|
||||
ln -s ../uploads uploads
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/folder/createFolder.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->createFolder();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/folder/createShareFolderLink.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->createShareFolderLink();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/folder/deleteFolder.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->deleteFolder();
|
||||
6
public/api/folder/deleteShareFolderLink.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->deleteShareFolderLink();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/folder/downloadSharedFile.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->downloadSharedFile();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/folder/getFolderList.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->getFolderList();
|
||||
6
public/api/folder/getShareFolderLinks.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->getShareFolderLinks();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/folder/renameFolder.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->renameFolder();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/folder/shareFolder.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->shareFolder();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/folder/uploadToSharedFolder.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/folderController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
$folderController = new FolderController();
|
||||
$folderController->uploadToSharedFolder();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/getUserPermissions.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->getUserPermissions();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/getUsers.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->getUsers(); // This will output the JSON response
|
||||
15
public/api/profile/getCurrentUser.php
Normal 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);
|
||||
17
public/api/profile/uploadPicture.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
// Always JSON, even on PHP notices
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
$userController = new UserController();
|
||||
$userController->uploadPicture();
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Exception: ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/removeUser.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->removeUser();
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->disableTOTP();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/totp_recover.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->recoverTOTP();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/totp_saveCode.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->saveTOTPRecoveryCode();
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->setupTOTP();
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/vendor/autoload.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->verifyTOTP();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/updateUserPanel.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->updateUserPanel();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/updateUserPermissions.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/userController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||
|
||||
$userController = new UserController();
|
||||
$userController->updateUserPermissions();
|
||||
@@ -2,7 +2,7 @@
|
||||
// public/api/upload/removeChunks.php
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/uploadController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||
|
||||
$uploadController = new UploadController();
|
||||
$uploadController->removeChunks();
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
// public/api/upload/upload.php
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/uploadController.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
|
||||
|
||||
$uploadController = new UploadController();
|
||||
$uploadController->handleUpload();
|
||||
BIN
public/assets/default-avatar.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
@@ -80,6 +80,9 @@ body.dark-mode .header-container {
|
||||
background-color: #1f1f1f;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
#darkModeIcon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
max-height: 50px;
|
||||
@@ -131,17 +134,27 @@ body.dark-mode header {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 9px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.header-buttons button:not(#userDropdownToggle) {
|
||||
border-radius: 50%;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
#userDropdownToggle {
|
||||
border-radius: 4px !important;
|
||||
padding: 6px 10px !important;
|
||||
}
|
||||
|
||||
.header-buttons button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 600px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
@@ -835,6 +848,27 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
|
||||
background-color: #00796B;
|
||||
}
|
||||
|
||||
#createBtn {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body.dark-mode .dropdown-menu {
|
||||
background-color: #2c2c2c !important;
|
||||
border-color: #444 !important;
|
||||
color: #e0e0e0!important;
|
||||
}
|
||||
body.dark-mode .dropdown-menu .dropdown-item {
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: rgba(0,0,0,0.05);
|
||||
}
|
||||
body.dark-mode .dropdown-item:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
#fileList button.edit-btn {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
@@ -952,6 +986,29 @@ body.dark-mode #fileList table tr {
|
||||
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
|
||||
@@ -1325,26 +1382,6 @@ body.dark-mode .image-preview-modal-content {
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.preview-btn,
|
||||
.download-btn,
|
||||
.rename-btn,
|
||||
.share-btn,
|
||||
.edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 0px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.image-modal-img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
@@ -2099,13 +2136,23 @@ body.dark-mode .header-drop-zone.drag-active {
|
||||
color: black;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
#fileSummary {
|
||||
float: none !important;
|
||||
margin: 0 auto !important;
|
||||
text-align: center !important;
|
||||
#fileSummary,
|
||||
#rowHeightSliderContainer,
|
||||
#viewSliderContainer {
|
||||
float: none !important;
|
||||
margin: 0 auto !important;
|
||||
text-align: center !important;
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
#viewSliderContainer label,
|
||||
#viewSliderContainer span {
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body.dark-mode #fileSummary {
|
||||
color: white;
|
||||
}
|
||||
@@ -2162,4 +2209,100 @@ body.dark-mode #searchIcon .material-icons {
|
||||
body.dark-mode .btn-icon:hover,
|
||||
body.dark-mode .btn-icon:focus {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.user-dropdown .user-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background: var(--bs-body-bg, #fff);
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
min-width: 150px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.user-dropdown .user-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-dropdown .user-menu .item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-dropdown .user-menu .item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.user-dropdown .dropdown-caret {
|
||||
border-top: 5px solid currentColor;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
body.dark-mode .user-dropdown .user-menu {
|
||||
background: #2c2c2c;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
body.dark-mode .user-dropdown .user-menu .item {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .user-dropdown .user-menu .item:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.user-dropdown .dropdown-username {
|
||||
margin: 0 8px;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -5,24 +5,40 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title data-i18n-key="title">FileRise</title>
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('logout') === '1') {
|
||||
localStorage.removeItem("username");
|
||||
localStorage.removeItem("userTOTPEnabled");
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||
<meta name="csrf-token" 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 -->
|
||||
<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" />
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css">
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
|
||||
integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
|
||||
integrity="sha384-zaeBlB/vwYsDRSlFajnDd7OydJ0cWk+c2OWybl3eSUf6hW2EbhlCsQPqKr3gkznT" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"
|
||||
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
|
||||
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
|
||||
crossorigin="anonymous"></script>
|
||||
@@ -41,9 +57,9 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js"
|
||||
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
|
||||
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
|
||||
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
|
||||
crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
</head>
|
||||
|
||||
@@ -78,16 +94,16 @@
|
||||
stroke: white;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
|
||||
.divider {
|
||||
stroke: #1565C0;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
|
||||
.drawer {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
|
||||
.handle {
|
||||
fill: #1565C0;
|
||||
}
|
||||
@@ -124,9 +140,6 @@
|
||||
<!-- Your header drop zone -->
|
||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||
<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;">
|
||||
<i class="material-icons">vpn_key</i>
|
||||
</button>
|
||||
@@ -159,16 +172,52 @@
|
||||
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
|
||||
<i class="material-icons">person_remove</i>
|
||||
</button>
|
||||
<button id="darkModeToggle" class="dark-mode-toggle" data-i18n-key="dark_mode_toggle">Dark Mode</button>
|
||||
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
|
||||
<span class="material-icons" id="darkModeIcon">
|
||||
dark_mode
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="loadingOverlay"></div>
|
||||
|
||||
<!-- Custom Toast Container -->
|
||||
<div id="customToast"></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 -->
|
||||
<div class="main-wrapper">
|
||||
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
|
||||
@@ -176,36 +225,6 @@
|
||||
<!-- Main Column -->
|
||||
<div id="mainColumn" class="main-column">
|
||||
<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 />
|
||||
</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 -->
|
||||
<div id="mainOperations">
|
||||
<div class="container" style="max-width: 1400px; margin: 0 auto;">
|
||||
@@ -284,10 +303,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
||||
<i class="material-icons">share</i>
|
||||
</button>
|
||||
</button>
|
||||
<button id="deleteFolderBtn" class="btn btn-danger ml-2" data-i18n-title="delete_folder">
|
||||
<i class="material-icons">delete</i>
|
||||
</button>
|
||||
@@ -370,8 +389,55 @@
|
||||
</div>
|
||||
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
|
||||
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>
|
||||
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
||||
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
|
||||
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||
</button>
|
||||
<ul
|
||||
id="createMenu"
|
||||
class="dropdown-menu"
|
||||
style="
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
min-width: 140px;
|
||||
"
|
||||
>
|
||||
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file" style="padding:8px 12px; cursor:pointer;">
|
||||
${t('create_file')}
|
||||
</li>
|
||||
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder" style="padding:8px 12px; cursor:pointer;">
|
||||
${t('create_folder')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- 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 class="modal-content">
|
||||
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
|
||||
@@ -391,36 +457,42 @@
|
||||
</div> <!-- end mainColumn -->
|
||||
</div> <!-- end main-wrapper -->
|
||||
|
||||
<!-- Download Progress Modal -->
|
||||
<div id="downloadProgressModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||
<!-- Material icon spinner with a dedicated class -->
|
||||
<span class="material-icons download-spinner">autorenew</span>
|
||||
<p data-i18n-key="preparing_download">Preparing your download...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Download Progress Modal -->
|
||||
<div id="downloadProgressModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||
<h4 id="downloadProgressTitle" data-i18n-key="preparing_download">
|
||||
Preparing your download...
|
||||
</h4>
|
||||
|
||||
<!-- Single File Download Modal -->
|
||||
<div id="downloadFileModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||
<h4 data-i18n-key="download_file">Download File</h4>
|
||||
<p data-i18n-key="confirm_or_change_filename">Confirm or change the download file name:</p>
|
||||
<input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename" placeholder="Filename" />
|
||||
<div style="margin-top: 15px; text-align: right;">
|
||||
<button id="cancelDownloadFile" class="btn btn-secondary"
|
||||
onclick="document.getElementById('downloadFileModal').style.display = 'none';"
|
||||
data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmSingleDownloadButton" class="btn btn-primary"
|
||||
onclick="confirmSingleDownload()"
|
||||
data-i18n-key="download">Download</button>
|
||||
<!-- spinner -->
|
||||
<span class="material-icons download-spinner">autorenew</span>
|
||||
|
||||
<!-- these were missing -->
|
||||
<progress id="downloadProgressBar" value="0" max="100" style="width:100%; height:1.5em; display:none;"></progress>
|
||||
<p>
|
||||
<span id="downloadProgressPercent" style="display:none;">0%</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single File Download Modal -->
|
||||
<div id="downloadFileModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||
<h4 data-i18n-key="download_file">Download File</h4>
|
||||
<p data-i18n-key="confirm_or_change_filename">Confirm or change the download file name:</p>
|
||||
<input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename"
|
||||
placeholder="Filename" />
|
||||
<div style="margin-top: 15px; text-align: right;">
|
||||
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
||||
<div id="changePasswordModal" class="modal" style="display:none;">
|
||||
<div class="modal-content" style="max-width:400px; margin:auto;">
|
||||
<span id="closeChangePasswordModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">×</span>
|
||||
<span id="closeChangePasswordModal" class="editor-close-btn">×</span>
|
||||
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
||||
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
||||
placeholder="Old Password" style="width:100%; margin: 5px 0;" />
|
||||
@@ -431,24 +503,36 @@
|
||||
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="addUserModal" class="modal">
|
||||
<div id="addUserModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h3 data-i18n-key="create_new_user_title">Create New User</h3>
|
||||
<label for="newUsername" data-i18n-key="username">Username:</label>
|
||||
<input type="text" id="newUsername" class="form-control" />
|
||||
<label for="addUserPassword" data-i18n-key="password">Password:</label>
|
||||
<input type="password" id="addUserPassword" class="form-control" />
|
||||
<div id="adminCheckboxContainer">
|
||||
<input type="checkbox" id="isAdmin" />
|
||||
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<button id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||
<button id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">Save User</button>
|
||||
</div>
|
||||
<!-- 1) Add a form around these fields -->
|
||||
<form id="addUserForm">
|
||||
<label for="newUsername" data-i18n-key="username">Username:</label>
|
||||
<input type="text" id="newUsername" class="form-control" required />
|
||||
|
||||
<label for="addUserPassword" data-i18n-key="password">Password:</label>
|
||||
<input type="password" id="addUserPassword" class="form-control" required />
|
||||
|
||||
<div id="adminCheckboxContainer">
|
||||
<input type="checkbox" id="isAdmin" />
|
||||
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<!-- Cancel stays type="button" -->
|
||||
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<!-- Save becomes type="submit" -->
|
||||
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">
|
||||
Save User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="removeUserModal" class="modal">
|
||||
<div id="removeUserModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<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>
|
||||
@@ -459,7 +543,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="renameFileModal" class="modal">
|
||||
<div id="renameFileModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h4 data-i18n-key="rename_file_title">Rename File</h4>
|
||||
<input type="text" id="newFileName" class="form-control" data-i18n-placeholder="rename_file_placeholder"
|
||||
|
||||
706
public/js/adminPanel.js
Normal file
@@ -0,0 +1,706 @@
|
||||
import { t } from './i18n.js';
|
||||
import { loadAdminConfigFunc } from './auth.js';
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
|
||||
const version = "v1.3.13";
|
||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||
|
||||
// ————— Inject updated styles —————
|
||||
(function () {
|
||||
if (document.getElementById('adminPanelStyles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'adminPanelStyles';
|
||||
style.textContent = `
|
||||
/* Modal sizing */
|
||||
#adminPanelModal .modal-content {
|
||||
max-width: 1100px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
/* Small phones: 90% width */
|
||||
@media (max-width: 900px) {
|
||||
#adminPanelModal .modal-content {
|
||||
width: 90% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark-mode fixes */
|
||||
body.dark-mode #adminPanelModal .modal-content {
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
/* enforce light‐mode styling */
|
||||
#adminPanelModal .modal-content {
|
||||
max-width: 1100px;
|
||||
width: 50%;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* enforce dark‐mode styling */
|
||||
body.dark-mode #adminPanelModal .modal-content {
|
||||
background: #2c2c2c !important;
|
||||
color: #e0e0e0 !important;
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
/* form controls in dark */
|
||||
body.dark-mode .form-control {
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
color: #eee;
|
||||
}
|
||||
body.dark-mode .form-control::placeholder { color: #888; }
|
||||
|
||||
/* Section headers */
|
||||
.section-header {
|
||||
background: #f5f5f5;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.section-header:first-of-type { margin-top: 0; }
|
||||
.section-header.collapsed .material-icons { transform: rotate(-90deg); }
|
||||
.section-header .material-icons { transition: transform .3s; color: #444; }
|
||||
|
||||
body.dark-mode .section-header {
|
||||
background: #3a3a3a;
|
||||
color: #eee;
|
||||
}
|
||||
body.dark-mode .section-header .material-icons { color: #ccc; }
|
||||
|
||||
/* Hidden by default */
|
||||
.section-content {
|
||||
display: none;
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
#adminPanelModal .editor-close-btn {
|
||||
position: absolute; top:10px; right:10px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:20px; font-weight:bold; cursor:pointer;
|
||||
z-index:1000; width:32px; height:32px; border-radius:50%;
|
||||
text-align:center; line-height:30px;
|
||||
color:#ff4d4d; background:rgba(255,255,255,0.9);
|
||||
border:2px solid transparent; transition:all .3s;
|
||||
}
|
||||
#adminPanelModal .editor-close-btn:hover {
|
||||
color:white; background:#ff4d4d;
|
||||
box-shadow:0 0 6px rgba(255,77,77,.8);
|
||||
transform:scale(1.05);
|
||||
}
|
||||
body.dark-mode #adminPanelModal .editor-close-btn {
|
||||
background:rgba(0,0,0,0.6);
|
||||
color:#ff4d4d;
|
||||
}
|
||||
|
||||
/* Action-row */
|
||||
.action-row {
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
margin-top:15px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
// ————————————————————————————————————
|
||||
|
||||
let originalAdminConfig = {};
|
||||
function captureInitialAdminConfig() {
|
||||
originalAdminConfig = {
|
||||
headerTitle: document.getElementById("headerTitle").value.trim(),
|
||||
oidcProviderUrl: document.getElementById("oidcProviderUrl").value.trim(),
|
||||
oidcClientId: document.getElementById("oidcClientId").value.trim(),
|
||||
oidcClientSecret: document.getElementById("oidcClientSecret").value.trim(),
|
||||
oidcRedirectUri: document.getElementById("oidcRedirectUri").value.trim(),
|
||||
disableFormLogin: document.getElementById("disableFormLogin").checked,
|
||||
disableBasicAuth: document.getElementById("disableBasicAuth").checked,
|
||||
disableOIDCLogin: document.getElementById("disableOIDCLogin").checked,
|
||||
enableWebDAV: document.getElementById("enableWebDAV").checked,
|
||||
sharedMaxUploadSize: document.getElementById("sharedMaxUploadSize").value.trim(),
|
||||
globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim()
|
||||
};
|
||||
}
|
||||
function hasUnsavedChanges() {
|
||||
const o = originalAdminConfig;
|
||||
return (
|
||||
document.getElementById("headerTitle").value.trim() !== o.headerTitle ||
|
||||
document.getElementById("oidcProviderUrl").value.trim() !== o.oidcProviderUrl ||
|
||||
document.getElementById("oidcClientId").value.trim() !== o.oidcClientId ||
|
||||
document.getElementById("oidcClientSecret").value.trim() !== o.oidcClientSecret ||
|
||||
document.getElementById("oidcRedirectUri").value.trim() !== o.oidcRedirectUri ||
|
||||
document.getElementById("disableFormLogin").checked !== o.disableFormLogin ||
|
||||
document.getElementById("disableBasicAuth").checked !== o.disableBasicAuth ||
|
||||
document.getElementById("disableOIDCLogin").checked !== o.disableOIDCLogin ||
|
||||
document.getElementById("enableWebDAV").checked !== o.enableWebDAV ||
|
||||
document.getElementById("sharedMaxUploadSize").value.trim() !== o.sharedMaxUploadSize ||
|
||||
document.getElementById("globalOtpauthUrl").value.trim() !== o.globalOtpauthUrl
|
||||
);
|
||||
}
|
||||
|
||||
function showCustomConfirmModal(message) {
|
||||
return new Promise(resolve => {
|
||||
const modal = document.getElementById("customConfirmModal");
|
||||
const msg = document.getElementById("confirmMessage");
|
||||
const yes = document.getElementById("confirmYesBtn");
|
||||
const no = document.getElementById("confirmNoBtn");
|
||||
msg.textContent = message;
|
||||
modal.style.display = "block";
|
||||
function clean() {
|
||||
modal.style.display = "none";
|
||||
yes.removeEventListener("click", onYes);
|
||||
no.removeEventListener("click", onNo);
|
||||
}
|
||||
function onYes() { clean(); resolve(true); }
|
||||
function onNo() { clean(); resolve(false); }
|
||||
yes.addEventListener("click", onYes);
|
||||
no.addEventListener("click", onNo);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSection(id) {
|
||||
const hdr = document.getElementById(id + "Header");
|
||||
const cnt = document.getElementById(id + "Content");
|
||||
const isCollapsedNow = hdr.classList.toggle("collapsed");
|
||||
// collapsed class present => hide; absent => show
|
||||
cnt.style.display = isCollapsedNow ? "none" : "block";
|
||||
if (!isCollapsedNow && id === "shareLinks") {
|
||||
loadShareLinksSection();
|
||||
}
|
||||
}
|
||||
|
||||
function loadShareLinksSection() {
|
||||
const container = document.getElementById("shareLinksContent");
|
||||
container.textContent = t("loading") + "...";
|
||||
|
||||
// helper: fetch one metadata file, but never throw —
|
||||
// on non-2xx (including 404) or network error, resolve to {}
|
||||
function fetchMeta(fileName) {
|
||||
return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, {
|
||||
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([
|
||||
fetchMeta("share_folder_links.json"),
|
||||
fetchMeta("share_links.json")
|
||||
])
|
||||
.then(([folders, files]) => {
|
||||
// if *both* are empty, show "no shared links"
|
||||
const hasAny = Object.keys(folders).length || Object.keys(files).length;
|
||||
if (!hasAny) {
|
||||
container.innerHTML = `<p>${t("no_shared_links_available")}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<h5>${t("folder_shares")}</h5><ul>`;
|
||||
Object.entries(folders).forEach(([token, o]) => {
|
||||
const lock = o.password ? "🔒 " : "";
|
||||
html += `
|
||||
<li>
|
||||
${lock}<strong>${o.folder}</strong>
|
||||
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
|
||||
<button type="button"
|
||||
data-key="${token}"
|
||||
data-type="folder"
|
||||
class="btn btn-sm btn-link delete-share">🗑️</button>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
html += `</ul><h5 style="margin-top:1em;">${t("file_shares")}</h5><ul>`;
|
||||
Object.entries(files).forEach(([token, o]) => {
|
||||
const lock = o.password ? "🔒 " : "";
|
||||
html += `
|
||||
<li>
|
||||
${lock}<strong>${o.folder}/${o.file}</strong>
|
||||
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
|
||||
<button type="button"
|
||||
data-key="${token}"
|
||||
data-type="file"
|
||||
class="btn btn-sm btn-link delete-share">🗑️</button>
|
||||
</li>`;
|
||||
});
|
||||
html += `</ul>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// wire up delete buttons
|
||||
container.querySelectorAll(".delete-share").forEach(btn => {
|
||||
btn.addEventListener("click", evt => {
|
||||
evt.preventDefault();
|
||||
const token = btn.dataset.key;
|
||||
const isFolder = btn.dataset.type === "folder";
|
||||
const endpoint = isFolder
|
||||
? "/api/folder/deleteShareFolderLink.php"
|
||||
: "/api/file/deleteShareLink.php";
|
||||
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ token })
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(json => {
|
||||
if (json.success) {
|
||||
showToast(t("share_deleted_successfully"));
|
||||
loadShareLinksSection();
|
||||
} else {
|
||||
showToast(t("error_deleting_share") + ": " + (json.error || ""), "error");
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Delete error:", err);
|
||||
showToast(t("error_deleting_share"), "error");
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("loadShareLinksSection error:", err);
|
||||
container.textContent = t("error_loading_share_links");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function openAdminPanel() {
|
||||
fetch("/api/admin/getConfig.php", { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(config => {
|
||||
// apply header title + globals
|
||||
if (config.header_title) {
|
||||
document.querySelector(".header-title h1").textContent = config.header_title;
|
||||
window.headerTitle = config.header_title;
|
||||
}
|
||||
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
|
||||
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
|
||||
|
||||
const dark = document.body.classList.contains("dark-mode");
|
||||
const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const inner = `
|
||||
background:${dark ? "#2c2c2c" : "#fff"};
|
||||
color:${dark ? "#e0e0e0" : "#000"};
|
||||
padding:20px; max-width:1100px; width:50%;
|
||||
border-radius:8px; position:relative;
|
||||
max-height:90vh; overflow:auto;
|
||||
border:1px solid ${dark ? "#555" : "#ccc"};
|
||||
`;
|
||||
|
||||
let mdl = document.getElementById("adminPanelModal");
|
||||
if (!mdl) {
|
||||
mdl = document.createElement("div");
|
||||
mdl.id = "adminPanelModal";
|
||||
mdl.style.cssText = `
|
||||
position:fixed; top:0; left:0;
|
||||
width:100vw; height:100vh;
|
||||
background:${bg};
|
||||
display:flex; justify-content:center; align-items:center;
|
||||
z-index:3000;
|
||||
`;
|
||||
mdl.innerHTML = `
|
||||
<div class="modal-content" style="${inner}">
|
||||
<div class="editor-close-btn" id="closeAdminPanel">×</div>
|
||||
<h3>${adminTitle}</h3>
|
||||
<form id="adminPanelForm">
|
||||
|
||||
<!-- each section: header + content -->
|
||||
${[
|
||||
{ id: "userManagement", label: t("user_management") },
|
||||
{ id: "headerSettings", label: t("header_settings") },
|
||||
{ id: "loginOptions", label: t("login_options") },
|
||||
{ id: "webdav", label: "WebDAV Access" },
|
||||
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
|
||||
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
|
||||
{ id: "shareLinks", label: t("manage_shared_links") }
|
||||
].map(sec => `
|
||||
<div id="${sec.id}Header" class="section-header collapsed">
|
||||
${sec.label} <i class="material-icons">expand_more</i>
|
||||
</div>
|
||||
<div id="${sec.id}Content" class="section-content"></div>
|
||||
`).join("")}
|
||||
|
||||
<div class="action-row">
|
||||
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">${t("cancel")}</button>
|
||||
<button type="button" id="saveAdminSettings" class="btn btn-primary">${t("save_settings")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(mdl);
|
||||
|
||||
// Bind close & cancel
|
||||
document.getElementById("closeAdminPanel")
|
||||
.addEventListener("click", closeAdminPanel);
|
||||
document.getElementById("cancelAdminSettings")
|
||||
.addEventListener("click", closeAdminPanel);
|
||||
|
||||
// Section toggles
|
||||
["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks"]
|
||||
.forEach(id => {
|
||||
document.getElementById(id + "Header")
|
||||
.addEventListener("click", () => toggleSection(id));
|
||||
});
|
||||
|
||||
// Populate each section’s CONTENT:
|
||||
// — User Mgmt —
|
||||
document.getElementById("userManagementContent").innerHTML = `
|
||||
<button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button>
|
||||
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button>
|
||||
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${t("user_permissions")}</button>
|
||||
`;
|
||||
document.getElementById("adminOpenAddUser")
|
||||
.addEventListener("click", () => {
|
||||
toggleVisibility("addUserModal", true);
|
||||
document.getElementById("newUsername").focus();
|
||||
});
|
||||
document.getElementById("adminOpenRemoveUser")
|
||||
.addEventListener("click", () => {
|
||||
if (typeof window.loadUserList === "function") window.loadUserList();
|
||||
toggleVisibility("removeUserModal", true);
|
||||
});
|
||||
document.getElementById("adminOpenUserPermissions")
|
||||
.addEventListener("click", openUserPermissionsModal);
|
||||
|
||||
// — Header Settings —
|
||||
document.getElementById("headerSettingsContent").innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="headerTitle">${t("header_title_text")}:</label>
|
||||
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle}" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
// — Login Options —
|
||||
document.getElementById("loginOptionsContent").innerHTML = `
|
||||
<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="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 —
|
||||
document.getElementById("webdavContent").innerHTML = `
|
||||
<div class="form-group"><input type="checkbox" id="enableWebDAV" /> <label for="enableWebDAV">Enable WebDAV</label></div>
|
||||
`;
|
||||
|
||||
// — Upload —
|
||||
document.getElementById("uploadContent").innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="sharedMaxUploadSize">${t("shared_max_upload_size_bytes")}:</label>
|
||||
<input type="number" id="sharedMaxUploadSize" class="form-control" placeholder="e.g. 52428800" />
|
||||
<small>${t("max_bytes_shared_uploads_note")}</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// — OIDC & TOTP —
|
||||
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="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="oidcRedirectUri">${t("oidc_redirect_uri")}:</label><input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig.redirectUri}" /></div>
|
||||
<div class="form-group"><label for="globalOtpauthUrl">${t("global_otpauth_url")}:</label><input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" /></div>
|
||||
`;
|
||||
|
||||
// — Share Links —
|
||||
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
|
||||
|
||||
// — Save handler & constraints —
|
||||
document.getElementById("saveAdminSettings")
|
||||
.addEventListener("click", handleSave);
|
||||
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"].forEach(id => {
|
||||
document.getElementById(id)
|
||||
.addEventListener("change", e => {
|
||||
const chk = ["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"]
|
||||
.filter(i => document.getElementById(i).checked).length;
|
||||
if (chk === 3) {
|
||||
showToast(t("at_least_one_login_method"));
|
||||
e.target.checked = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
// 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
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === 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("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
|
||||
captureInitialAdminConfig();
|
||||
|
||||
} else {
|
||||
// modal already exists → just refresh values & re-show
|
||||
mdl.style.display = "flex";
|
||||
// update dark/light as above...
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === 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("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
|
||||
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl;
|
||||
document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId;
|
||||
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret;
|
||||
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri;
|
||||
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || '';
|
||||
captureInitialAdminConfig();
|
||||
}
|
||||
})
|
||||
.catch(() => {/* if even fetching fails, open empty panel */ });
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const dFL = document.getElementById("disableFormLogin").checked;
|
||||
const dBA = document.getElementById("disableBasicAuth").checked;
|
||||
const dOIDC = document.getElementById("disableOIDCLogin").checked;
|
||||
const aBypass= document.getElementById("authBypass").checked;
|
||||
const aHeader= document.getElementById("authHeaderName").value.trim() || "X-Remote-User";
|
||||
const eWD = document.getElementById("enableWebDAV").checked;
|
||||
const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0;
|
||||
const nHT = document.getElementById("headerTitle").value.trim();
|
||||
const nOIDC = {
|
||||
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
|
||||
clientId: document.getElementById("oidcClientId").value.trim(),
|
||||
clientSecret:document.getElementById("oidcClientSecret").value.trim(),
|
||||
redirectUri: document.getElementById("oidcRedirectUri").value.trim()
|
||||
};
|
||||
const gURL = document.getElementById("globalOtpauthUrl").value.trim();
|
||||
|
||||
if ([dFL, dBA, dOIDC].filter(x => x).length === 3) {
|
||||
showToast(t("at_least_one_login_method"));
|
||||
return;
|
||||
}
|
||||
|
||||
sendRequest("/api/admin/updateConfig.php", "POST", {
|
||||
header_title: nHT,
|
||||
oidc: nOIDC,
|
||||
loginOptions: {
|
||||
disableFormLogin: dFL,
|
||||
disableBasicAuth: dBA,
|
||||
disableOIDCLogin: dOIDC,
|
||||
authBypass: aBypass,
|
||||
authHeaderName: aHeader
|
||||
},
|
||||
enableWebDAV: eWD,
|
||||
sharedMaxUploadSize: sMax,
|
||||
globalOtpauthUrl: gURL
|
||||
}, {
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
})
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
captureInitialAdminConfig();
|
||||
closeAdminPanel();
|
||||
loadAdminConfigFunc();
|
||||
} else {
|
||||
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error");
|
||||
}
|
||||
}).catch(() => {/*noop*/});
|
||||
}
|
||||
|
||||
export async function closeAdminPanel() {
|
||||
if (hasUnsavedChanges()) {
|
||||
const ok = await showCustomConfirmModal(t("unsaved_changes_confirm"));
|
||||
if (!ok) return;
|
||||
}
|
||||
document.getElementById("adminPanelModal").style.display = "none";
|
||||
}
|
||||
|
||||
// --- New: User Permissions Modal ---
|
||||
export function openUserPermissionsModal() {
|
||||
let userPermissionsModal = document.getElementById("userPermissionsModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const modalContentStyles = `
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
if (!userPermissionsModal) {
|
||||
userPermissionsModal = document.createElement("div");
|
||||
userPermissionsModal.id = "userPermissionsModal";
|
||||
userPermissionsModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: ${overlayBackground};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 3500;
|
||||
`;
|
||||
userPermissionsModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeUserPermissionsModal" class="editor-close-btn">×</span>
|
||||
<h3>${t("user_permissions")}</h3>
|
||||
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">
|
||||
<!-- User rows will be loaded here -->
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button>
|
||||
<button type="button" id="saveUserPermissionsBtn" class="btn btn-primary">${t("save_permissions")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(userPermissionsModal);
|
||||
document.getElementById("closeUserPermissionsModal").addEventListener("click", () => {
|
||||
userPermissionsModal.style.display = "none";
|
||||
});
|
||||
document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => {
|
||||
userPermissionsModal.style.display = "none";
|
||||
});
|
||||
document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => {
|
||||
// Collect permissions data from each user row.
|
||||
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
||||
const permissionsData = [];
|
||||
rows.forEach(row => {
|
||||
const username = row.getAttribute("data-username");
|
||||
const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']");
|
||||
const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']");
|
||||
const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']");
|
||||
permissionsData.push({
|
||||
username,
|
||||
folderOnly: folderOnlyCheckbox.checked,
|
||||
readOnly: readOnlyCheckbox.checked,
|
||||
disableUpload: disableUploadCheckbox.checked
|
||||
});
|
||||
});
|
||||
// Send the permissionsData to the server.
|
||||
sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
showToast(t("user_permissions_updated_successfully"));
|
||||
userPermissionsModal.style.display = "none";
|
||||
} else {
|
||||
showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error")));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast(t("error_updating_permissions"));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
userPermissionsModal.style.display = "flex";
|
||||
}
|
||||
// Load the list of users into the modal.
|
||||
loadUserPermissionsList();
|
||||
}
|
||||
|
||||
function loadUserPermissionsList() {
|
||||
const listContainer = document.getElementById("userPermissionsList");
|
||||
if (!listContainer) return;
|
||||
listContainer.innerHTML = "";
|
||||
|
||||
// First, fetch the current permissions from the server.
|
||||
fetch("/api/getUserPermissions.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(permissionsData => {
|
||||
// Then, fetch the list of users.
|
||||
return fetch("/api/getUsers.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(usersData => {
|
||||
const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
|
||||
if (users.length === 0) {
|
||||
listContainer.innerHTML = "<p>" + t("no_users_found") + "</p>";
|
||||
return;
|
||||
}
|
||||
users.forEach(user => {
|
||||
// Skip admin users.
|
||||
if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return;
|
||||
|
||||
// Use stored permissions if available; otherwise fall back to defaults.
|
||||
const defaultPerm = {
|
||||
folderOnly: false,
|
||||
readOnly: false,
|
||||
disableUpload: false,
|
||||
};
|
||||
|
||||
// Normalize the username key to match server storage (e.g., lowercase)
|
||||
const usernameKey = user.username.toLowerCase();
|
||||
|
||||
const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData))
|
||||
? permissionsData[usernameKey]
|
||||
: defaultPerm;
|
||||
|
||||
// Create a row for the user.
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("user-permission-row");
|
||||
row.setAttribute("data-username", user.username);
|
||||
row.style.padding = "10px 0";
|
||||
row.innerHTML = `
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">${user.username}</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} />
|
||||
${t("user_folder_only")}
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} />
|
||||
${t("read_only")}
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 5px;">
|
||||
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} />
|
||||
${t("disable_upload")}
|
||||
</label>
|
||||
</div>
|
||||
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;">
|
||||
`;
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
||||
});
|
||||
}
|
||||
@@ -15,16 +15,17 @@ import {
|
||||
openUserPanel,
|
||||
openTOTPModal,
|
||||
closeTOTPModal,
|
||||
openAdminPanel,
|
||||
closeAdminPanel,
|
||||
setLastLoginData
|
||||
setLastLoginData,
|
||||
openApiModal
|
||||
} from './authModals.js';
|
||||
import { openAdminPanel } from './adminPanel.js';
|
||||
import { initializeApp, triggerLogout } from './main.js';
|
||||
|
||||
// Production OIDC configuration (override via API as needed)
|
||||
const currentOIDCConfig = {
|
||||
providerUrl: "https://your-oidc-provider.com",
|
||||
clientId: "YOUR_CLIENT_ID",
|
||||
clientSecret: "YOUR_CLIENT_SECRET",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback",
|
||||
globalOtpauthUrl: ""
|
||||
};
|
||||
@@ -52,28 +53,24 @@ const originalFetch = window.fetch;
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function fetchWithCsrf(url, options = {}) {
|
||||
options = { credentials: 'include', headers: {}, ...options };
|
||||
options.headers['X-CSRF-Token'] = window.csrfToken;
|
||||
// 1) Merge in credentials + header
|
||||
options = {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
};
|
||||
options.headers = {
|
||||
...(options.headers || {}),
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
};
|
||||
|
||||
// 1) First attempt using the original fetch
|
||||
// 2) First attempt
|
||||
let res = await originalFetch(url, options);
|
||||
|
||||
// 2) Soft‐failure JSON check (200 + {csrf_expired})
|
||||
if (res.ok && res.headers.get('content-type')?.includes('application/json')) {
|
||||
const clone = res.clone();
|
||||
const data = await clone.json();
|
||||
if (data.csrf_expired) {
|
||||
const newToken = data.csrf_token;
|
||||
window.csrfToken = newToken;
|
||||
document.querySelector('meta[name="csrf-token"]').content = newToken;
|
||||
options.headers['X-CSRF-Token'] = newToken;
|
||||
return originalFetch(url, options);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) HTTP 403 fallback
|
||||
// 3) If we got a 403, try to refresh token & retry
|
||||
if (res.status === 403) {
|
||||
// 3a) See if the server gave us a new token header
|
||||
let newToken = res.headers.get('X-CSRF-Token');
|
||||
// 3b) Otherwise fall back to the /api/auth/token endpoint
|
||||
if (!newToken) {
|
||||
const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' });
|
||||
if (tokRes.ok) {
|
||||
@@ -82,17 +79,21 @@ export async function fetchWithCsrf(url, options = {}) {
|
||||
}
|
||||
}
|
||||
if (newToken) {
|
||||
// 3c) Update global + meta
|
||||
window.csrfToken = newToken;
|
||||
document.querySelector('meta[name="csrf-token"]').content = newToken;
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta) meta.content = newToken;
|
||||
|
||||
// 3d) Retry the original request with the new token
|
||||
options.headers['X-CSRF-Token'] = newToken;
|
||||
res = await originalFetch(url, options);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Return the real Response—no body peeking here!
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
// wrap the TOTP modal opener to disable other login buttons only for Basic/OIDC flows
|
||||
function openTOTPLoginModal() {
|
||||
originalOpenTOTPLoginModal();
|
||||
@@ -125,10 +126,24 @@ 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 }) {
|
||||
const authForm = document.getElementById("authForm");
|
||||
|
||||
if (authForm) authForm.style.display = disableFormLogin ? "none" : "block";
|
||||
if
|
||||
(authForm) {
|
||||
authForm.style.display = disableFormLogin ? "none" : "block";
|
||||
setTimeout(() => {
|
||||
const loginInput = document.getElementById('loginUsername');
|
||||
if (loginInput) loginInput.focus();
|
||||
}, 0);
|
||||
}
|
||||
const basicAuthLink = document.querySelector("a[href='/api/auth/login_basic.php']");
|
||||
if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block";
|
||||
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
|
||||
@@ -139,7 +154,8 @@ function updateLoginOptionsUIFromStorage() {
|
||||
updateLoginOptionsUI({
|
||||
disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
|
||||
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
|
||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true"
|
||||
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true",
|
||||
authBypass: localStorage.getItem("authBypass") === "true"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +170,8 @@ export function loadAdminConfigFunc() {
|
||||
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
|
||||
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin);
|
||||
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();
|
||||
|
||||
@@ -182,16 +200,48 @@ function insertAfter(newNode, referenceNode) {
|
||||
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("mainOperations", true);
|
||||
toggleVisibility("uploadFileForm", true);
|
||||
toggleVisibility("fileListContainer", true);
|
||||
attachEnterKeyListener("addUserModal", "saveUserBtn");
|
||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
|
||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||
|
||||
// 3) Persist auth flags (unchanged)
|
||||
if (typeof data.totp_enabled !== "undefined") {
|
||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||
}
|
||||
@@ -199,64 +249,157 @@ function updateAuthenticatedUI(data) {
|
||||
localStorage.setItem("username", data.username);
|
||||
}
|
||||
if (typeof data.folderOnly !== "undefined") {
|
||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
|
||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
|
||||
}
|
||||
|
||||
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
|
||||
const profilePicUrl = await fetchProfilePicture();
|
||||
localStorage.setItem("profilePicUrl", profilePicUrl);
|
||||
|
||||
// 5) Build / update header buttons
|
||||
const headerButtons = document.querySelector(".header-buttons");
|
||||
const firstButton = headerButtons.firstElementChild;
|
||||
const firstButton = headerButtons.firstElementChild;
|
||||
|
||||
// a) restore-from-trash for admins
|
||||
if (data.isAdmin) {
|
||||
let restoreBtn = document.getElementById("restoreFilesBtn");
|
||||
if (!restoreBtn) {
|
||||
restoreBtn = document.createElement("button");
|
||||
restoreBtn.id = "restoreFilesBtn";
|
||||
restoreBtn.classList.add("btn", "btn-warning");
|
||||
restoreBtn.setAttribute("data-i18n-title", "trash_restore_delete");
|
||||
restoreBtn.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||
if (firstButton) insertAfter(restoreBtn, firstButton);
|
||||
else headerButtons.appendChild(restoreBtn);
|
||||
}
|
||||
restoreBtn.style.display = "block";
|
||||
|
||||
let adminPanelBtn = document.getElementById("adminPanelBtn");
|
||||
if (!adminPanelBtn) {
|
||||
adminPanelBtn = document.createElement("button");
|
||||
adminPanelBtn.id = "adminPanelBtn";
|
||||
adminPanelBtn.classList.add("btn", "btn-info");
|
||||
adminPanelBtn.setAttribute("data-i18n-title", "admin_panel");
|
||||
adminPanelBtn.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||
insertAfter(adminPanelBtn, restoreBtn);
|
||||
adminPanelBtn.addEventListener("click", openAdminPanel);
|
||||
} else {
|
||||
adminPanelBtn.style.display = "block";
|
||||
let r = document.getElementById("restoreFilesBtn");
|
||||
if (!r) {
|
||||
r = document.createElement("button");
|
||||
r.id = "restoreFilesBtn";
|
||||
r.classList.add("btn","btn-warning");
|
||||
r.setAttribute("data-i18n-title","trash_restore_delete");
|
||||
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||
if (firstButton) insertAfter(r, firstButton);
|
||||
else headerButtons.appendChild(r);
|
||||
}
|
||||
r.style.display = "block";
|
||||
} else {
|
||||
const restoreBtn = document.getElementById("restoreFilesBtn");
|
||||
if (restoreBtn) restoreBtn.style.display = "none";
|
||||
const adminPanelBtn = document.getElementById("adminPanelBtn");
|
||||
if (adminPanelBtn) adminPanelBtn.style.display = "none";
|
||||
const r = document.getElementById("restoreFilesBtn");
|
||||
if (r) r.style.display = "none";
|
||||
}
|
||||
|
||||
if (window.location.hostname !== "demo.filerise.net") {
|
||||
let userPanelBtn = document.getElementById("userPanelBtn");
|
||||
if (!userPanelBtn) {
|
||||
userPanelBtn = document.createElement("button");
|
||||
userPanelBtn.id = "userPanelBtn";
|
||||
userPanelBtn.classList.add("btn", "btn-user");
|
||||
userPanelBtn.setAttribute("data-i18n-title", "user_panel");
|
||||
userPanelBtn.innerHTML = '<i class="material-icons">account_circle</i>';
|
||||
// b) admin panel button only on demo.filerise.net
|
||||
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
||||
let a = document.getElementById("adminPanelBtn");
|
||||
if (!a) {
|
||||
a = document.createElement("button");
|
||||
a.id = "adminPanelBtn";
|
||||
a.classList.add("btn","btn-info");
|
||||
a.setAttribute("data-i18n-title","admin_panel");
|
||||
a.innerHTML = '<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 {
|
||||
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();
|
||||
updateItemsPerPageSelect();
|
||||
updateLoginOptionsUIFromStorage();
|
||||
@@ -266,6 +409,12 @@ function checkAuthentication(showLoginToast = true) {
|
||||
return sendRequest("/api/auth/checkAuth.php")
|
||||
.then(data => {
|
||||
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;
|
||||
if (showLoginToast) showToast("Setup mode: No users found. Please add an admin user.");
|
||||
toggleVisibility("loginForm", false);
|
||||
@@ -277,11 +426,13 @@ function checkAuthentication(showLoginToast = true) {
|
||||
}
|
||||
window.setupMode = false;
|
||||
if (data.authenticated) {
|
||||
|
||||
localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false');
|
||||
localStorage.setItem("folderOnly", data.folderOnly);
|
||||
localStorage.setItem("readOnly", data.readOnly);
|
||||
localStorage.setItem("disableUpload", data.disableUpload);
|
||||
updateLoginOptionsUIFromStorage();
|
||||
applyProxyBypassUI();
|
||||
if (typeof data.totp_enabled !== "undefined") {
|
||||
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
|
||||
}
|
||||
@@ -292,8 +443,14 @@ function checkAuthentication(showLoginToast = true) {
|
||||
updateAuthenticatedUI(data);
|
||||
return data;
|
||||
} 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.");
|
||||
toggleVisibility("loginForm", true);
|
||||
toggleVisibility("loginForm", !(localStorage.getItem("authBypass") === "true"));
|
||||
toggleVisibility("mainOperations", false);
|
||||
toggleVisibility("uploadFileForm", false);
|
||||
toggleVisibility("fileListContainer", false);
|
||||
@@ -437,45 +594,54 @@ function initAuth() {
|
||||
submitLogin(formData);
|
||||
});
|
||||
}
|
||||
document.getElementById("logoutBtn").addEventListener("click", function () {
|
||||
fetch("/api/auth/logout.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "X-CSRF-Token": window.csrfToken }
|
||||
}).then(() => window.location.reload(true)).catch(() => { });
|
||||
});
|
||||
|
||||
document.getElementById("addUserBtn").addEventListener("click", function () {
|
||||
resetUserForm();
|
||||
toggleVisibility("addUserModal", true);
|
||||
document.getElementById("newUsername").focus();
|
||||
});
|
||||
document.getElementById("saveUserBtn").addEventListener("click", function () {
|
||||
|
||||
// remove your old saveUserBtn click-handler…
|
||||
|
||||
// instead:
|
||||
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();
|
||||
const newPassword = document.getElementById("addUserPassword").value.trim();
|
||||
const isAdmin = document.getElementById("isAdmin").checked;
|
||||
|
||||
if (!newUsername || !newPassword) {
|
||||
showToast("Username and password are required!");
|
||||
return;
|
||||
}
|
||||
|
||||
let url = "/api/addUser.php";
|
||||
if (window.setupMode) url += "?setup=1";
|
||||
|
||||
fetchWithCsrf(url, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(r => r.json())
|
||||
.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(() => { });
|
||||
.catch(() => {
|
||||
showToast("Error: Could not add user");
|
||||
});
|
||||
});
|
||||
document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal);
|
||||
|
||||
|
||||
@@ -25,46 +25,74 @@ export function toggleAllCheckboxes(masterCheckbox) {
|
||||
const checkboxes = document.querySelectorAll(".file-checkbox");
|
||||
checkboxes.forEach(chk => {
|
||||
chk.checked = masterCheckbox.checked;
|
||||
updateRowHighlight(chk);
|
||||
});
|
||||
updateFileActionButtons(); // update buttons based on current selection
|
||||
updateFileActionButtons();
|
||||
}
|
||||
|
||||
export function updateFileActionButtons() {
|
||||
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
|
||||
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||||
const copyBtn = document.getElementById("copySelectedBtn");
|
||||
const moveBtn = document.getElementById("moveSelectedBtn");
|
||||
const deleteBtn = document.getElementById("deleteSelectedBtn");
|
||||
const zipBtn = document.getElementById("downloadZipBtn");
|
||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||
const createBtn = document.getElementById("createBtn");
|
||||
|
||||
if (fileCheckboxes.length === 0) {
|
||||
if (copyBtn) copyBtn.style.display = "none";
|
||||
if (moveBtn) moveBtn.style.display = "none";
|
||||
if (deleteBtn) deleteBtn.style.display = "none";
|
||||
if (zipBtn) zipBtn.style.display = "none";
|
||||
if (extractZipBtn) extractZipBtn.style.display = "none";
|
||||
} else {
|
||||
if (copyBtn) copyBtn.style.display = "inline-block";
|
||||
if (moveBtn) moveBtn.style.display = "inline-block";
|
||||
if (deleteBtn) deleteBtn.style.display = "inline-block";
|
||||
if (zipBtn) zipBtn.style.display = "inline-block";
|
||||
if (extractZipBtn) extractZipBtn.style.display = "inline-block";
|
||||
const anyFiles = fileCheckboxes.length > 0;
|
||||
const anySelected = selectedCheckboxes.length > 0;
|
||||
const anyZip = Array.from(selectedCheckboxes)
|
||||
.some(cb => cb.value.toLowerCase().endsWith(".zip"));
|
||||
|
||||
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;
|
||||
// — Select All checkbox sync (unchanged) —
|
||||
const master = document.getElementById("selectAll");
|
||||
if (master) {
|
||||
if (selectedCheckboxes.length === fileCheckboxes.length) {
|
||||
master.checked = true;
|
||||
master.indeterminate = false;
|
||||
} else if (selectedCheckboxes.length === 0) {
|
||||
master.checked = false;
|
||||
master.indeterminate = false;
|
||||
} else {
|
||||
master.checked = false;
|
||||
master.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 shouldn’t 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) {
|
||||
@@ -91,7 +119,7 @@ export function showToast(message, duration = 3000) {
|
||||
export function buildSearchAndPaginationControls({ currentPage, totalPages, searchTerm }) {
|
||||
const safeSearchTerm = escapeHTML(searchTerm);
|
||||
// Choose the placeholder text based on advanced search mode
|
||||
const placeholderText = window.advancedSearchEnabled
|
||||
const placeholderText = window.advancedSearchEnabled
|
||||
? t("search_placeholder_advanced")
|
||||
: t("search_placeholder");
|
||||
|
||||
@@ -101,7 +129,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
||||
<div class="input-group">
|
||||
<!-- Advanced Search Toggle Button -->
|
||||
<div class="input-group-prepend">
|
||||
<button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" onclick="toggleAdvancedSearch()" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}">
|
||||
<button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}">
|
||||
<i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -117,9 +145,9 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
||||
</div>
|
||||
<div class="col-12 col-md-4 text-left">
|
||||
<div class="d-flex justify-content-center justify-content-md-start align-items-center">
|
||||
<button class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""} onclick="changePage(${currentPage - 1})">${t("prev")}</button>
|
||||
<button id="prevPageBtn" class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""}>${t("prev")}</button>
|
||||
<span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span>
|
||||
<button class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""} onclick="changePage(${currentPage + 1})">${t("next")}</button>
|
||||
<button id="nextPageBtn" class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""}>${t("next")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +159,7 @@ export function buildFileTableHeader(sortOrder) {
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col"><input type="checkbox" id="selectAll" onclick="toggleAllCheckboxes(this)"></th>
|
||||
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
|
||||
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
@@ -162,15 +190,20 @@ export function buildFileTableRow(file, folderPath) {
|
||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||
}
|
||||
previewButton = `<button class="btn btn-sm btn-info preview-btn" onclick="event.stopPropagation(); previewFile('${folderPath + encodeURIComponent(file.name)}', '${safeFileName}')">
|
||||
${previewIcon}
|
||||
</button>`;
|
||||
previewButton = `<button
|
||||
type="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 `
|
||||
<tr onclick="toggleRowSelection(event, '${safeFileName}')" class="clickable-row">
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);">
|
||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
||||
</td>
|
||||
<td class="file-name-cell">${safeFileName}</td>
|
||||
<td class="hide-small nowrap">${safeModified}</td>
|
||||
@@ -178,25 +211,44 @@ export function buildFileTableRow(file, folderPath) {
|
||||
<td class="hide-small nowrap">${safeSize}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||
<td>
|
||||
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;">
|
||||
<button type="button" class="btn btn-sm btn-success download-btn"
|
||||
onclick="openDownloadModal('${file.name}', '${file.folder || 'root'}')"
|
||||
<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')}">
|
||||
<i class="material-icons">file_download</i>
|
||||
</button>
|
||||
|
||||
${file.editable ? `
|
||||
<button class="btn btn-sm edit-btn"
|
||||
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
||||
title="${t('edit')}">
|
||||
<i class="material-icons">edit</i>
|
||||
</button>
|
||||
` : ""}
|
||||
<button
|
||||
type="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}
|
||||
<button class="btn btn-sm btn-warning rename-btn"
|
||||
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(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>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -207,10 +259,10 @@ export function buildBottomControls(itemsPerPageSetting) {
|
||||
return `
|
||||
<div class="d-flex align-items-center mt-3 bottom-controls">
|
||||
<label class="label-inline mr-2 mb-0">${t("show")}</label>
|
||||
<select class="form-control bottom-select" onchange="changeItemsPerPage(this.value)">
|
||||
<select class="form-control bottom-select" id="itemsPerPageSelect">
|
||||
${[10, 20, 50, 100]
|
||||
.map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`)
|
||||
.join("")}
|
||||
.map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`)
|
||||
.join("")}
|
||||
</select>
|
||||
<span class="items-per-page-text ml-2 mb-0">${t("items_per_page")}</span>
|
||||
</div>
|
||||
@@ -277,8 +329,6 @@ export function toggleRowSelection(event, fileName) {
|
||||
const start = Math.min(currentIndex, lastIndex);
|
||||
const end = Math.max(currentIndex, lastIndex);
|
||||
|
||||
// If neither CTRL nor Meta is pressed, you might choose
|
||||
// to clear existing selections. For this example we leave existing selections intact.
|
||||
for (let i = start; i <= end; i++) {
|
||||
const cb = allRows[i].querySelector(".file-checkbox");
|
||||
if (cb) {
|
||||
@@ -345,4 +395,7 @@ export function showCustomConfirmModal(message) {
|
||||
yesBtn.addEventListener("click", onYes);
|
||||
noBtn.addEventListener("click", onNo);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.toggleRowSelection = toggleRowSelection;
|
||||
window.updateRowHighlight = updateRowHighlight;
|
||||
@@ -32,23 +32,33 @@ export function loadSidebarOrder() {
|
||||
updateSidebarVisibility();
|
||||
}
|
||||
|
||||
// NEW: Load header order from localStorage.
|
||||
|
||||
export function loadHeaderOrder() {
|
||||
const headerDropArea = document.getElementById('headerDropArea');
|
||||
if (!headerDropArea) return;
|
||||
const orderStr = localStorage.getItem('headerOrder');
|
||||
if (orderStr) {
|
||||
const order = JSON.parse(orderStr);
|
||||
if (order.length > 0) {
|
||||
order.forEach(id => {
|
||||
const card = document.getElementById(id);
|
||||
// Only load if card is not already in header drop zone.
|
||||
if (card && card.parentNode.id !== 'headerDropArea') {
|
||||
insertCardInHeader(card, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 1) Clear out any icons that might already be in the drop area
|
||||
headerDropArea.innerHTML = '';
|
||||
|
||||
// 2) Read the saved array (or empty array if invalid/missing)
|
||||
let stored;
|
||||
try {
|
||||
stored = JSON.parse(localStorage.getItem('headerOrder') || '[]');
|
||||
} 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.
|
||||
|
||||
@@ -76,20 +76,86 @@ export function handleDownloadZipSelected(e) {
|
||||
}, 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) {
|
||||
// Store file details globally for the download confirmation function.
|
||||
window.singleFileToDownload = fileName;
|
||||
window.currentFolder = folder || "root";
|
||||
|
||||
|
||||
// Optionally pre-fill the file name input in the modal.
|
||||
const input = document.getElementById("downloadFileNameInput");
|
||||
if (input) {
|
||||
input.value = fileName; // Use file name as-is (or modify if desired)
|
||||
}
|
||||
|
||||
|
||||
// Show the single file download modal (a new modal element).
|
||||
document.getElementById("downloadFileModal").style.display = "block";
|
||||
|
||||
|
||||
// Optionally focus the input after a short delay.
|
||||
setTimeout(() => {
|
||||
if (input) input.focus();
|
||||
@@ -97,58 +163,34 @@ export function openDownloadModal(fileName, folder) {
|
||||
}
|
||||
|
||||
export function confirmSingleDownload() {
|
||||
// Get the file name from the modal. Users can change it if desired.
|
||||
let fileName = document.getElementById("downloadFileNameInput").value.trim();
|
||||
// 1) Get and validate the filename
|
||||
const input = document.getElementById("downloadFileNameInput");
|
||||
const fileName = input.value.trim();
|
||||
if (!fileName) {
|
||||
showToast("Please enter a name for the file.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the download modal.
|
||||
|
||||
// 2) Hide the download-name modal
|
||||
document.getElementById("downloadFileModal").style.display = "none";
|
||||
// Show the progress modal (same as in your ZIP download flow).
|
||||
document.getElementById("downloadProgressModal").style.display = "block";
|
||||
|
||||
// Build the URL for download.php using GET parameters.
|
||||
|
||||
// 3) Build the direct download URL
|
||||
const folder = window.currentFolder || "root";
|
||||
const downloadURL = "/api/file/download.php?folder=" + encodeURIComponent(folder) +
|
||||
"&file=" + encodeURIComponent(window.singleFileToDownload);
|
||||
|
||||
fetch(downloadURL, {
|
||||
method: "GET",
|
||||
credentials: "include"
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
throw new Error("Failed to download file: " + text);
|
||||
});
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Received empty file.");
|
||||
}
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
// Hide the progress modal.
|
||||
document.getElementById("downloadProgressModal").style.display = "none";
|
||||
showToast("Download started.");
|
||||
})
|
||||
.catch(error => {
|
||||
// Hide progress modal and show error.
|
||||
document.getElementById("downloadProgressModal").style.display = "none";
|
||||
console.error("Error downloading file:", error);
|
||||
showToast("Error downloading file: " + error.message);
|
||||
});
|
||||
const downloadURL = "/api/file/download.php"
|
||||
+ "?folder=" + encodeURIComponent(folder)
|
||||
+ "&file=" + encodeURIComponent(window.singleFileToDownload);
|
||||
|
||||
// 4) Trigger native browser download
|
||||
const a = document.createElement("a");
|
||||
a.href = downloadURL;
|
||||
a.download = fileName;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
// 5) Notify the user
|
||||
showToast("Download started. Check your browser’s download manager.");
|
||||
}
|
||||
|
||||
export function handleExtractZipSelected(e) {
|
||||
@@ -168,16 +210,21 @@ export function handleExtractZipSelected(e) {
|
||||
showToast("No zip files selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Change progress modal text to "Extracting files..."
|
||||
const progressText = document.querySelector("#downloadProgressModal p");
|
||||
if (progressText) {
|
||||
progressText.textContent = "Extracting files...";
|
||||
}
|
||||
|
||||
// Show the progress modal.
|
||||
document.getElementById("downloadProgressModal").style.display = "block";
|
||||
|
||||
|
||||
// Prepare and show the spinner-only modal
|
||||
const modal = document.getElementById("downloadProgressModal");
|
||||
const titleEl = document.getElementById("downloadProgressTitle");
|
||||
const spinner = modal.querySelector(".download-spinner");
|
||||
const progressBar = document.getElementById("downloadProgressBar");
|
||||
const progressPct = document.getElementById("downloadProgressPercent");
|
||||
|
||||
if (titleEl) titleEl.textContent = "Extracting files…";
|
||||
if (spinner) spinner.style.display = "inline-block";
|
||||
if (progressBar) progressBar.style.display = "none";
|
||||
if (progressPct) progressPct.style.display = "none";
|
||||
|
||||
modal.style.display = "block";
|
||||
|
||||
fetch("/api/file/extractZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
@@ -192,45 +239,85 @@ export function handleExtractZipSelected(e) {
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Hide the progress modal once the request has completed.
|
||||
document.getElementById("downloadProgressModal").style.display = "none";
|
||||
modal.style.display = "none";
|
||||
if (data.success) {
|
||||
let toastMessage = "Zip file(s) extracted successfully!";
|
||||
if (data.extractedFiles && Array.isArray(data.extractedFiles) && data.extractedFiles.length) {
|
||||
toastMessage = "Extracted: " + data.extractedFiles.join(", ");
|
||||
let msg = "Zip file(s) extracted successfully!";
|
||||
if (Array.isArray(data.extractedFiles) && data.extractedFiles.length) {
|
||||
msg = "Extracted: " + data.extractedFiles.join(", ");
|
||||
}
|
||||
showToast(toastMessage);
|
||||
showToast(msg);
|
||||
loadFileList(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error extracting zip: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Hide the progress modal on error.
|
||||
document.getElementById("downloadProgressModal").style.display = "none";
|
||||
modal.style.display = "none";
|
||||
console.error("Error extracting zip files:", error);
|
||||
showToast("Error extracting zip files.");
|
||||
});
|
||||
}
|
||||
|
||||
const extractZipBtn = document.getElementById("extractZipBtn");
|
||||
if (extractZipBtn) {
|
||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
||||
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const cancelDownloadZip = document.getElementById("cancelDownloadZip");
|
||||
if (cancelDownloadZip) {
|
||||
cancelDownloadZip.addEventListener("click", function () {
|
||||
document.getElementById("downloadZipModal").style.display = "none";
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const zipNameModal = document.getElementById("downloadZipModal");
|
||||
const progressModal = document.getElementById("downloadProgressModal");
|
||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||
|
||||
if (cancelCreate) {
|
||||
cancelCreate.addEventListener('click', () => {
|
||||
document.getElementById('createFileModal').style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// This part remains in your confirmDownloadZip event handler:
|
||||
const confirmDownloadZip = document.getElementById("confirmDownloadZip");
|
||||
if (confirmDownloadZip) {
|
||||
confirmDownloadZip.addEventListener("click", function () {
|
||||
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
|
||||
if (cancelZipBtn) {
|
||||
cancelZipBtn.addEventListener("click", () => {
|
||||
zipNameModal.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Confirm button kicks off the zip+download
|
||||
if (confirmZipBtn) {
|
||||
confirmZipBtn.addEventListener("click", async () => {
|
||||
// a) Validate ZIP filename
|
||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||
if (!zipName) {
|
||||
showToast("Please enter a name for the zip file.");
|
||||
@@ -239,52 +326,56 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (!zipName.toLowerCase().endsWith(".zip")) {
|
||||
zipName += ".zip";
|
||||
}
|
||||
// Hide the ZIP name input modal
|
||||
document.getElementById("downloadZipModal").style.display = "none";
|
||||
// Show the progress modal here only on confirm
|
||||
console.log("Download confirmed. Showing progress modal.");
|
||||
document.getElementById("downloadProgressModal").style.display = "block";
|
||||
const folder = window.currentFolder || "root";
|
||||
fetch("/api/file/downloadZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({ folder: folder, files: window.filesToDownload })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
throw new Error("Failed to create zip file: " + text);
|
||||
});
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Received empty zip file.");
|
||||
}
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = zipName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
// Hide the progress modal after download starts
|
||||
document.getElementById("downloadProgressModal").style.display = "none";
|
||||
showToast("Download started.");
|
||||
})
|
||||
.catch(error => {
|
||||
// Hide the progress modal on error
|
||||
document.getElementById("downloadProgressModal").style.display = "none";
|
||||
console.error("Error downloading zip:", error);
|
||||
showToast("Error downloading selected files as zip: " + error.message);
|
||||
|
||||
// b) Hide the name‐input modal, show the spinner modal
|
||||
zipNameModal.style.display = "none";
|
||||
progressModal.style.display = "block";
|
||||
|
||||
// c) (Optional) update the “Preparing…” text if you gave it an ID
|
||||
const titleEl = document.getElementById("downloadProgressTitle");
|
||||
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
||||
|
||||
try {
|
||||
// d) POST and await the ZIP blob
|
||||
const res = await fetch("/api/file/downloadZip.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: window.currentFolder || "root",
|
||||
files: window.filesToDownload
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(txt || `Status ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error("Received empty ZIP file.");
|
||||
}
|
||||
|
||||
// e) Hand off to the browser’s download manager
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = zipName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error downloading ZIP:", err);
|
||||
showToast("Error: " + err.message);
|
||||
} finally {
|
||||
// f) Always hide spinner modal
|
||||
progressModal.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -571,6 +662,61 @@ export function initFileActions() {
|
||||
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
|
||||
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 single‐file download modal buttons
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
|
||||
if (cancelDownloadFileBtn) {
|
||||
cancelDownloadFileBtn.addEventListener("click", () => {
|
||||
document.getElementById("downloadFileModal").style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
const confirmSingleDownloadBtn = document.getElementById("confirmSingleDownloadButton");
|
||||
if (confirmSingleDownloadBtn) {
|
||||
confirmSingleDownloadBtn.addEventListener("click", confirmSingleDownload);
|
||||
}
|
||||
|
||||
// Make Enter also confirm the download
|
||||
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = document.getElementById('createBtn');
|
||||
const menu = document.getElementById('createMenu');
|
||||
const fileOpt = document.getElementById('createFileOption');
|
||||
const folderOpt= document.getElementById('createFolderOption');
|
||||
|
||||
// Toggle dropdown on click
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// Create File
|
||||
fileOpt.addEventListener('click', () => {
|
||||
menu.style.display = 'none';
|
||||
openCreateFileModal(); // your existing function
|
||||
});
|
||||
|
||||
// Create Folder
|
||||
folderOpt.addEventListener('click', () => {
|
||||
menu.style.display = 'none';
|
||||
document.getElementById('createFolderModal').style.display = 'block';
|
||||
document.getElementById('newFolderName').focus();
|
||||
});
|
||||
|
||||
// Close if you click anywhere else
|
||||
document.addEventListener('click', () => {
|
||||
menu.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
window.renameFile = renameFile;
|
||||
@@ -16,6 +16,21 @@ import { t } from './i18n.js';
|
||||
import { bindFileListContextMenu } from './fileMenu.js';
|
||||
import { openDownloadModal } from './fileActions.js';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js';
|
||||
import {
|
||||
getParentFolder,
|
||||
updateBreadcrumbTitle,
|
||||
setupBreadcrumbDelegation,
|
||||
showFolderManagerContextMenu,
|
||||
hideFolderManagerContextMenu,
|
||||
openRenameFolderModal,
|
||||
openDeleteFolderModal
|
||||
} from './folderManager.js';
|
||||
import { openFolderShareModal } from './folderShareModal.js';
|
||||
import {
|
||||
folderDragOverHandler,
|
||||
folderDragLeaveHandler,
|
||||
folderDropHandler
|
||||
} from './fileDragDrop.js';
|
||||
|
||||
export let fileData = [];
|
||||
export let sortOrder = { column: "uploaded", ascending: true };
|
||||
@@ -186,100 +201,293 @@ export function formatFolderName(folder) {
|
||||
window.toggleRowSelection = toggleRowSelection;
|
||||
window.updateRowHighlight = updateRowHighlight;
|
||||
|
||||
/**
|
||||
* --- FILE LIST & VIEW RENDERING ---
|
||||
*/
|
||||
export function loadFileList(folderParam) {
|
||||
export async function loadFileList(folderParam) {
|
||||
const folder = folderParam || "root";
|
||||
const fileListContainer = document.getElementById("fileList");
|
||||
const actionsContainer = document.getElementById("fileListActions");
|
||||
|
||||
// 1) show loader
|
||||
fileListContainer.style.visibility = "hidden";
|
||||
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
||||
|
||||
return fetch("/api/file/getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime())
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
showToast("Session expired. Please log in again.");
|
||||
window.location.href = "/api/auth/logout.php";
|
||||
throw new Error("Unauthorized");
|
||||
try {
|
||||
// 2) fetch files + folders in parallel
|
||||
const [filesRes, foldersRes] = await Promise.all([
|
||||
fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`),
|
||||
fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`)
|
||||
]);
|
||||
|
||||
if (filesRes.status === 401) {
|
||||
window.location.href = "/api/auth/logout.php";
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const data = await filesRes.json();
|
||||
const folderRaw = await foldersRes.json();
|
||||
|
||||
// --- build ONLY the *direct* children of current folder ---
|
||||
let subfolders = [];
|
||||
const hidden = new Set(["profile_pics", "trash"]);
|
||||
if (Array.isArray(folderRaw)) {
|
||||
const allPaths = folderRaw.map(item => item.folder ?? item);
|
||||
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
||||
subfolders = allPaths
|
||||
.filter(p => {
|
||||
if (folder === "root") {
|
||||
return p.indexOf("/") === -1;
|
||||
}
|
||||
if (!p.startsWith(folder + "/")) return false;
|
||||
return p.split("/").length === depth;
|
||||
})
|
||||
.map(p => ({ name: p.split("/").pop(), full: p }));
|
||||
}
|
||||
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
|
||||
|
||||
// 3) clear loader
|
||||
fileListContainer.innerHTML = "";
|
||||
|
||||
// 4) handle “no files” case
|
||||
if (!data.files || Object.keys(data.files).length === 0) {
|
||||
fileListContainer.textContent = t("no_files_found");
|
||||
|
||||
// hide summary + slider
|
||||
const summaryElem = document.getElementById("fileSummary");
|
||||
if (summaryElem) summaryElem.style.display = "none";
|
||||
const sliderContainer = document.getElementById("viewSliderContainer");
|
||||
if (sliderContainer) sliderContainer.style.display = "none";
|
||||
|
||||
// show/hide folder strip *even when there are no files*
|
||||
let strip = document.getElementById("folderStripContainer");
|
||||
if (!strip) {
|
||||
strip = document.createElement("div");
|
||||
strip.id = "folderStripContainer";
|
||||
strip.className = "folder-strip-container";
|
||||
actionsContainer.parentNode.insertBefore(strip, fileListContainer);
|
||||
}
|
||||
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;
|
||||
if (window.showFoldersInList && subfolders.length) {
|
||||
strip.innerHTML = subfolders.map(sf => `
|
||||
<div class="folder-item" data-folder="${sf.full}">
|
||||
<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 => {
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
loadFileList(dest);
|
||||
});
|
||||
}
|
||||
// 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.
|
||||
const actionsContainer = document.getElementById("fileListActions");
|
||||
if (actionsContainer) {
|
||||
let summaryElem = document.getElementById("fileSummary");
|
||||
if (!summaryElem) {
|
||||
summaryElem = document.createElement("div");
|
||||
summaryElem.id = "fileSummary";
|
||||
summaryElem.style.float = "right";
|
||||
summaryElem.style.marginLeft = "auto";
|
||||
summaryElem.style.marginRight = "60px";
|
||||
summaryElem.style.fontSize = "0.9em";
|
||||
actionsContainer.appendChild(summaryElem);
|
||||
} else {
|
||||
summaryElem.style.display = "block";
|
||||
}
|
||||
summaryElem.innerHTML = buildFolderSummary(fileData);
|
||||
}
|
||||
|
||||
// Render view based on the view mode.
|
||||
if (window.viewMode === "gallery") {
|
||||
renderGalleryView(folder);
|
||||
updateFileActionButtons();
|
||||
} else {
|
||||
renderFileTable(folder);
|
||||
}
|
||||
} else {
|
||||
fileListContainer.textContent = t("no_files_found");
|
||||
const summaryElem = document.getElementById("fileSummary");
|
||||
if (summaryElem) {
|
||||
summaryElem.style.display = "none";
|
||||
}
|
||||
updateFileActionButtons();
|
||||
}
|
||||
return data.files || [];
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error loading file list:", error);
|
||||
if (error.message !== "Unauthorized") {
|
||||
fileListContainer.textContent = "Error loading files.";
|
||||
strip.style.display = "none";
|
||||
}
|
||||
|
||||
updateFileActionButtons();
|
||||
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";
|
||||
|
||||
// wire up each folder‐tile
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
// 1) click to navigate
|
||||
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);
|
||||
});
|
||||
|
||||
// 2) drag & drop
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
|
||||
// 3) right-click context menu
|
||||
el.addEventListener("contextmenu", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
|
||||
// highlight the strip tile
|
||||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
|
||||
// reuse folderManager menu
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("create_folder"),
|
||||
action: () => document.getElementById("createFolderModal").style.display = "block"
|
||||
},
|
||||
{
|
||||
label: t("rename_folder"),
|
||||
action: () => openRenameFolderModal()
|
||||
},
|
||||
{
|
||||
label: t("folder_share"),
|
||||
action: () => openFolderShareModal(dest)
|
||||
},
|
||||
{
|
||||
label: t("delete_folder"),
|
||||
action: () => openDeleteFolderModal()
|
||||
}
|
||||
];
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
});
|
||||
});
|
||||
|
||||
// one global click to hide any open context menu
|
||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||
|
||||
} 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.
|
||||
*/
|
||||
export function renderFileTable(folder, container) {
|
||||
export function renderFileTable(folder, container, subfolders) {
|
||||
const fileListContent = container || document.getElementById("fileList");
|
||||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
||||
@@ -327,9 +535,6 @@ export function renderFileTable(folder, container) {
|
||||
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, 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;
|
||||
});
|
||||
} else {
|
||||
@@ -340,6 +545,92 @@ export function renderFileTable(folder, container) {
|
||||
|
||||
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
||||
|
||||
fileListContent.querySelectorAll('.folder-item').forEach(el => {
|
||||
el.addEventListener('click', () => loadFileList(el.dataset.folder));
|
||||
});
|
||||
|
||||
// pagination clicks
|
||||
const prevBtn = document.getElementById("prevPageBtn");
|
||||
if (prevBtn) prevBtn.addEventListener("click", () => {
|
||||
if (window.currentPage > 1) {
|
||||
window.currentPage--;
|
||||
renderFileTable(folder, container);
|
||||
}
|
||||
});
|
||||
const nextBtn = document.getElementById("nextPageBtn");
|
||||
if (nextBtn) nextBtn.addEventListener("click", () => {
|
||||
// totalPages is computed above in this scope
|
||||
if (window.currentPage < totalPages) {
|
||||
window.currentPage++;
|
||||
renderFileTable(folder, container);
|
||||
}
|
||||
});
|
||||
|
||||
// ADD: advanced search toggle
|
||||
const advToggle = document.getElementById("advancedSearchToggle");
|
||||
if (advToggle) advToggle.addEventListener("click", () => {
|
||||
toggleAdvancedSearch();
|
||||
});
|
||||
|
||||
// items-per-page selector
|
||||
const itemsSelect = document.getElementById("itemsPerPageSelect");
|
||||
if (itemsSelect) itemsSelect.addEventListener("change", e => {
|
||||
window.itemsPerPage = parseInt(e.target.value, 10);
|
||||
localStorage.setItem("itemsPerPage", window.itemsPerPage);
|
||||
window.currentPage = 1;
|
||||
renderFileTable(folder, container);
|
||||
});
|
||||
|
||||
// hook up the master checkbox
|
||||
const selectAll = document.getElementById("selectAll");
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", () => {
|
||||
toggleAllCheckboxes(selectAll);
|
||||
});
|
||||
}
|
||||
|
||||
// 1) Row-click selects the row
|
||||
fileListContent.querySelectorAll("tbody tr").forEach(row => {
|
||||
row.addEventListener("click", e => {
|
||||
// grab the underlying checkbox value
|
||||
const cb = row.querySelector(".file-checkbox");
|
||||
if (!cb) return;
|
||||
toggleRowSelection(e, cb.value);
|
||||
});
|
||||
});
|
||||
|
||||
// 2) Download buttons
|
||||
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
|
||||
});
|
||||
});
|
||||
|
||||
// 3) Edit buttons
|
||||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
editFile(btn.dataset.editName, btn.dataset.editFolder);
|
||||
});
|
||||
});
|
||||
|
||||
// 4) Rename buttons
|
||||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||||
});
|
||||
});
|
||||
|
||||
// 5) Preview buttons
|
||||
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
|
||||
});
|
||||
});
|
||||
|
||||
createViewToggleButton();
|
||||
|
||||
// Setup event listeners.
|
||||
@@ -359,6 +650,17 @@ export function renderFileTable(folder, container) {
|
||||
}, 0);
|
||||
}, 300));
|
||||
}
|
||||
const slider = document.getElementById('rowHeightSlider');
|
||||
const valueDisplay = document.getElementById('rowHeightValue');
|
||||
if (slider) {
|
||||
slider.addEventListener('input', e => {
|
||||
const v = +e.target.value; // slider value in px
|
||||
document.documentElement.style.setProperty('--file-row-height', v + 'px');
|
||||
localStorage.setItem('rowHeight', v);
|
||||
valueDisplay.textContent = v + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("table.table thead th[data-column]").forEach(cell => {
|
||||
cell.addEventListener("click", function () {
|
||||
const column = this.getAttribute("data-column");
|
||||
@@ -448,18 +750,17 @@ export function renderGalleryView(folder, container) {
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// --- Column slider ---
|
||||
// --- Column slider with responsive max ---
|
||||
const numColumns = window.galleryColumns || 3;
|
||||
galleryHTML += `
|
||||
<div class="gallery-slider" style="margin:10px; text-align:center;">
|
||||
<label for="galleryColumnsSlider" style="margin-right:5px;">
|
||||
${t('columns')}:
|
||||
</label>
|
||||
<input type="range" id="galleryColumnsSlider" min="1" max="6"
|
||||
value="${numColumns}" style="vertical-align:middle;">
|
||||
<span id="galleryColumnsValue">${numColumns}</span>
|
||||
</div>
|
||||
`;
|
||||
// clamp slider max to 1 on small (<600px), 2 on medium (<900px), else up to 6
|
||||
const w = window.innerWidth;
|
||||
let maxCols = 6;
|
||||
if (w < 600) maxCols = 1;
|
||||
else if (w < 900) maxCols = 2;
|
||||
|
||||
// ensure current value doesn’t exceed the new max
|
||||
const startCols = Math.min(numColumns, maxCols);
|
||||
window.galleryColumns = startCols;
|
||||
|
||||
// --- Start gallery grid ---
|
||||
galleryHTML += `
|
||||
@@ -476,23 +777,26 @@ export function renderGalleryView(folder, container) {
|
||||
|
||||
pageFiles.forEach((file, idx) => {
|
||||
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
|
||||
const cacheKey = folderPath + encodeURIComponent(file.name);
|
||||
|
||||
// thumbnail
|
||||
let thumbnail;
|
||||
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
||||
const cacheKey = folderPath + encodeURIComponent(file.name);
|
||||
if (window.imageCache && window.imageCache[cacheKey]) {
|
||||
thumbnail = `<img src="${window.imageCache[cacheKey]}"
|
||||
class="gallery-thumbnail"
|
||||
alt="${escapeHTML(file.name)}"
|
||||
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
||||
thumbnail = `<img
|
||||
src="${window.imageCache[cacheKey]}"
|
||||
class="gallery-thumbnail"
|
||||
data-cache-key="${cacheKey}"
|
||||
alt="${escapeHTML(file.name)}"
|
||||
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
||||
} else {
|
||||
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
|
||||
thumbnail = `<img src="${imageUrl}"
|
||||
onload="cacheImage(this,'${cacheKey}')"
|
||||
class="gallery-thumbnail"
|
||||
alt="${escapeHTML(file.name)}"
|
||||
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
||||
thumbnail = `<img
|
||||
src="${imageUrl}"
|
||||
class="gallery-thumbnail"
|
||||
data-cache-key="${cacheKey}"
|
||||
alt="${escapeHTML(file.name)}"
|
||||
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
||||
}
|
||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||
thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`;
|
||||
@@ -529,9 +833,9 @@ export function renderGalleryView(folder, container) {
|
||||
<label for="cb-${idSafe}"
|
||||
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
|
||||
|
||||
<div class="gallery-preview"
|
||||
style="cursor:pointer;"
|
||||
onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t='+Date.now(), '${file.name}')">
|
||||
<div class="gallery-preview" style="cursor:pointer;"
|
||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||
data-preview-name="${file.name}">
|
||||
${thumbnail}
|
||||
</div>
|
||||
|
||||
@@ -542,29 +846,52 @@ export function renderGalleryView(folder, container) {
|
||||
</span>
|
||||
${tagBadgesHTML}
|
||||
|
||||
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;">
|
||||
<button type="button" class="btn btn-sm btn-success download-btn"
|
||||
onclick="openDownloadModal('${file.name}', '${file.folder || "root"}')"
|
||||
title="${t('download')}">
|
||||
<i class="material-icons">file_download</i>
|
||||
</button>
|
||||
${file.editable ? `
|
||||
<button class="btn btn-sm edit-btn"
|
||||
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
||||
title="${t('Edit')}">
|
||||
<i class="material-icons">edit</i>
|
||||
</button>` : ""}
|
||||
<button class="btn btn-sm btn-warning rename-btn"
|
||||
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})'
|
||||
title="${t('rename')}">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary share-btn"
|
||||
data-file="${escapeHTML(file.name)}"
|
||||
title="${t('share')}">
|
||||
<i class="material-icons">share</i>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="btn-group btn-group-sm btn-group-hover"
|
||||
role="group"
|
||||
aria-label="File actions"
|
||||
style="margin-top:5px;"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success py-1 download-btn"
|
||||
data-download-name="${escapeHTML(file.name)}"
|
||||
data-download-folder="${file.folder || "root"}"
|
||||
title="${t('download')}"
|
||||
>
|
||||
<i class="material-icons">file_download</i>
|
||||
</button>
|
||||
|
||||
${file.editable ? `
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary py-1 edit-btn"
|
||||
data-edit-name="${escapeHTML(file.name)}"
|
||||
data-edit-folder="${file.folder || "root"}"
|
||||
title="${t('edit')}"
|
||||
>
|
||||
<i class="material-icons">edit</i>
|
||||
</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>
|
||||
@@ -579,13 +906,93 @@ export function renderGalleryView(folder, container) {
|
||||
// render
|
||||
fileListContent.innerHTML = galleryHTML;
|
||||
|
||||
// ensure toggle button
|
||||
createViewToggleButton();
|
||||
// --- Now wire up all behaviors without inline handlers ---
|
||||
|
||||
// attach listeners
|
||||
// ADD: pagination buttons for gallery
|
||||
const prevBtn = document.getElementById("prevPageBtn");
|
||||
if (prevBtn) prevBtn.addEventListener("click", () => {
|
||||
if (window.currentPage > 1) {
|
||||
window.currentPage--;
|
||||
renderGalleryView(folder, container);
|
||||
}
|
||||
});
|
||||
const nextBtn = document.getElementById("nextPageBtn");
|
||||
if (nextBtn) nextBtn.addEventListener("click", () => {
|
||||
if (window.currentPage < totalPages) {
|
||||
window.currentPage++;
|
||||
renderGalleryView(folder, container);
|
||||
}
|
||||
});
|
||||
|
||||
// ←— ADD: advanced search toggle
|
||||
const advToggle = document.getElementById("advancedSearchToggle");
|
||||
if (advToggle) advToggle.addEventListener("click", () => {
|
||||
toggleAdvancedSearch();
|
||||
});
|
||||
|
||||
// ←— ADD: wire up context-menu in gallery
|
||||
bindFileListContextMenu();
|
||||
|
||||
// ADD: items-per-page selector for gallery
|
||||
const itemsSelect = document.getElementById("itemsPerPageSelect");
|
||||
if (itemsSelect) itemsSelect.addEventListener("change", e => {
|
||||
window.itemsPerPage = parseInt(e.target.value, 10);
|
||||
localStorage.setItem("itemsPerPage", window.itemsPerPage);
|
||||
window.currentPage = 1;
|
||||
renderGalleryView(folder, container);
|
||||
});
|
||||
|
||||
// cache images on load
|
||||
fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => {
|
||||
const key = img.dataset.cacheKey;
|
||||
img.addEventListener('load', () => cacheImage(img, key));
|
||||
});
|
||||
|
||||
// preview clicks
|
||||
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
previewFile(el.dataset.previewUrl, el.dataset.previewName);
|
||||
});
|
||||
});
|
||||
|
||||
// download clicks
|
||||
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
|
||||
});
|
||||
});
|
||||
|
||||
// edit clicks
|
||||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
editFile(btn.dataset.editName, btn.dataset.editFolder);
|
||||
});
|
||||
});
|
||||
|
||||
// rename clicks
|
||||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||||
});
|
||||
});
|
||||
|
||||
// share clicks
|
||||
fileListContent.querySelectorAll(".share-btn").forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
const fileName = btn.dataset.file;
|
||||
const fileObj = fileData.find(f => f.name === fileName);
|
||||
if (fileObj) {
|
||||
import('./filePreview.js').then(m => m.openShareModal(fileObj, folder));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// checkboxes
|
||||
document.querySelectorAll(".file-checkbox").forEach(cb => {
|
||||
fileListContent.querySelectorAll(".file-checkbox").forEach(cb => {
|
||||
cb.addEventListener("change", () => updateFileActionButtons());
|
||||
});
|
||||
|
||||
@@ -603,14 +1010,13 @@ export function renderGalleryView(folder, container) {
|
||||
});
|
||||
}
|
||||
|
||||
// pagination
|
||||
// pagination functions
|
||||
window.changePage = newPage => {
|
||||
window.currentPage = newPage;
|
||||
if (window.viewMode === "gallery") renderGalleryView(folder);
|
||||
else renderFileTable(folder);
|
||||
};
|
||||
|
||||
// items per page
|
||||
window.changeItemsPerPage = cnt => {
|
||||
window.itemsPerPage = +cnt;
|
||||
localStorage.setItem("itemsPerPage", cnt);
|
||||
@@ -619,8 +1025,9 @@ export function renderGalleryView(folder, container) {
|
||||
else renderFileTable(folder);
|
||||
};
|
||||
|
||||
// update toolbar buttons
|
||||
// update toolbar and toggle button
|
||||
updateFileActionButtons();
|
||||
createViewToggleButton();
|
||||
}
|
||||
|
||||
// Responsive slider constraints based on screen size.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// fileMenu.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 { editFile } from './fileEditor.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);
|
||||
|
||||
let menuItems = [
|
||||
{ label: t("create_file"), action: () => openCreateFileModal() },
|
||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
||||
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
||||
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
||||
|
||||
@@ -4,36 +4,68 @@ import { fileData } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
export function openShareModal(file, folder) {
|
||||
// Remove any existing modal
|
||||
const existing = document.getElementById("shareModal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Build the modal
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "shareModal";
|
||||
modal.classList.add("modal");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content share-modal-content" style="width: 600px; max-width:90vw;">
|
||||
<div class="modal-content share-modal-content" style="width:600px;max-width:90vw;">
|
||||
<div class="modal-header">
|
||||
<h3>${t("share_file")}: ${escapeHTML(file.name)}</h3>
|
||||
<span class="close-image-modal" id="closeShareModal" title="Close">×</span>
|
||||
<span id="closeShareModal" title="${t("close")}" class="close-image-modal">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>${t("set_expiration")}</p>
|
||||
<select id="shareExpiration">
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="60" selected>60 minutes</option>
|
||||
<option value="120">120 minutes</option>
|
||||
<option value="180">180 minutes</option>
|
||||
<option value="240">240 minutes</option>
|
||||
<option value="1440">1 Day</option>
|
||||
<select id="shareExpiration" style="width:100%;padding:5px;">
|
||||
<option value="30">30 ${t("minutes")}</option>
|
||||
<option value="60" selected>60 ${t("minutes")}</option>
|
||||
<option value="120">120 ${t("minutes")}</option>
|
||||
<option value="180">180 ${t("minutes")}</option>
|
||||
<option value="240">240 ${t("minutes")}</option>
|
||||
<option value="1440">1 ${t("day")}</option>
|
||||
<option value="custom">${t("custom")}…</option>
|
||||
</select>
|
||||
<p>${t("password_optional")}</p>
|
||||
<input type="text" id="sharePassword" placeholder=${t("password_optional")} style="width: 100%;"/>
|
||||
<br>
|
||||
<button id="generateShareLinkBtn" class="btn btn-primary" style="margin-top:10px;">${t("generate_share_link")}</button>
|
||||
<div id="shareLinkDisplay" style="margin-top: 10px; display:none;">
|
||||
|
||||
<div id="customExpirationContainer" style="display:none;margin-top:10px;">
|
||||
<label for="customExpirationValue">${t("duration")}:</label>
|
||||
<input type="number" id="customExpirationValue" min="1" value="1" style="width:60px;margin:0 8px;"/>
|
||||
<select id="customExpirationUnit">
|
||||
<option value="seconds">${t("seconds")}</option>
|
||||
<option value="minutes" selected>${t("minutes")}</option>
|
||||
<option value="hours">${t("hours")}</option>
|
||||
<option value="days">${t("days")}</option>
|
||||
</select>
|
||||
<p class="share-warning" style="color:#a33;font-size:0.9em;margin-top:5px;">
|
||||
${t("custom_duration_warning")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:15px;">${t("password_optional")}</p>
|
||||
<input
|
||||
type="text"
|
||||
id="sharePassword"
|
||||
placeholder="${t("password_optional")}"
|
||||
style="width:100%;padding:5px;"
|
||||
/>
|
||||
|
||||
<button
|
||||
id="generateShareLinkBtn"
|
||||
class="btn btn-primary"
|
||||
style="margin-top:15px;"
|
||||
>
|
||||
${t("generate_share_link")}
|
||||
</button>
|
||||
|
||||
<div id="shareLinkDisplay" style="margin-top:15px;display:none;">
|
||||
<p>${t("shareable_link")}</p>
|
||||
<input type="text" id="shareLinkInput" readonly style="width:100%;"/>
|
||||
<button id="copyShareLinkBtn" class="btn btn-primary" style="margin-top:5px;">${t("copy_link")}</button>
|
||||
<input type="text" id="shareLinkInput" readonly style="width:100%;padding:5px;"/>
|
||||
<button id="copyShareLinkBtn" class="btn btn-secondary" style="margin-top:5px;">
|
||||
${t("copy_link")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,52 +73,72 @@ export function openShareModal(file, folder) {
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
|
||||
document.getElementById("closeShareModal").addEventListener("click", () => {
|
||||
modal.remove();
|
||||
});
|
||||
// Close handler
|
||||
document.getElementById("closeShareModal")
|
||||
.addEventListener("click", () => modal.remove());
|
||||
|
||||
document.getElementById("generateShareLinkBtn").addEventListener("click", () => {
|
||||
const expiration = document.getElementById("shareExpiration").value;
|
||||
const password = document.getElementById("sharePassword").value;
|
||||
fetch("/api/file/createShareLink.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: folder,
|
||||
file: file.name,
|
||||
expirationMinutes: parseInt(expiration),
|
||||
password: password
|
||||
// Show/hide custom-duration inputs
|
||||
document.getElementById("shareExpiration")
|
||||
.addEventListener("change", e => {
|
||||
const container = document.getElementById("customExpirationContainer");
|
||||
container.style.display = e.target.value === "custom" ? "block" : "none";
|
||||
});
|
||||
|
||||
// Generate share link
|
||||
document.getElementById("generateShareLinkBtn")
|
||||
.addEventListener("click", () => {
|
||||
const sel = document.getElementById("shareExpiration");
|
||||
let value, unit;
|
||||
|
||||
if (sel.value === "custom") {
|
||||
value = parseInt(document.getElementById("customExpirationValue").value, 10);
|
||||
unit = document.getElementById("customExpirationUnit").value;
|
||||
} else {
|
||||
value = parseInt(sel.value, 10);
|
||||
unit = "minutes";
|
||||
}
|
||||
|
||||
const password = document.getElementById("sharePassword").value;
|
||||
|
||||
fetch("/api/file/createShareLink.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder,
|
||||
file: file.name,
|
||||
expirationValue: value,
|
||||
expirationUnit: unit,
|
||||
password
|
||||
})
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.token) {
|
||||
const shareEndpoint = `${window.location.origin}/api/file/share.php`;
|
||||
const shareUrl = `${shareEndpoint}?token=${encodeURIComponent(data.token)}`;
|
||||
const displayDiv = document.getElementById("shareLinkDisplay");
|
||||
const inputField = document.getElementById("shareLinkInput");
|
||||
inputField.value = shareUrl;
|
||||
displayDiv.style.display = "block";
|
||||
const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
|
||||
document.getElementById("shareLinkInput").value = url;
|
||||
document.getElementById("shareLinkDisplay").style.display = "block";
|
||||
} else {
|
||||
showToast("Error generating share link: " + (data.error || "Unknown error"));
|
||||
showToast(t("error_generating_share") + ": " + (data.error||"Unknown"));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error generating share link:", err);
|
||||
showToast("Error generating share link.");
|
||||
console.error(err);
|
||||
showToast(t("error_generating_share"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("copyShareLinkBtn").addEventListener("click", () => {
|
||||
const input = document.getElementById("shareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast("Link copied to clipboard!");
|
||||
});
|
||||
// Copy to clipboard
|
||||
document.getElementById("copyShareLinkBtn")
|
||||
.addEventListener("click", () => {
|
||||
const input = document.getElementById("shareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast(t("link_copied"));
|
||||
});
|
||||
}
|
||||
|
||||
export function previewFile(fileUrl, fileName) {
|
||||
@@ -364,16 +416,21 @@ export function previewFile(fileUrl, fileName) {
|
||||
}
|
||||
} else {
|
||||
// Handle non-image file previews.
|
||||
if (extension === "pdf") {
|
||||
const embed = document.createElement("embed");
|
||||
const separator = fileUrl.indexOf('?') === -1 ? '?' : '&';
|
||||
embed.src = fileUrl + separator + 't=' + new Date().getTime();
|
||||
embed.type = "application/pdf";
|
||||
embed.style.width = "80vw";
|
||||
embed.style.height = "80vh";
|
||||
embed.style.border = "none";
|
||||
container.appendChild(embed);
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
|
||||
if (extension === "pdf") {
|
||||
// build a cache‐busted URL
|
||||
const separator = fileUrl.includes('?') ? '&' : '?';
|
||||
const urlWithTs = fileUrl + separator + 't=' + Date.now();
|
||||
|
||||
// open in a new tab (avoids CSP frame-ancestors)
|
||||
window.open(urlWithTs, "_blank");
|
||||
|
||||
// tear down the just-created modal
|
||||
const modal = document.getElementById("filePreviewModal");
|
||||
if (modal) modal.remove();
|
||||
|
||||
// stop further preview logic
|
||||
return;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
|
||||
const video = document.createElement("video");
|
||||
video.src = fileUrl;
|
||||
video.controls = true;
|
||||
|
||||
@@ -13,10 +13,19 @@ export function openTagModal(file) {
|
||||
modal.id = 'tagModal';
|
||||
modal.className = 'modal';
|
||||
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;">
|
||||
<h3 style="margin:0;">${t("tag_file")}: ${file.name}</h3>
|
||||
<span id="closeTagModal" style="cursor:pointer; font-size:24px;">×</span>
|
||||
<h3 style="
|
||||
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">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="margin-top:10px;">
|
||||
<label for="tagNameInput">${t("tag_name")}</label>
|
||||
@@ -83,10 +92,10 @@ export function openMultiTagModal(files) {
|
||||
modal.id = 'multiTagModal';
|
||||
modal.className = 'modal';
|
||||
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;">
|
||||
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
|
||||
<span id="closeMultiTagModal" style="cursor:pointer; font-size:24px;">×</span>
|
||||
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="margin-top:10px;">
|
||||
<label for="multiTagNameInput">Tag Name:</label>
|
||||
|
||||
@@ -56,7 +56,7 @@ function saveFolderTreeState(state) {
|
||||
}
|
||||
|
||||
// Helper for getting the parent folder.
|
||||
function getParentFolder(folder) {
|
||||
export function getParentFolder(folder) {
|
||||
if (folder === "root") return "root";
|
||||
const lastSlash = folder.lastIndexOf("/");
|
||||
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
|
||||
@@ -104,24 +104,26 @@ export function setupBreadcrumbDelegation() {
|
||||
|
||||
// Click handler via delegation
|
||||
function breadcrumbClickHandler(e) {
|
||||
// find the nearest .breadcrumb-link
|
||||
const link = e.target.closest(".breadcrumb-link");
|
||||
if (!link) return;
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const folder = link.getAttribute("data-folder");
|
||||
const folder = link.dataset.folder;
|
||||
window.currentFolder = folder;
|
||||
localStorage.setItem("lastOpenedFolder", folder);
|
||||
|
||||
// Update the container with sanitized breadcrumbs.
|
||||
const container = document.getElementById("fileListTitle");
|
||||
const sanitizedBreadcrumb = DOMPurify.sanitize(renderBreadcrumb(folder));
|
||||
container.innerHTML = t("files_in") + " (" + sanitizedBreadcrumb + ")";
|
||||
|
||||
// rebuild the title safely
|
||||
updateBreadcrumbTitle(folder);
|
||||
expandTreePath(folder);
|
||||
document.querySelectorAll(".folder-option").forEach(item => item.classList.remove("selected"));
|
||||
const targetOption = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (targetOption) targetOption.classList.add("selected");
|
||||
document.querySelectorAll(".folder-option").forEach(el =>
|
||||
el.classList.remove("selected")
|
||||
);
|
||||
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (target) target.classList.add("selected");
|
||||
|
||||
loadFileList(folder);
|
||||
}
|
||||
|
||||
@@ -234,7 +236,8 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
const state = loadFolderTreeState();
|
||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
||||
for (const folder in tree) {
|
||||
if (folder.toLowerCase() === "trash") continue;
|
||||
const name = folder.toLowerCase();
|
||||
if (name === "trash" || name === "profile_pics") continue;
|
||||
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
||||
const hasChildren = Object.keys(tree[folder]).length > 0;
|
||||
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
||||
@@ -335,6 +338,38 @@ function folderDropHandler(event) {
|
||||
/* ----------------------
|
||||
Main Folder Tree Rendering and Event Binding
|
||||
----------------------*/
|
||||
// --- Helpers for safe breadcrumb rendering ---
|
||||
function renderBreadcrumbFragment(folderPath) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const parts = folderPath.split("/");
|
||||
let acc = "";
|
||||
|
||||
parts.forEach((part, idx) => {
|
||||
acc = idx === 0 ? part : acc + "/" + part;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("breadcrumb-link");
|
||||
span.dataset.folder = acc;
|
||||
span.textContent = part;
|
||||
frag.appendChild(span);
|
||||
|
||||
if (idx < parts.length - 1) {
|
||||
frag.appendChild(document.createTextNode(" / "));
|
||||
}
|
||||
});
|
||||
|
||||
return frag;
|
||||
}
|
||||
|
||||
export function updateBreadcrumbTitle(folder) {
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.textContent = "";
|
||||
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
|
||||
titleEl.appendChild(renderBreadcrumbFragment(folder));
|
||||
titleEl.appendChild(document.createTextNode(")"));
|
||||
setupBreadcrumbDelegation();
|
||||
}
|
||||
|
||||
export async function loadFolderTree(selectedFolder) {
|
||||
try {
|
||||
// Check if the user has folder-only permission.
|
||||
@@ -420,9 +455,8 @@ export async function loadFolderTree(selectedFolder) {
|
||||
}
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(window.currentFolder) + ")";
|
||||
setupBreadcrumbDelegation();
|
||||
// Initial breadcrumb update
|
||||
updateBreadcrumbTitle(window.currentFolder);
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
const folderState = loadFolderTreeState();
|
||||
@@ -436,6 +470,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
selectedEl.classList.add("selected");
|
||||
}
|
||||
|
||||
// Folder-option click: update selection, breadcrumbs, and file list
|
||||
container.querySelectorAll(".folder-option").forEach(el => {
|
||||
el.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
@@ -444,13 +479,14 @@ export async function loadFolderTree(selectedFolder) {
|
||||
const selected = this.getAttribute("data-folder");
|
||||
window.currentFolder = selected;
|
||||
localStorage.setItem("lastOpenedFolder", selected);
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
titleEl.innerHTML = t("files_in") + " (" + renderBreadcrumb(selected) + ")";
|
||||
setupBreadcrumbDelegation();
|
||||
|
||||
// Safe breadcrumb update
|
||||
updateBreadcrumbTitle(selected);
|
||||
loadFileList(selected);
|
||||
});
|
||||
});
|
||||
|
||||
// Root toggle handler
|
||||
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
||||
if (rootToggle) {
|
||||
rootToggle.addEventListener("click", function (e) {
|
||||
@@ -474,6 +510,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
});
|
||||
}
|
||||
|
||||
// Other folder-toggle handlers
|
||||
container.querySelectorAll(".folder-toggle").forEach(toggle => {
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
@@ -502,6 +539,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For backward compatibility.
|
||||
export function loadFolderList(selectedFolder) {
|
||||
loadFolderTree(selectedFolder);
|
||||
@@ -513,7 +551,7 @@ export function loadFolderList(selectedFolder) {
|
||||
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
|
||||
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
|
||||
|
||||
function openRenameFolderModal() {
|
||||
export function openRenameFolderModal() {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") {
|
||||
showToast("Please select a valid folder to rename.");
|
||||
@@ -576,7 +614,7 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
|
||||
});
|
||||
});
|
||||
|
||||
function openDeleteFolderModal() {
|
||||
export function openDeleteFolderModal() {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") {
|
||||
showToast("Please select a valid folder to delete.");
|
||||
@@ -680,7 +718,7 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
|
||||
});
|
||||
|
||||
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
||||
function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
let menu = document.getElementById("folderManagerContextMenu");
|
||||
if (!menu) {
|
||||
menu = document.createElement("div");
|
||||
@@ -727,7 +765,7 @@ function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
menu.style.display = "block";
|
||||
}
|
||||
|
||||
function hideFolderManagerContextMenu() {
|
||||
export function hideFolderManagerContextMenu() {
|
||||
const menu = document.getElementById("folderManagerContextMenu");
|
||||
if (menu) {
|
||||
menu.style.display = "none";
|
||||
@@ -758,7 +796,7 @@ function folderManagerContextMenuHandler(e) {
|
||||
},
|
||||
{
|
||||
label: t("folder_share"),
|
||||
action: () => { openFolderShareModal(); }
|
||||
action: () => { openFolderShareModal(folder); }
|
||||
},
|
||||
{
|
||||
label: t("delete_folder"),
|
||||
|
||||
@@ -1,44 +1,75 @@
|
||||
// folderShareModal.js
|
||||
// js/folderShareModal.js
|
||||
import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
export function openFolderShareModal(folder) {
|
||||
// Remove any existing folder share modal
|
||||
// Remove any existing modal
|
||||
const existing = document.getElementById("folderShareModal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Create the modal container
|
||||
// Build modal
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "folderShareModal";
|
||||
modal.classList.add("modal");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content share-modal-content" style="width: 600px; max-width: 90vw;">
|
||||
<div class="modal-content share-modal-content" style="width:600px;max-width:90vw;">
|
||||
<div class="modal-header">
|
||||
<h3>${t("share_folder")}: ${escapeHTML(folder)}</h3>
|
||||
<span class="close-image-modal" id="closeFolderShareModal" title="Close">×</span>
|
||||
<span id="closeFolderShareModal" title="${t("close")}" class="close-image-modal">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>${t("set_expiration")}</p>
|
||||
<select id="folderShareExpiration">
|
||||
<select id="folderShareExpiration" style="width:100%;padding:5px;">
|
||||
<option value="30">30 ${t("minutes")}</option>
|
||||
<option value="60" selected>60 ${t("minutes")}</option>
|
||||
<option value="120">120 ${t("minutes")}</option>
|
||||
<option value="180">180 ${t("minutes")}</option>
|
||||
<option value="240">240 ${t("minutes")}</option>
|
||||
<option value="1440">1 ${t("day")}</option>
|
||||
<option value="custom">${t("custom")}…</option>
|
||||
</select>
|
||||
<p>${t("password_optional")}</p>
|
||||
<input type="text" id="folderSharePassword" placeholder="${t("enter_password")}" style="width: 100%;"/>
|
||||
<br>
|
||||
<label>
|
||||
<input type="checkbox" id="folderShareAllowUpload"> ${t("allow_uploads")}
|
||||
|
||||
<div id="customFolderExpirationContainer" style="display:none;margin-top:10px;">
|
||||
<label for="customFolderExpirationValue">${t("duration")}:</label>
|
||||
<input type="number" id="customFolderExpirationValue" min="1" value="1" style="width:60px;margin:0 8px;"/>
|
||||
<select id="customFolderExpirationUnit">
|
||||
<option value="seconds">${t("seconds")}</option>
|
||||
<option value="minutes" selected>${t("minutes")}</option>
|
||||
<option value="hours">${t("hours")}</option>
|
||||
<option value="days">${t("days")}</option>
|
||||
</select>
|
||||
<p class="share-warning" style="color:#a33;font-size:0.9em;margin-top:5px;">
|
||||
${t("custom_duration_warning")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:15px;">${t("password_optional")}</p>
|
||||
<input
|
||||
type="text"
|
||||
id="folderSharePassword"
|
||||
placeholder="${t("enter_password")}"
|
||||
style="width:100%;padding:5px;"
|
||||
/>
|
||||
|
||||
<label style="margin-top:10px;display:block;">
|
||||
<input type="checkbox" id="folderShareAllowUpload" />
|
||||
${t("allow_uploads")}
|
||||
</label>
|
||||
<br><br>
|
||||
<button id="generateFolderShareLinkBtn" class="btn btn-primary" style="margin-top: 10px;">${t("generate_share_link")}</button>
|
||||
<div id="folderShareLinkDisplay" style="margin-top: 10px; display: none;">
|
||||
|
||||
<button
|
||||
id="generateFolderShareLinkBtn"
|
||||
class="btn btn-primary"
|
||||
style="margin-top:15px;"
|
||||
>
|
||||
${t("generate_share_link")}
|
||||
</button>
|
||||
|
||||
<div id="folderShareLinkDisplay" style="margin-top:15px;display:none;">
|
||||
<p>${t("shareable_link")}</p>
|
||||
<input type="text" id="folderShareLinkInput" readonly style="width: 100%;"/>
|
||||
<button id="copyFolderShareLinkBtn" class="btn btn-primary" style="margin-top: 5px;">${t("copy_link")}</button>
|
||||
<input type="text" id="folderShareLinkInput" readonly style="width:100%;padding:5px;"/>
|
||||
<button id="copyFolderShareLinkBtn" class="btn btn-secondary" style="margin-top:5px;">
|
||||
${t("copy_link")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,62 +77,75 @@ export function openFolderShareModal(folder) {
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
|
||||
// Close button handler
|
||||
document.getElementById("closeFolderShareModal").addEventListener("click", () => {
|
||||
modal.remove();
|
||||
});
|
||||
// Close
|
||||
document.getElementById("closeFolderShareModal")
|
||||
.addEventListener("click", () => modal.remove());
|
||||
|
||||
// Handler for generating the share link
|
||||
document.getElementById("generateFolderShareLinkBtn").addEventListener("click", () => {
|
||||
const expiration = document.getElementById("folderShareExpiration").value;
|
||||
const password = document.getElementById("folderSharePassword").value;
|
||||
const allowUpload = document.getElementById("folderShareAllowUpload").checked ? 1 : 0;
|
||||
|
||||
// Retrieve the CSRF token from the meta tag.
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
|
||||
if (!csrfToken) {
|
||||
showToast(t("csrf_error"));
|
||||
return;
|
||||
}
|
||||
// Post to the createFolderShareLink endpoint.
|
||||
fetch("/api/folder/createShareFolderLink.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder: folder,
|
||||
expirationMinutes: parseInt(expiration, 10),
|
||||
password: password,
|
||||
allowUpload: allowUpload
|
||||
// Toggle custom inputs
|
||||
document.getElementById("folderShareExpiration")
|
||||
.addEventListener("change", e => {
|
||||
document.getElementById("customFolderExpirationContainer")
|
||||
.style.display = e.target.value === "custom" ? "block" : "none";
|
||||
});
|
||||
|
||||
// Generate link
|
||||
document.getElementById("generateFolderShareLinkBtn")
|
||||
.addEventListener("click", () => {
|
||||
const sel = document.getElementById("folderShareExpiration");
|
||||
let value, unit;
|
||||
if (sel.value === "custom") {
|
||||
value = parseInt(document.getElementById("customFolderExpirationValue").value, 10);
|
||||
unit = document.getElementById("customFolderExpirationUnit").value;
|
||||
} else {
|
||||
value = parseInt(sel.value, 10);
|
||||
unit = "minutes";
|
||||
}
|
||||
|
||||
const password = document.getElementById("folderSharePassword").value;
|
||||
const allowUpload = document.getElementById("folderShareAllowUpload").checked ? 1 : 0;
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
|
||||
if (!csrfToken) {
|
||||
showToast(t("csrf_error"));
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/folder/createShareFolderLink.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder,
|
||||
expirationValue: value,
|
||||
expirationUnit: unit,
|
||||
password,
|
||||
allowUpload
|
||||
})
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.token && data.link) {
|
||||
const shareUrl = data.link;
|
||||
const displayDiv = document.getElementById("folderShareLinkDisplay");
|
||||
const inputField = document.getElementById("folderShareLinkInput");
|
||||
inputField.value = shareUrl;
|
||||
displayDiv.style.display = "block";
|
||||
document.getElementById("folderShareLinkInput").value = data.link;
|
||||
document.getElementById("folderShareLinkDisplay").style.display = "block";
|
||||
showToast(t("share_link_generated"));
|
||||
} else {
|
||||
showToast(t("error_generating_share_link") + ": " + (data.error || t("unknown_error")));
|
||||
showToast(t("error_generating_share_link") + ": " + (data.error||t("unknown_error")));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error generating folder share link:", err);
|
||||
showToast(t("error_generating_share_link") + ": " + (err.error || t("unknown_error")));
|
||||
console.error(err);
|
||||
showToast(t("error_generating_share_link") + ": " + t("unknown_error"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Copy share link button handler
|
||||
document.getElementById("copyFolderShareLinkBtn").addEventListener("click", () => {
|
||||
const input = document.getElementById("folderShareLinkInput");
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
showToast(t("link_copied"));
|
||||
});
|
||||
// Copy
|
||||
document.getElementById("copyFolderShareLinkBtn")
|
||||
.addEventListener("click", () => {
|
||||
const inp = document.getElementById("folderShareLinkInput");
|
||||
inp.select();
|
||||
document.execCommand("copy");
|
||||
showToast(t("link_copied"));
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,7 @@ const translations = {
|
||||
// Additional keys for HTML translations:
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"header_title_text": "Header Title",
|
||||
"logout": "Logout",
|
||||
"change_password": "Change Password",
|
||||
"restore_text": "Restore or",
|
||||
@@ -150,6 +151,13 @@ const translations = {
|
||||
"allow_uploads": "Allow Uploads",
|
||||
"share_link_generated": "Share Link Generated",
|
||||
"error_generating_share_link": "Error Generating Share Link",
|
||||
"custom": "Custom",
|
||||
"duration": "Duration",
|
||||
"seconds": "Seconds",
|
||||
"minutes": "Minutes",
|
||||
"hours": "Hours",
|
||||
"days": "Days",
|
||||
"custom_duration_warning": "⚠️ Using a long expiration may pose security risks. Use with caution.",
|
||||
|
||||
// Folder
|
||||
"folder_share": "Share Folder",
|
||||
@@ -166,20 +174,39 @@ const translations = {
|
||||
"user": "User:",
|
||||
"unknown_error": "Unknown Error",
|
||||
"link_copied": "Link Copied to Clipboard",
|
||||
"minutes": "minutes",
|
||||
"hours": "hours",
|
||||
"days": "days",
|
||||
"weeks": "weeks",
|
||||
"months": "months",
|
||||
"seconds": "seconds",
|
||||
|
||||
// Dark Mode Toggle
|
||||
"dark_mode_toggle": "Dark Mode",
|
||||
"light_mode_toggle": "Light Mode",
|
||||
"switch_to_light_mode": "Switch to light mode",
|
||||
"switch_to_dark_mode": "Switch to dark mode",
|
||||
|
||||
// Admin Panel
|
||||
"header_settings": "Header Settings",
|
||||
"shared_max_upload_size_bytes_title": "Shared Max Upload Size",
|
||||
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
|
||||
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
|
||||
"manage_shared_links": "Manage Shared Links",
|
||||
"folder_shares": "Folder Shares",
|
||||
"file_shares": "File Shares",
|
||||
"loading": "Loading…",
|
||||
"error_loading_share_links": "Error loading share links",
|
||||
"share_deleted_successfully": "Share deleted successfully",
|
||||
"error_deleting_share": "Error deleting share",
|
||||
"password_protected": "Password protected",
|
||||
"no_shared_links_available": "No shared links available",
|
||||
|
||||
|
||||
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
|
||||
"admin_panel": "Admin Panel",
|
||||
"user_panel": "User Panel",
|
||||
"user_settings": "User Settings",
|
||||
"save_profile_picture": "Save Profile Picture",
|
||||
"please_select_picture": "Please select a picture",
|
||||
"profile_picture_updated": "Profile picture updated",
|
||||
"error_updating_picture": "Error updating profile picture",
|
||||
"trash_restore_delete": "Trash Restore/Delete",
|
||||
"totp_settings": "TOTP Settings",
|
||||
"enable_totp": "Enable TOTP",
|
||||
@@ -237,8 +264,18 @@ const translations = {
|
||||
"ok": "OK",
|
||||
"show": "Show",
|
||||
"items_per_page": "items per page",
|
||||
"columns":"Columns",
|
||||
"api_docs": "API Docs"
|
||||
"columns": "Columns",
|
||||
"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: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
@@ -295,6 +332,7 @@ const translations = {
|
||||
// Additional keys for HTML translations:
|
||||
"title": "FileRise",
|
||||
"header_title": "FileRise",
|
||||
"header_title_text": "Header Title",
|
||||
"logout": "Cerrar sesión",
|
||||
"change_password": "Cambiar contraseña",
|
||||
"restore_text": "Restaurar o",
|
||||
@@ -804,7 +842,7 @@ const translations = {
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter",
|
||||
"page": "Seite",
|
||||
"of": "von",
|
||||
"of": "von",
|
||||
|
||||
// Login Form keys:
|
||||
"login": "Anmelden",
|
||||
|
||||
@@ -14,57 +14,96 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
|
||||
import { editFile, saveFile } from './fileEditor.js';
|
||||
import { t, applyTranslations, setLocale } from './i18n.js';
|
||||
|
||||
// Remove the retry logic version and just use loadCsrfToken directly:
|
||||
/**
|
||||
* Fetches the current CSRF token (and share URL), updates window globals
|
||||
* and <meta> tags, and returns the data.
|
||||
*
|
||||
* @returns {Promise<{csrf_token: string, share_url: string}>}
|
||||
*/
|
||||
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() {
|
||||
return fetch('/api/auth/token.php', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token fetch failed with status: ${response.status}`);
|
||||
}
|
||||
// Prefer header if set, otherwise fall back to body
|
||||
const headerToken = response.headers.get('X-CSRF-Token');
|
||||
return response.json()
|
||||
.then(body => ({
|
||||
csrf_token: headerToken || body.csrf_token,
|
||||
share_url: body.share_url
|
||||
}));
|
||||
return fetchWithCsrf('/api/auth/token.php', { method: 'GET' })
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(({ csrf_token, share_url }) => {
|
||||
// Update globals
|
||||
window.csrfToken = csrf_token;
|
||||
window.SHARE_URL = share_url;
|
||||
|
||||
// Sync <meta name="csrf-token">
|
||||
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'csrf-token';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
// update CSRF meta
|
||||
let meta = document.querySelector('meta[name="csrf-token"]') ||
|
||||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' });
|
||||
meta.content = csrf_token;
|
||||
|
||||
// Sync <meta name="share-url">
|
||||
let shareMeta = document.querySelector('meta[name="share-url"]');
|
||||
if (!shareMeta) {
|
||||
shareMeta = document.createElement('meta');
|
||||
shareMeta.name = 'share-url';
|
||||
document.head.appendChild(shareMeta);
|
||||
}
|
||||
shareMeta.content = share_url;
|
||||
// force share_url to match wherever we're browsing
|
||||
const actualShare = window.location.origin;
|
||||
let shareMeta = document.querySelector('meta[name="share-url"]') ||
|
||||
Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' });
|
||||
shareMeta.content = actualShare;
|
||||
|
||||
return { csrf_token, share_url };
|
||||
return { csrf_token, share_url: actualShare };
|
||||
});
|
||||
}
|
||||
|
||||
// 1) Immediately clear “?logout=1” flag
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('logout') === '1') {
|
||||
localStorage.removeItem("username");
|
||||
localStorage.removeItem("userTOTPEnabled");
|
||||
}
|
||||
|
||||
export function triggerLogout() {
|
||||
fetch("/api/auth/logout.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "X-CSRF-Token": window.csrfToken }
|
||||
})
|
||||
.then(() => window.location.reload(true))
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
|
||||
// Expose functions for inline handlers.
|
||||
window.sendRequest = sendRequest;
|
||||
@@ -96,30 +135,9 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
// Continue with initializations that rely on a valid CSRF token:
|
||||
checkAuthentication().then(authenticated => {
|
||||
if (authenticated) {
|
||||
window.currentFolder = "root";
|
||||
initTagSearch();
|
||||
loadFileList(window.currentFolder);
|
||||
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.");
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.remove();
|
||||
initializeApp();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,48 +153,55 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
// --- Dark Mode Persistence ---
|
||||
const darkModeToggle = document.getElementById("darkModeToggle");
|
||||
const storedDarkMode = localStorage.getItem("darkMode");
|
||||
const darkModeIcon = document.getElementById("darkModeIcon");
|
||||
|
||||
if (storedDarkMode === "true") {
|
||||
document.body.classList.add("dark-mode");
|
||||
} else if (storedDarkMode === "false") {
|
||||
document.body.classList.remove("dark-mode");
|
||||
} else {
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
document.body.classList.add("dark-mode");
|
||||
} else {
|
||||
document.body.classList.remove("dark-mode");
|
||||
if (darkModeToggle && darkModeIcon) {
|
||||
// 1) Load stored preference (or null)
|
||||
let stored = localStorage.getItem("darkMode");
|
||||
const hasStored = stored !== null;
|
||||
|
||||
// 2) Determine initial mode
|
||||
const isDark = hasStored
|
||||
? (stored === "true")
|
||||
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
document.body.classList.toggle("dark-mode", isDark);
|
||||
darkModeToggle.classList.toggle("active", isDark);
|
||||
|
||||
// 3) Helper to update icon & aria-label
|
||||
function updateIcon() {
|
||||
const dark = document.body.classList.contains("dark-mode");
|
||||
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
|
||||
darkModeToggle.setAttribute(
|
||||
"aria-label",
|
||||
dark ? t("light_mode") : t("dark_mode")
|
||||
);
|
||||
darkModeToggle.setAttribute(
|
||||
"title",
|
||||
dark
|
||||
? t("switch_to_light_mode")
|
||||
: t("switch_to_dark_mode")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (darkModeToggle) {
|
||||
darkModeToggle.textContent = document.body.classList.contains("dark-mode")
|
||||
? t("light_mode")
|
||||
: t("dark_mode");
|
||||
updateIcon();
|
||||
|
||||
darkModeToggle.addEventListener("click", function () {
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
document.body.classList.remove("dark-mode");
|
||||
localStorage.setItem("darkMode", "false");
|
||||
darkModeToggle.textContent = t("dark_mode");
|
||||
} else {
|
||||
document.body.classList.add("dark-mode");
|
||||
localStorage.setItem("darkMode", "true");
|
||||
darkModeToggle.textContent = t("light_mode");
|
||||
}
|
||||
// 4) Click handler: always override and store preference
|
||||
darkModeToggle.addEventListener("click", () => {
|
||||
const nowDark = document.body.classList.toggle("dark-mode");
|
||||
localStorage.setItem("darkMode", nowDark ? "true" : "false");
|
||||
updateIcon();
|
||||
});
|
||||
}
|
||||
|
||||
if (localStorage.getItem("darkMode") === null && window.matchMedia) {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => {
|
||||
if (event.matches) {
|
||||
document.body.classList.add("dark-mode");
|
||||
if (darkModeToggle) darkModeToggle.textContent = t("light_mode");
|
||||
} else {
|
||||
document.body.classList.remove("dark-mode");
|
||||
if (darkModeToggle) darkModeToggle.textContent = t("dark_mode");
|
||||
}
|
||||
});
|
||||
// 5) OS‐level change: only if no stored pref at load
|
||||
if (!hasStored && window.matchMedia) {
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", e => {
|
||||
document.body.classList.toggle("dark-mode", e.matches);
|
||||
updateIcon();
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- End Dark Mode Persistence ---
|
||||
|
||||
@@ -190,7 +215,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
});
|
||||
|
||||
// --- Auto-scroll During Drag ---
|
||||
// Adjust these values as needed:
|
||||
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
|
||||
const SCROLL_SPEED = 20; // pixels to scroll per event
|
||||
|
||||
|
||||
6
public/js/redoc-init.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// public/js/redoc-init.js
|
||||
if (!customElements.get('redoc')) {
|
||||
Redoc.init(window.location.origin + '/api.php?spec=1',
|
||||
{},
|
||||
document.getElementById('redoc-container'));
|
||||
}
|
||||
90
public/js/sharedFolderView.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// sharedFolderView.js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let viewMode = 'list';
|
||||
const payload = JSON.parse(
|
||||
document.getElementById('shared-data').textContent
|
||||
);
|
||||
const token = payload.token;
|
||||
const filesData = payload.files;
|
||||
const downloadBase = `${window.location.origin}/api/folder/downloadSharedFile.php?token=${encodeURIComponent(token)}&file=`;
|
||||
const btn = document.getElementById('toggleBtn');
|
||||
if (btn) btn.classList.add('toggle-btn');
|
||||
|
||||
function toggleViewMode() {
|
||||
const listEl = document.getElementById('listViewContainer');
|
||||
const galleryEl = document.getElementById('galleryViewContainer');
|
||||
|
||||
if (viewMode === 'list') {
|
||||
viewMode = 'gallery';
|
||||
listEl.style.display = 'none';
|
||||
renderGalleryView();
|
||||
galleryEl.style.display = 'block';
|
||||
btn.textContent = 'Switch to List View';
|
||||
} else {
|
||||
viewMode = 'list';
|
||||
galleryEl.style.display = 'none';
|
||||
listEl.style.display = 'block';
|
||||
btn.textContent = 'Switch to Gallery View';
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', toggleViewMode);
|
||||
|
||||
function renderGalleryView() {
|
||||
const container = document.getElementById('galleryViewContainer');
|
||||
// clear previous
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'shared-gallery-container';
|
||||
|
||||
filesData.forEach(file => {
|
||||
const url = downloadBase + encodeURIComponent(file);
|
||||
const ext = file.split('.').pop().toLowerCase();
|
||||
const isImg = /^(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/.test(ext);
|
||||
|
||||
// card
|
||||
const card = document.createElement('div');
|
||||
card.className = 'shared-gallery-card';
|
||||
|
||||
// preview
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'gallery-preview';
|
||||
preview.style.cursor = 'pointer';
|
||||
preview.dataset.url = url;
|
||||
|
||||
if (isImg) {
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.alt = file; // safe, file is not HTML
|
||||
preview.appendChild(img);
|
||||
} else {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'material-icons';
|
||||
icon.textContent = 'insert_drive_file';
|
||||
preview.appendChild(icon);
|
||||
}
|
||||
card.appendChild(preview);
|
||||
|
||||
// info
|
||||
const info = document.createElement('div');
|
||||
info.className = 'gallery-info';
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'gallery-file-name';
|
||||
nameSpan.textContent = file; // textContent escapes any HTML
|
||||
info.appendChild(nameSpan);
|
||||
card.appendChild(info);
|
||||
|
||||
grid.appendChild(card);
|
||||
|
||||
preview.addEventListener('click', () => {
|
||||
window.location.href = preview.dataset.url;
|
||||
});
|
||||
});
|
||||
|
||||
container.appendChild(grid);
|
||||
}
|
||||
|
||||
window.renderGalleryView = renderGalleryView;
|
||||
});
|
||||
@@ -79,15 +79,16 @@ export function setupTrashRestoreDelete() {
|
||||
body: JSON.stringify({ files })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.success);
|
||||
toggleVisibility("restoreFilesModal", false);
|
||||
loadFileList(window.currentFolder);
|
||||
loadFolderTree(window.currentFolder);
|
||||
.then(() => {
|
||||
// Always report what we actually restored
|
||||
if (files.length === 1) {
|
||||
showToast(`Restored file: ${files[0]}`);
|
||||
} else {
|
||||
showToast(data.error);
|
||||
showToast(`Restored files: ${files.join(", ")}`);
|
||||
}
|
||||
toggleVisibility("restoreFilesModal", false);
|
||||
loadFileList(window.currentFolder);
|
||||
loadFolderTree(window.currentFolder);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error restoring files:", err);
|
||||
@@ -119,16 +120,15 @@ export function setupTrashRestoreDelete() {
|
||||
body: JSON.stringify({ files })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.success);
|
||||
toggleVisibility("restoreFilesModal", false);
|
||||
loadFileList(window.currentFolder);
|
||||
loadFolderTree(window.currentFolder);
|
||||
|
||||
.then(() => {
|
||||
if (files.length === 1) {
|
||||
showToast(`Restored file: ${files[0]}`);
|
||||
} else {
|
||||
showToast(data.error);
|
||||
showToast(`Restored files: ${files.join(", ")}`);
|
||||
}
|
||||
toggleVisibility("restoreFilesModal", false);
|
||||
loadFileList(window.currentFolder);
|
||||
loadFolderTree(window.currentFolder);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error restoring files:", err);
|
||||
|
||||
@@ -669,6 +669,18 @@ function submitFiles(allFiles) {
|
||||
}
|
||||
allSucceeded = false;
|
||||
}
|
||||
if (file.isClipboard) {
|
||||
setTimeout(() => {
|
||||
window.selectedFiles = [];
|
||||
updateFileInfoCount();
|
||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||
if (progressContainer) progressContainer.innerHTML = "";
|
||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||
if (fileInfoContainer) {
|
||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ─── Only now count this chunk as finished ───────────────────
|
||||
finishedCount++;
|
||||
@@ -847,4 +859,39 @@ function initUpload() {
|
||||
}
|
||||
}
|
||||
|
||||
export { initUpload };
|
||||
export { initUpload };
|
||||
|
||||
// -------------------------
|
||||
// Clipboard Paste Handler (Mimics Drag-and-Drop)
|
||||
// -------------------------
|
||||
document.addEventListener('paste', function handlePasteUpload(e) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const files = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const ext = file.name.split('.').pop() || 'png';
|
||||
const renamedFile = new File([file], `image${Date.now()}.${ext}`, { type: file.type });
|
||||
renamedFile.isClipboard = true;
|
||||
|
||||
Object.defineProperty(renamedFile, 'customRelativePath', {
|
||||
value: renamedFile.name,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
files.push(renamedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
showToast('Pasted file added to upload list.', 'success');
|
||||
}
|
||||
});
|
||||
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 626 KiB After Width: | Height: | Size: 764 KiB |
|
Before Width: | Height: | Size: 662 KiB After Width: | Height: | Size: 736 KiB |
|
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 560 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 378 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 403 KiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 457 KiB After Width: | Height: | Size: 504 KiB |
BIN
resources/light-user-panel.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
143
scripts/scan_uploads.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* scan_uploads.php
|
||||
* Rebuild/repair per-folder metadata used by FileRise models.
|
||||
* - Uses UPLOAD_DIR / META_DIR / DATE_TIME_FORMAT from config.php
|
||||
* - Per-folder metadata naming matches FileModel/FolderModel:
|
||||
* "root" -> root_metadata.json
|
||||
* "<sub/dir>" -> str_replace(['/', '\\', ' '], '-', '<sub/dir>') . '_metadata.json'
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
// ---------- helpers that mirror model behavior ----------
|
||||
|
||||
/** Compute the metadata JSON path for a folder key (e.g., "root", "invoices/2025"). */
|
||||
function folder_metadata_path(string $folderKey): string {
|
||||
if (strtolower(trim($folderKey)) === 'root' || trim($folderKey) === '') {
|
||||
return rtrim(META_DIR, '/\\') . '/root_metadata.json';
|
||||
}
|
||||
$safe = str_replace(['/', '\\', ' '], '-', trim($folderKey));
|
||||
return rtrim(META_DIR, '/\\') . '/' . $safe . '_metadata.json';
|
||||
}
|
||||
|
||||
/** Turn an absolute path under UPLOAD_DIR into a folder key (“root” or relative with slashes). */
|
||||
function to_folder_key(string $absPath): string {
|
||||
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
if (realpath($absPath) === realpath(rtrim(UPLOAD_DIR, '/\\'))) {
|
||||
return 'root';
|
||||
}
|
||||
$rel = ltrim(str_replace('\\', '/', substr($absPath, strlen($base))), '/');
|
||||
return $rel;
|
||||
}
|
||||
|
||||
/** List immediate files in a directory (no subdirs). */
|
||||
function list_files(string $dir): array {
|
||||
$out = [];
|
||||
$entries = @scandir($dir);
|
||||
if ($entries === false) return $out;
|
||||
foreach ($entries as $name) {
|
||||
if ($name === '.' || $name === '..') continue;
|
||||
$p = $dir . DIRECTORY_SEPARATOR . $name;
|
||||
if (is_file($p)) $out[] = $name;
|
||||
}
|
||||
sort($out, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Recursively list subfolders (relative folder keys), skipping trash/. */
|
||||
function list_all_folders(string $root): array {
|
||||
$root = rtrim($root, '/\\');
|
||||
$folders = ['root'];
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($it as $path => $info) {
|
||||
if ($info->isDir()) {
|
||||
// relative key like "foo/bar"
|
||||
$rel = ltrim(str_replace(['\\'], '/', substr($path, strlen($root) + 1)), '/');
|
||||
if ($rel === '') continue;
|
||||
// skip trash and profile_pics subtrees
|
||||
if ($rel === 'trash' || strpos($rel, 'trash/') === 0) continue;
|
||||
if ($rel === 'profile_pics' || strpos($rel, 'profile_pics/') === 0) continue;
|
||||
// obey the app’s folder-name regex to stay consistent
|
||||
if (preg_match(REGEX_FOLDER_NAME, basename($rel))) {
|
||||
$folders[] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
// de-dup and sort
|
||||
$folders = array_values(array_unique($folders));
|
||||
sort($folders, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
return $folders;
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
|
||||
$uploads = rtrim(UPLOAD_DIR, '/\\');
|
||||
$metaDir = rtrim(META_DIR, '/\\');
|
||||
|
||||
// Ensure metadata dir exists
|
||||
if (!is_dir($metaDir)) {
|
||||
@mkdir($metaDir, 0775, true);
|
||||
}
|
||||
|
||||
$now = date(DATE_TIME_FORMAT);
|
||||
$folders = list_all_folders($uploads);
|
||||
|
||||
$totalCreated = 0;
|
||||
$totalPruned = 0;
|
||||
|
||||
foreach ($folders as $folderKey) {
|
||||
$absFolder = ($folderKey === 'root')
|
||||
? $uploads
|
||||
: $uploads . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folderKey);
|
||||
|
||||
if (!is_dir($absFolder)) continue;
|
||||
|
||||
$files = list_files($absFolder);
|
||||
|
||||
$metaPath = folder_metadata_path($folderKey);
|
||||
$metadata = [];
|
||||
if (is_file($metaPath)) {
|
||||
$decoded = json_decode(@file_get_contents($metaPath), true);
|
||||
if (is_array($decoded)) $metadata = $decoded;
|
||||
}
|
||||
|
||||
// Build a quick lookup of existing entries
|
||||
$existing = array_keys($metadata);
|
||||
|
||||
// ADD missing files
|
||||
foreach ($files as $name) {
|
||||
// Keep same filename validation used in FileModel
|
||||
if (!preg_match(REGEX_FILE_NAME, $name)) continue;
|
||||
|
||||
if (!isset($metadata[$name])) {
|
||||
$metadata[$name] = [
|
||||
'uploaded' => $now,
|
||||
'modified' => $now,
|
||||
'uploader' => 'Imported'
|
||||
];
|
||||
$totalCreated++;
|
||||
echo "Indexed: " . ($folderKey === 'root' ? '' : $folderKey . '/') . $name . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
// PRUNE stale metadata entries for files that no longer exist
|
||||
foreach ($existing as $name) {
|
||||
if (!in_array($name, $files, true)) {
|
||||
unset($metadata[$name]);
|
||||
$totalPruned++;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure parent dir exists and write metadata
|
||||
@mkdir(dirname($metaPath), 0775, true);
|
||||
if (@file_put_contents($metaPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
|
||||
fwrite(STDERR, "Failed to write metadata for folder: {$folderKey}\n");
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done. Created {$totalCreated} entr" . ($totalCreated === 1 ? "y" : "ies") .
|
||||
", pruned {$totalPruned}.\n";
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
// src/controllers/adminController.php
|
||||
// src/controllers/AdminController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||
@@ -54,12 +54,27 @@ class AdminController
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
$config = AdminModel::getConfig();
|
||||
|
||||
// If an error was encountered, send a 500 status.
|
||||
if (isset($config['error'])) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -122,111 +137,106 @@ class AdminController
|
||||
* @return void Outputs a JSON response indicating success or failure.
|
||||
*/
|
||||
public function updateConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Ensure the user is authenticated and is an admin.
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
|
||||
) {
|
||||
http_response_code(403);
|
||||
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);
|
||||
// —– auth & CSRF checks —–
|
||||
if (
|
||||
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
|
||||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
|
||||
) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized access.']);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
// src/controllers/authController.php
|
||||
// src/controllers/AuthController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/models/AuthModel.php';
|
||||
@@ -111,6 +111,8 @@ class AuthController
|
||||
$cfg['oidc']['clientSecret']
|
||||
);
|
||||
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
||||
$oidc->addScope(['openid','profile','email']);
|
||||
|
||||
|
||||
if ($oidcAction === 'callback') {
|
||||
try {
|
||||
@@ -342,48 +344,48 @@ class AuthController
|
||||
public function checkAuth(): void
|
||||
{
|
||||
|
||||
// 1) Remember-me re-login
|
||||
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
|
||||
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
|
||||
if ($payload) {
|
||||
$old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['csrf_token'] = $old;
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['username'] = $payload['username'];
|
||||
$_SESSION['isAdmin'] = !empty($payload['isAdmin']);
|
||||
$_SESSION['folderOnly'] = $payload['folderOnly'] ?? false;
|
||||
$_SESSION['readOnly'] = $payload['readOnly'] ?? false;
|
||||
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
|
||||
// regenerate CSRF if you use one
|
||||
|
||||
// 1) Remember-me re-login
|
||||
if (empty($_SESSION['authenticated']) && !empty($_COOKIE['remember_me_token'])) {
|
||||
$payload = AuthModel::validateRememberToken($_COOKIE['remember_me_token']);
|
||||
if ($payload) {
|
||||
$old = $_SESSION['csrf_token'] ?? bin2hex(random_bytes(32));
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['csrf_token'] = $old;
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['username'] = $payload['username'];
|
||||
$_SESSION['isAdmin'] = !empty($payload['isAdmin']);
|
||||
$_SESSION['folderOnly'] = $payload['folderOnly'] ?? false;
|
||||
$_SESSION['readOnly'] = $payload['readOnly'] ?? false;
|
||||
$_SESSION['disableUpload'] = $payload['disableUpload'] ?? false;
|
||||
// regenerate CSRF if you use one
|
||||
|
||||
// TOTP enabled? (same logic as below)
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$totp = false;
|
||||
if (file_exists($usersFile)) {
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
|
||||
$totp = true;
|
||||
break;
|
||||
|
||||
// TOTP enabled? (same logic as below)
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
$totp = false;
|
||||
if (file_exists($usersFile)) {
|
||||
foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$parts = explode(':', trim($line));
|
||||
if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) {
|
||||
$totp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'authenticated' => true,
|
||||
'csrf_token' => $_SESSION['csrf_token'],
|
||||
'isAdmin' => $_SESSION['isAdmin'],
|
||||
'totp_enabled' => $totp,
|
||||
'username' => $_SESSION['username'],
|
||||
'folderOnly' => $_SESSION['folderOnly'],
|
||||
'readOnly' => $_SESSION['readOnly'],
|
||||
'disableUpload' => $_SESSION['disableUpload']
|
||||
]);
|
||||
exit();
|
||||
echo json_encode([
|
||||
'authenticated' => true,
|
||||
'csrf_token' => $_SESSION['csrf_token'],
|
||||
'isAdmin' => $_SESSION['isAdmin'],
|
||||
'totp_enabled' => $totp,
|
||||
'username' => $_SESSION['username'],
|
||||
'folderOnly' => $_SESSION['folderOnly'],
|
||||
'readOnly' => $_SESSION['readOnly'],
|
||||
'disableUpload' => $_SESSION['disableUpload']
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$usersFile = USERS_DIR . USERS_FILE;
|
||||
|
||||
@@ -453,11 +455,11 @@ class AuthController
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
|
||||
// 2) Emit headers
|
||||
header('Content-Type: application/json');
|
||||
header('X-CSRF-Token: ' . $_SESSION['csrf_token']);
|
||||
|
||||
|
||||
// 3) Return JSON payload
|
||||
echo json_encode([
|
||||
'csrf_token' => $_SESSION['csrf_token'],
|
||||